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
39 changes: 35 additions & 4 deletions core/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.map
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.UUID
import java.util.logging.Logger

/**
* App-specific implementation of a task list.
Expand All @@ -26,6 +28,8 @@ class LocalTaskList (
internal val dmfsTaskList: DmfsTaskList
): LocalCollection<LocalTask> {

private val logger = Logger.getLogger(javaClass.name)

override val readOnly
get() = dmfsTaskList.accessLevel.let {
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
Expand Down Expand Up @@ -88,10 +92,37 @@ class LocalTaskList (
recurringTaskList.queryTasksAndExceptions(Tasks._DIRTY, null).map { LocalTask(recurringTaskList, it) }

override suspend fun findByName(name: String): LocalTask? {
val result = recurringTaskList.findTaskAndExceptions("${Tasks._SYNC_ID}=?", arrayOf(name))
return result?.let {
LocalTask(recurringTaskList, it)
val matches = recurringTaskList.findAllTasksWithSyncId(name)
if (matches.isEmpty()) return null

if (matches.size == 1)
return LocalTask(recurringTaskList, matches.first())

// There are multiple tasks with the same _SYNC_ID. This happens when a task was created
// locally with a UID that already exists on the server: one entry is already synced (has
// an eTag) and another was never successfully uploaded (dirty, no eTag). Reassign the
// dirty/no-eTag duplicate a fresh UUID so it can be uploaded as a genuinely new resource.
for (task in matches) {
val values = task.main.entityValues
val isDirty = (values.getAsInteger(Tasks._DIRTY) ?: 0) != 0
val hasNoETag = values.getAsString(DmfsTasksContract.COLUMN_ETAG) == null
if (isDirty && hasNoETag) {
val taskId = values.getAsLong(Tasks._ID) ?: continue
val newSyncId = "${UUID.randomUUID()}.ics"
logger.warning("Task $taskId has duplicate _SYNC_ID '$name' but no eTag; reassigning to $newSyncId to resolve collision")
dmfsTaskList.updateTaskRow(taskId, contentValuesOf(Tasks._SYNC_ID to newSyncId))
}
}

// Return the first remaining match with the original name, preferring one with an eTag.
val remainingMatches = recurringTaskList.findAllTasksWithSyncId(name)
val synced =
remainingMatches.firstOrNull { task ->
val values = task.main.entityValues
val hasETag = values.getAsString(DmfsTasksContract.COLUMN_ETAG) != null
hasETag
} ?: remainingMatches.firstOrNull() ?: return null
return LocalTask(recurringTaskList, synced)
}

override fun markNotDirty(flags: Int): Int =
Expand Down Expand Up @@ -123,4 +154,4 @@ class LocalTaskList (
private const val COLUMN_TASKLIST_SYNC_STATE = TaskContract.TaskLists.SYNC_VERSION
}

}
}
107 changes: 107 additions & 0 deletions core/src/test/kotlin/at/bitfire/davdroid/resource/LocalTaskListTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/

package at.bitfire.davdroid.resource

import android.content.ContentValues
import android.content.Entity
import androidx.core.content.contentValuesOf
import at.bitfire.synctools.storage.tasks.DmfsTaskList
import at.bitfire.synctools.storage.tasks.DmfsTasksContract
import io.mockk.every
import io.mockk.mockk
import org.dmfs.tasks.contract.TaskContract.Tasks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.ConscryptMode

@RunWith(RobolectricTestRunner::class)
@ConscryptMode(ConscryptMode.Mode.OFF)
class LocalTaskListTest {
private lateinit var providerTasks: MutableList<Entity>
private lateinit var taskList: LocalTaskList
private var queryCount = 0

@Before
fun setUp() {
providerTasks = mutableListOf()
queryCount = 0

val dmfsTaskList = mockk<DmfsTaskList>()
every { dmfsTaskList.findTasks(any(), any()) } returns emptyList()
every { dmfsTaskList.iterateTasks(any(), any(), any()) } answers {
queryCount++
val syncId = secondArg<Array<String>>().single()
val body = thirdArg<(Entity) -> Unit>()
providerTasks
.filter { it.entityValues.getAsString(Tasks._SYNC_ID) == syncId }
.map { Entity(ContentValues(it.entityValues)) }
.forEach(body)
}
every { dmfsTaskList.updateTaskRow(any(), any()) } answers {
val id = firstArg<Long>()
val values = secondArg<ContentValues>()
providerTasks
.single { it.entityValues.getAsLong(Tasks._ID) == id }
.entityValues
.putAll(values)
}

taskList = LocalTaskList(dmfsTaskList)
}

@Test
fun testFindByName_ReassignsDirtyDuplicateAndReturnsSyncedTask() {
val syncId = "duplicate.ics"
providerTasks += task(id = 1, syncId = syncId, eTag = "etag-1", dirty = 0)
providerTasks += task(id = 2, syncId = syncId, eTag = null, dirty = 1)

val result = taskList.findByName(syncId)

assertNotNull(result)
assertEquals(1L, result!!.id)
assertEquals(syncId, result.fileName)
assertEquals("etag-1", result.eTag)
val reassignedSyncId =
providerTasks
.single { it.entityValues.getAsLong(Tasks._ID) == 2L }
.entityValues
.getAsString(Tasks._SYNC_ID)
assertNotEquals(syncId, reassignedSyncId)
assertTrue(reassignedSyncId?.endsWith(".ics") == true)
assertEquals(2, queryCount)
}

@Test
fun testFindByName_AllDuplicatesReassigned_ReturnsNull() {
val syncId = "duplicate.ics"
providerTasks += task(id = 1, syncId = syncId, eTag = null, dirty = 1)
providerTasks += task(id = 2, syncId = syncId, eTag = null, dirty = 1)

assertNull(taskList.findByName(syncId))
assertTrue(providerTasks.none { it.entityValues.getAsString(Tasks._SYNC_ID) == syncId })
assertEquals(2, queryCount)
}

private fun task(
id: Long,
syncId: String,
eTag: String?,
dirty: Int,
) = Entity(
contentValuesOf(
Tasks._ID to id,
Tasks._SYNC_ID to syncId,
DmfsTasksContract.COLUMN_ETAG to eTag,
Tasks._DIRTY to dirty,
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,62 @@ class DmfsRecurringTaskListTest(providerName: TaskProvider.ProviderName) :
assertNull(recurringTaskList.findTaskAndExceptions("${Tasks._SYNC_ID}=?", arrayOf("not-existent")))
}

@Test
fun testFindAllTasksWithSyncId_ReturnsMultipleMatches() = runTest {
// Insert a task that simulates an already-synced server copy (has an eTag via SYNC1).
val syncId = "duplicate-uid-${UUID.randomUUID()}"
val now = 1754233504000L
val syncedTask = Entity(
contentValuesOf(
Tasks.LIST_ID to taskList.id,
Tasks._SYNC_ID to syncId,
Tasks.TITLE to "Synced Task",
Tasks.DTSTART to now,
Tasks.TZ to timeZoneId,
// Simulate an already-synced task: set SYNC1 (eTag) to a non-null value.
// We write it directly as a task row value.
org.dmfs.tasks.contract.TaskContract.Tasks.SYNC1 to "etag-abc123"
)
)
taskList.addTask(syncedTask)

// Insert a second task with the same _SYNC_ID that was never uploaded (no eTag, dirty).
val dirtyTask = Entity(
contentValuesOf(
Tasks.LIST_ID to taskList.id,
Tasks._SYNC_ID to syncId,
Tasks.TITLE to "Dirty Local Task",
Tasks.DTSTART to now + 3600000,
Tasks.TZ to timeZoneId,
Tasks._DIRTY to 1,
// No SYNC1 (eTag) — simulates a task that was never uploaded.
)
)
taskList.addTask(dirtyTask)

// Both tasks should be found.
val results = recurringTaskList.findAllTasksWithSyncId(syncId)
assertEquals("findAllTasksWithSyncId must return both tasks with the same syncId", 2, results.size)

val titles = results.map { it.main.entityValues.getAsString(Tasks.TITLE) }.toSet()
assertTrue(titles.contains("Synced Task"))
assertTrue(titles.contains("Dirty Local Task"))
}

@Test
fun testFindAllTasksWithSyncId_SingleMatch() = runTest {
val syncId = "single-${UUID.randomUUID()}"
insertRecurring(syncId = syncId)
val results = recurringTaskList.findAllTasksWithSyncId(syncId)
assertEquals(1, results.size)
}

@Test
fun testFindAllTasksWithSyncId_NoMatch() = runTest {
val results = recurringTaskList.findAllTasksWithSyncId("no-such-id")
assertEquals(0, results.size)
}

@Test
fun testGetById_ExceptionId_ReturnsNull() = runTest {
val task = insertRecurring()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ class DmfsRecurringTaskList(
suspend fun getById(mainTaskId: Long): TaskAndExceptions? =
findTaskAndExceptions("${Tasks._ID}=?", arrayOf(mainTaskId.toString()))

/**
* Finds all main tasks in [taskList] that have the given [Tasks._SYNC_ID], together with their exceptions.
*
* Normally a sync ID is unique, but when a task is created locally with the same UID as a task
* already present on the server, there can be two entries with the same [Tasks._SYNC_ID]:
* one that is already synced (with an eTag) and one that was never uploaded (dirty, no eTag).
*
* @param syncId value of [Tasks._SYNC_ID] to search for
* @return list of all matching tasks (each with their exceptions); may be empty
*/
suspend fun findAllTasksWithSyncId(syncId: String): List<TaskAndExceptions> =
queryTasksAndExceptions("${Tasks._SYNC_ID}=?", arrayOf(syncId)).toList()

/**
* Cold [Flow] of main tasks together with their exceptions; the per-main exceptions lookup
* stays a small bounded query (exceptions of a single task are not streamed).
Expand Down
Loading