From 08a3b9d05a434b8f3d825ef89f86119dc4ab7605 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:57:33 +0200 Subject: [PATCH 1/3] removed duplicated dependencies, changed synctools to davx5 --- app/build.gradle.kts | 5 ----- gradle/libs.versions.toml | 5 ++--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eefc51859..88b7d466f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -241,12 +241,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5fd677530..1450c3fe8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ playServicesLocation = "21.4.0" playServicesMaps = "20.0.0" profileinstaller = "1.4.1" reorderable = "3.1.0" -synctools = "b5d43f5712" +synctools = "v4.5.17.1-ose" uiTextGoogleFonts = "1.11.4" robolectric = "4.16.1" room = "2.8.4" @@ -113,7 +113,7 @@ 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" } +synctools = { group = "com.github.bitfireat.davx5-ose", name = "synctools", version.ref = "synctools" } volley = { module = "com.android.volley:volley", version.ref = "volley" } # gplay and managed build variants @@ -138,4 +138,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" } - From 05f52c904e97918ac364f125df08f99a9631de70 Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:34:41 +0200 Subject: [PATCH 2/3] Migrate to synctools library for iCalendar handling (#2348) * Migrate Ical4androidUtil to davx5 synctools API (ical4j 4.x) The davx5-ose synctools submodule replaced the former fat JtxICalObject / JtxCollection classes with an Entity + builder/handler design and moved the packages from at.bitfire.ical4android to at.bitfire.synctools. Rewrite the ICS import/export helpers accordingly, mirroring DAVx5's own JtxSyncManager usage: - Look up collections via JtxCollectionProvider.getCollection(). - Export: map jtx objects (main + recurrence exceptions) to VJOURNAL/VTODO with JtxObjectHandler + AndroidAttachmentFetcher, then build and output a single VCALENDAR via ical4j. - Import: parse with ICalendarParser, group components by UID with CalendarUidSplitter, build entities with JtxObjectBuilder, mark them dirty and insert via JtxRecurringCollection; keep UID/sequence de-duplication against existing entries. - Drop the obsolete LocalJtxICalObject / LocalJtxCollection subclasses. - Acquire and close the content provider client per call. Also point ICalObject.kt's TimeApiExtensions.toLocalDate import at the new at.bitfire.synctools.util package. The in-file duplicate-UID import test now expects 0 skipped, because CalendarUidSplitter already collapses same-UID/same-RECURRENCE-ID components during parsing (per RFC 5545); DB-level skipping on re-import is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JEizQuqZkmoSJf6saEPDH * Source synctools via davx5-ose submodule composite build The jitpack artifact com.github.bitfireat.davx5-ose:synctools:v4.5.17.1-ose does not resolve (jitpack can't build/serve the davx5-ose multi-module project), which fails the build before any source compiles. Consume synctools from source instead: - Add davx5-ose as a Git submodule pinned to tag v4.5.17.1-ose. - Include it as a Gradle composite build in settings.gradle.kts, with an explicit dependencySubstitution mapping the version-catalog coordinate (libs.synctools) to the local :synctools project. The explicit target keeps only :synctools and davx5-ose's build-logic configured, not :app-ose/:core. jtxBoard and davx5-ose v4.5.17.1-ose are version-aligned (Gradle 9.6.1, AGP 9.2.1, Kotlin 2.4.0, KSP 2.3.9, Java toolchain 21), synctools' own dependencies come from Maven Central/Google, and all CI workflows already check out submodules, so no workflow changes are needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JEizQuqZkmoSJf6saEPDH * Use desugar_jdk_libs_nio to match synctools requirement The synctools library requires the desugar_jdk_libs_nio core-library desugaring flavor (java.nio support), so :app:checkOseDebugAarMetadata failed with the base desugar_jdk_libs variant. Switch to the _nio artifact (same version 2.1.5, a superset), matching davx5-ose's own configuration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JEizQuqZkmoSJf6saEPDH --------- Co-authored-by: Claude --- .gitmodules | 3 + .../techbee/jtx/util/Ical4androidUtilTest.kt | 7 +- .../at/techbee/jtx/database/ICalObject.kt | 2 +- .../at/techbee/jtx/util/Ical4androidUtil.kt | 291 +++++++++++------- davx5-ose | 1 + gradle/libs.versions.toml | 5 +- settings.gradle.kts | 11 + 7 files changed, 199 insertions(+), 121 deletions(-) create mode 100644 .gitmodules create mode 160000 davx5-ose diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..a223e1173 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "davx5-ose"] + path = davx5-ose + url = https://github.com/bitfireAT/davx5-ose.git 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..f8f326944 100644 --- a/app/src/androidTest/java/at/techbee/jtx/util/Ical4androidUtilTest.kt +++ b/app/src/androidTest/java/at/techbee/jtx/util/Ical4androidUtilTest.kt @@ -143,8 +143,13 @@ class Ical4androidUtilTest { "SUMMARY:Second entry\n" + "END:VTODO\n" + "END:VCALENDAR\n" + // Note: synctools' iCalendar parser (CalendarUidSplitter) collapses components that share + // the same UID and RECURRENCE-ID within a single iCalendar, keeping the one with the highest + // SEQUENCE (per RFC 5545). The duplicate VTODO above is therefore merged into a single object + // during parsing, so 2 objects are added and none is skipped. Skipping still applies when + // re-importing an entry that already exists in the collection with an equal/lower SEQUENCE. val num = Ical4androidUtil.insertFromReader(defaultTestAccount, context, defaultCollectionId!!, ics.reader()) assertEquals(2, num.first) - assertEquals(1, num.second) + assertEquals(0, num.second) } } \ No newline at end of file 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..bac6ecc79 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.bitfire.synctools.util.TimeApiExtensions.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/util/Ical4androidUtil.kt b/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt index 3d2919f97..9544db0df 100644 --- a/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt +++ b/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt @@ -9,47 +9,74 @@ package at.techbee.jtx.util import android.accounts.Account -import android.content.ContentProviderClient -import android.content.ContentValues import android.content.Context +import android.content.Entity 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.bitfire.synctools.icalendar.CalendarUidSplitter +import at.bitfire.synctools.icalendar.ICalendarParser +import at.bitfire.synctools.mapping.jtx.JtxObjectBuilder +import at.bitfire.synctools.mapping.jtx.JtxObjectHandler +import at.bitfire.synctools.mapping.jtx.handler.AndroidAttachmentFetcher +import at.bitfire.synctools.storage.jtx.JtxCollection +import at.bitfire.synctools.storage.jtx.JtxCollectionProvider +import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions +import at.bitfire.synctools.storage.jtx.JtxRecurringCollection import at.techbee.jtx.contract.JtxContract -import at.techbee.jtx.contract.JtxContract.asSyncAdapter import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.component.CalendarComponent -import net.fortuna.ical4j.model.component.VJournal -import net.fortuna.ical4j.model.component.VToDo 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 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 the 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) + val client = context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) ?: return null + try { + val collection = JtxCollectionProvider(account, client).getCollection(collectionId) ?: return null + val handler = jtxObjectHandler(collection) + + // gather all main jtx objects of the collection (exceptions are exported with their main object) + val mainIds = mutableListOf() + collection.iterateJtxObjectRows( + arrayOf(JtxContract.JtxICalObject.ID), + "${JtxContract.JtxICalObject.RECURID} IS NULL", + null + ) { row -> row.getAsLong(JtxContract.JtxICalObject.ID)?.let { mainIds += it } } + + val components = mutableListOf() + mainIds.forEach { mainId -> components += componentsForObject(collection, handler, mainId) } + + val writer = StringWriter() + writeComponents(components, writer) + return writer.toString() + } catch (e: Exception) { + Log.w(TAG, e.stackTraceToString()) + return null + } finally { + client.close() + } } - /** * @param [account] to look up * @param [context] to get the content provider client @@ -65,137 +92,165 @@ object Ical4androidUtil { iCalObjectIds: List, os: OutputStream ): Boolean { + val client = context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) ?: return false + try { + val collection = JtxCollectionProvider(account, client).getCollection(collectionId) ?: return false + val handler = jtxObjectHandler(collection) - 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 components = mutableListOf() + iCalObjectIds.forEach { iCalObjectId -> + components += componentsForObject(collection, handler, iCalObjectId) } - } - - val ical = Calendar( - PropertyList(listOf(ImmutableVersion.VERSION_2_0, prodId)), - ComponentList(calendarComponentList) - ) - //Ical4Android.checkThreadContextClassLoader() - try { - CalendarOutputter(false).output(ical, os) + writeComponents(components, os) + return true } catch (e: Exception) { - Log.w("Ical4AndroidUtil", e.stackTraceToString()) + Log.w(TAG, e.stackTraceToString()) return false + } finally { + client.close() } - 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 * @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) + try { + val collection = JtxCollectionProvider(account, client).getCollection(collectionId) ?: return Pair(0, 0) + val recurringCollection = JtxRecurringCollection(collection) - 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() - - var numAdded = 0 - var numSkipped = 0 - - 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 + val calendar = ICalendarParser().parse(reader) + val splitter = CalendarUidSplitter() + val associatedComponents = + splitter.associateByUid(calendar, Component.VJOURNAL).values + + splitter.associateByUid(calendar, Component.VTODO).values + + var numAdded = 0 + var numSkipped = 0 + + associatedComponents.forEach { component -> + // exceptions can't be imported without their main object + if (component.main == null) return@forEach + + val builtObject = JtxObjectBuilder( + collectionId = collection.id, + fileName = null, + eTag = null, + scheduleTag = null, + flags = 0 + ).build(component) + + val mainValues = builtObject.main.entity.entityValues + val uid = mainValues.getAsString(JtxContract.JtxICalObject.UID) + val newSequence = mainValues.getAsInteger(JtxContract.JtxICalObject.SEQUENCE) ?: 0 + + // Check if UID already exists. If yes, check sequence and delete (to insert) or skip entry. + if (uid != null) { + val foundRow = collection.findJtxObjectRow( + arrayOf(JtxContract.JtxICalObject.ID, JtxContract.JtxICalObject.SEQUENCE), + "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", + arrayOf(uid) + ) + if (foundRow != null) { + val foundSequence = foundRow.getAsInteger(JtxContract.JtxICalObject.SEQUENCE) ?: 0 + if (newSequence > foundSequence) { + foundRow.getAsLong(JtxContract.JtxICalObject.ID)?.let { foundId -> + recurringCollection.deleteJtxObjectAndExceptions(foundId) + } + } else { + numSkipped += 1 + return@forEach + } + } } - } - it.dirty = true - it.add() - numAdded += 1 - } - return Pair(numAdded, numSkipped) - } -} + // mark as dirty so that the imported entry gets synchronized + mainValues.put(JtxContract.JtxICalObject.DIRTY, true) + recurringCollection.addJtxEntityAndExceptions(builtObject) + numAdded += 1 + } + return Pair(numAdded, numSkipped) + } catch (e: Exception) { + Log.w(TAG, e.stackTraceToString()) + return Pair(0, 0) + } finally { + client.close() + } + } -class LocalJtxICalObject(collection: JtxCollection<*>) : - JtxICalObject(collection) { - object Factory : JtxICalObjectFactory { + /** + * @return a [JtxObjectHandler] that maps jtx objects of the given [collection] to iCalendar components. + */ + private fun jtxObjectHandler(collection: JtxCollection) = JtxObjectHandler( + prodId = prodId, + attachmentFetcher = AndroidAttachmentFetcher(collection.client, collection.account) + ) - override fun fromProvider( - collection: JtxCollection, - values: ContentValues - ): LocalJtxICalObject { - return LocalJtxICalObject(collection).apply { - populateFromContentValues(values) + /** + * Maps a main jtx object (and its recurrence exceptions) to iCalendar components. + * + * @param [mainId] ID of a jtx object; recurrence exception rows (RECURID set) are skipped as they + * are exported together with their main object. + * @return the mapped iCalendar components (main component followed by its exceptions), or an empty + * list if the object can't be found or mapped. + */ + private fun componentsForObject( + collection: JtxCollection, + handler: JtxObjectHandler, + mainId: Long + ): List { + val main = collection.getJtxObject(mainId) ?: return emptyList() + + // skip recurrence exceptions – they are exported with their main object + if (main.entityValues.getAsString(JtxContract.JtxICalObject.RECURID) != null) + return emptyList() + + val uid = main.entityValues.getAsString(JtxContract.JtxICalObject.UID) + val exceptions = mutableListOf() + if (uid != null) { + val exceptionIds = mutableListOf() + collection.iterateJtxObjectRows( + arrayOf(JtxContract.JtxICalObject.ID), + "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", + arrayOf(uid) + ) { row -> row.getAsLong(JtxContract.JtxICalObject.ID)?.let { exceptionIds += it } } + exceptionIds.forEach { exceptionId -> + collection.getJtxObject(exceptionId)?.let { exceptions += it } } } - } -} + return try { + val result = handler.mapToCalendarComponents(JtxObjectAndExceptions(main, exceptions)) + listOfNotNull(result.associatedComponents.main) + result.associatedComponents.exceptions + } catch (e: Exception) { + Log.w(TAG, e.stackTraceToString()) + emptyList() + } + } -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/davx5-ose b/davx5-ose new file mode 160000 index 000000000..6c66734ce --- /dev/null +++ b/davx5-ose @@ -0,0 +1 @@ +Subproject commit 6c66734cecaee79c9bafd46dad54a2ef670bae96 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1450c3fe8..be2b634c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ playServicesLocation = "21.4.0" playServicesMaps = "20.0.0" profileinstaller = "1.4.1" reorderable = "3.1.0" +# synctools is provided by the davx5-ose Git submodule via a Gradle composite build +# (see settings.gradle.kts). Keep this tag in sync with the submodule's checked-out commit. synctools = "v4.5.17.1-ose" uiTextGoogleFonts = "1.11.4" robolectric = "4.16.1" @@ -58,7 +60,8 @@ baselineprofile = "1.5.0-alpha07" [libraries] amazon-appstore-sdk = { module = "com.amazon.device:amazon-appstore-sdk", version.ref = "amazonAppstoreSdk" } -android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" } +# _nio flavor required by the synctools library (davx5-ose); it is a superset of the base variant +android-desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "android-desugaring" } androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ee33cf66..9dbe54feb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,5 +29,16 @@ dependencyResolutionManagement { } } +// Consume the synctools library from the davx5-ose submodule as a Gradle composite build +// instead of a (currently unavailable) jitpack artifact. The explicit dependency substitution +// keeps only the :synctools project (and davx5-ose's build-logic) configured, and maps the +// coordinate declared in the version catalog (libs.synctools) to the local project. +includeBuild("davx5-ose") { + dependencySubstitution { + substitute(module("com.github.bitfireat.davx5-ose:synctools")) + .using(project(":synctools")) + } +} + include(":app") include(":baselineprofile") From 31a236ef7211bdf752799e4f2d0aa5b4cf6d7f90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 11:16:11 +0000 Subject: [PATCH 3/3] Implement self-contained ical4j iCalendar import/export (drop synctools) Remove the dependency on the (discontinued) synctools library and the davx5-ose Git submodule entirely, and implement the local .ics import/export directly on ical4j 4.x against jtx Board's own data model. - ICalendarMapping.kt: maps ICalObject + sub-entities <-> VJOURNAL/VTODO (incl. VALARM), ported from the proven pre-synctools ical4j mapping and adapted to jtx Board's models, contract JSON helpers and Css3Color. - UnknownProperty.kt: minimal ical4j property <-> JSON (un)known-property helper. - TemporalExtensions.kt: toTimestamp/toLocalDate/getTimeZoneId helpers that replace the former synctools time utilities. - Ical4androidUtil.kt: reads/writes via the Room DAO (blocking *Sync methods), assembles/parses the iCalendar with ical4j; keeps UID/sequence de-duplication on import and marks imported entries dirty. - ICalDatabaseDao.kt: add blocking getters needed for export (organizer/unknown/relatedto per object, ids-by-collection, by-UID). - Point ICalObject/DetailsCardRecur time-util imports at the new local helpers. - Remove the synctools dependency + davx5-ose submodule + composite build, and revert core library desugaring to the base desugar_jdk_libs variant. Note: local unit tests only cover compilation; the round-trip Ical4androidUtilTest requires an emulator and should be run before merge. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011JEizQuqZkmoSJf6saEPDH --- .gitmodules | 3 - app/build.gradle.kts | 1 - .../techbee/jtx/util/Ical4androidUtilTest.kt | 9 +- .../techbee/jtx/database/ICalDatabaseDao.kt | 19 + .../at/techbee/jtx/database/ICalObject.kt | 2 +- .../techbee/jtx/ui/detail/DetailsCardRecur.kt | 2 +- .../at/techbee/jtx/util/ICalendarMapping.kt | 601 ++++++++++++++++++ .../at/techbee/jtx/util/Ical4androidUtil.kt | 259 +++----- .../at/techbee/jtx/util/TemporalExtensions.kt | 66 ++ .../at/techbee/jtx/util/UnknownProperty.kt | 80 +++ davx5-ose | 1 - gradle/libs.versions.toml | 7 +- settings.gradle.kts | 11 - 13 files changed, 867 insertions(+), 194 deletions(-) delete mode 100644 .gitmodules create mode 100644 app/src/main/java/at/techbee/jtx/util/ICalendarMapping.kt create mode 100644 app/src/main/java/at/techbee/jtx/util/TemporalExtensions.kt create mode 100644 app/src/main/java/at/techbee/jtx/util/UnknownProperty.kt delete mode 160000 davx5-ose diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index a223e1173..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "davx5-ose"] - path = davx5-ose - url = https://github.com/bitfireAT/davx5-ose.git diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 88b7d466f..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) 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 f8f326944..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,13 +143,10 @@ class Ical4androidUtilTest { "SUMMARY:Second entry\n" + "END:VTODO\n" + "END:VCALENDAR\n" - // Note: synctools' iCalendar parser (CalendarUidSplitter) collapses components that share - // the same UID and RECURRENCE-ID within a single iCalendar, keeping the one with the highest - // SEQUENCE (per RFC 5545). The duplicate VTODO above is therefore merged into a single object - // during parsing, so 2 objects are added and none is skipped. Skipping still applies when - // re-importing an entry that already exists in the collection with an equal/lower SEQUENCE. + // 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(0, num.second) + assertEquals(1, num.second) } } \ No newline at end of file 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 bac6ecc79..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.synctools.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 9544db0df..6a40b89cd 100644 --- a/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt +++ b/app/src/main/java/at/techbee/jtx/util/Ical4androidUtil.kt @@ -10,25 +10,19 @@ package at.techbee.jtx.util import android.accounts.Account import android.content.Context -import android.content.Entity import android.util.Log -import at.bitfire.synctools.icalendar.CalendarUidSplitter -import at.bitfire.synctools.icalendar.ICalendarParser -import at.bitfire.synctools.mapping.jtx.JtxObjectBuilder -import at.bitfire.synctools.mapping.jtx.JtxObjectHandler -import at.bitfire.synctools.mapping.jtx.handler.AndroidAttachmentFetcher -import at.bitfire.synctools.storage.jtx.JtxCollection -import at.bitfire.synctools.storage.jtx.JtxCollectionProvider -import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions -import at.bitfire.synctools.storage.jtx.JtxRecurringCollection -import at.techbee.jtx.contract.JtxContract +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.Component import net.fortuna.ical4j.model.ComponentList import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.component.VJournal +import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.immutable.ImmutableVersion import java.io.OutputStream @@ -36,6 +30,16 @@ 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" @@ -43,47 +47,25 @@ object 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 jtx objects of the collection as iCalendar (or null on error). + * @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 client = context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) ?: return null - try { - val collection = JtxCollectionProvider(account, client).getCollection(collectionId) ?: return null - val handler = jtxObjectHandler(collection) - - // gather all main jtx objects of the collection (exceptions are exported with their main object) - val mainIds = mutableListOf() - collection.iterateJtxObjectRows( - arrayOf(JtxContract.JtxICalObject.ID), - "${JtxContract.JtxICalObject.RECURID} IS NULL", - null - ) { row -> row.getAsLong(JtxContract.JtxICalObject.ID)?.let { mainIds += it } } - - val components = mutableListOf() - mainIds.forEach { mainId -> components += componentsForObject(collection, handler, mainId) } - - val writer = StringWriter() - writeComponents(components, writer) - return writer.toString() + 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()) - return null - } finally { - client.close() + 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, @@ -92,30 +74,21 @@ object Ical4androidUtil { iCalObjectIds: List, os: OutputStream ): Boolean { - val client = context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) ?: return false - try { - val collection = JtxCollectionProvider(account, client).getCollection(collectionId) ?: return false - val handler = jtxObjectHandler(collection) - - val components = mutableListOf() - iCalObjectIds.forEach { iCalObjectId -> - components += componentsForObject(collection, handler, iCalObjectId) - } - + context ?: return false + val dao = ICalDatabase.getInstance(context).iCalDatabaseDao() + return try { + val components = iCalObjectIds.mapNotNull { loadComponent(dao, context, it) } writeComponents(components, os) - return true + true } catch (e: Exception) { Log.w(TAG, e.stackTraceToString()) - return false - } finally { - client.close() + false } } /** - * @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( @@ -124,122 +97,80 @@ object Ical4androidUtil { collectionId: Long, reader: Reader ): Pair { - val client = context?.contentResolver?.acquireContentProviderClient(JtxContract.AUTHORITY) ?: return Pair(0, 0) - try { - val collection = JtxCollectionProvider(account, client).getCollection(collectionId) ?: return Pair(0, 0) - val recurringCollection = JtxRecurringCollection(collection) + context ?: return Pair(0, 0) + val dao = ICalDatabase.getInstance(context).iCalDatabaseDao() - val calendar = ICalendarParser().parse(reader) - val splitter = CalendarUidSplitter() - val associatedComponents = - splitter.associateByUid(calendar, Component.VJOURNAL).values + - splitter.associateByUid(calendar, Component.VTODO).values - - var numAdded = 0 - var numSkipped = 0 - - associatedComponents.forEach { component -> - // exceptions can't be imported without their main object - if (component.main == null) - return@forEach - - val builtObject = JtxObjectBuilder( - collectionId = collection.id, - fileName = null, - eTag = null, - scheduleTag = null, - flags = 0 - ).build(component) - - val mainValues = builtObject.main.entity.entityValues - val uid = mainValues.getAsString(JtxContract.JtxICalObject.UID) - val newSequence = mainValues.getAsInteger(JtxContract.JtxICalObject.SEQUENCE) ?: 0 + 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 + } - // Check if UID already exists. If yes, check sequence and delete (to insert) or skip entry. - if (uid != null) { - val foundRow = collection.findJtxObjectRow( - arrayOf(JtxContract.JtxICalObject.ID, JtxContract.JtxICalObject.SEQUENCE), - "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NULL", - arrayOf(uid) - ) - if (foundRow != null) { - val foundSequence = foundRow.getAsInteger(JtxContract.JtxICalObject.SEQUENCE) ?: 0 - if (newSequence > foundSequence) { - foundRow.getAsLong(JtxContract.JtxICalObject.ID)?.let { foundId -> - recurringCollection.deleteJtxObjectAndExceptions(foundId) - } - } 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 } } - // mark as dirty so that the imported entry gets synchronized - mainValues.put(JtxContract.JtxICalObject.DIRTY, true) - - recurringCollection.addJtxEntityAndExceptions(builtObject) + 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 } - - return Pair(numAdded, numSkipped) } catch (e: Exception) { Log.w(TAG, e.stackTraceToString()) - return Pair(0, 0) - } finally { - client.close() } + return Pair(numAdded, numSkipped) } - /** - * @return a [JtxObjectHandler] that maps jtx objects of the given [collection] to iCalendar components. - */ - private fun jtxObjectHandler(collection: JtxCollection) = JtxObjectHandler( - prodId = prodId, - attachmentFetcher = AndroidAttachmentFetcher(collection.client, collection.account) - ) - - /** - * Maps a main jtx object (and its recurrence exceptions) to iCalendar components. - * - * @param [mainId] ID of a jtx object; recurrence exception rows (RECURID set) are skipped as they - * are exported together with their main object. - * @return the mapped iCalendar components (main component followed by its exceptions), or an empty - * list if the object can't be found or mapped. - */ - private fun componentsForObject( - collection: JtxCollection, - handler: JtxObjectHandler, - mainId: Long - ): List { - val main = collection.getJtxObject(mainId) ?: return emptyList() - - // skip recurrence exceptions – they are exported with their main object - if (main.entityValues.getAsString(JtxContract.JtxICalObject.RECURID) != null) - return emptyList() - - val uid = main.entityValues.getAsString(JtxContract.JtxICalObject.UID) - val exceptions = mutableListOf() - if (uid != null) { - val exceptionIds = mutableListOf() - collection.iterateJtxObjectRows( - arrayOf(JtxContract.JtxICalObject.ID), - "${JtxContract.JtxICalObject.UID} = ? AND ${JtxContract.JtxICalObject.RECURID} IS NOT NULL AND ${JtxContract.JtxICalObject.SEQUENCE} > 0", - arrayOf(uid) - ) { row -> row.getAsLong(JtxContract.JtxICalObject.ID)?.let { exceptionIds += it } } - exceptionIds.forEach { exceptionId -> - collection.getJtxObject(exceptionId)?.let { exceptions += it } + /** 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 + } } - } - - return try { - val result = handler.mapToCalendarComponents(JtxObjectAndExceptions(main, exceptions)) - listOfNotNull(result.associatedComponents.main) + result.associatedComponents.exceptions - } catch (e: Exception) { - Log.w(TAG, e.stackTraceToString()) - emptyList() - } + ) } private fun buildCalendar(components: List): Calendar = 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/davx5-ose b/davx5-ose deleted file mode 160000 index 6c66734ce..000000000 --- a/davx5-ose +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6c66734cecaee79c9bafd46dad54a2ef670bae96 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be2b634c1..7904dc5ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,9 +41,6 @@ playServicesLocation = "21.4.0" playServicesMaps = "20.0.0" profileinstaller = "1.4.1" reorderable = "3.1.0" -# synctools is provided by the davx5-ose Git submodule via a Gradle composite build -# (see settings.gradle.kts). Keep this tag in sync with the submodule's checked-out commit. -synctools = "v4.5.17.1-ose" uiTextGoogleFonts = "1.11.4" robolectric = "4.16.1" room = "2.8.4" @@ -60,8 +57,7 @@ baselineprofile = "1.5.0-alpha07" [libraries] amazon-appstore-sdk = { module = "com.amazon.device:amazon-appstore-sdk", version.ref = "amazonAppstoreSdk" } -# _nio flavor required by the synctools library (davx5-ose); it is a superset of the base variant -android-desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "android-desugaring" } +android-desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" } androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } @@ -116,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 = { group = "com.github.bitfireat.davx5-ose", name = "synctools", version.ref = "synctools" } volley = { module = "com.android.volley:volley", version.ref = "volley" } # gplay and managed build variants diff --git a/settings.gradle.kts b/settings.gradle.kts index 9dbe54feb..8ee33cf66 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,16 +29,5 @@ dependencyResolutionManagement { } } -// Consume the synctools library from the davx5-ose submodule as a Gradle composite build -// instead of a (currently unavailable) jitpack artifact. The explicit dependency substitution -// keeps only the :synctools project (and davx5-ose's build-logic) configured, and maps the -// coordinate declared in the version catalog (libs.synctools) to the local project. -includeBuild("davx5-ose") { - dependencySubstitution { - substitute(module("com.github.bitfireat.davx5-ose:synctools")) - .using(project(":synctools")) - } -} - include(":app") include(":baselineprofile")