diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eefc51859..2a7939164 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -218,7 +218,6 @@ dependencies { ksp(libs.room.compiler) // bitfire libraries - implementation(libs.synctools) // third-party libs implementation(libs.mikepenz.aboutLibraries) @@ -241,12 +240,7 @@ dependencies { androidTestImplementation(libs.androidx.work.testing) androidTestImplementation(libs.junit) androidTestImplementation(libs.room.testing) - androidTestImplementation(libs.androidx.test.core) - androidTestImplementation(libs.androidx.test.junit) - androidTestImplementation(libs.androidx.arch.core.testing) androidTestImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.rules) // Testing testImplementation(libs.junit) diff --git a/app/src/androidTest/java/at/techbee/jtx/util/Ical4androidUtilTest.kt b/app/src/androidTest/java/at/techbee/jtx/util/Ical4androidUtilTest.kt index 540b85727..b5900012a 100644 --- a/app/src/androidTest/java/at/techbee/jtx/util/Ical4androidUtilTest.kt +++ b/app/src/androidTest/java/at/techbee/jtx/util/Ical4androidUtilTest.kt @@ -143,6 +143,8 @@ class Ical4androidUtilTest { "SUMMARY:Second entry\n" + "END:VTODO\n" + "END:VCALENDAR\n" + // The second VTODO shares the UID of the first; on import it is recognised as an already + // existing entry with an equal SEQUENCE and therefore skipped. val num = Ical4androidUtil.insertFromReader(defaultTestAccount, context, defaultCollectionId!!, ics.reader()) assertEquals(2, num.first) assertEquals(1, num.second) diff --git a/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt b/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt index b7ace4821..cb7891239 100644 --- a/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt +++ b/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt @@ -47,6 +47,10 @@ import at.techbee.jtx.database.properties.COLUMN_CATEGORY_ICALOBJECT_ID import at.techbee.jtx.database.properties.COLUMN_CATEGORY_TEXT import at.techbee.jtx.database.properties.COLUMN_COMMENT_ICALOBJECT_ID import at.techbee.jtx.database.properties.COLUMN_RELATEDTO_ICALOBJECT_ID +import at.techbee.jtx.database.properties.COLUMN_ORGANIZER_ICALOBJECT_ID +import at.techbee.jtx.database.properties.COLUMN_UNKNOWN_ICALOBJECT_ID +import at.techbee.jtx.database.properties.TABLE_NAME_ORGANIZER +import at.techbee.jtx.database.properties.TABLE_NAME_UNKNOWN import at.techbee.jtx.database.properties.COLUMN_RELATEDTO_RELTYPE import at.techbee.jtx.database.properties.COLUMN_RELATEDTO_TEXT import at.techbee.jtx.database.properties.COLUMN_RESOURCE_ICALOBJECT_ID @@ -360,6 +364,21 @@ interface ICalDatabaseDao { @Query("SELECT * FROM $TABLE_NAME_ALARM WHERE $COLUMN_ALARM_ICALOBJECT_ID = :iCalObjectId") fun getAlarmsSync(iCalObjectId: Long): List + @Query("SELECT * FROM $TABLE_NAME_ORGANIZER WHERE $COLUMN_ORGANIZER_ICALOBJECT_ID = :iCalObjectId LIMIT 1") + fun getOrganizerSync(iCalObjectId: Long): Organizer? + + @Query("SELECT * FROM $TABLE_NAME_RELATEDTO WHERE $COLUMN_RELATEDTO_ICALOBJECT_ID = :iCalObjectId") + fun getRelatedtoSync(iCalObjectId: Long): List + + @Query("SELECT * FROM $TABLE_NAME_UNKNOWN WHERE $COLUMN_UNKNOWN_ICALOBJECT_ID = :iCalObjectId") + fun getUnknownSync(iCalObjectId: Long): List + + @Query("SELECT $COLUMN_ID FROM $TABLE_NAME_ICALOBJECT WHERE $COLUMN_ICALOBJECT_COLLECTIONID = :collectionId") + fun getICalObjectIdsByCollectionSync(collectionId: Long): List + + @Query("SELECT * FROM $TABLE_NAME_ICALOBJECT WHERE $COLUMN_UID = :uid AND $COLUMN_RECURID IS NULL LIMIT 1") + fun getICalObjectByUidSync(uid: String): ICalObject? + /** * Returns a list of (distinct) ICalObjects that have an active alarm * (an alarm, that was already triggered but not removed) diff --git a/app/src/main/java/at/techbee/jtx/database/ICalObject.kt b/app/src/main/java/at/techbee/jtx/database/ICalObject.kt index 6370d5817..f36440f86 100644 --- a/app/src/main/java/at/techbee/jtx/database/ICalObject.kt +++ b/app/src/main/java/at/techbee/jtx/database/ICalObject.kt @@ -23,7 +23,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey -import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate +import at.techbee.jtx.util.toLocalDate import at.techbee.jtx.R import at.techbee.jtx.contract.JtxContract import at.techbee.jtx.ui.settings.DropdownSettingOption diff --git a/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt b/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt index 86ff9afa2..0d7fe2859 100644 --- a/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt +++ b/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt @@ -52,7 +52,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp +import at.techbee.jtx.util.toTimestamp import at.techbee.jtx.R import at.techbee.jtx.database.ICalObject import at.techbee.jtx.database.ICalObject.Companion.TZ_ALLDAY diff --git a/app/src/main/java/at/techbee/jtx/util/ICalendarMapping.kt b/app/src/main/java/at/techbee/jtx/util/ICalendarMapping.kt new file mode 100644 index 000000000..e5d46baf8 --- /dev/null +++ b/app/src/main/java/at/techbee/jtx/util/ICalendarMapping.kt @@ -0,0 +1,601 @@ +/* + * Copyright (c) Techbee e.U. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + * + * The VJOURNAL/VTODO <-> jtx object mapping is adapted from the ical4j-based mapping that was + * previously provided by the (now discontinued) bitfireAT/synctools library, ported here so that + * jtx Board no longer depends on that library for local iCalendar import/export. + */ + +package at.techbee.jtx.util + +import android.util.Base64 +import android.util.Log +import at.techbee.jtx.contract.JtxContract +import at.techbee.jtx.contract.JtxContract.JtxICalObject.TZ_ALLDAY +import at.techbee.jtx.database.ICalObject +import at.techbee.jtx.database.Module +import at.techbee.jtx.database.properties.Alarm +import at.techbee.jtx.database.properties.AlarmAction +import at.techbee.jtx.database.properties.AlarmRelativeTo +import at.techbee.jtx.database.properties.Attachment +import at.techbee.jtx.database.properties.Attendee +import at.techbee.jtx.database.properties.Category +import at.techbee.jtx.database.properties.Comment +import at.techbee.jtx.database.properties.Organizer +import at.techbee.jtx.database.properties.Relatedto +import at.techbee.jtx.database.properties.Reltype +import at.techbee.jtx.database.properties.Resource +import at.techbee.jtx.database.properties.Unknown +import net.fortuna.ical4j.model.ComponentList +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyList +import net.fortuna.ical4j.model.TextList +import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.component.VJournal +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.parameter.AltRep +import net.fortuna.ical4j.model.parameter.Cn +import net.fortuna.ical4j.model.parameter.CuType +import net.fortuna.ical4j.model.parameter.DelegatedFrom +import net.fortuna.ical4j.model.parameter.DelegatedTo +import net.fortuna.ical4j.model.parameter.Dir +import net.fortuna.ical4j.model.parameter.FmtType +import net.fortuna.ical4j.model.parameter.Language +import net.fortuna.ical4j.model.parameter.Member +import net.fortuna.ical4j.model.parameter.PartStat +import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.parameter.Related +import net.fortuna.ical4j.model.parameter.Role +import net.fortuna.ical4j.model.parameter.Rsvp +import net.fortuna.ical4j.model.parameter.SentBy +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.parameter.XParameter +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.Attach +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Contact +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStamp +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import net.fortuna.ical4j.model.property.Repeat +import net.fortuna.ical4j.model.property.Resources +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Trigger +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import net.fortuna.ical4j.model.property.XProperty +import net.fortuna.ical4j.model.property.immutable.ImmutableAction +import net.fortuna.ical4j.model.property.immutable.ImmutablePriority +import java.net.URI +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal +import kotlin.jvm.optionals.getOrNull + +private const val TAG = "ICalendarMapping" + +// Extended (X-)properties/parameters, using the same names as DAVx5/synctools for interoperability. +private const val X_PROP_COMPLETEDTIMEZONE = "X-COMPLETEDTIMEZONE" +private const val X_PARAM_ATTACH_LABEL = "X-LABEL" // used for filename in KOrganizer +private const val X_PARAM_FILENAME = "FILENAME" // used for filename in GNOME Evolution +private const val X_PROP_XSTATUS = "X-STATUS" // extended status (additionally to standard status) +private const val X_PROP_GEOFENCE_RADIUS = "X-GEOFENCE-RADIUS" + +private val VTODO = JtxContract.JtxICalObject.Component.VTODO.name +private val VJOURNAL = JtxContract.JtxICalObject.Component.VJOURNAL.name + +/** Adds a [Parameter] to this [Property] in place (ical4j's [Property.add] mutates the property). */ +private operator fun Property.plusAssign(parameter: Parameter) { + add(parameter) +} + +/** Bundles a parsed [ICalObject] with its associated sub-entities (with `icalObjectId` not yet set). */ +data class ParsedICalObject( + val iCalObject: ICalObject, + val categories: List = emptyList(), + val comments: List = emptyList(), + val resources: List = emptyList(), + val attendees: List = emptyList(), + val organizer: Organizer? = null, + val relatedto: List = emptyList(), + val attachments: List = emptyList(), + val alarms: List = emptyList(), + val unknowns: List = emptyList() +) + + +// ------------------------------------------------------------------------------------------------- +// Export: jtx object (+ sub-entities) -> VJOURNAL / VTODO +// ------------------------------------------------------------------------------------------------- + +/** + * Builds a [VJournal] or [VToDo] (including [VAlarm] sub-components for tasks) from the given jtx + * object and its sub-entities. Returns `null` for unsupported component types. + * + * @param readAttachmentBytes resolves the binary content of a `content://` attachment uri, or `null` + */ +fun ICalObject.toICalComponent( + categories: List = emptyList(), + comments: List = emptyList(), + resources: List = emptyList(), + attendees: List = emptyList(), + organizer: Organizer? = null, + relatedto: List = emptyList(), + attachments: List = emptyList(), + alarms: List = emptyList(), + unknowns: List = emptyList(), + readAttachmentBytes: (String) -> ByteArray? = { null } +): CalendarComponent? { + val isTodo = when (component) { + VTODO -> true + VJOURNAL -> false + else -> return null + } + + val props = mutableListOf() + + props += Uid(uid) + props += Sequence((sequence ?: 0L).toInt()) + props += DtStamp(Instant.ofEpochMilli(dtstamp)) + created?.let { props += Created(Instant.ofEpochMilli(it)) } + lastModified?.let { props += LastModified(Instant.ofEpochMilli(it)) } + summary?.let { props += Summary(it) } + description?.let { props += Description(it) } + + location?.let { loc -> + val locationProp = Location(loc) + locationAltrep?.let { locationProp += AltRep(it) } + props += locationProp + } + if (geoLat != null && geoLong != null) + props += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) + geofenceRadius?.let { props += XProperty(X_PROP_GEOFENCE_RADIUS, it.toString()) } + + color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } + url?.let { + try { + props += Url(URI(it)) + } catch (e: Exception) { + Log.w(TAG, "Ignoring invalid URL: $it") + } + } + contact?.let { props += Contact(it) } + classification?.let { props += Clazz(it) } + status?.let { props += Status(it) } + xstatus?.let { props += XProperty(X_PROP_XSTATUS, it) } + + categories.mapNotNull { it.text.ifBlank { null } }.let { + if (it.isNotEmpty()) props += Categories(TextList(it)) + } + resources.mapNotNull { it.text?.ifBlank { null } }.let { + if (it.isNotEmpty()) props += Resources(it) + } + + comments.forEach { comment -> + props += net.fortuna.ical4j.model.property.Comment(comment.text).apply { + comment.altrep?.let { this += AltRep(it) } + comment.language?.let { this += Language(it) } + comment.other?.let { JtxContract.getXParametersFromJson(it).forEach { p -> this += p } } + } + } + + attendees.forEach { attendee -> + if (attendee.caladdress.isBlank()) return@forEach + val calAddr = try { + URI(attendee.caladdress) + } catch (e: Exception) { + Log.w(TAG, "Ignoring invalid attendee URI: ${attendee.caladdress}") + return@forEach + } + props += net.fortuna.ical4j.model.property.Attendee().apply { + calAddress = calAddr + attendee.cn?.let { this += Cn(it) } + attendee.cutype?.let { + this += when { + it.equals(CuType.INDIVIDUAL.value, true) -> CuType.INDIVIDUAL + it.equals(CuType.GROUP.value, true) -> CuType.GROUP + it.equals(CuType.ROOM.value, true) -> CuType.ROOM + it.equals(CuType.RESOURCE.value, true) -> CuType.RESOURCE + else -> CuType.UNKNOWN + } + } + attendee.delegatedfrom?.let { this += DelegatedFrom(it) } + attendee.delegatedto?.let { this += DelegatedTo(it) } + attendee.dir?.let { this += Dir(it) } + attendee.language?.let { this += Language(it) } + attendee.member?.let { this += Member(it) } + attendee.partstat?.let { this += PartStat(it) } + attendee.role?.let { this += Role(it) } + attendee.rsvp?.let { this += Rsvp(it) } + attendee.sentby?.let { this += SentBy(it) } + attendee.other?.let { JtxContract.getXParametersFromJson(it).forEach { p -> this += p } } + } + } + + organizer?.let { org -> + props += net.fortuna.ical4j.model.property.Organizer().apply { + if (org.caladdress.isNotBlank()) + try { + calAddress = URI(org.caladdress) + } catch (e: Exception) { + Log.w(TAG, "Ignoring invalid organizer URI: ${org.caladdress}") + } + org.cn?.let { this += Cn(it) } + org.dir?.let { this += Dir(it) } + org.language?.let { this += Language(it) } + org.sentby?.let { this += SentBy(it) } + org.other?.let { JtxContract.getXParametersFromJson(it).forEach { p -> this += p } } + } + } + + attachments.forEach { attachment -> + try { + val bytes = when { + attachment.binary?.isNotEmpty() == true -> Base64.decode(attachment.binary, Base64.DEFAULT) + attachment.uri?.startsWith("content://") == true -> readAttachmentBytes(attachment.uri!!) + else -> null + } + val att = when { + bytes != null -> Attach(bytes) + attachment.uri?.isNotEmpty() == true -> Attach(URI(attachment.uri)) + else -> return@forEach + } + attachment.fmttype?.let { att += FmtType(it) } + attachment.filename?.let { + att += XParameter(X_PARAM_ATTACH_LABEL, it) + att += XParameter(X_PARAM_FILENAME, it) + } + props += att + } catch (e: Exception) { + Log.w(TAG, "Ignoring attachment ${attachment.uri}: ${e.message}") + } + } + + unknowns.forEach { unknown -> + unknown.value?.let { + try { + props += UnknownProperty.fromJsonString(it) + } catch (e: Exception) { + Log.w(TAG, "Ignoring unparseable unknown property") + } + } + } + + relatedto.forEach { rel -> + val param: Parameter = when (rel.reltype) { + RelType.CHILD.value -> RelType.CHILD + RelType.SIBLING.value -> RelType.SIBLING + RelType.PARENT.value -> RelType.PARENT + else -> return@forEach + } + rel.text?.let { props += net.fortuna.ical4j.model.property.RelatedTo(ParameterList().add(param), it) } + } + + dtstart?.let { props += DtStart(temporalFor(it, dtstartTimezone)) } + rrule?.let { props += RRule(it) } + recurid?.let { + props += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) + RecurrenceId(it) + else + RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), it) + } + rdate?.let { props += RDate(dateListFor(JtxContract.getLongListFromString(it), dtstartTimezone)) } + exdate?.let { props += ExDate(dateListFor(JtxContract.getLongListFromString(it), dtstartTimezone)) } + duration?.let { props += Duration().apply { value = it } } + + if (isTodo) { + completed?.let { + props += Completed(Instant.ofEpochMilli(it)) + completedTimezone?.let { tz -> props += XProperty(X_PROP_COMPLETEDTIMEZONE, tz) } + } + percent?.let { props += PercentComplete(it) } + if (priority != null && priority != ImmutablePriority.UNDEFINED.level) + priority?.let { props += Priority(it) } + due?.let { props += Due(temporalFor(it, dueTimezone ?: dtstartTimezone)) } + } + + val propertyList = PropertyList(props) + return if (isTodo) { + // VALARMs are only valid inside VTODO (not VJOURNAL per RFC 5545) + VToDo(propertyList, ComponentList(alarms.map { it.toVAlarm() })) + } else { + VJournal(propertyList) + } +} + +private fun Alarm.toVAlarm(): VAlarm { + val alarmProps = mutableListOf() + action?.let { + when (it) { + AlarmAction.DISPLAY.name -> alarmProps += ImmutableAction.DISPLAY + AlarmAction.AUDIO.name -> alarmProps += ImmutableAction.AUDIO + AlarmAction.EMAIL.name -> alarmProps += ImmutableAction.EMAIL + else -> {} + } + } + when { + triggerRelativeDuration != null -> alarmProps += Trigger().apply { + try { + duration = java.time.Duration.parse(triggerRelativeDuration) + if (triggerRelativeTo == AlarmRelativeTo.END.name) this += Related.END + else this += Related.START + } catch (e: Exception) { + Log.w(TAG, "Could not parse alarm trigger duration: $triggerRelativeDuration") + } + } + triggerTime != null -> alarmProps += Trigger().apply { + date = if (triggerTimezone == ZoneOffset.UTC.id || triggerTimezone.isNullOrEmpty()) + Instant.ofEpochMilli(triggerTime!!) + else + ZonedDateTime.ofInstant(Instant.ofEpochMilli(triggerTime!!), ZoneId.of(triggerTimezone)).toInstant() + } + } + summary?.let { alarmProps += Summary(it) } + repeat?.let { alarmProps += Repeat().apply { value = it } } + duration?.let { dur -> + alarmProps += Duration().apply { + try { + duration = java.time.Duration.parse(dur) + } catch (e: Exception) { + Log.w(TAG, "Could not parse alarm duration: $dur") + } + } + } + description?.let { alarmProps += Description(it) } + attach?.let { alarmProps += Attach().apply { value = it } } + other?.let { alarmProps.addAll(JtxContract.getXPropertyListFromJson(it).all) } + + return VAlarm().apply { propertyList = PropertyList(alarmProps) } +} + +/** Builds an ical4j date value from a jtx timestamp + timezone string. */ +private fun temporalFor(timestamp: Long, timezone: String?): Temporal { + val instant = Instant.ofEpochMilli(timestamp) + return when { + timezone == TZ_ALLDAY -> instant.toLocalDate() + timezone == ZoneOffset.UTC.id -> instant.atZone(ZoneOffset.UTC) + timezone.isNullOrEmpty() -> instant.atZone(ZoneId.systemDefault()).toLocalDateTime() + else -> instant.atZone(ZoneId.of(timezone)) + } +} + +private fun dateListFor(timestamps: List, timezone: String?): DateList { + val temporals: List = timestamps.map { ts -> + val instant = Instant.ofEpochMilli(ts) + when { + timezone == TZ_ALLDAY -> LocalDate.ofInstant(instant, ZoneOffset.UTC) + timezone == ZoneOffset.UTC.id -> ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + timezone.isNullOrEmpty() -> LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) + else -> ZonedDateTime.ofInstant(instant, ZoneId.of(timezone)) + } + } + return DateList(temporals) +} + + +// ------------------------------------------------------------------------------------------------- +// Import: VJOURNAL / VTODO -> jtx object (+ sub-entities) +// ------------------------------------------------------------------------------------------------- + +/** + * Parses a [VJournal] or [VToDo] (including its [VAlarm] sub-components) into an [ICalObject] and + * its sub-entities. Returns `null` for unsupported component types. + */ +fun parseICalComponent(component: CalendarComponent): ParsedICalObject? { + if (component !is VToDo && component !is VJournal) + return null + // Start from a clean object (avoid the UI-oriented defaults of ICalObject.createTask/createJournal). + val iCalObject = ICalObject( + component = if (component is VToDo) VTODO else VJOURNAL, + module = if (component is VToDo) Module.TODO.name else Module.NOTE.name + ) + iCalObject.sequence = 0 + + val categories = mutableListOf() + val comments = mutableListOf() + val resources = mutableListOf() + val attendees = mutableListOf() + var organizer: Organizer? = null + val relatedto = mutableListOf() + val attachments = mutableListOf() + val unknowns = mutableListOf() + + for (prop in component.propertyList.all) { + when (prop) { + is Uid -> iCalObject.uid = prop.value + is Sequence -> iCalObject.sequence = prop.sequenceNo.toLong() + is Created -> iCalObject.created = prop.date.toTimestamp() + is LastModified -> iCalObject.lastModified = prop.date.toTimestamp() + is Summary -> iCalObject.summary = prop.value + is Description -> iCalObject.description = prop.value + is Location -> { + iCalObject.location = prop.value + iCalObject.locationAltrep = prop.getParameter(Parameter.ALTREP).getOrNull()?.value + } + is Geo -> { + iCalObject.geoLat = prop.latitude.toDouble() + iCalObject.geoLong = prop.longitude.toDouble() + } + is Color -> iCalObject.color = Css3Color.fromString(prop.value)?.argb + is Url -> iCalObject.url = prop.value + is Contact -> iCalObject.contact = prop.value + is Priority -> iCalObject.priority = prop.level + is Clazz -> iCalObject.classification = prop.value + is Status -> iCalObject.status = prop.value + is DtStart<*> -> { + iCalObject.dtstart = prop.date.toTimestamp() + iCalObject.dtstartTimezone = prop.date.getTimeZoneId() + } + is DtEnd<*> -> Log.w(TAG, "DTEND is not supported for VTODO/VJOURNAL, ignoring") + is Completed -> if (iCalObject.component == VTODO) iCalObject.completed = prop.date.toTimestamp() + is Due<*> -> if (iCalObject.component == VTODO) { + iCalObject.due = prop.date.toTimestamp() + iCalObject.dueTimezone = prop.date.getTimeZoneId() + } + is Duration -> iCalObject.duration = prop.value + is PercentComplete -> if (iCalObject.component == VTODO) iCalObject.percent = prop.percentage + is RRule<*> -> iCalObject.rrule = prop.value + is RDate<*> -> iCalObject.rdate = mergeTimestamps(iCalObject.rdate, prop.dates.dates) + is ExDate<*> -> iCalObject.exdate = mergeTimestamps(iCalObject.exdate, prop.dates.dates) + is RecurrenceId<*> -> { + iCalObject.recurid = prop.value + iCalObject.recuridTimezone = prop.date.getTimeZoneId() + } + is Categories -> prop.categories.texts.forEach { categories += Category(text = it) } + is Resources -> prop.resources.texts.forEach { resources += Resource(text = it) } + is net.fortuna.ical4j.model.property.Comment -> comments += Comment().apply { + text = prop.value + language = prop.getParameter(Parameter.LANGUAGE).getOrNull()?.value + altrep = prop.getParameter(Parameter.ALTREP).getOrNull()?.value + prop.removeAll(Parameter.LANGUAGE, Parameter.ALTREP) + other = JtxContract.getJsonStringFromXParameters(prop.parameterList) + } + is Attach -> { + val attachment = Attachment() + prop.uri?.let { attachment.uri = it.toString() } + prop.binary?.let { attachment.binary = Base64.encodeToString(it, Base64.DEFAULT) } + prop.getParameter(Parameter.FMTTYPE).getOrNull()?.let { attachment.fmttype = it.value } + (prop.getParameter(X_PARAM_ATTACH_LABEL).getOrNull() + ?: prop.getParameter(X_PARAM_FILENAME).getOrNull())?.let { attachment.filename = it.value } + prop.removeAll(Parameter.FMTTYPE, X_PARAM_ATTACH_LABEL, X_PARAM_FILENAME) + attachment.other = JtxContract.getJsonStringFromXParameters(prop.parameterList) + if (attachment.uri?.isNotEmpty() == true || attachment.binary?.isNotEmpty() == true) + attachments += attachment + } + is net.fortuna.ical4j.model.property.RelatedTo -> relatedto += Relatedto().apply { + text = prop.value + reltype = prop.getParameter(Parameter.RELTYPE).getOrNull()?.value ?: Reltype.PARENT.name + prop.removeAll(Parameter.RELTYPE) + other = JtxContract.getJsonStringFromXParameters(prop.parameterList) + } + is net.fortuna.ical4j.model.property.Attendee -> attendees += Attendee().apply { + caladdress = prop.calAddress?.toString() ?: "" + cn = prop.getParameter(Parameter.CN).getOrNull()?.value + delegatedto = prop.getParameter(Parameter.DELEGATED_TO).getOrNull()?.value + delegatedfrom = prop.getParameter(Parameter.DELEGATED_FROM).getOrNull()?.value + cutype = prop.getParameter(Parameter.CUTYPE).getOrNull()?.value + dir = prop.getParameter(Parameter.DIR).getOrNull()?.value + language = prop.getParameter(Parameter.LANGUAGE).getOrNull()?.value + member = prop.getParameter(Parameter.MEMBER).getOrNull()?.value + partstat = prop.getParameter(Parameter.PARTSTAT).getOrNull()?.value + role = prop.getParameter(Parameter.ROLE).getOrNull()?.value + rsvp = prop.getParameter(Parameter.RSVP).getOrNull()?.value?.toBoolean() + sentby = prop.getParameter(Parameter.SENT_BY).getOrNull()?.value + prop.removeAll( + Parameter.CN, Parameter.DELEGATED_TO, Parameter.DELEGATED_FROM, Parameter.CUTYPE, + Parameter.DIR, Parameter.LANGUAGE, Parameter.MEMBER, Parameter.PARTSTAT, + Parameter.ROLE, Parameter.RSVP, Parameter.SENT_BY + ) + other = JtxContract.getJsonStringFromXParameters(prop.parameterList) + } + is net.fortuna.ical4j.model.property.Organizer -> organizer = Organizer().apply { + caladdress = prop.calAddress?.toString() ?: "" + cn = prop.getParameter(Parameter.CN).getOrNull()?.value + dir = prop.getParameter(Parameter.DIR).getOrNull()?.value + language = prop.getParameter(Parameter.LANGUAGE).getOrNull()?.value + sentby = prop.getParameter(Parameter.SENT_BY).getOrNull()?.value + prop.removeAll(Parameter.CN, Parameter.DIR, Parameter.LANGUAGE, Parameter.SENT_BY) + other = JtxContract.getJsonStringFromXParameters(prop.parameterList) + } + is ProdId, is DtStamp -> { /* not stored */ } + else -> when (prop.name) { + X_PROP_COMPLETEDTIMEZONE -> iCalObject.completedTimezone = prop.value + X_PROP_XSTATUS -> iCalObject.xstatus = prop.value + X_PROP_GEOFENCE_RADIUS -> iCalObject.geofenceRadius = prop.value.toIntOrNull() + else -> unknowns += Unknown(value = UnknownProperty.toJsonString(prop)) + } + } + } + + // A VJOURNAL with a start date is a journal entry, without one it is a note. + if (component is VJournal) + iCalObject.module = if (iCalObject.dtstart != null) Module.JOURNAL.name else Module.NOTE.name + + // VALARMs are only valid inside VTODO (per RFC 5545) + val alarms = when (component) { + is VToDo -> component.componentList.all.filterIsInstance().map { it.toAlarm() } + else -> emptyList() + } + + return ParsedICalObject( + iCalObject = iCalObject, + categories = categories, + comments = comments, + resources = resources, + attendees = attendees, + organizer = organizer, + relatedto = relatedto, + attachments = attachments, + alarms = alarms, + unknowns = unknowns + ) +} + +private fun VAlarm.toAlarm(): Alarm = Alarm().apply { + getProperty(Property.ACTION).getOrNull()?.let { + action = when (it.value?.uppercase()) { + AlarmAction.DISPLAY.name -> AlarmAction.DISPLAY.name + AlarmAction.AUDIO.name -> AlarmAction.AUDIO.name + AlarmAction.EMAIL.name -> AlarmAction.EMAIL.name + else -> null + } + } + getProperty(Property.TRIGGER).getOrNull()?.let { trigger -> + val relativeDuration = trigger.duration + if (relativeDuration != null) { + triggerRelativeDuration = relativeDuration.toString() + triggerRelativeTo = when (trigger.getParameter(Parameter.RELATED).getOrNull()) { + Related.END -> AlarmRelativeTo.END.name + else -> AlarmRelativeTo.START.name + } + } else { + trigger.date?.let { + triggerTime = it.toTimestamp() + triggerTimezone = it.getTimeZoneId() + } + } + } + getProperty(Property.SUMMARY).getOrNull()?.let { summary = it.value } + getProperty(Property.DESCRIPTION).getOrNull()?.let { description = it.value } + getProperty(Property.DURATION).getOrNull()?.let { duration = it.value } + getProperty(Property.REPEAT).getOrNull()?.let { repeat = it.value } + getProperty(Property.ATTACH).getOrNull()?.let { attach = it.value } +} + +/** Appends the timestamps of an ical4j date list to an existing comma-separated jtx timestamp string. */ +private fun mergeTimestamps(existing: String?, dates: List): String = + buildList { + if (!existing.isNullOrEmpty()) addAll(JtxContract.getLongListFromString(existing)) + dates.forEach { add(it.toTimestamp()) } + }.joinToString(separator = ",") diff --git a/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt b/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt index 3d2919f97..6a40b89cd 100644 --- a/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt +++ b/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt @@ -9,17 +9,12 @@ package at.techbee.jtx.util import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentValues import android.content.Context import android.util.Log -import at.bitfire.ical4android.JtxCollection -import at.bitfire.ical4android.JtxCollectionFactory -import at.bitfire.ical4android.JtxICalObject -import at.bitfire.ical4android.JtxICalObjectFactory -import at.bitfire.synctools.storage.toContentValues -import at.techbee.jtx.contract.JtxContract -import at.techbee.jtx.contract.JtxContract.asSyncAdapter +import androidx.core.net.toUri +import at.techbee.jtx.database.ICalDatabase +import at.techbee.jtx.database.ICalDatabaseDao +import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.ComponentList @@ -32,31 +27,45 @@ import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.immutable.ImmutableVersion import java.io.OutputStream import java.io.Reader - +import java.io.StringWriter +import java.io.Writer + +/** + * Local iCalendar (.ics) import/export for jtx Board. + * + * The VJOURNAL/VTODO mapping lives in [ICalendarMapping]; this object only handles reading/writing + * the jtx Board database (via [ICalDatabaseDao]) and assembling/parsing the iCalendar. It no longer + * depends on any external sync library. + * + * The [account] parameters are kept for source compatibility with existing callers, but are unused: + * a collection is uniquely identified by its [collectionId]. + */ object Ical4androidUtil { + private const val TAG = "Ical4AndroidUtil" + private val prodId = ProdId("+//IDN techbee.at//jtx Board") /** - * @param [account] to look up - * @param [context] to get the content provider client - * @param [collectionId] to look up - * @return a string with all the JtxICalObjects as iCalendar. + * @return a string with all jtx objects of the collection as iCalendar (or `null` on error). */ fun getICSFormatForCollectionFromProvider(account: Account, context: Context?, collectionId: Long): String? { - val collection = getCollection(account, context, collectionId) ?: return null - return collection.getICSForCollection(prodId) + context ?: return null + val dao = ICalDatabase.getInstance(context).iCalDatabaseDao() + return try { + val components = dao.getICalObjectIdsByCollectionSync(collectionId) + .mapNotNull { loadComponent(dao, context, it) } + StringWriter().also { writeComponents(components, it) }.toString() + } catch (e: Exception) { + Log.w(TAG, e.stackTraceToString()) + null + } } - - /** - * @param [account] to look up - * @param [context] to get the content provider client - * @param [collectionId] to look up - * @param [iCalObjectIds] to look up - * @param [os] the output stream where the ics should be written to - * @return true if the ics was written successfully to the os, false otherwise + * Writes the given jtx objects as a single iCalendar to [os]. + * + * @return true if the ics was written successfully, false otherwise */ fun writeICSFormatFromProviderToOS( account: Account, @@ -65,137 +74,114 @@ object Ical4androidUtil { iCalObjectIds: List, os: OutputStream ): Boolean { - - val collection = getCollection(account, context, collectionId) ?: return false - val calendarComponentList = mutableListOf() - - iCalObjectIds.forEach { iCalObjectId -> - val uri = JtxContract.JtxICalObject.CONTENT_URI - .asSyncAdapter(account) - .buildUpon() - .appendPath(iCalObjectId.toString()) - .build() - - collection.client.query(uri,null, null, null, null)?.use { cursor -> - //Ical4Android.log.fine("writeICSFormatFromProviderToOS: found ${cursor.count} records in ${account.name}") - - while (cursor.moveToNext()) { - val jtxIcalObject = JtxICalObject(collection) - jtxIcalObject.populateFromContentValues(cursor.toContentValues()) - val singleICS = jtxIcalObject.getICalendarFormat(prodId) - singleICS?.componentList?.all?.forEach { component -> - if(component is VToDo || component is VJournal) - calendarComponentList.add(component) - } - } - } - } - - val ical = Calendar( - PropertyList(listOf(ImmutableVersion.VERSION_2_0, prodId)), - ComponentList(calendarComponentList) - ) - - //Ical4Android.checkThreadContextClassLoader() - try { - CalendarOutputter(false).output(ical, os) + context ?: return false + val dao = ICalDatabase.getInstance(context).iCalDatabaseDao() + return try { + val components = iCalObjectIds.mapNotNull { loadComponent(dao, context, it) } + writeComponents(components, os) + true } catch (e: Exception) { - Log.w("Ical4AndroidUtil", e.stackTraceToString()) - return false + Log.w(TAG, e.stackTraceToString()) + false } - return true } /** - * @param [account] to look up - * @param [context] - * @param [collectionId] to look up - * @return A JtxCollection object or null (if not found or if the query returned more than 1 result) - */ - private fun getCollection(account: Account, - context: Context?, - collectionId: Long): JtxCollection? { - - val client = - context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) - ?: return null - val collections = JtxCollection.find(account, client, context, LocalJtxCollection.Factory, "${JtxContract.JtxCollection.ID} = ?", arrayOf(collectionId.toString())) - return if (collections.size != 1) - null - else - collections.first() - } - - - /** - * @param [account] to look up - * @param [context] - * @param [collectionId] where the parsed items should be inserted + * Parses the iCalendar from [reader] and inserts the contained jtx objects into the collection. + * * @return A pair with */ - fun insertFromReader(account: Account, - context: Context?, - collectionId: Long, - reader: Reader + fun insertFromReader( + account: Account, + context: Context?, + collectionId: Long, + reader: Reader ): Pair { - - val client = context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) ?: return Pair(0,0) - val collections = JtxCollection.find(account, client, context, LocalJtxCollection.Factory, "${JtxContract.JtxCollection.ID} = ?", arrayOf(collectionId.toString())) - if (collections.size != 1) - return Pair(0,0) - val collection = collections.first() + context ?: return Pair(0, 0) + val dao = ICalDatabase.getInstance(context).iCalDatabaseDao() var numAdded = 0 var numSkipped = 0 + try { + val calendar = CalendarBuilder().build(reader) + val components = calendar.getComponents() + .filter { it is VToDo || it is VJournal } + + components.forEach { component -> + val parsed = parseICalComponent(component) ?: return@forEach + val iCalObject = parsed.iCalObject.apply { + this.collectionId = collectionId + dirty = true // imported entries need to be synchronized + deleted = false + } - val jtxICalObjects = JtxICalObject.fromReader(reader, collection) - jtxICalObjects.forEach { - - //Check if UID already exists. If yes, check sequence and delete (to insert) or skip entry - val foundCV = collection.queryByUID(it.uid) - if(foundCV != null) { - val found = JtxICalObject(collection) - found.populateFromContentValues(foundCV) - if(it.sequence > found.sequence) - found.delete() - else { - numSkipped += 1 - return@forEach + // Check if UID already exists. If yes, check sequence and delete (to re-insert) or skip. + val existing = dao.getICalObjectByUidSync(iCalObject.uid) + if (existing != null) { + if ((iCalObject.sequence ?: 0L) > (existing.sequence ?: 0L)) { + dao.deleteICalObjectsbyId(existing.id) + } else { + numSkipped += 1 + return@forEach + } + } + + val newId = dao.insertICalObjectSync(iCalObject) + parsed.categories.forEach { it.icalObjectId = newId; dao.insertCategorySync(it) } + parsed.comments.forEach { it.icalObjectId = newId; dao.insertCommentSync(it) } + parsed.resources.forEach { it.icalObjectId = newId; dao.insertResourceSync(it) } + parsed.attendees.forEach { it.icalObjectId = newId; dao.insertAttendeeSync(it) } + parsed.organizer?.let { it.icalObjectId = newId; dao.insertOrganizerSync(it) } + parsed.relatedto.forEach { + it.icalObjectId = newId + it.linkedICalObjectId = it.text?.let { uid -> dao.getICalObjectByUidSync(uid)?.id } + dao.insertRelatedtoSync(it) } + parsed.attachments.forEach { it.icalObjectId = newId; dao.insertAttachmentSync(it) } + parsed.alarms.forEach { it.icalObjectId = newId; dao.insertAlarmSync(it) } + parsed.unknowns.forEach { it.icalObjectId = newId; dao.insertUnknownSync(it) } + numAdded += 1 } - it.dirty = true - it.add() - numAdded += 1 + } catch (e: Exception) { + Log.w(TAG, e.stackTraceToString()) } - return Pair(numAdded, numSkipped) } -} - -class LocalJtxICalObject(collection: JtxCollection<*>) : - JtxICalObject(collection) { - - object Factory : JtxICalObjectFactory { - - override fun fromProvider( - collection: JtxCollection, - values: ContentValues - ): LocalJtxICalObject { - return LocalJtxICalObject(collection).apply { - populateFromContentValues(values) + /** Loads a jtx object with all its sub-entities and maps it to a [VJournal]/[VToDo]. */ + private fun loadComponent(dao: ICalDatabaseDao, context: Context, id: Long): CalendarComponent? { + val iCalObject = dao.getICalObjectByIdSync(id) ?: return null + return iCalObject.toICalComponent( + categories = dao.getCategoriesSync(id), + comments = dao.getCommentsSync(id), + resources = dao.getResourcesSync(id), + attendees = dao.getAttendeesSync(id), + organizer = dao.getOrganizerSync(id), + relatedto = dao.getRelatedtoSync(id), + attachments = dao.getAttachmentsSync(id), + alarms = dao.getAlarmsSync(id), + unknowns = dao.getUnknownSync(id), + readAttachmentBytes = { uri -> + try { + context.contentResolver.openInputStream(uri.toUri())?.use { it.readBytes() } + } catch (e: Exception) { + Log.w(TAG, "Could not read attachment $uri: ${e.message}") + null + } } - } + ) } -} - -class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long): - JtxCollection(account, client, LocalJtxICalObject.Factory, id){ + private fun buildCalendar(components: List): Calendar = + Calendar( + PropertyList(listOf(ImmutableVersion.VERSION_2_0, prodId)), + ComponentList(components) + ) - object Factory: JtxCollectionFactory { - override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id) - } + private fun writeComponents(components: List, os: OutputStream) = + CalendarOutputter(false).output(buildCalendar(components), os) -} \ No newline at end of file + private fun writeComponents(components: List, writer: Writer) = + CalendarOutputter(false).output(buildCalendar(components), writer) +} diff --git a/app/src/main/java/at/techbee/jtx/util/TemporalExtensions.kt b/app/src/main/java/at/techbee/jtx/util/TemporalExtensions.kt new file mode 100644 index 000000000..04a71766c --- /dev/null +++ b/app/src/main/java/at/techbee/jtx/util/TemporalExtensions.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) Techbee e.U. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.techbee.jtx.util + +import at.techbee.jtx.contract.JtxContract.JtxICalObject.TZ_ALLDAY +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal + +/** + * Extensions to convert ical4j [Temporal] date values to/from the representation used by the + * jtx Board content provider (a UNIX timestamp in milliseconds plus a separate timezone string). + * + * These replace the equivalent helpers that were previously provided by the synctools library. + */ + +/** Converts this [Temporal] to an [Instant]. All-day dates are anchored to UTC midnight, + * floating date-times to the system default time zone. */ +fun Temporal.toInstant(): Instant = when (this) { + is Instant -> this + is ZonedDateTime -> toInstant() + is OffsetDateTime -> toInstant() + is LocalDateTime -> atZone(ZoneId.systemDefault()).toInstant() + is LocalDate -> atStartOfDay(ZoneOffset.UTC).toInstant() + else -> error("Unsupported Temporal type: ${this::class.qualifiedName}") +} + +/** UNIX timestamp in milliseconds (as stored by the jtx Board provider). */ +fun Temporal.toTimestamp(): Long = toInstant().toEpochMilli() + +/** The [LocalDate] part of this [Temporal]. */ +fun Temporal.toLocalDate(): LocalDate = when (this) { + is LocalDate -> this + is LocalDateTime -> toLocalDate() + is OffsetDateTime -> toLocalDate() + is ZonedDateTime -> toLocalDate() + is Instant -> LocalDate.ofInstant(this, ZoneOffset.UTC) + else -> error("Unsupported Temporal type: ${this::class.qualifiedName}") +} + +/** + * The timezone identifier to store for this [Temporal] in the jtx Board provider: + * - [TZ_ALLDAY] for a plain date (all-day), + * - `null` for a floating date-time, + * - `"UTC"` for a UTC date-time, + * - the zone id for a zoned date-time. + */ +fun Temporal.getTimeZoneId(): String? = when (this) { + is ZonedDateTime -> zone.id + is OffsetDateTime -> ZoneOffset.UTC.id + is Instant -> ZoneOffset.UTC.id + is LocalDateTime -> null + is LocalDate -> TZ_ALLDAY + else -> null +} diff --git a/app/src/main/java/at/techbee/jtx/util/UnknownProperty.kt b/app/src/main/java/at/techbee/jtx/util/UnknownProperty.kt new file mode 100644 index 000000000..96ec17fba --- /dev/null +++ b/app/src/main/java/at/techbee/jtx/util/UnknownProperty.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) Techbee e.U. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + * + * Serialization format adapted from bitfireAT/synctools (GPL-3.0-or-later). + */ + +package at.techbee.jtx.util + +import net.fortuna.ical4j.data.DefaultParameterFactorySupplier +import net.fortuna.ical4j.data.DefaultPropertyFactorySupplier +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.ParameterBuilder +import net.fortuna.ical4j.model.ParameterFactory +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.PropertyBuilder +import net.fortuna.ical4j.model.PropertyFactory +import org.json.JSONArray +import org.json.JSONObject + +/** + * Helpers to (de)serialize an unknown iCalendar [Property] as a JSON string so that it can be + * stored in the jtx Board content provider (see [at.techbee.jtx.database.properties.Unknown]). + * + * Format: `[propertyName, propertyValue, { param1Name: param1Value, ... }]`, with the third + * array element (parameters) being optional. + */ +object UnknownProperty { + + private val propertyFactorySupplier: List> = DefaultPropertyFactorySupplier().get() + private val parameterFactorySupplier: List> = DefaultParameterFactorySupplier().get() + + /** + * Deserializes a JSON string to an ical4j [Property]. + * + * @throws org.json.JSONException when the input value can't be parsed + */ + fun fromJsonString(jsonString: String): Property { + val json = JSONArray(jsonString) + val name = json.getString(0) + val value = json.getString(1) + + val builder = PropertyBuilder(propertyFactorySupplier) + .name(name) + .value(value) + + json.optJSONObject(2)?.let { jsonParams -> + for (paramName in jsonParams.keys()) + builder.parameter( + ParameterBuilder(parameterFactorySupplier) + .name(paramName) + .value(jsonParams.getString(paramName)) + .build() + ) + } + + return builder.build() + } + + /** + * Serializes an ical4j [Property] to a JSON string. + */ + fun toJsonString(prop: Property): String { + val json = JSONArray() + json.put(prop.name) + json.put(prop.value) + + if (prop.parameterList.all.isNotEmpty()) { + val jsonParams = JSONObject() + for (param in prop.parameterList.all) + jsonParams.put(param.name, param.value) + json.put(jsonParams) + } + + return json.toString() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5fd677530..7904dc5ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,6 @@ playServicesLocation = "21.4.0" playServicesMaps = "20.0.0" profileinstaller = "1.4.1" reorderable = "3.1.0" -synctools = "b5d43f5712" uiTextGoogleFonts = "1.11.4" robolectric = "4.16.1" room = "2.8.4" @@ -113,7 +112,6 @@ room-base = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-testing = { module = "androidx.room:room-testing", version.ref = "room" } -synctools = { module = "com.github.bitfireat:synctools", version.ref = "synctools" } volley = { module = "com.android.volley:volley", version.ref = "volley" } # gplay and managed build variants @@ -138,4 +136,3 @@ kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", vers android-test = { id = "com.android.test", version.ref = "android-agp" } baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } #huawei-agconnect = { id = "com.huawei.agconnect", version.ref = "huawei" } -