Skip to content

Avoid NPE when missing DTSTART for recurring events #1336

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Apr 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/

package at.bitfire.davdroid

import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import kotlin.reflect.KClass

/**
* Use this custom rule to ignore exceptions thrown by another rule.
*
* @param innerRule The rule to wrap.
* @param exceptionsToIgnore The exceptions to ignore.
*/
class CatchExceptionsRule(
private val innerRule: TestRule,
private vararg val exceptionsToIgnore: KClass<out Throwable>
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
try {
innerRule.apply(base, description).evaluate()
} catch (e: Throwable) {
val shouldIgnore = exceptionsToIgnore.any { it.isInstance(e) }
if (shouldIgnore)
base.evaluate()
else
throw e
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/

package at.bitfire.davdroid.sync

import android.content.ContentProviderClient
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.CatchExceptionsRule
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.techbee.jtx.JtxContract
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.StringReader
import javax.inject.Inject


/**
* Ensure you have jtxBoard installed on the emulator, before running these tests. Otherwise they
* will be skipped.
*/
@HiltAndroidTest
class JtxSyncManagerTest {

@Inject
@ApplicationContext
lateinit var context: Context

@Inject
lateinit var httpClientBuilder: HttpClient.Builder

@Inject
lateinit var serviceRepository: DavServiceRepository

@Inject
lateinit var localJtxCollectionStore: LocalJtxCollectionStore

@Inject
lateinit var jtxSyncManagerFactory: JtxSyncManager.Factory

@get:Rule
val hiltRule = HiltAndroidRule(this)

@get:Rule
val permissionRule = CatchExceptionsRule(
GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX),
SecurityException::class
)

private val account = TestAccount.create()

private lateinit var provider: ContentProviderClient
private lateinit var syncManager: JtxSyncManager
private lateinit var localJtxCollection: LocalJtxCollection

@Before
fun setUp() {
hiltRule.inject()

// Check jtxBoard permissions were granted (+jtxBoard is installed); skip test otherwise
assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX))

// Acquire the jtx content provider
provider = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)!!

// Create dummy dependencies
val service = Service(0, account.name, Service.TYPE_CALDAV, null)
val serviceId = serviceRepository.insertOrReplace(service)
val dbCollection = Collection(
0,
serviceId,
type = Collection.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
)
localJtxCollection = localJtxCollectionStore.create(provider, dbCollection)!!
syncManager = jtxSyncManagerFactory.jtxSyncManager(
account = account,
extras = arrayOf(),
httpClient = httpClientBuilder.build(),
authority = JtxContract.AUTHORITY,
syncResult = SyncResult(),
localCollection = localJtxCollection,
collection = dbCollection
)
}

@After
fun tearDown() {
if (this::localJtxCollection.isInitialized)
localJtxCollectionStore.delete(localJtxCollection)
serviceRepository.deleteAll()
if (this::provider.isInitialized)
provider.closeCompat()
TestAccount.remove(account)
}


@Test
fun testProcessICalObject_addsVtodo() {
val calendar = "BEGIN:VCALENDAR\n" +
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Main VTODO)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"END:VTODO\n" +
"END:VCALENDAR"

// Should create "demo-calendar"
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))

// Verify main VTODO is created
val localJtxIcalObject = localJtxCollection.findByName("demo-calendar")!!
assertEquals("47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f", localJtxIcalObject.uid)
assertEquals("abc123", localJtxIcalObject.eTag)
assertEquals("Test Task (Main VTODO)", localJtxIcalObject.summary)
}

@Test
fun testProcessICalObject_addsRecurringVtodo_withoutDtStart() {
// Valid calendar example (See bitfireAT/davx5-ose#1265)
// Note: We don't support starting a recurrence from DUE (RFC 5545 leaves it open to interpretation)
val calendar = "BEGIN:VCALENDAR\n" +
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTODO\n" +

"SUMMARY:Test Task (Exception)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"DUE;TZID=America/New_York:20250228T130000\n" +
"RECURRENCE-ID;TZID=America/New_York:20250228T130000\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +

"END:VTODO\n" +
"BEGIN:VTODO\n" +

"SUMMARY:Test Task (Main VTODO)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"DUE;TZID=America/New_York:20250228T130000\n" + // Due date will NOT be assumed as start for recurrence
"SEQUENCE:1\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20250505T235959Z\n" +

"END:VTODO\n" +
"END:VCALENDAR"

// Create and store calendar
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))

// Verify main VTODO was created with RRULE present
val mainVtodo = localJtxCollection.findByName("demo-calendar")!!
assertEquals("Test Task (Main VTODO)", mainVtodo.summary)
assertEquals("FREQ=WEEKLY;UNTIL=20250505T235959Z;INTERVAL=1;BYDAY=FR", mainVtodo.rrule)

// Verify the RRULE exception instance was created with correct recurrence-id timezone
val vtodoException = localJtxCollection.findRecurInstance(
uid = "47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f",
recurid = "20250228T130000"
)!!
assertEquals("Test Task (Exception)", vtodoException.summary)
assertEquals("America/New_York", vtodoException.recuridTimezone)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,13 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
}

/**
* Finds and returns a recurring instance of a [LocalJtxICalObject]
* Finds and returns a recurrence instance of a [LocalJtxICalObject]
* @param uid UID of the main VTODO
* @param recurid RECURRENCE-ID of the recurrence instance
* @return LocalJtxICalObject or null if none or multiple entries found
*/
fun findRecurring(uid: String, recurid: String, dtstart: Long): LocalJtxICalObject? {
val values = queryRecur(uid, recurid, dtstart) ?: return null
fun findRecurInstance(uid: String, recurid: String): LocalJtxICalObject? {
val values = queryRecur(uid, recurid) ?: return null
return LocalJtxICalObject.Factory.fromProvider(this, values)
}

Expand Down
13 changes: 8 additions & 5 deletions app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package at.bitfire.davdroid.sync

import android.accounts.Account
import android.text.format.Formatter
import androidx.annotation.OpenForTesting
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
Expand Down Expand Up @@ -148,7 +149,8 @@ class JtxSyncManager @AssistedInject constructor(
context.getString(R.string.sync_invalid_event)


private fun processICalObject(fileName: String, eTag: String, reader: Reader) {
@OpenForTesting
internal fun processICalObject(fileName: String, eTag: String, reader: Reader) {
val icalobjects: MutableList<JtxICalObject> = mutableListOf()
try {
// parse the reader content and return the list of ICalObjects
Expand All @@ -163,11 +165,12 @@ class JtxSyncManager @AssistedInject constructor(

icalobjects.forEach { jtxICalObject ->
// if the entry is a recurring entry (and therefore has a recurid)
// we udpate the existing (generated) entry
if(jtxICalObject.recurid != null) {
val local = localCollection.findRecurring(jtxICalObject.uid, jtxICalObject.recurid!!, jtxICalObject.dtstart!!)
// we update the existing (generated) entry
val recurid = jtxICalObject.recurid
if(recurid != null) {
val local = localCollection.findRecurInstance(jtxICalObject.uid, recurid)
SyncException.wrapWithLocalResource(local) {
logger.log(Level.INFO, "Updating $fileName with recur instance ${jtxICalObject.recurid} in local list", jtxICalObject)
logger.log(Level.INFO, "Updating $fileName with recur instance $recurid in local list", jtxICalObject)
if(local != null) {
local.update(jtxICalObject)
} else {
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ androidx-test-junit = "1.2.1"
androidx-work = "2.10.0"
bitfire-cert4android = "701ae604eb"
bitfire-dav4jvm = "7d0c4f6fb9"
bitfire-ical4android = "883954c7ca"
bitfire-ical4android = "afa7724bcb"
bitfire-vcard4android = "ae5d609f92"
compose-accompanist = "0.37.2"
compose-bom = "2025.04.00"
Expand Down