Don't subclass AndroidEvent / AndroidCalendar populate / build methods anymore (#1544)

* Fix tests

* Update synctools; use AndroidCalendar SyncState

* Update synctools; move companion objects to end of class declarations
This commit is contained in:
Ricki Hirner
2025-06-25 14:17:58 +02:00
committed by GitHub
parent 42cd8d8631
commit a7f8ea8a48
7 changed files with 111 additions and 174 deletions

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -29,6 +30,7 @@ class OkhttpClientTest {
@Test
@SdkSuppress(maxSdkVersion = 34)
fun testIcloudWithSettings() {
httpClientBuilder.build().use { client ->
client.okHttpClient

View File

@@ -19,7 +19,7 @@ class Android10ResolverTest {
val FQDN_DAVX5 = "www.davx5.com"
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q, maxSdkVersion = 34)
fun testResolveA() {
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance<Inet4Address>().first()

View File

@@ -204,10 +204,12 @@ open class LocalAddressBook @AssistedInject constructor(
.newUpdate(groupsSyncUri())
.withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(Groups.ACCOUNT_NAME, newAccount.name)
.withValue(Groups.ACCOUNT_TYPE, newAccount.type)
batch += BatchOperation.CpoBuilder
.newUpdate(rawContactsSyncUri())
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
.withValue(RawContacts.ACCOUNT_TYPE, newAccount.type)
batch.commit()
// update AndroidAddressBook.account

View File

@@ -7,13 +7,13 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.CalendarBatchOperation
@@ -32,14 +32,9 @@ class LocalCalendar private constructor(
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
private val logger: Logger
get() = Logger.getLogger(javaClass.name)
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
private val logger: Logger
get() = Logger.getGlobal()
}
override val dbCollectionId: Long?
get() = syncId?.toLongOrNull()
@@ -50,29 +45,16 @@ class LocalCalendar private constructor(
override val title: String
get() = displayName ?: id.toString()
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
get() = accessLevel?.let { it <= Calendars.CAL_ACCESS_READ } ?: false
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
get() = readSyncState()?.let { SyncState.fromString(it) }
set(state) {
val values = contentValuesOf(COLUMN_SYNC_STATE to state.toString())
provider.update(calendarSyncURI(), values, null, null)
writeSyncState(state.toString())
}
override fun populate(info: ContentValues) {
super.populate(info)
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
}
override fun findDeleted() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
@@ -112,7 +94,7 @@ class LocalCalendar private constructor(
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(LocalEvent.COLUMN_FLAGS to flags)
val values = contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags)
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
@@ -122,7 +104,7 @@ class LocalCalendar private constructor(
var deleted = 0
// list all non-dirty events with the given flags and delete every row + its exceptions
provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${AndroidEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
val batch = CalendarBatchOperation(provider)
while (cursor.moveToNext()) {
@@ -138,7 +120,7 @@ class LocalCalendar private constructor(
}
override fun forgetETags() {
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
val values = contentValuesOf(AndroidEvent.COLUMN_ETAG to null)
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.toString()))
}
@@ -149,7 +131,7 @@ class LocalCalendar private constructor(
logger.info("Processing deleted exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
@@ -162,7 +144,7 @@ class LocalCalendar private constructor(
// get original event's SEQUENCE
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
arrayOf(AndroidEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
@@ -171,7 +153,7 @@ class LocalCalendar private constructor(
// re-schedule original event and set it to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(AndroidEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
}
}
@@ -186,7 +168,7 @@ class LocalCalendar private constructor(
logger.info("Processing dirty exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
@@ -203,7 +185,7 @@ class LocalCalendar private constructor(
// increase SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(AndroidEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}

View File

@@ -20,22 +20,103 @@ import at.bitfire.ical4android.Event
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.ical4jVersion
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.BatchOperation
import net.fortuna.ical4j.model.property.ProdId
import java.util.UUID
class LocalEvent: AndroidEvent, LocalResource<Event> {
class LocalEvent : AndroidEvent, LocalResource<Event> {
override var fileName: String?
get() = syncId
private set(value) {
syncId = value
}
val weAreOrganizer
get() = event!!.isOrganizer == true
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
: super(calendar, event, fileName, eTag, scheduleTag, flags)
private constructor(calendar: AndroidCalendar<*>, values: ContentValues)
: super(calendar, values)
/**
* Creates and sets a new UID in the calendar provider, if no UID is already set.
* It also returns the desired file name for the event for further processing in the sync algorithm.
*
* @return file name to use at upload
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = event!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in calendar provider
val values = contentValuesOf(Events.UID_2445 to newUid)
calendar.provider.update(eventSyncURI(), values, null, null)
// update this event
event?.uid = newUid
newUid
}
val uidIsGoodFilename = uid.all { char ->
// see RFC 2396 2.2
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
).contains(char)
}
return if (uidIsGoodFilename)
"$uid.ics" // use UID as file name
else
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
val values = ContentValues(5)
if (fileName != null)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
values.put(Events.DIRTY, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
override fun updateFlags(flags: Int) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
}
override fun resetDeleted() {
val values = contentValuesOf(Events.DELETED to 0)
calendar.provider.update(eventSyncURI(), values, null, null)
}
object Factory : AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
companion object {
init {
ICalendar.prodId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/" + ical4jVersion)
}
const val COLUMN_ETAG = Events.SYNC_DATA1
const val COLUMN_FLAGS = Events.SYNC_DATA2
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
/**
* Marks the event as deleted
* @param eventID
@@ -126,7 +207,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
val exceptionInstances = numDirectInstances(provider, account, exceptionEventID)
if (exceptionInstances == null)
// number of instances of exception can't be determined; so the total number of instances is also unclear
// number of instances of exception can't be determined; so the total number of instances is also unclear
return null
numInstances += exceptionInstances
@@ -136,134 +217,4 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
}
}
override var fileName: String? = null
private set
override var eTag: String? = null
override var scheduleTag: String? = null
override var flags: Int = 0
private set
var weAreOrganizer = false
private set
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
val event = requireNotNull(event)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
super.populateEvent(row, groupScheduled)
}
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
super.buildEvent(recurrence, builder)
}
/**
* Creates and sets a new UID in the calendar provider, if no UID is already set.
* It also returns the desired file name for the event for further processing in the sync algorithm.
*
* @return file name to use at upload
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = event!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in calendar provider
val values = contentValuesOf(Events.UID_2445 to newUid)
calendar.provider.update(eventSyncURI(), values, null, null)
// update this event
event?.uid = newUid
newUid
}
val uidIsGoodFilename = uid.all { char ->
// see RFC 2396 2.2
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?'
'-','_','.','!','~','*','\'','(',')' // allow unreserved characters
).contains(char)
}
return if (uidIsGoodFilename)
"$uid.ics" // use UID as file name
else
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
val values = ContentValues(5)
if (fileName != null)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
values.put(Events.DIRTY, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
override fun updateFlags(flags: Int) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
}
override fun resetDeleted() {
val values = contentValuesOf(Events.DELETED to 0)
calendar.provider.update(eventSyncURI(), values, null, null)
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
}
}

View File

@@ -110,7 +110,7 @@ class LocalTaskList private constructor(
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}

View File

@@ -20,7 +20,7 @@ androidx-test-junit = "1.2.1"
androidx-work = "2.10.2"
bitfire-cert4android = "b67ba86d31"
bitfire-dav4jvm = "05fb8ecda6"
bitfire-synctools = "366184ea7b"
bitfire-synctools = "78cacf8eba"
compose-accompanist = "0.37.3"
compose-bom = "2025.06.01"
dnsjava = "3.6.3"