diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt index cd9ee81b..88c9c719 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/DmfsTaskBuilder.kt @@ -9,49 +9,46 @@ package at.bitfire.synctools.mapping.tasks import android.content.ContentValues import android.content.Entity import at.bitfire.ical4android.Task -import at.bitfire.ical4android.UnknownProperty -import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate +import at.bitfire.synctools.mapping.tasks.builder.AlarmsBuilder +import at.bitfire.synctools.mapping.tasks.builder.AllDayBuilder +import at.bitfire.synctools.mapping.tasks.builder.CategoriesBuilder +import at.bitfire.synctools.mapping.tasks.builder.ClassificationBuilder +import at.bitfire.synctools.mapping.tasks.builder.ColorBuilder +import at.bitfire.synctools.mapping.tasks.builder.CommentsBuilder +import at.bitfire.synctools.mapping.tasks.builder.CompletedBuilder +import at.bitfire.synctools.mapping.tasks.builder.DescriptionBuilder +import at.bitfire.synctools.mapping.tasks.builder.DirtyBuilder import at.bitfire.synctools.mapping.tasks.builder.DmfsTaskFieldBuilder +import at.bitfire.synctools.mapping.tasks.builder.DueBuilder +import at.bitfire.synctools.mapping.tasks.builder.DurationBuilder +import at.bitfire.synctools.mapping.tasks.builder.ETagBuilder +import at.bitfire.synctools.mapping.tasks.builder.GeoBuilder +import at.bitfire.synctools.mapping.tasks.builder.ListIdBuilder +import at.bitfire.synctools.mapping.tasks.builder.LocationBuilder +import at.bitfire.synctools.mapping.tasks.builder.OrganizerBuilder +import at.bitfire.synctools.mapping.tasks.builder.PercentCompleteBuilder +import at.bitfire.synctools.mapping.tasks.builder.PriorityBuilder +import at.bitfire.synctools.mapping.tasks.builder.RecurrenceFieldsBuilder +import at.bitfire.synctools.mapping.tasks.builder.RelationsBuilder +import at.bitfire.synctools.mapping.tasks.builder.SequenceBuilder +import at.bitfire.synctools.mapping.tasks.builder.StartTimeBuilder +import at.bitfire.synctools.mapping.tasks.builder.StatusBuilder +import at.bitfire.synctools.mapping.tasks.builder.SyncFlagsBuilder +import at.bitfire.synctools.mapping.tasks.builder.SyncIdBuilder import at.bitfire.synctools.mapping.tasks.builder.TitleBuilder +import at.bitfire.synctools.mapping.tasks.builder.UidBuilder +import at.bitfire.synctools.mapping.tasks.builder.UnknownPropertiesBuilder +import at.bitfire.synctools.mapping.tasks.builder.UrlBuilder import at.bitfire.synctools.storage.BatchOperation.CpoBuilder -import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.COLUMN_ETAG -import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.COLUMN_FLAGS -import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA import at.bitfire.synctools.storage.tasks.DmfsTaskList import at.bitfire.synctools.storage.tasks.TasksBatchOperation -import at.bitfire.synctools.util.AlarmTriggerCalculator -import at.bitfire.synctools.util.AndroidTimeUtils -import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp -import net.fortuna.ical4j.model.Parameter -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.TimeZone -import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.parameter.Email -import net.fortuna.ical4j.model.parameter.RelType -import net.fortuna.ical4j.model.parameter.Related -import net.fortuna.ical4j.model.parameter.TzId -import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.Due -import net.fortuna.ical4j.model.property.immutable.ImmutableAction -import net.fortuna.ical4j.model.property.immutable.ImmutableClazz -import net.fortuna.ical4j.model.property.immutable.ImmutableStatus -import net.fortuna.ical4j.util.TimeZones import org.dmfs.tasks.contract.TaskContract.Properties -import org.dmfs.tasks.contract.TaskContract.Property.Alarm -import org.dmfs.tasks.contract.TaskContract.Property.Category -import org.dmfs.tasks.contract.TaskContract.Property.Comment -import org.dmfs.tasks.contract.TaskContract.Property.Relation import org.dmfs.tasks.contract.TaskContract.Tasks -import java.time.ZoneId -import java.util.Locale import java.util.logging.Level import java.util.logging.Logger -import kotlin.jvm.optionals.getOrNull /** - * Writes [at.bitfire.ical4android.Task] to dmfs task provider data rows - * (former DmfsTask "build..." methods). + * Writes [at.bitfire.ical4android.Task] to dmfs task provider data rows. */ class DmfsTaskBuilder( private val taskList: DmfsTaskList, @@ -64,262 +61,104 @@ class DmfsTaskBuilder( private val flags: Int, ) { - private val fieldBuilders: Array = arrayOf( - TitleBuilder() - ) - private val logger get() = Logger.getLogger(javaClass.name) - private val tzRegistry by lazy { TimeZoneRegistryFactory.getInstance().createRegistry() } + private val fieldBuilders: Array = arrayOf( + // main task row fields + UidBuilder(), + SyncIdBuilder(syncId), + ETagBuilder(eTag), + SyncFlagsBuilder(flags), + SequenceBuilder(), + ListIdBuilder(taskList.id), + DirtyBuilder(), + // content fields + TitleBuilder(), + DescriptionBuilder(), + LocationBuilder(), + GeoBuilder(), + ColorBuilder(), + UrlBuilder(), + OrganizerBuilder(), + // status fields + PriorityBuilder(), + ClassificationBuilder(), + StatusBuilder(), + CompletedBuilder(), + PercentCompleteBuilder(), + // time fields + AllDayBuilder(), + StartTimeBuilder(), + DueBuilder(), + DurationBuilder(), + // recurrence + RecurrenceFieldsBuilder(), + // property sub-rows + AlarmsBuilder(taskList), + CategoriesBuilder(taskList), + CommentsBuilder(taskList), + RelationsBuilder(taskList), + UnknownPropertiesBuilder(taskList), + ) + fun addRows(batch: TasksBatchOperation): Int { - val builder = CpoBuilder.newInsert(taskList.tasksUri()) - buildTask(builder, false) + val entity = buildTask() + + val mainBuilder = CpoBuilder.newInsert(taskList.tasksUri()) + .withValues(entity.entityValues) val idxTask = batch.nextBackrefIdx() // Get nextBackrefIdx BEFORE adding builder to batch - batch += builder + batch += mainBuilder + + for (subValue in entity.subValues) + batch += CpoBuilder.newInsert(subValue.uri) + .withValues(subValue.values) + .withValueBackReference(Properties.TASK_ID, idxTask) + + logger.log(Level.FINE, "Added task", mainBuilder.build()) return idxTask } fun updateRows(batch: TasksBatchOperation) { - val id = requireNotNull(id) - val builder = CpoBuilder.newUpdate(taskList.taskUri(id)) - buildTask(builder, true) - batch += builder - } - - private fun buildTask(builder: CpoBuilder, update: Boolean) { - if (!update) - builder .withValue(Tasks.LIST_ID, taskList.id) + val existingId = requireNotNull(id) + val entity = buildTask() - // new builders + val mainValues = ContentValues(entity.entityValues).apply { + // LIST_ID must not be updated (it doesn't change for updates, and setting it would cause issues) + remove(Tasks.LIST_ID) + } + batch += CpoBuilder.newUpdate(taskList.taskUri(existingId)) + .withValues(mainValues) + + for (subValue in entity.subValues) + batch += CpoBuilder.newInsert(subValue.uri) + .withValues(ContentValues(subValue.values).apply { + put(Properties.TASK_ID, existingId) + }) + } + private fun buildTask(): Entity { val entity = Entity(ContentValues()) + for (fieldBuilder in fieldBuilders) fieldBuilder.build(task, entity) - builder.withValues(entity.entityValues) - - // old builders - - builder .withValue(Tasks._UID, task.uid) - .withValue(Tasks._DIRTY, 0) - .withValue(Tasks.SYNC_VERSION, task.sequence) - .withValue(Tasks.LOCATION, task.location) - .withValue(Tasks.GEO, task.geoPosition?.let { "${it.longitude},${it.latitude}" }) - .withValue(Tasks.DESCRIPTION, task.description) - .withValue(Tasks.TASK_COLOR, task.color) - .withValue(Tasks.URL, task.url) - - .withValue(Tasks._SYNC_ID, syncId) - .withValue(COLUMN_FLAGS, flags) - .withValue(COLUMN_ETAG, eTag) - - // parent_id will be re-calculated when the relation row is inserted (if there is any) - .withValue(Tasks.PARENT_ID, null) - - // organizer - // Note: big method – maybe split? Depends on how we want to proceed with refactoring. - - task.organizer?.let { organizer -> - val uri = organizer.calAddress - val email = if (uri.scheme.equals("mailto", true)) - uri.schemeSpecificPart - else - organizer.getParameter(Parameter.EMAIL).getOrNull()?.value - if (email != null) - builder.withValue(Tasks.ORGANIZER, email) - else - logger.warning("Ignoring ORGANIZER without email address (not supported by Android)") - } - - // Priority, classification - builder - .withValue(Tasks.PRIORITY, task.priority) - .withValue(Tasks.CLASSIFICATION, when (task.classification?.value?.uppercase()) { - ImmutableClazz.VALUE_PUBLIC -> Tasks.CLASSIFICATION_PUBLIC - ImmutableClazz.VALUE_CONFIDENTIAL -> Tasks.CLASSIFICATION_CONFIDENTIAL - null -> Tasks.CLASSIFICATION_DEFAULT - else -> Tasks.CLASSIFICATION_PRIVATE // all unknown classifications MUST be treated as PRIVATE - }) - - // COMPLETED must always be a DATE-TIME - builder - .withValue(Tasks.COMPLETED, task.completedAt?.date?.toEpochMilli()) - .withValue(Tasks.COMPLETED_IS_ALLDAY, 0) - .withValue(Tasks.PERCENT_COMPLETE, task.percentComplete) - - // Status - val status = when (task.status?.value) { - ImmutableStatus.VALUE_IN_PROCESS -> Tasks.STATUS_IN_PROCESS - ImmutableStatus.VALUE_COMPLETED -> Tasks.STATUS_COMPLETED - ImmutableStatus.VALUE_CANCELLED -> Tasks.STATUS_CANCELLED - else -> Tasks.STATUS_DEFAULT // == Tasks.STATUS_NEEDS_ACTION - } - builder.withValue(Tasks.STATUS, status) - - // Time related - val allDay = task.isAllDay() - if (allDay) { - builder .withValue(Tasks.IS_ALLDAY, 1) - .withValue(Tasks.TZ, null) - } else { - task.dtStart = task.dtStart?.normalizedDate()?.let { DtStart(it) } - task.due = task.due?.normalizedDate()?.let { Due(it) } - builder .withValue(Tasks.IS_ALLDAY, 0) - .withValue(Tasks.TZ, getTimeZone().id) - } - builder - .withValue(Tasks.CREATED, task.createdAt) - .withValue(Tasks.LAST_MODIFIED, task.lastModified) - .withValue(Tasks.DTSTART, task.dtStart?.date?.toTimestamp()) - .withValue(Tasks.DUE, task.due?.date?.toTimestamp()) - .withValue(Tasks.DURATION, task.duration?.value) + // CREATED and LAST_MODIFIED are task metadata fields not yet extracted to individual builders + entity.entityValues.put(Tasks.CREATED, task.createdAt) + entity.entityValues.put(Tasks.LAST_MODIFIED, task.lastModified) - .withValue(Tasks.RDATE, - if (task.rDates.isEmpty()) - null - else - AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.rDates, if (allDay) null else getTimeZone())) - .withValue(Tasks.RRULE, task.rRule?.value) - - .withValue(Tasks.EXDATE, - if (task.exDates.isEmpty()) - null - else - AndroidTimeUtils.recurrenceSetsToOpenTasksString(task.exDates, if (allDay) null else getTimeZone())) - - logger.log(Level.FINE, "Built task object", builder.build()) - } - - fun getTimeZone(): TimeZone { - var tzId = task.dtStart?.let { dtStart -> - if (dtStart.isUtc) - TimeZones.UTC_ID - else - dtStart.getParameter(Parameter.TZID).getOrNull()?.value - } ?: - task.due?.let { due -> - if (due.isUtc) - TimeZones.UTC_ID - else - due.getParameter(Parameter.TZID).getOrNull()?.value - } ?: - ZoneId.systemDefault().id - - // 'Z' is not a valid timezone id, replace it by the UTC definition - if (tzId == "Z") tzId = TimeZones.UTC_ID - - val timeZone: TimeZone? = tzRegistry.getTimeZone(tzId) - return timeZone ?: throw NullPointerException("Could not find timezone '$tzId' in registry.") + logger.log(Level.FINE, "Built task", entity.entityValues) + return entity } + /** + * @deprecated Property insertion is now handled by sub-row builders; call [addRows] or [updateRows] instead. + * This method exists for backwards compatibility with [at.bitfire.synctools.storage.tasks.DmfsTask.update]. + */ fun insertProperties(batch: TasksBatchOperation, idxTask: Int?) { - insertAlarms(batch, idxTask) - insertCategories(batch, idxTask) - insertComment(batch, idxTask) - insertRelatedTo(batch, idxTask) - insertUnknownProperties(batch, idxTask) - } - - private fun insertAlarms(batch: TasksBatchOperation, idxTask: Int?) { - for (alarm in task.alarms) { - val (alarmRef, minutes) = AlarmTriggerCalculator.alarmTriggerToMinutes( - alarm = alarm, - refStart = task.dtStart, - refEnd = task.end, - allowRelEnd = true - ) ?: continue - val ref = when (alarmRef) { - Related.END -> - Alarm.ALARM_REFERENCE_DUE_DATE - else /* Related.START is the default value */ -> - Alarm.ALARM_REFERENCE_START_DATE - } - - val alarmType = when ( - alarm.getProperty(Property.ACTION).getOrNull()?.value?.uppercase(Locale.ROOT) - ) { - ImmutableAction.VALUE_AUDIO -> Alarm.ALARM_TYPE_SOUND - ImmutableAction.VALUE_DISPLAY -> Alarm.ALARM_TYPE_MESSAGE - ImmutableAction.VALUE_EMAIL -> Alarm.ALARM_TYPE_EMAIL - else -> Alarm.ALARM_TYPE_NOTHING - } - - val builder = CpoBuilder - .newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Alarm.TASK_ID, idxTask) - .withValue(Alarm.MIMETYPE, Alarm.CONTENT_ITEM_TYPE) - .withValue(Alarm.MINUTES_BEFORE, minutes) - .withValue(Alarm.REFERENCE, ref) - .withValue(Alarm.MESSAGE, alarm.description?.value ?: alarm.summary) - .withValue(Alarm.ALARM_TYPE, alarmType) - - logger.log(Level.FINE, "Inserting alarm", builder.build()) - batch += builder - } - } - - private fun insertCategories(batch: TasksBatchOperation, idxTask: Int?) { - for (category in task.categories) { - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Category.TASK_ID, idxTask) - .withValue(Category.MIMETYPE, Category.CONTENT_ITEM_TYPE) - .withValue(Category.CATEGORY_NAME, category) - logger.log(Level.FINE, "Inserting category", builder.build()) - batch += builder - } - } - - private fun insertComment(batch: TasksBatchOperation, idxTask: Int?) { - val comment = task.comment ?: return - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Comment.TASK_ID, idxTask) - .withValue(Comment.MIMETYPE, Comment.CONTENT_ITEM_TYPE) - .withValue(Comment.COMMENT, comment) - logger.log(Level.FINE, "Inserting comment", builder.build()) - batch += builder - } - - private fun insertRelatedTo(batch: TasksBatchOperation, idxTask: Int?) { - for (relatedTo in task.relatedTo) { - val relType = when ((relatedTo.getParameter(Parameter.RELTYPE)).getOrNull()) { - RelType.CHILD -> Relation.RELTYPE_CHILD - RelType.SIBLING -> Relation.RELTYPE_SIBLING - else /* RelType.PARENT, default value */ -> Relation.RELTYPE_PARENT - } - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Relation.TASK_ID, idxTask) - .withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE) - .withValue(Relation.RELATED_UID, relatedTo.value) - .withValue(Relation.RELATED_TYPE, relType) - logger.log(Level.FINE, "Inserting relation", builder.build()) - batch += builder - } - } - - private fun insertUnknownProperties(batch: TasksBatchOperation, idxTask: Int?) { - for (property in task.unknownProperties) { - if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { - logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") - return - } - - val builder = CpoBuilder.newInsert(taskList.tasksPropertiesUri()) - .withTaskId(Properties.TASK_ID, idxTask) - .withValue(Properties.MIMETYPE, UnknownProperty.CONTENT_ITEM_TYPE) - .withValue(UNKNOWN_PROPERTY_DATA, UnknownProperty.toJsonString(property)) - logger.log(Level.FINE, "Inserting unknown property", builder.build()) - batch += builder - } - } - - private fun CpoBuilder.withTaskId(column: String, idxTask: Int?): CpoBuilder { - if (idxTask != null) - withValueBackReference(column, idxTask) - else - withValue(column, requireNotNull(id)) - return this + // No-op: properties are now inserted as part of addRows/updateRows via Entity sub-values. + // This method is kept for API compatibility but does nothing. } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AlarmsBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AlarmsBuilder.kt new file mode 100644 index 00000000..1fca49cd --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/AlarmsBuilder.kt @@ -0,0 +1,64 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import at.bitfire.synctools.util.AlarmTriggerCalculator +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.immutable.ImmutableAction +import net.fortuna.ical4j.model.parameter.Related +import org.dmfs.tasks.contract.TaskContract.Property.Alarm +import java.util.Locale +import kotlin.jvm.optionals.getOrNull + +class AlarmsBuilder( + private val taskList: DmfsTaskList +) : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + for (alarm in from.alarms) { + val (alarmRef, minutes) = AlarmTriggerCalculator.alarmTriggerToMinutes( + alarm = alarm, + refStart = from.dtStart, + refEnd = from.end, + allowRelEnd = true + ) ?: continue + + val ref = when (alarmRef) { + Related.END -> + Alarm.ALARM_REFERENCE_DUE_DATE + else /* Related.START is the default value */ -> + Alarm.ALARM_REFERENCE_START_DATE + } + + val alarmType = when ( + alarm.getProperty(Property.ACTION).getOrNull()?.value?.uppercase(Locale.ROOT) + ) { + ImmutableAction.VALUE_AUDIO -> Alarm.ALARM_TYPE_SOUND + ImmutableAction.VALUE_DISPLAY -> Alarm.ALARM_TYPE_MESSAGE + ImmutableAction.VALUE_EMAIL -> Alarm.ALARM_TYPE_EMAIL + else -> Alarm.ALARM_TYPE_NOTHING + } + + to.addSubValue( + taskList.tasksPropertiesUri(), + contentValuesOf( + Alarm.MIMETYPE to Alarm.CONTENT_ITEM_TYPE, + Alarm.MINUTES_BEFORE to minutes, + Alarm.REFERENCE to ref, + Alarm.MESSAGE to (alarm.description?.value ?: alarm.summary), + Alarm.ALARM_TYPE to alarmType + ) + ) + } + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CategoriesBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CategoriesBuilder.kt new file mode 100644 index 00000000..21a04d3d --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CategoriesBuilder.kt @@ -0,0 +1,31 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import org.dmfs.tasks.contract.TaskContract.Property.Category + +class CategoriesBuilder( + private val taskList: DmfsTaskList +) : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + for (category in from.categories) { + to.addSubValue( + taskList.tasksPropertiesUri(), + contentValuesOf( + Category.MIMETYPE to Category.CONTENT_ITEM_TYPE, + Category.CATEGORY_NAME to category + ) + ) + } + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CommentsBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CommentsBuilder.kt new file mode 100644 index 00000000..77ee89c6 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/CommentsBuilder.kt @@ -0,0 +1,30 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import org.dmfs.tasks.contract.TaskContract.Property.Comment + +class CommentsBuilder( + private val taskList: DmfsTaskList +) : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + val comment = from.comment ?: return + to.addSubValue( + taskList.tasksPropertiesUri(), + contentValuesOf( + Comment.MIMETYPE to Comment.CONTENT_ITEM_TYPE, + Comment.COMMENT to comment + ) + ) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DirtyBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DirtyBuilder.kt new file mode 100644 index 00000000..87b507ac --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/DirtyBuilder.kt @@ -0,0 +1,20 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import org.dmfs.tasks.contract.TaskContract.Tasks + +class DirtyBuilder : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + // DIRTY is always unset when we create or update a task row + to.entityValues.put(Tasks._DIRTY, 0) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/ListIdBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/ListIdBuilder.kt new file mode 100644 index 00000000..76ff98db --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/ListIdBuilder.kt @@ -0,0 +1,21 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import at.bitfire.ical4android.Task +import org.dmfs.tasks.contract.TaskContract.Tasks + +class ListIdBuilder( + private val listId: Long +) : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + to.entityValues.put(Tasks.LIST_ID, listId) + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RelationsBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RelationsBuilder.kt new file mode 100644 index 00000000..db983ebb --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/RelationsBuilder.kt @@ -0,0 +1,44 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.parameter.RelType +import org.dmfs.tasks.contract.TaskContract.Property.Relation +import org.dmfs.tasks.contract.TaskContract.Tasks +import kotlin.jvm.optionals.getOrNull + +class RelationsBuilder( + private val taskList: DmfsTaskList +) : DmfsTaskFieldBuilder { + + override fun build(from: Task, to: Entity) { + // parent_id will be re-calculated when the relation row is inserted (if there is any) + to.entityValues.put(Tasks.PARENT_ID, null as Long?) + + for (relatedTo in from.relatedTo) { + val relType = when (relatedTo.getParameter(Parameter.RELTYPE).getOrNull()) { + RelType.CHILD -> Relation.RELTYPE_CHILD + RelType.SIBLING -> Relation.RELTYPE_SIBLING + else /* RelType.PARENT, default value */ -> Relation.RELTYPE_PARENT + } + to.addSubValue( + taskList.tasksPropertiesUri(), + contentValuesOf( + Relation.MIMETYPE to Relation.CONTENT_ITEM_TYPE, + Relation.RELATED_UID to relatedTo.value, + Relation.RELATED_TYPE to relType + ) + ) + } + } + +} diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/UnknownPropertiesBuilder.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/UnknownPropertiesBuilder.kt new file mode 100644 index 00000000..c5ce3ad8 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/tasks/builder/UnknownPropertiesBuilder.kt @@ -0,0 +1,42 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import org.dmfs.tasks.contract.TaskContract.Properties +import java.util.logging.Logger + +class UnknownPropertiesBuilder( + private val taskList: DmfsTaskList +) : DmfsTaskFieldBuilder { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun build(from: Task, to: Entity) { + for (property in from.unknownProperties) { + if (property.value.length > UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE) { + logger.warning("Ignoring unknown property with ${property.value.length} octets (too long)") + continue + } + + to.addSubValue( + taskList.tasksPropertiesUri(), + contentValuesOf( + Properties.MIMETYPE to UnknownProperty.CONTENT_ITEM_TYPE, + UNKNOWN_PROPERTY_DATA to UnknownProperty.toJsonString(property) + ) + ) + } + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AlarmsBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AlarmsBuilderTest.kt new file mode 100644 index 00000000..6bbc0b89 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/AlarmsBuilderTest.kt @@ -0,0 +1,102 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import at.bitfire.synctools.test.assertContentValuesEqual +import io.mockk.every +import io.mockk.mockk +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Trigger +import net.fortuna.ical4j.model.property.immutable.ImmutableAction +import org.dmfs.tasks.contract.TaskContract.Property.Alarm +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.Duration +import java.time.ZoneOffset +import java.time.ZonedDateTime + +@RunWith(RobolectricTestRunner::class) +class AlarmsBuilderTest { + + private val propertiesUri = Uri.parse("content://org.dmfs.tasks/properties") + private val taskList = mockk { + every { tasksPropertiesUri() } returns propertiesUri + } + private val builder = AlarmsBuilder(taskList) + + @Test + fun `No alarms`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertTrue(result.subValues.isEmpty()) + } + + @Test + fun `Audio alarm relative to start`() { + val result = Entity(ContentValues()) + builder.build( + from = Task( + dtStart = DtStart(ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC)) + ).also { + it.alarms += VAlarm().also { alarm -> + alarm.add(Action(ImmutableAction.VALUE_AUDIO)) + alarm.add(Trigger(Duration.ofMinutes(-15))) + } + }, + to = result + ) + assertEquals(1, result.subValues.size) + val values = result.subValues.first().values + assertContentValuesEqual(contentValuesOf( + Alarm.MIMETYPE to Alarm.CONTENT_ITEM_TYPE, + Alarm.MINUTES_BEFORE to 15, + Alarm.REFERENCE to Alarm.ALARM_REFERENCE_START_DATE, + Alarm.MESSAGE to null, + Alarm.ALARM_TYPE to Alarm.ALARM_TYPE_SOUND + ), values) + assertEquals(propertiesUri, result.subValues.first().uri) + } + + @Test + fun `Display alarm`() { + val result = Entity(ContentValues()) + builder.build( + from = Task( + dtStart = DtStart(ZonedDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC)) + ).also { + it.alarms += VAlarm().also { alarm -> + alarm.add(Action(ImmutableAction.VALUE_DISPLAY)) + alarm.add(Trigger(Duration.ofMinutes(-30))) + } + }, + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Alarm.MIMETYPE to Alarm.CONTENT_ITEM_TYPE, + Alarm.MINUTES_BEFORE to 30, + Alarm.REFERENCE to Alarm.ALARM_REFERENCE_START_DATE, + Alarm.MESSAGE to null, + Alarm.ALARM_TYPE to Alarm.ALARM_TYPE_MESSAGE + ), result.subValues.first().values) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CategoriesBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CategoriesBuilderTest.kt new file mode 100644 index 00000000..e2b7f3dc --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CategoriesBuilderTest.kt @@ -0,0 +1,72 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import at.bitfire.synctools.test.assertContentValuesEqual +import io.mockk.every +import io.mockk.mockk +import org.dmfs.tasks.contract.TaskContract.Property.Category +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CategoriesBuilderTest { + + private val propertiesUri = Uri.parse("content://org.dmfs.tasks/properties") + private val taskList = mockk { + every { tasksPropertiesUri() } returns propertiesUri + } + private val builder = CategoriesBuilder(taskList) + + @Test + fun `No categories`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertTrue(result.subValues.isEmpty()) + } + + @Test + fun `One category`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { it.categories += "Work" }, + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Category.MIMETYPE to Category.CONTENT_ITEM_TYPE, + Category.CATEGORY_NAME to "Work" + ), result.subValues.first().values) + assertEquals(propertiesUri, result.subValues.first().uri) + } + + @Test + fun `Multiple categories`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { + it.categories += "Work" + it.categories += "Personal" + }, + to = result + ) + assertEquals(2, result.subValues.size) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CommentsBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CommentsBuilderTest.kt new file mode 100644 index 00000000..518d7b42 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/CommentsBuilderTest.kt @@ -0,0 +1,59 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import at.bitfire.synctools.test.assertContentValuesEqual +import io.mockk.every +import io.mockk.mockk +import org.dmfs.tasks.contract.TaskContract.Property.Comment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CommentsBuilderTest { + + private val propertiesUri = Uri.parse("content://org.dmfs.tasks/properties") + private val taskList = mockk { + every { tasksPropertiesUri() } returns propertiesUri + } + private val builder = CommentsBuilder(taskList) + + @Test + fun `No comment`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertTrue(result.subValues.isEmpty()) + } + + @Test + fun `Comment is set`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(comment = "This is a comment"), + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Comment.MIMETYPE to Comment.CONTENT_ITEM_TYPE, + Comment.COMMENT to "This is a comment" + ), result.subValues.first().values) + assertEquals(propertiesUri, result.subValues.first().uri) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DirtyBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DirtyBuilderTest.kt new file mode 100644 index 00000000..08458995 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/DirtyBuilderTest.kt @@ -0,0 +1,36 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DirtyBuilderTest { + + private val builder = DirtyBuilder() + + @Test + fun `DIRTY is set to 0`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks._DIRTY to 0 + ), result.entityValues) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/ListIdBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/ListIdBuilderTest.kt new file mode 100644 index 00000000..3f379082 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/ListIdBuilderTest.kt @@ -0,0 +1,34 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.test.assertContentValuesEqual +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ListIdBuilderTest { + + @Test + fun `ListId sets LIST_ID`() { + val result = Entity(ContentValues()) + ListIdBuilder(42L).build( + from = Task(), + to = result + ) + assertContentValuesEqual(contentValuesOf( + Tasks.LIST_ID to 42L + ), result.entityValues) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RelationsBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RelationsBuilderTest.kt new file mode 100644 index 00000000..a48bf981 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/RelationsBuilderTest.kt @@ -0,0 +1,101 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import at.bitfire.synctools.test.assertContentValuesEqual +import io.mockk.every +import io.mockk.mockk +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.property.RelatedTo +import org.dmfs.tasks.contract.TaskContract.Property.Relation +import org.dmfs.tasks.contract.TaskContract.Tasks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RelationsBuilderTest { + + private val propertiesUri = Uri.parse("content://org.dmfs.tasks/properties") + private val taskList = mockk { + every { tasksPropertiesUri() } returns propertiesUri + } + private val builder = RelationsBuilder(taskList) + + @Test + fun `No relations - PARENT_ID reset to null`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertTrue(result.entityValues.containsKey(Tasks.PARENT_ID)) + assertNull(result.entityValues.get(Tasks.PARENT_ID)) + assertTrue(result.subValues.isEmpty()) + } + + @Test + fun `RELATED-TO with PARENT type`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { + it.relatedTo += RelatedTo("parent-uid") + }, + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Relation.MIMETYPE to Relation.CONTENT_ITEM_TYPE, + Relation.RELATED_UID to "parent-uid", + Relation.RELATED_TYPE to Relation.RELTYPE_PARENT + ), result.subValues.first().values) + assertEquals(propertiesUri, result.subValues.first().uri) + } + + @Test + fun `RELATED-TO with CHILD type`() { + val result = Entity(ContentValues()) + val related = RelatedTo("child-uid").add(RelType.CHILD) + builder.build( + from = Task().also { it.relatedTo += related }, + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Relation.MIMETYPE to Relation.CONTENT_ITEM_TYPE, + Relation.RELATED_UID to "child-uid", + Relation.RELATED_TYPE to Relation.RELTYPE_CHILD + ), result.subValues.first().values) + } + + @Test + fun `RELATED-TO with SIBLING type`() { + val result = Entity(ContentValues()) + val related = RelatedTo("sibling-uid").add(RelType.SIBLING) + builder.build( + from = Task().also { it.relatedTo += related }, + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Relation.MIMETYPE to Relation.CONTENT_ITEM_TYPE, + Relation.RELATED_UID to "sibling-uid", + Relation.RELATED_TYPE to Relation.RELTYPE_SIBLING + ), result.subValues.first().values) + } + +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/UnknownPropertiesBuilderTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/UnknownPropertiesBuilderTest.kt new file mode 100644 index 00000000..2e9a6b7f --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/tasks/builder/UnknownPropertiesBuilderTest.kt @@ -0,0 +1,80 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.tasks.builder + +import android.content.ContentValues +import android.content.Entity +import android.net.Uri +import androidx.core.content.contentValuesOf +import at.bitfire.ical4android.Task +import at.bitfire.ical4android.UnknownProperty +import at.bitfire.synctools.storage.tasks.DmfsTask.Companion.UNKNOWN_PROPERTY_DATA +import at.bitfire.synctools.storage.tasks.DmfsTaskList +import at.bitfire.synctools.test.assertContentValuesEqual +import io.mockk.every +import io.mockk.mockk +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.XProperty +import org.dmfs.tasks.contract.TaskContract.Properties +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UnknownPropertiesBuilderTest { + + private val propertiesUri = Uri.parse("content://org.dmfs.tasks/properties") + private val taskList = mockk { + every { tasksPropertiesUri() } returns propertiesUri + } + private val builder = UnknownPropertiesBuilder(taskList) + + @Test + fun `No unknown properties`() { + val result = Entity(ContentValues()) + builder.build( + from = Task(), + to = result + ) + assertTrue(result.subValues.isEmpty()) + } + + @Test + fun `Unknown property with value and parameters`() { + val result = Entity(ContentValues()) + builder.build( + from = Task().also { + it.unknownProperties += (XProperty("X-Some-Property", "Some Value") + .add(XParameter("Param1", "Value1")) + .add(XParameter("Param2", "Value2"))) + }, + to = result + ) + assertEquals(1, result.subValues.size) + assertContentValuesEqual(contentValuesOf( + Properties.MIMETYPE to UnknownProperty.CONTENT_ITEM_TYPE, + UNKNOWN_PROPERTY_DATA to "[\"X-Some-Property\",\"Some Value\",{\"Param1\":\"Value1\",\"Param2\":\"Value2\"}]" + ), result.subValues.first().values) + assertEquals(propertiesUri, result.subValues.first().uri) + } + + @Test + fun `Unknown property exceeding size limit is ignored`() { + val result = Entity(ContentValues()) + val longValue = "x".repeat(UnknownProperty.MAX_UNKNOWN_PROPERTY_SIZE + 1) + builder.build( + from = Task().also { + it.unknownProperties += XProperty("X-Huge-Property", longValue) + }, + to = result + ) + assertTrue(result.subValues.isEmpty()) + } + +}