AndroidCalendar refactoring (#1560)

* [WIP] Refactor calendar sync manager to use synctools library

* [WIP] Update synctools

* [WIP] Tests

* Remove test logger module and update calendar color methods

* Fix migrations

* Update libs.versions.toml
This commit is contained in:
Ricki Hirner
2025-07-03 22:12:21 +02:00
committed by GitHub
parent b62c7eff0b
commit 58344099f7
22 changed files with 207 additions and 419 deletions

View File

@@ -11,7 +11,11 @@ import android.os.Bundle
import androidx.test.runner.AndroidJUnitRunner
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
import at.bitfire.davdroid.sync.SyncAdapterService
import at.bitfire.davdroid.test.BuildConfig
import at.bitfire.synctools.log.LogcatHandler
import dagger.hilt.android.testing.HiltTestApplication
import java.util.logging.Level
import java.util.logging.Logger
@Suppress("unused")
class HiltTestRunner : AndroidJUnitRunner() {
@@ -22,6 +26,12 @@ class HiltTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments)
// set root logger to adb Logcat
val rootLogger = Logger.getLogger("")
rootLogger.level = Level.ALL
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
// MockK requirements
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")

View File

@@ -1,33 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.log.LogcatHandler
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Singleton
/**
* Module that provides verbose logging for tests.
*/
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [LoggerModule::class]
)
@Module
class TestLoggerModule {
@Provides
@Singleton
fun logger(): Logger = Logger.getGlobal().apply {
level = Level.ALL
addHandler(LogcatHandler())
}
}

View File

@@ -12,11 +12,11 @@ import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.bitfire.synctools.test.InitCalendarProviderRule
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
@@ -37,21 +37,21 @@ class LocalCalendarTest {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
private lateinit var provider: ContentProviderClient
private lateinit var client: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
client.closeCompat()
}
}
@@ -61,8 +61,8 @@ class LocalCalendarTest {
@Before
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = LocalCalendar(AndroidCalendar.findByID(account, provider, ContentUris.parseId(uri)))
val provider = AndroidCalendarProvider(account, client)
calendar = LocalCalendar(provider.createAndGetCalendar(ContentValues()))
}
@After
@@ -102,7 +102,7 @@ class LocalCalendarTest {
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -110,7 +110,7 @@ class LocalCalendarTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -132,7 +132,7 @@ class LocalCalendarTest {
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -140,7 +140,7 @@ class LocalCalendarTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->

View File

@@ -12,10 +12,10 @@ import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
@@ -36,8 +36,8 @@ class LocalEventTest {
@Before
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = LocalCalendar(AndroidCalendar.findByID(account, provider, ContentUris.parseId(uri)))
val provider = AndroidCalendarProvider(account, client)
calendar = LocalCalendar(provider.createAndGetCalendar(ContentValues()))
}
@After
@@ -64,7 +64,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage should be the same as file name
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -91,7 +91,7 @@ class LocalEventTest {
assertEquals(event.uid, fileName)
// UID in calendar storage should still be set, too
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -119,7 +119,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -165,7 +165,7 @@ class LocalEventTest {
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -173,7 +173,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -194,7 +194,7 @@ class LocalEventTest {
val eventId = localEvent.id!!
// set event as dirty
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -202,7 +202,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -214,19 +214,19 @@ class LocalEventTest {
companion object {
private lateinit var provider: ContentProviderClient
private lateinit var client: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
client.closeCompat()
}
}

View File

@@ -127,16 +127,16 @@ class AccountSettingsMigration20Test {
Calendars.NAME to url,
Calendars.SYNC_EVENTS to 1
)
)!!
)!!.asSyncAdapter(account)
try {
migration.migrateCalendars(account, calDavServiceId = 1)
migration.migrateCalendars(account, 1)
provider.query(uri.asSyncAdapter(account), arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
provider.query(uri, arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
cursor.moveToNext()
assertEquals(collectionId, cursor.getLongOrNull(0))
}
} finally {
provider.delete(uri.asSyncAdapter(account), null, null)
provider.delete(uri, null, null)
}
}
}

View File

@@ -12,9 +12,11 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.LogFileHandler.Companion.debugDir
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.synctools.log.PlainTextFormatter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.Closeable
import java.io.File

View File

@@ -8,6 +8,7 @@ import android.content.Context
import android.util.Log
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.synctools.log.LogcatHandler
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -79,7 +80,7 @@ class LogManager @Inject constructor(
// root logger: set default log level and always log to logcat
val rootLogger = Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
rootLogger.addHandler(LogcatHandler())
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
// log to file, if requested
if (logToFile)

View File

@@ -1,52 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.os.Build
import android.util.Log
import at.bitfire.davdroid.BuildConfig
import com.google.common.base.Ascii
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
/**
* Logging handler that logs to Android logcat.
*/
internal class LogcatHandler: Handler() {
init {
formatter = PlainTextFormatter.LOGCAT
}
override fun publish(r: LogRecord) {
val level = r.level.intValue()
val text = formatter.format(r)
// get class name that calls the logger (or fall back to package name)
val className = if (r.sourceClassName != null)
PlainTextFormatter.shortClassName(r.sourceClassName)
else
BuildConfig.APPLICATION_ID
// truncate class name to 23 characters on Android <8, see Log documentation
val tag = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
Ascii.truncate(className, 23, "")
else
className
when {
level >= Level.SEVERE.intValue() -> Log.e(tag, text, r.thrown)
level >= Level.WARNING.intValue() -> Log.w(tag, text, r.thrown)
level >= Level.CONFIG.intValue() -> Log.i(tag, text, r.thrown)
level >= Level.FINER.intValue() -> Log.d(tag, text, r.thrown)
else -> Log.v(tag, text, r.thrown)
}
}
override fun flush() {}
override fun close() {}
}

View File

@@ -1,111 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import com.google.common.base.Ascii
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.logging.Formatter
import java.util.logging.LogRecord
class PlainTextFormatter(
private val withTime: Boolean,
private val withSource: Boolean,
private val padSource: Int = 30,
private val withException: Boolean,
private val lineSeparator: String?
): Formatter() {
companion object {
/**
* Formatter intended for logcat output.
*/
val LOGCAT = PlainTextFormatter(
withTime = false,
withSource = false,
withException = false,
lineSeparator = null
)
/**
* Formatter intended for file output.
*/
val DEFAULT = PlainTextFormatter(
withTime = true,
withSource = true,
withException = true,
lineSeparator = System.lineSeparator()
)
/**
* Maximum length of a log line (estimate).
*/
const val MAX_LENGTH = 10000
fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), ".")
.replace(Regex("\\$.*$"), "")
private fun stackTrace(ex: Throwable): String {
val writer = StringWriter()
ex.printStackTrace(PrintWriter(writer))
return writer.toString()
}
}
private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
override fun format(r: LogRecord): String {
val builder = StringBuilder()
if (withTime)
builder .append(timeFormat.format(Date(r.millis)))
.append(" ").append(r.threadID).append(" ")
if (withSource && r.sourceClassName != null) {
val className = shortClassName(r.sourceClassName)
if (className != r.loggerName) {
val classNameColumn = "[$className] ".padEnd(padSource)
builder.append(classNameColumn)
}
}
builder.append(truncate(r.message))
if (withException && r.thrown != null) {
val indentedStackTrace = stackTrace(r.thrown)
.replace("\n", "\n\t")
.removeSuffix("\t")
builder.append("\n\tEXCEPTION ").append(indentedStackTrace)
}
r.parameters?.let {
for ((idx, param) in it.withIndex()) {
builder.append("\n\tPARAMETER #").append(idx + 1).append(" = ")
val valStr = if (param == null)
"(null)"
else
truncate(param.toString())
builder.append(valStr)
}
}
if (lineSeparator != null)
builder.append(lineSeparator)
return builder.toString()
}
private fun truncate(s: String) =
Ascii.truncate(s, MAX_LENGTH, "[…]")
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.log
import at.bitfire.synctools.log.PlainTextFormatter
import com.google.common.base.Ascii
import java.util.logging.Handler
import java.util.logging.LogRecord

View File

@@ -9,11 +9,11 @@ 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.AndroidEvent
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.CalendarBatchOperation
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
@@ -41,17 +41,20 @@ class LocalCalendar(
get() = androidCalendar.displayName ?: androidCalendar.id.toString()
override val readOnly
get() = androidCalendar.accessLevel?.let { it <= Calendars.CAL_ACCESS_READ } ?: false
get() = androidCalendar.accessLevel <= Calendars.CAL_ACCESS_READ
override var lastSyncState: SyncState?
get() = androidCalendar.readSyncState()?.let { SyncState.fromString(it) }
get() = androidCalendar.readSyncState()?.let {
SyncState.fromString(it)
}
set(state) {
androidCalendar.writeSyncState(state.toString())
}
override fun findDeleted() =
androidCalendar.queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
androidCalendar
.findEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
.map { LocalEvent(it) }
override fun findDirty(): List<LocalEvent> {
@@ -62,7 +65,7 @@ class LocalCalendar(
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
* CUA each time the "Organizer" makes a significant revision to the calendar component.
*/
for (androidEvent in androidCalendar.queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
for (androidEvent in androidCalendar.findEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
val localEvent = LocalEvent(androidEvent)
try {
val event = requireNotNull(androidEvent.event)
@@ -87,44 +90,38 @@ class LocalCalendar(
}
override fun findByName(name: String) =
androidCalendar.queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()?.let { LocalEvent(it) }
androidCalendar.findEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()?.let { LocalEvent(it) }
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags)
return androidCalendar.provider.update(
Events.CONTENT_URI.asSyncAdapter(androidCalendar.account), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
override fun markNotDirty(flags: Int) =
androidCalendar.updateEvents(
contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(androidCalendar.id.toString())
)
}
override fun removeNotDirtyMarked(flags: Int): Int {
var deleted = 0
// list all non-dirty events with the given flags and delete every row + its exceptions
androidCalendar.provider.query(
Events.CONTENT_URI.asSyncAdapter(androidCalendar.account), arrayOf(Events._ID),
val batch = CalendarBatchOperation(androidCalendar.client)
androidCalendar.iterateEvents(
arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${AndroidEvent.COLUMN_FLAGS}=?",
arrayOf(androidCalendar.id.toString(), flags.toString()), null
)?.use { cursor ->
val batch = CalendarBatchOperation(androidCalendar.provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch += BatchOperation.CpoBuilder
.newDelete(Events.CONTENT_URI.asSyncAdapter(androidCalendar.account))
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
}
deleted = batch.commit()
arrayOf(androidCalendar.id.toString(), flags.toString())
) { values ->
val id = values.getAsInteger(Events._ID)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch += BatchOperation.CpoBuilder
.newDelete(Events.CONTENT_URI.asSyncAdapter(androidCalendar.account))
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
}
return deleted
return batch.commit()
}
override fun forgetETags() {
val values = contentValuesOf(AndroidEvent.COLUMN_ETAG to null)
androidCalendar.provider.update(
Events.CONTENT_URI.asSyncAdapter(androidCalendar.account), values, "${Events.CALENDAR_ID}=?",
arrayOf(androidCalendar.id.toString())
androidCalendar.updateEvents(
contentValuesOf(AndroidEvent.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
@@ -132,68 +129,60 @@ class LocalCalendar(
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
androidCalendar.provider.query(
Events.CONTENT_URI.asSyncAdapter(androidCalendar.account),
androidCalendar.iterateEvents(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString()), null
)?.use { cursor ->
while (cursor.moveToNext()) {
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val batch = CalendarBatchOperation(androidCalendar.provider)
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
// get original event's SEQUENCE
androidCalendar.provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account),
arrayOf(AndroidEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
val batch = CalendarBatchOperation(androidCalendar.client)
// re-schedule original event and set it to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
}
}
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventValues(originalID, arrayOf(AndroidEvent.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
// completely remove deleted exception
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
batch.commit()
}
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
batch.commit()
}
// process dirty exceptions
logger.info("Processing dirty exceptions")
androidCalendar.provider.query(
Events.CONTENT_URI.asSyncAdapter(androidCalendar.account),
androidCalendar.iterateEvents(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString()), null
)?.use { cursor ->
while (cursor.moveToNext()) {
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val batch = CalendarBatchOperation(androidCalendar.provider)
// original event to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(Events.DIRTY, 1)
// increase SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val sequence = values.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: set original event to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(originalID))
.withValue(Events.DIRTY, 1)
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(AndroidEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
}
@@ -203,23 +192,21 @@ class LocalCalendar(
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
androidCalendar.provider.query(
Events.CONTENT_URI.asSyncAdapter(androidCalendar.account),
// Iterate dirty main events without exceptions
androidCalendar.iterateEvents(
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events)
null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val eventID = cursor.getLong(0)
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
) { values ->
val eventID = values.getAsLong(Events._ID)
// get number of instances
val numEventInstances = AndroidEvent.numInstances(androidCalendar.provider, androidCalendar.account, eventID)
// get number of instances
val numEventInstances = AndroidEvent.numInstances(androidCalendar.client, androidCalendar.account, eventID)
// delete event if there are no instances
if (numEventInstances == 0) {
logger.info("Marking event #$eventID without instances as deleted")
AndroidEvent.markAsDeleted(androidCalendar.provider, androidCalendar.account, eventID)
}
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventID without instances as deleted")
AndroidEvent.markAsDeleted(androidCalendar.client, androidCalendar.account, eventID)
}
}
}

View File

@@ -6,11 +6,13 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Attendees
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import android.provider.CalendarContract.Reminders
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
@@ -18,10 +20,9 @@ import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
@@ -40,7 +41,7 @@ class LocalCalendarStore @Inject constructor(
override fun acquireContentProvider() =
context.contentResolver.acquireContentProviderClient(authority)
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
@@ -69,27 +70,49 @@ class LocalCalendarStore @Inject constructor(
}
logger.log(Level.INFO, "Adding local calendar", values)
val uri = AndroidCalendar.create(account, provider, values)
return LocalCalendar(AndroidCalendar.findByID(account, provider, ContentUris.parseId(uri)))
val provider = AndroidCalendarProvider(account, client)
return LocalCalendar(provider.createAndGetCalendar(values))
}
override fun getAll(account: Account, provider: ContentProviderClient) =
AndroidCalendar.find(account, provider, "${Calendars.SYNC_EVENTS}!=0", null)
override fun getAll(account: Account, client: ContentProviderClient) =
AndroidCalendarProvider(account, client)
.findCalendars("${Calendars.SYNC_EVENTS}!=0", null)
.map { LocalCalendar(it) }
override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
override fun update(client: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.androidCalendar.account)
val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())
logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values)
localCollection.androidCalendar.update(values)
val androidCalendar = localCollection.androidCalendar
val provider = AndroidCalendarProvider(androidCalendar.account, client)
provider.updateCalendar(androidCalendar.id, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars._SYNC_ID, info.id)
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
val values = contentValuesOf(
Calendars._SYNC_ID to info.id,
Calendars.CALENDAR_DISPLAY_NAME to
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName,
Calendars.ALLOWED_AVAILABILITY to arrayOf(
Events.AVAILABILITY_BUSY,
Events.AVAILABILITY_FREE
).joinToString(",") { it.toString() },
Calendars.ALLOWED_ATTENDEE_TYPES to arrayOf(
Attendees.TYPE_NONE,
Attendees.TYPE_OPTIONAL,
Attendees.TYPE_REQUIRED,
Attendees.TYPE_RESOURCE
).joinToString(",") { it.toString() },
Calendars.ALLOWED_REMINDERS to arrayOf(
Reminders.METHOD_DEFAULT,
Reminders.METHOD_ALERT,
Reminders.METHOD_EMAIL
).joinToString(",") { it.toString() },
)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
@@ -105,9 +128,6 @@ class LocalCalendarStore @Inject constructor(
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
}
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}

View File

@@ -39,7 +39,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
*
* @return the new local collection, or `null` if creation failed
*/
fun create(provider: ContentProviderClient, fromCollection: Collection): T?
fun create(client: ContentProviderClient, fromCollection: Collection): T?
/**
* Returns all local collections of the data store, including those which don't have a corresponding remote
@@ -50,7 +50,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
*
* @return a list of all local collections
*/
fun getAll(account: Account, provider: ContentProviderClient): List<T>
fun getAll(account: Account, client: ContentProviderClient): List<T>
/**
* Updates the local collection with the data from the given (remote) collection info.
@@ -59,7 +59,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
* @param localCollection the local collection to update
* @param fromCollection collection info
*/
fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection)
fun update(client: ContentProviderClient, localCollection: T, fromCollection: Collection)
/**
* Deletes the local collection.

View File

@@ -8,10 +8,11 @@ import android.accounts.Account
import android.content.Context
import android.content.pm.PackageManager
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Reminders
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Binds
@@ -45,8 +46,14 @@ class AccountSettingsMigration10 @Inject constructor(
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
provider.update(
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
AndroidCalendar.calendarBaseValues, null, null)
Calendars.CONTENT_URI.asSyncAdapter(account),
contentValuesOf(
Calendars.ALLOWED_REMINDERS to arrayOf(
Reminders.METHOD_DEFAULT,
Reminders.METHOD_ALERT,
Reminders.METHOD_EMAIL
).joinToString(",") { it.toString() }
), null, null)
}
}

View File

@@ -7,7 +7,7 @@ package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract
import androidx.annotation.OpenForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.Service
@@ -18,6 +18,7 @@ import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.ical4android.JtxCollection
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract
import dagger.Binds
import dagger.Module
@@ -85,19 +86,18 @@ class AccountSettingsMigration20 @Inject constructor(
} catch (_: SecurityException) {
// no contacts permission
null
}?.use { provider ->
for (calendar in calendarStore.getAll(account, provider))
provider.query(calendar.androidCalendar.calendarSyncURI(), arrayOf(Calendars.NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
cursor.getString(0)?.let { url ->
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
calendar.androidCalendar.update(
contentValuesOf(
Calendars._SYNC_ID to collection.id
))
}
}
}?.use { client ->
val calendarProvider = AndroidCalendarProvider(account, client)
// for each calendar, assign _SYNC_ID := ID if collection (identified by NAME field = URL)
for (calendar in calendarProvider.findCalendars()) {
val url = calendar.name ?: continue
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
calendar.update(contentValuesOf(
CalendarContract.Calendars._SYNC_ID to collection.id
))
}
}
}
}

View File

@@ -10,7 +10,7 @@ import android.content.Context
import android.provider.CalendarContract
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@@ -19,7 +19,6 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import javax.inject.Inject
import kotlin.use
class AccountSettingsMigration7 @Inject constructor(
@ApplicationContext private val context: Context
@@ -27,8 +26,9 @@ class AccountSettingsMigration7 @Inject constructor(
override fun migrate(account: Account) {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
AndroidCalendar.insertColors(provider, account)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { client ->
val provider = AndroidCalendarProvider(account, client)
provider.provideCss3ColorIndices()
}
// update allowed WiFi settings key

View File

@@ -11,7 +11,7 @@ import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -43,10 +43,12 @@ class CalendarSyncer @AssistedInject constructor(
override fun prepare(provider: ContentProviderClient): Boolean {
// Update colors
val accountSettings = accountSettingsFactory.create(account)
val calendarProvider = AndroidCalendarProvider(account, provider)
if (accountSettings.getEventColors())
AndroidCalendar.insertColors(provider, account)
calendarProvider.provideCss3ColorIndices()
else
AndroidCalendar.removeColors(provider, account)
calendarProvider.removeColorIndices()
return true
}

View File

@@ -12,15 +12,11 @@ import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.ical4android.Css3Color
import at.bitfire.synctools.icalendar.Css3Color
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import java.text.Collator
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -28,6 +24,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import java.text.Collator
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
@HiltViewModel(assistedFactory = CreateCalendarModel.Factory::class)
class CreateCalendarModel @AssistedInject constructor(

View File

@@ -62,7 +62,7 @@ import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog
import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog
import at.bitfire.ical4android.Css3Color
import at.bitfire.synctools.icalendar.Css3Color
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable

View File

@@ -25,7 +25,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import at.bitfire.ical4android.Css3Color
import at.bitfire.synctools.icalendar.Css3Color
@OptIn(ExperimentalLayoutApi::class)
@Composable

View File

@@ -1,46 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.logging.Level
import java.util.logging.LogRecord
class PlainTextFormatterTest {
private val minimum = PlainTextFormatter(
withTime = false,
withSource = false,
withException = false,
lineSeparator = null
)
@Test
fun test_format_param_null() {
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
parameters = arrayOf(null)
})
assertEquals("Message\n\tPARAMETER #1 = (null)", result)
}
@Test
fun test_format_param_object() {
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
parameters = arrayOf(object {
override fun toString() = "SomeObject[]"
})
})
assertEquals("Message\n\tPARAMETER #1 = SomeObject[]", result)
}
@Test
fun test_format_truncatesMessage() {
val result = minimum.format(LogRecord(Level.INFO, "a".repeat(50000)))
// PlainTextFormatter.MAX_LENGTH is 10,000
assertEquals(10000, result.length)
}
}

View File

@@ -20,7 +20,7 @@ androidx-test-junit = "1.2.1"
androidx-work = "2.10.2"
bitfire-cert4android = "b67ba86d31"
bitfire-dav4jvm = "acbfbacbaf"
bitfire-synctools = "7bd154b5be"
bitfire-synctools = "a365b91c04"
compose-accompanist = "0.37.3"
compose-bom = "2025.06.01"
dnsjava = "3.6.3"