Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-20 14:05:57 +01:00
parent 324070df30
commit 2d33a757bf
644 changed files with 99721 additions and 2 deletions

View file

@ -0,0 +1,83 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import at.bitfire.davdroid.di.DefaultDispatcher
import at.bitfire.davdroid.log.LogManager
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.ui.UiUtils
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidApp
class App: Application(), Configuration.Provider {
@Inject
lateinit var logger: Logger
/**
* Creates the [LogManager] singleton and thus initializes logging.
*/
@Inject
lateinit var logManager: LogManager
@Inject
@DefaultDispatcher
lateinit var defaultDispatcher: CoroutineDispatcher
@Inject
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
logger.fine("Logging using LogManager $logManager")
// set light/dark mode
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
// some current activity and causes an IllegalStateException in rare cases
// run startup plugins (sync)
for (plugin in plugins.sortedBy { it.priority() }) {
logger.fine("Running startup plugin: $plugin (onAppCreate)")
plugin.onAppCreate()
}
// don't block UI for some background checks
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(defaultDispatcher) {
// clean up orphaned accounts in DB from time to time
AccountsCleanupWorker.enable(this@App)
// create/update app shortcuts
UiUtils.updateShortcuts(this@App)
// run startup plugins (async)
for (plugin in plugins.sortedBy { it.priorityAsync() }) {
logger.fine("Running startup plugin: $plugin (onAppCreateAsync)")
plugin.onAppCreateAsync()
}
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import at.bitfire.synctools.icalendar.ical4jVersion
import ezvcard.Ezvcard
import net.fortuna.ical4j.model.property.ProdId
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
*/
object Constants {
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
// product IDs for iCalendar/vCard
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
}

View file

@ -0,0 +1,86 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import java.util.Collections
class TextTable(
val headers: List<String>
) {
companion object {
fun indent(str: String, pos: Int): String =
" ".repeat(pos) +
str.split('\n').joinToString("\n" + " ".repeat(pos))
}
constructor(vararg headers: String): this(headers.toList())
private val lines = mutableListOf<Array<String>>()
fun addLine(values: List<Any?>) {
if (values.size != headers.size)
throw IllegalArgumentException("Table line must have ${headers.size} column(s)")
lines += values.map {
it?.toString() ?: ""
}.toTypedArray()
}
fun addLine(vararg values: Any?) = addLine(values.toList())
override fun toString(): String {
val sb = StringBuilder()
val headerWidths = headers.map { it.length }
val colWidths = Array<Int>(headers.size) { colIdx ->
Collections.max(listOf(headerWidths[colIdx]) + lines.map { it[colIdx] }.map { it.length })
}
// first line
sb.append("\n")
for (colIdx in headers.indices)
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┐' else '┬')
sb.append('\n')
// header
sb.append('│')
for (colIdx in headers.indices)
sb .append(' ')
.append(headers[colIdx].padEnd(colWidths[colIdx] + 1))
.append('│')
sb.append('\n')
// separator between header and body
sb.append('├')
for (colIdx in headers.indices) {
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┤' else '┼')
}
sb.append('\n')
// body
for (line in lines) {
for (colIdx in headers.indices)
sb .append("")
.append(line[colIdx].padEnd(colWidths[colIdx] + 1))
sb.append("\n")
}
// last line
sb.append("")
for (colIdx in headers.indices) {
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┘' else '┴')
}
sb.append("\n\n")
return sb.toString()
}
}

View file

@ -0,0 +1,160 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import android.accounts.AccountManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.db.migration.AutoMigration12
import at.bitfire.davdroid.db.migration.AutoMigration16
import at.bitfire.davdroid.db.migration.AutoMigration18
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.Writer
import javax.inject.Singleton
/**
* The app database. Managed via android jetpack room. Room provides an abstraction
* layer over SQLite.
*
* Note: In SQLite PRAGMA foreign_keys is off by default. Room activates it for
* production (non-test) databases.
*/
@Database(entities = [
Service::class,
HomeSet::class,
Collection::class,
Principal::class,
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 18, autoMigrations = [
AutoMigration(from = 17, to = 18, spec = AutoMigration18::class),
AutoMigration(from = 16, to = 17), // collection: add VAPID key
AutoMigration(from = 15, to = 16, spec = AutoMigration16::class),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 13, to = 14),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 11, to = 12, spec = AutoMigration12::class),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 9, to = 10)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object AppDatabaseModule {
@Provides
@Singleton
fun appDatabase(
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
@ApplicationContext context: Context,
manualMigrations: Set<@JvmSuppressWildcards Migration>,
notificationRegistry: NotificationRegistry
): AppDatabase = Room
.databaseBuilder(context, AppDatabase::class.java, "services.db")
.addMigrations(*manualMigrations.toTypedArray())
.apply {
for (spec in autoMigrations)
addAutoMigrationSpec(spec)
}
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
.setContentText(context.getString(R.string.database_destructive_migration_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(
TaskStackBuilder.create(context)
.addNextIntent(launcherIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.setAutoCancel(true)
.build()
}
// remove all accounts because they're unfortunately useless without database
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
am.removeAccountExplicitly(account)
}
})
.build()
}
// DAOs
abstract fun serviceDao(): ServiceDao
abstract fun homeSetDao(): HomeSetDao
abstract fun collectionDao(): CollectionDao
abstract fun principalDao(): PrincipalDao
abstract fun syncStatsDao(): SyncStatsDao
abstract fun webDavDocumentDao(): WebDavDocumentDao
abstract fun webDavMountDao(): WebDavMountDao
// helpers
fun dump(writer: Writer, ignoreTables: Array<String>) {
val db = openHelper.readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
while (cursorTables.moveToNext()) {
val tableName = cursorTables.getString(0)
if (ignoreTables.contains(tableName)) {
writer.append("$tableName: ")
db.query("SELECT COUNT(*) FROM $tableName").use { cursor ->
if (cursor.moveToNext())
writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n")
}
} else {
writer.append("$tableName\n")
db.query("SELECT * FROM $tableName").use { cursor ->
val table = TextTable(*cursor.columnNames)
val cols = cursor.columnCount
// print rows
while (cursor.moveToNext()) {
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
table.addLine(*values)
}
writer.append(table.toString())
}
}
}
db.endTransaction()
}
}
}

View file

@ -0,0 +1,266 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.annotation.StringDef
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.Source
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.push.WebPush
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.ical4android.util.DateUtils
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@Retention(AnnotationRetention.SOURCE)
@StringDef(
Collection.TYPE_ADDRESSBOOK,
Collection.TYPE_CALENDAR,
Collection.TYPE_WEBCAL
)
annotation class CollectionType
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL),
ForeignKey(entity = Principal::class, parentColumns = arrayOf("id"), childColumns = arrayOf("ownerId"), onDelete = ForeignKey.SET_NULL)
],
indices = [
Index("serviceId","type"),
Index("homeSetId","type"),
Index("ownerId","type"),
Index("pushTopic","type"),
Index("url")
]
)
data class Collection(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/**
* Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely
* identifiable via its [serviceId] and [url].
*/
val serviceId: Long = 0,
/**
* A home set this collection belongs to. Multiple homesets are not supported.
* If *null* the collection is considered homeless.
*/
val homeSetId: Long? = null,
/**
* Principal who is owner of this collection.
*/
val ownerId: Long? = null,
/**
* Type of service. CalDAV or CardDAV
*/
@CollectionType
val type: String,
/**
* Address where this collection lives - with trailing slash
*/
val url: HttpUrl,
/**
* Whether we have the permission to change contents of the collection on the server.
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
*/
val privWriteContent: Boolean = true,
/**
* Whether we have the permission to delete the collection on the server
*/
val privUnbind: Boolean = true,
/**
* Whether the user has manually set the "force read-only" flag.
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
*/
val forceReadOnly: Boolean = false,
/**
* Human-readable name of the collection
*/
val displayName: String? = null,
/**
* Human-readable description of the collection
*/
val description: String? = null,
// CalDAV only
val color: Int? = null,
/** default timezone (only timezone ID, like `Europe/Vienna`) */
val timezoneId: String? = null,
/** whether the collection supports VEVENT; in case of calendars: null means true */
val supportsVEVENT: Boolean? = null,
/** whether the collection supports VTODO; in case of calendars: null means true */
val supportsVTODO: Boolean? = null,
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
val supportsVJOURNAL: Boolean? = null,
/** Webcal subscription source URL */
val source: HttpUrl? = null,
/** whether this collection has been selected for synchronization */
val sync: Boolean = false,
/** WebDAV-Push topic */
val pushTopic: String? = null,
/** WebDAV-Push: whether this collection supports the Web Push Transport */
@ColumnInfo(defaultValue = "0")
val supportsWebPush: Boolean = false,
/** WebDAV-Push: VAPID public key */
val pushVapidKey: String? = null,
/** WebDAV-Push subscription URL */
val pushSubscription: String? = null,
/** when the [pushSubscription] expires (timestamp, used to determine whether we need to re-subscribe) */
val pushSubscriptionExpires: Long? = null,
/** when the [pushSubscription] was created/updated (timestamp) */
val pushSubscriptionCreated: Long? = null
) {
companion object {
const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK"
const val TYPE_CALENDAR = "CALENDAR"
const val TYPE_WEBCAL = "WEBCAL"
/**
* Generates a collection entity from a WebDAV response.
* @param dav WebDAV response
* @return null if the response doesn't represent a collection
*/
fun fromDavResponse(dav: Response): Collection? {
val url = UrlUtils.withTrailingSlash(dav.href)
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
when {
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
else -> null
}
} ?: return null
var privWriteContent = true
var privUnbind = true
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
privWriteContent = privilegeSet.mayWriteContent
privUnbind = privilegeSet.mayUnbind
}
val displayName = dav[DisplayName::class.java]?.displayName.trimToNull()
var description: String? = null
var color: Int? = null
var timezoneId: String? = null
var supportsVEVENT: Boolean? = null
var supportsVTODO: Boolean? = null
var supportsVJOURNAL: Boolean? = null
var source: HttpUrl? = null
when (type) {
TYPE_ADDRESSBOOK -> {
dav[AddressbookDescription::class.java]?.let { description = it.description }
}
TYPE_CALENDAR, TYPE_WEBCAL -> {
dav[CalendarDescription::class.java]?.let { description = it.description }
dav[CalendarColor::class.java]?.let { color = it.color }
dav[CalendarTimezoneId::class.java]?.let { timezoneId = it.identifier }
if (timezoneId == null)
dav[CalendarTimezone::class.java]?.vTimeZone?.let {
timezoneId = DateUtils.parseVTimeZone(it)?.timeZoneId?.value
}
if (type == TYPE_CALENDAR) {
supportsVEVENT = true
supportsVTODO = true
supportsVJOURNAL = true
dav[SupportedCalendarComponentSet::class.java]?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
supportsVJOURNAL = it.supportsJournal
}
} else { // Type.WEBCAL
dav[Source::class.java]?.let {
source = it.hrefs.firstOrNull()?.let { rawHref ->
val href = rawHref
.replace("^webcal://".toRegex(), "http://")
.replace("^webcals://".toRegex(), "https://")
href.toHttpUrlOrNull()
}
}
supportsVEVENT = true
}
}
}
// WebDAV-Push
var supportsWebPush = false
var vapidPublicKey: String? = null
dav[PushTransports::class.java]?.let { pushTransports ->
for (transport in pushTransports.transports)
if (transport is WebPush) {
supportsWebPush = true
vapidPublicKey = transport.vapidPublicKey?.key
}
}
val pushTopic = dav[Topic::class.java]?.topic
return Collection(
type = type,
url = url,
privWriteContent = privWriteContent,
privUnbind = privUnbind,
displayName = displayName,
description = description,
color = color,
timezoneId = timezoneId,
supportsVEVENT = supportsVEVENT,
supportsVTODO = supportsVTODO,
supportsVJOURNAL = supportsVJOURNAL,
source = source,
supportsWebPush = supportsWebPush,
pushVapidKey = vapidPublicKey,
pushTopic = pushTopic
)
}
}
// calculated properties
fun title() = displayName ?: url.lastSegment
fun readOnly() = forceReadOnly || !privWriteContent
}

View file

@ -0,0 +1,132 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface CollectionDao {
@Query("SELECT * FROM collection WHERE id=:id")
fun get(id: Long): Collection?
@Query("SELECT * FROM collection WHERE id=:id")
suspend fun getAsync(id: Long): Collection?
@Query("SELECT * FROM collection WHERE id=:id")
fun getFlow(id: Long): Flow<Collection?>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
suspend fun getByService(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId")
fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List<Collection>
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
suspend fun getSyncableByPushTopic(topic: String): Collection?
@Suppress("unused") // for build variant
@Query("SELECT * FROM collection WHERE sync")
fun getSyncCollections(): List<Collection>
@Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1")
suspend fun getFirstVapidKey(serviceId: Long): String?
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
suspend fun anyOfType(serviceId: Long, @CollectionType type: String): Boolean
@Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL")
suspend fun anyPushCapable(): Boolean
/**
* Returns collections which
* - support VEVENT and/or VTODO (= supported calendar collections), or
* - have supportsVEVENT = supportsVTODO = null (= address books)
*/
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " +
"AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName COLLATE NOCASE, URL COLLATE NOCASE")
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
fun getByServiceAndSync(serviceId: Long): List<Collection>
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName COLLATE NOCASE, collection.url COLLATE NOCASE")
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url")
fun getByServiceAndUrl(serviceId: Long, url: String): Collection?
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncCalendars(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncJtxCollections(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncTaskLists(serviceId: Long): List<Collection>
/**
* Get a list of collections that are both sync enabled and push capable (supportsWebPush and
* pushTopic is available).
*/
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL")
suspend fun getPushCapableSyncCollections(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL")
suspend fun getPushRegistered(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL AND NOT sync")
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long): List<Collection>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAsync(collection: Collection): Long
@Update
fun update(collection: Collection)
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
suspend fun updateSync(id: Long, sync: Boolean)
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @param collection Collection to be inserted or updated
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrl(collection: Collection): Long = getByServiceAndUrl(
collection.serviceId,
collection.url.toString()
)?.let { localCollection ->
update(collection.copy(id = localCollection.id))
localCollection.id
} ?: insert(collection)
@Delete
fun delete(collection: Collection)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.TypeConverter
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
@TypeConverter
fun mediaTypeToString(mediaType: MediaType?) =
mediaType?.toString()
@TypeConverter
fun stringToHttpUrl(url: String?): HttpUrl? =
url?.toHttpUrlOrNull()
@TypeConverter
fun stringToMediaType(mimeType: String?): MediaType? =
mimeType?.toMediaTypeOrNull()
}

View file

@ -0,0 +1,43 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.DavUtils.lastSegment
import okhttp3.HttpUrl
@Entity(tableName = "homeset",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = ["id"], childColumns = ["serviceId"], onDelete = ForeignKey.CASCADE)
],
indices = [
// index by service; no duplicate URLs per service
Index("serviceId", "url", unique = true)
]
)
data class HomeSet(
@PrimaryKey(autoGenerate = true)
val id: Long,
val serviceId: Long,
/**
* Whether this homeset belongs to the [Service.principal] given by [serviceId].
*/
val personal: Boolean,
val url: HttpUrl,
val privBind: Boolean = true,
val displayName: String? = null
) {
fun title() = displayName ?: url.lastSegment
}

View file

@ -0,0 +1,60 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE id=:homesetId")
fun getById(homesetId: Long): HomeSet?
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url")
fun getByUrl(serviceId: Long, url: String): HomeSet?
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE")
fun getBindableByAccountAndServiceTypeFlow(accountName: String, @ServiceType serviceType: String): Flow<List<HomeSet>>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByServiceFlow(serviceId: Long): Flow<List<HomeSet>>
@Insert
fun insert(homeSet: HomeSet): Long
@Update
fun update(homeset: HomeSet)
/**
* If a homeset with the given service ID and URL already exists, it is updated with the other fields.
* Otherwise, a new homeset is inserted.
*
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @param homeSet home set to insert/update
*
* @return ID of the row that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
getByUrl(homeSet.serviceId, homeSet.url.toString())?.let { existingHomeset ->
update(homeSet.copy(id = existingHomeset.id))
existingHomeset.id
} ?: insert(homeSet)
@Delete
fun delete(homeset: HomeSet)
}

View file

@ -0,0 +1,70 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.util.trimToNull
import okhttp3.HttpUrl
/**
* A principal entity representing a WebDAV principal (rfc3744).
*/
@Entity(tableName = "principal",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
],
indices = [
// index by service, urls are unique
Index("serviceId", "url", unique = true)
]
)
data class Principal(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val serviceId: Long,
/** URL of the principal, always without trailing slash */
val url: HttpUrl,
val displayName: String? = null
) {
companion object {
/**
* Generates a principal entity from a WebDAV response.
* @param dav WebDAV response (make sure that you have queried `DAV:resource-type` and `DAV:display-name`)
* @return generated principal data object (with `id`=0), `null` if the response doesn't represent a principal
*/
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
// Check if response is a principal
val resourceType = dav[ResourceType::class.java] ?: return null
if (!resourceType.types.contains(ResourceType.PRINCIPAL))
return null
// Try getting the display name of the principal
val displayName: String? = dav[DisplayName::class.java]?.displayName.trimToNull()
// Create and return principal - even without it's display name
return Principal(
serviceId = serviceId,
url = UrlUtils.omitTrailingSlash(dav.href),
displayName = displayName
)
}
fun fromServiceAndUrl(service: Service, url: HttpUrl) = Principal(
serviceId = service.id,
url = UrlUtils.omitTrailingSlash(url)
)
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import okhttp3.HttpUrl
@Dao
interface PrincipalDao {
@Query("SELECT * FROM principal WHERE id=:id")
fun get(id: Long): Principal
@Query("SELECT * FROM principal WHERE id=:id")
suspend fun getAsync(id: Long): Principal
@Query("SELECT * FROM principal WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Principal>
@Query("SELECT * FROM principal WHERE serviceId=:serviceId AND url=:url")
fun getByUrl(serviceId: Long, url: HttpUrl): Principal?
/**
* Gets all principals who do not own any collections
*/
@Query("SELECT * FROM principal WHERE principal.id NOT IN (SELECT ownerId FROM collection WHERE ownerId IS NOT NULL)")
fun getAllWithoutCollections(): List<Principal>
@Insert
fun insert(principal: Principal): Long
@Update
fun update(principal: Principal)
@Delete
fun delete(principal: Principal)
/**
* Inserts, updates or just gets existing principal if its display name has not
* changed (will not update/overwrite with null values).
*
* @param principal Principal to be inserted or updated
* @return ID of the newly inserted or already existing principal
*/
fun insertOrUpdate(serviceId: Long, principal: Principal): Long {
// Try to get existing principal by URL
val oldPrincipal = getByUrl(serviceId, principal.url)
// Insert new principal if not existing
if (oldPrincipal == null)
return insert(principal)
// Otherwise update the existing principal
if (principal.displayName != oldPrincipal.displayName)
update(principal.copy(id = oldPrincipal.id))
// In any case return the id of the principal
return oldPrincipal.id
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.annotation.StringDef
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Retention(AnnotationRetention.SOURCE)
@StringDef(Service.TYPE_CALDAV, Service.TYPE_CARDDAV)
annotation class ServiceType
/**
* A service entity.
*
* Services represent accounts and are unique. They are of type CardDAV or CalDAV and may have an associated principal.
*/
@Entity(tableName = "service",
indices = [
// only one service per type and account
Index("accountName", "type", unique = true)
])
data class Service(
@PrimaryKey(autoGenerate = true)
val id: Long,
val accountName: String,
@ServiceType
val type: String,
val principal: HttpUrl? = null
) {
companion object {
const val TYPE_CALDAV = "caldav"
const val TYPE_CARDDAV = "carddav"
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service?
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow<Service?>
@Query("SELECT id FROM service WHERE accountName=:accountName")
suspend fun getIdsByAccountAsync(accountName: String): List<Long>
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): Service?
@Query("SELECT * FROM service WHERE id=:id")
suspend fun getAsync(id: Long): Service?
@Query("SELECT * FROM service")
suspend fun getAll(): List<Service>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(service: Service): Long
@Query("DELETE FROM service")
fun deleteAll()
@Query("DELETE FROM service WHERE accountName=:accountName")
suspend fun deleteByAccount(accountName: String)
@Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)")
fun deleteExceptAccounts(accountNames: Array<String>)
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
suspend fun renameAccount(oldName: String, newName: String)
}

View file

@ -0,0 +1,28 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "syncstats",
foreignKeys = [
ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE)
],
indices = [
Index(value = ["collectionId", "dataType"], unique = true)
]
)
data class SyncStats(
@PrimaryKey(autoGenerate = true)
val id: Long,
val collectionId: Long,
val dataType: String,
val lastSync: Long
)

View file

@ -0,0 +1,22 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncStatsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrReplace(syncStats: SyncStats)
@Query("SELECT * FROM syncstats WHERE collectionId=:id")
fun getByCollectionIdFlow(id: Long): Flow<List<SyncStats>>
}

View file

@ -0,0 +1,140 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import android.annotation.SuppressLint
import android.os.Bundle
import android.provider.DocumentsContract.Document
import android.webkit.MimeTypeMap
import androidx.core.os.bundleOf
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
import at.bitfire.davdroid.webdav.DocumentState
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import java.io.FileNotFoundException
import java.time.Instant
@Entity(
tableName = "webdav_document",
foreignKeys = [
ForeignKey(entity = WebDavMount::class, parentColumns = ["id"], childColumns = ["mountId"], onDelete = ForeignKey.CASCADE),
ForeignKey(entity = WebDavDocument::class, parentColumns = ["id"], childColumns = ["parentId"], onDelete = ForeignKey.CASCADE)
],
indices = [
Index("mountId", "parentId", "name", unique = true),
Index("parentId")
]
)
// If any column name is modified, also change it in [DavDocumentsProvider$queryChildDocuments]
data class WebDavDocument(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/** refers to the [WebDavMount] the document belongs to */
val mountId: Long,
/** refers to parent document (*null* when this document is a root document) */
val parentId: Long?,
/** file name (without any slashes) */
val name: String,
val isDirectory: Boolean = false,
val displayName: String? = null,
val mimeType: MediaType? = null,
val eTag: String? = null,
val lastModified: Long? = null,
val size: Long? = null,
val mayBind: Boolean? = null,
val mayUnbind: Boolean? = null,
val mayWriteContent: Boolean? = null,
val quotaAvailable: Long? = null,
val quotaUsed: Long? = null
) {
fun cacheKey(): CacheKey? {
if (eTag != null || lastModified != null)
return CacheKey(id, DocumentState(eTag, lastModified?.let { ts -> Instant.ofEpochMilli(ts) }))
return null
}
@SuppressLint("InlinedApi")
fun toBundle(parent: WebDavDocument?): Bundle {
if (parent?.isDirectory == false)
throw IllegalArgumentException("Parent must be a directory")
val bundle = bundleOf(
Document.COLUMN_DOCUMENT_ID to id.toString(),
Document.COLUMN_DISPLAY_NAME to name
)
displayName?.let { bundle.putString(Document.COLUMN_SUMMARY, it) }
size?.let { bundle.putLong(Document.COLUMN_SIZE, it) }
lastModified?.let { bundle.putLong(Document.COLUMN_LAST_MODIFIED, it) }
// see RFC 3744 appendix B for required privileges for the various operations
var flags = Document.FLAG_SUPPORTS_COPY
if (isDirectory) {
bundle.putString(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
if (mayBind != false)
flags += Document.FLAG_DIR_SUPPORTS_CREATE
} else {
val reportedMimeType = mimeType ?:
MimeTypeMap.getSingleton().getMimeTypeFromExtension(
MimeTypeMap.getFileExtensionFromUrl(name)
)?.toMediaTypeOrNull() ?:
MEDIA_TYPE_OCTET_STREAM
bundle.putString(Document.COLUMN_MIME_TYPE, reportedMimeType.toString())
if (mimeType?.type == "image")
flags += Document.FLAG_SUPPORTS_THUMBNAIL
if (mayWriteContent != false)
flags += Document.FLAG_SUPPORTS_WRITE
}
if (parent?.mayUnbind != false)
flags += Document.FLAG_SUPPORTS_DELETE or
Document.FLAG_SUPPORTS_MOVE or
Document.FLAG_SUPPORTS_RENAME
bundle.putInt(Document.COLUMN_FLAGS, flags)
return bundle
}
suspend fun toHttpUrl(db: AppDatabase): HttpUrl {
val mount = db.webDavMountDao().getById(mountId)
val segments = mutableListOf(name)
var parentIter = parentId
while (parentIter != null) {
val parent = db.webDavDocumentDao().get(parentIter) ?: throw FileNotFoundException()
segments += parent.name
parentIter = parent.parentId
}
val builder = mount.url.newBuilder()
for (segment in segments.reversed())
builder.addPathSegment(segment)
return builder.build()
}
/**
* Represents a WebDAV document in a given state (with a given ETag/Last-Modified).
*/
data class CacheKey(
val docId: Long,
val documentState: DocumentState
)
}

View file

@ -0,0 +1,107 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.RoomRawQuery
import androidx.room.Transaction
import androidx.room.Update
@Dao
interface WebDavDocumentDao {
@Query("SELECT * FROM webdav_document WHERE id=:id")
fun get(id: Long): WebDavDocument?
@Query("SELECT * FROM webdav_document WHERE mountId=:mountId AND (parentId=:parentId OR (parentId IS NULL AND :parentId IS NULL)) AND name=:name")
fun getByParentAndName(mountId: Long, parentId: Long?, name: String): WebDavDocument?
@RawQuery
fun query(query: RoomRawQuery): List<WebDavDocument>
/**
* Gets all the child documents from a given parent id.
*
* @param parentId The id of the parent document to get the documents from.
* @param orderBy If desired, a SQL clause to specify how to order the results.
* **The caller is responsible for the correct formatting of this argument. Syntax won't be validated!**
*/
fun getChildren(parentId: Long, orderBy: String = DEFAULT_ORDER): List<WebDavDocument> {
return query(
RoomRawQuery("SELECT * FROM webdav_document WHERE parentId = ? ORDER BY $orderBy") {
it.bindLong(1, parentId)
}
)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(document: WebDavDocument): Long
@Query("DELETE FROM webdav_document WHERE parentId=:parentId")
fun removeChildren(parentId: Long)
@Insert
fun insert(document: WebDavDocument): Long
@Update
fun update(document: WebDavDocument)
@Delete
fun delete(document: WebDavDocument)
// complex operations
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdate(document: WebDavDocument): Long {
val parentId = document.parentId
?: return insert(document)
val existingDocument = getByParentAndName(document.mountId, parentId, document.name)
?: return insert(document)
update(document.copy(id = existingDocument.id))
return existingDocument.id
}
@Transaction
fun getOrCreateRoot(mount: WebDavMount): WebDavDocument {
getByParentAndName(mount.id, null, "")?.let { existing ->
return existing
}
val newDoc = WebDavDocument(
mountId = mount.id,
parentId = null,
name = "",
isDirectory = true,
displayName = mount.name
)
val id = insertOrReplace(newDoc)
return newDoc.copy(id = id)
}
companion object {
/**
* Default ORDER BY value to use when content provider doesn't specify a sort order:
* _sort by name (directories first)_
*/
const val DEFAULT_ORDER = "isDirectory DESC, name ASC"
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Entity
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Entity(tableName = "webdav_mount")
data class WebDavMount(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
/** display name of the WebDAV mount */
val name: String,
/** URL of the WebDAV service, including trailing slash */
val url: HttpUrl
// credentials are stored using CredentialsStore
)

View file

@ -0,0 +1,42 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface WebDavMountDao {
@Delete
suspend fun deleteAsync(mount: WebDavMount)
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
suspend fun getAll(): List<WebDavMount>
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
fun getAllFlow(): Flow<List<WebDavMount>>
@Query("SELECT * FROM webdav_mount WHERE id=:id")
suspend fun getById(id: Long): WebDavMount
@Insert
suspend fun insert(mount: WebDavMount): Long
// complex queries
/**
* Gets a list of mounts with the quotas of their root document, if available.
*/
@Query("SELECT webdav_mount.*, quotaAvailable, quotaUsed FROM webdav_mount " +
"LEFT JOIN webdav_document ON (webdav_mount.id=webdav_document.mountId AND webdav_document.parentId IS NULL) " +
"ORDER BY webdav_mount.name, webdav_mount.url")
fun getAllWithQuotaFlow(): Flow<List<WebDavMountWithQuota>>
}

View file

@ -0,0 +1,18 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.room.Embedded
/**
* A [WebDavMount] with an optional root document (that contains information like quota).
*/
data class WebDavMountWithQuota(
@Embedded
val mount: WebDavMount,
val quotaAvailable: Long? = null,
val quotaUsed: Long? = null
)

View file

@ -0,0 +1,47 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import android.content.Context
import androidx.room.DeleteColumn
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import java.util.logging.Logger
import javax.inject.Inject
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration12 @Inject constructor(
@ApplicationContext val context: Context,
val logger: Logger
): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
logger.info("Database update to v12, refreshing services to get display names of owners")
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
RefreshCollectionsWorker.enqueue(context, serviceId)
}
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AutoMigrationModule {
@Binds @IntoSet
abstract fun provide(impl: AutoMigration12): AutoMigrationSpec
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.ical4android.util.DateUtils
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Inject
/**
* The timezone column has been renamed to timezoneId, but still contains the VTIMEZONE.
* So we need to parse the VTIMEZONE, extract the timezone ID and save it back.
*/
@ProvidedAutoMigrationSpec
@RenameColumn(tableName = "collection", fromColumnName = "timezone", toColumnName = "timezoneId")
class AutoMigration16 @Inject constructor(): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.query("SELECT id, timezoneId FROM collection").use { cursor ->
while (cursor.moveToNext()) {
val id: Long = cursor.getLong(0)
val timezoneDef: String = cursor.getString(1) ?: continue
val vTimeZone = DateUtils.parseVTimeZone(timezoneDef)
val timezoneId = vTimeZone?.timeZoneId?.value
db.execSQL("UPDATE collection SET timezoneId=? WHERE id=?", arrayOf(timezoneId, id))
}
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AutoMigrationModule {
@Binds @IntoSet
abstract fun provide(impl: AutoMigration16): AutoMigrationSpec
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.ical4android.TaskProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Inject
/**
* Renames syncstats.authority to dataType, and maps values to SyncDataType enum names.
*/
@ProvidedAutoMigrationSpec
@RenameColumn(tableName = "syncstats", fromColumnName = "authority", toColumnName = "dataType")
class AutoMigration18 @Inject constructor() : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
// Drop old unique index
db.execSQL("DROP INDEX IF EXISTS index_syncstats_collectionId_authority")
val seen = mutableSetOf<Pair<Long, String>>() // (collectionId, dataType)
db.query(
"SELECT id, collectionId, dataType, lastSync FROM syncstats ORDER BY lastSync DESC"
).use { cursor ->
val idIndex = cursor.getColumnIndex("id")
val collectionIdIndex = cursor.getColumnIndex("collectionId")
val authorityIndex = cursor.getColumnIndex("dataType")
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val collectionId = cursor.getLong(collectionIdIndex)
val authority = cursor.getString(authorityIndex)
val dataType = when (authority) {
ContactsContract.AUTHORITY -> SyncDataType.CONTACTS.name
CalendarContract.AUTHORITY -> SyncDataType.EVENTS.name
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.TasksOrg.authority,
TaskProvider.ProviderName.OpenTasks.authority -> SyncDataType.TASKS.name
else -> {
db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id))
continue
}
}
val keyValue = collectionId to dataType
if (seen.contains(keyValue)) {
db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id))
} else {
db.execSQL("UPDATE syncstats SET dataType = ? WHERE id = ?", arrayOf<Any>(dataType, id))
seen.add(keyValue)
}
}
}
// Create new unique index
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_syncstats_collectionId_dataType ON syncstats (collectionId, dataType)")
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AutoMigrationModule {
@Binds
@IntoSet
abstract fun provide(impl: AutoMigration18): AutoMigrationSpec
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration2 = Migration(1, 2) { db ->
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
db.execSQL("UPDATE collections SET type=(" +
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
"FROM services WHERE _id=collections.serviceID" +
")",
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration2Module {
@Provides @IntoSet
fun provide(): Migration = Migration2
}

View file

@ -0,0 +1,26 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import java.util.logging.Logger
val Migration3 = Migration(2, 3) { db ->
// We don't have access to the context in a Room migration now, so
// we will just drop those settings from old DAVx5 versions.
Logger.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration3Module {
@Provides @IntoSet
fun provide(): Migration = Migration3
}

View file

@ -0,0 +1,23 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration4 = Migration(3, 4) { db ->
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration4Module {
@Provides @IntoSet
fun provide(): Migration = Migration4
}

View file

@ -0,0 +1,29 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration5 = Migration(4, 5) { db ->
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration5Module {
@Provides @IntoSet
fun provide(): Migration = Migration5
}

View file

@ -0,0 +1,71 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration6 = Migration(5, 6) { db ->
val sql = arrayOf(
// migrate "services" to "service": rename columns, make id NOT NULL
"CREATE TABLE service(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"accountName TEXT NOT NULL," +
"type TEXT NOT NULL," +
"principal TEXT DEFAULT NULL" +
")",
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
"DROP TABLE services",
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
"CREATE TABLE homeset(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"url TEXT NOT NULL," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
"DROP TABLE homesets",
// migrate "collections" to "collection": rename columns, make id NOT NULL
"CREATE TABLE collection(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"type TEXT NOT NULL," +
"url TEXT NOT NULL," +
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
"privUnbind INTEGER NOT NULL DEFAULT 1," +
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
"displayName TEXT DEFAULT NULL," +
"description TEXT DEFAULT NULL," +
"color INTEGER DEFAULT NULL," +
"timezone TEXT DEFAULT NULL," +
"supportsVEVENT INTEGER DEFAULT NULL," +
"supportsVTODO INTEGER DEFAULT NULL," +
"supportsVJOURNAL INTEGER DEFAULT NULL," +
"source TEXT DEFAULT NULL," +
"sync INTEGER NOT NULL DEFAULT 0," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
"DROP TABLE collections"
)
sql.forEach { db.execSQL(it) }
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration6Module {
@Provides @IntoSet
fun provide(): Migration = Migration6
}

View file

@ -0,0 +1,24 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration7 = Migration(6, 7) { db ->
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration7Module {
@Provides @IntoSet
fun provide(): Migration = Migration7
}

View file

@ -0,0 +1,26 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration8 = Migration(7, 8) { db ->
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration8Module {
@Provides @IntoSet
fun provide(): Migration = Migration8
}

View file

@ -0,0 +1,30 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration9 = Migration(8, 9) { db ->
db.execSQL("CREATE TABLE syncstats (" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
"authority TEXT NOT NULL," +
"lastSync INTEGER NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration9Module {
@Provides @IntoSet
fun provide(): Migration = Migration9
}

View file

@ -0,0 +1,61 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class DefaultDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class IoDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class MainDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class SyncDispatcher
@Module
@InstallIn(SingletonComponent::class)
class CoroutineDispatchersModule {
@Provides
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@MainDispatcher
fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main
/**
* A dispatcher for background sync operations. They're not run on [ioDispatcher] because there can
* be many long-blocking operations at the same time which shouldn't never block other I/O operations
* like database access for the UI.
*
* It uses the I/O dispatcher and limits the number of parallel operations to the number of available processors.
*/
@Provides
@SyncDispatcher
@Singleton
fun syncDispatcher(): CoroutineDispatcher =
Dispatchers.IO.limitedParallelism(Runtime.getRuntime().availableProcessors())
}

View file

@ -0,0 +1,30 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
class CoroutineScopesModule {
@Singleton
@Provides
@ApplicationScope
fun applicationScope(@MainDispatcher mainDispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope(SupervisorJob() + mainDispatcher)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.logging.Logger
@Module
@InstallIn(SingletonComponent::class)
class LoggerModule {
@Provides
fun globalLogger(): Logger = Logger.getGlobal()
}

View file

@ -0,0 +1,177 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Process
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
import java.util.Date
import java.util.logging.FileHandler
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
import java.util.logging.Logger
import javax.inject.Inject
/**
* Logging handler that logs to a debug log file.
*
* Shows a permanent notification as long as it's active (until [close] is called).
*
* Only one [LogFileHandler] should be active at once, because the notification is shared.
*/
class LogFileHandler @Inject constructor(
@ApplicationContext val context: Context,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry
): Handler(), Closeable {
companion object {
private const val DEBUG_INFO_DIRECTORY = "debug"
/**
* Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored.
* Must match the contents of `res/xml/debug.paths.xml`.
*
* @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully.
*/
fun debugDir(context: Context): File? {
val dir = File(context.filesDir, DEBUG_INFO_DIRECTORY)
if (dir.exists() && dir.isDirectory)
return dir
if (dir.mkdir())
return dir
return null
}
/**
* The file (in [debugDir]) where verbose logs are stored.
*
* @return The file where verbose logs are stored, or `null` if there's no [debugDir].
*/
fun getDebugLogFile(context: Context): File? {
val logDir = debugDir(context) ?: return null
return File(logDir, "davx5-log.txt")
}
}
private var fileHandler: FileHandler? = null
private val notificationManager = NotificationManagerCompat.from(context)
private val logFile = getDebugLogFile(context)
init {
if (logFile != null) {
if (logFile.createNewFile())
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
// actual logging is handled by a FileHandler
fileHandler = FileHandler(logFile.toString(), true).apply {
formatter = PlainTextFormatter.DEFAULT
}
showNotification()
} else {
logger.severe("Couldn't create log file in app-private directory $DEBUG_INFO_DIRECTORY/.")
level = Level.OFF
}
}
@Synchronized
override fun publish(record: LogRecord) {
fileHandler?.publish(record)
}
@Synchronized
override fun flush() {
fileHandler?.flush()
}
@Synchronized
override fun close() {
fileHandler?.close()
fileHandler = null
// remove all files in debug info directory, may also contain zip files from debug info activity etc.
logFile?.parentFile?.deleteRecursively()
removeNotification()
}
// notifications
private fun showNotification() {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_VERBOSE_LOGGING) {
val builder = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_DEBUG)
builder.setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.app_settings_logging))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(
context.getString(
R.string.logging_notification_text, context.getString(
R.string.app_name
)
)
)
.setOngoing(true)
// add action to view/share the logs
val shareIntent = DebugInfoActivity.IntentBuilder(context)
.newTask()
.share()
val pendingShare = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(shareIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_share,
context.getString(R.string.logging_notification_view_share),
pendingShare
).build()
)
// add action to disable verbose logging
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingPref = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(prefIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_settings,
context.getString(R.string.logging_notification_disable),
pendingPref
).build()
)
builder.build()
}
}
private fun removeNotification() {
notificationManager.cancel(NotificationRegistry.NOTIFY_VERBOSE_LOGGING)
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
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
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* Handles logging configuration and which loggers are active at a moment.
* To initialize, just make sure that the [LogManager] singleton is created.
*
* Configures the root logger like this:
*
* - Always logs to logcat.
* - Watches the "log to file" preference and activates or deactivates file logging accordingly.
* - If "log to file" is enabled, log level is set to [Level.ALL].
* - Otherwise, log level is set to [Level.INFO].
*
* Preferred ways to get a [Logger] are:
*
* - `@Inject` [Logger] for a general-purpose logger when injection is possible
* - `Logger.getGlobal()` for a general-purpose logger
* - `Logger.getLogger(javaClass.name)` for a specific logger that can be customized
*
* When using the global logger, the class name of the logging calls will still be logged, so there's
* no need to always get a separate logger for each class (only if the class wants to customize it).
*/
@Singleton
class LogManager @Inject constructor(
@ApplicationContext private val context: Context,
private val logFileHandler: Provider<LogFileHandler>,
private val logger: Logger,
private val prefs: PreferenceRepository
) : AutoCloseable {
private val scope = CoroutineScope(Dispatchers.Default)
init {
// observe preference changes
scope.launch {
prefs.logToFileFlow().collect {
reloadConfig()
}
}
reloadConfig()
}
override fun close() {
scope.cancel()
}
@Synchronized
fun reloadConfig() {
val logToFile = prefs.logToFile()
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(logger.name, Log.DEBUG)
logger.info("Verbose logging = $logVerbose; log to file = $logToFile")
// reset existing loggers and initialize from assets/logging.properties
context.assets.open("logging.properties").use {
val javaLogManager = java.util.logging.LogManager.getLogManager()
javaLogManager.readConfiguration(it)
}
// 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(BuildConfig.APPLICATION_ID))
// log to file, if requested
if (logToFile)
rootLogger.addHandler(logFileHandler.get())
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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
/**
* Handler that writes log messages to a string buffer.
*
* @param maxSize Maximum size of the buffer. If the buffer exceeds this size, it will be truncated.
*/
class StringHandler(
private val maxSize: Int
): Handler() {
companion object {
const val TRUNCATION_MARKER = "[...]"
}
val builder = StringBuilder()
init {
formatter = PlainTextFormatter.DEFAULT
}
override fun publish(record: LogRecord) {
var text = formatter.format(record)
val currentSize = builder.length
val sizeLeft = maxSize - currentSize
when {
// Append the text if there is enough space
sizeLeft > text.length ->
builder.append(text)
// Truncate the text if there is not enough space
sizeLeft > TRUNCATION_MARKER.length -> {
text = Ascii.truncate(text, maxSize - currentSize, TRUNCATION_MARKER)
builder.append(text)
}
// Do nothing if the buffer is already full
}
}
override fun flush() {}
override fun close() {}
override fun toString() = builder.toString()
}

View file

@ -0,0 +1,73 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.net.DnsResolver
import android.os.Build
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.runBlocking
import org.xbill.DNS.EDNSOption
import org.xbill.DNS.Message
import org.xbill.DNS.Resolver
import org.xbill.DNS.TSIG
import java.io.IOException
import java.time.Duration
/**
* dnsjava [Resolver] that uses Android's [DnsResolver] API, which can resolve raw queries and
* is available since Android 10.
*/
@RequiresApi(Build.VERSION_CODES.Q)
class Android10Resolver : Resolver {
private val executor = Dispatchers.IO.asExecutor()
private val resolver = DnsResolver.getInstance()
override fun send(query: Message): Message = runBlocking {
val future = CompletableDeferred<Message>()
resolver.rawQuery(null, query.toWire(), DnsResolver.FLAG_EMPTY, executor, null, object: DnsResolver.Callback<ByteArray> {
override fun onAnswer(rawAnswer: ByteArray, rcode: Int) {
future.complete(Message((rawAnswer)))
}
override fun onError(error: DnsResolver.DnsException) {
// wrap into IOException as expected by dnsjava
future.completeExceptionally(IOException(error))
}
})
future.await()
}
override fun setPort(port: Int) {
// not applicable
}
override fun setTCP(flag: Boolean) {
// not applicable
}
override fun setIgnoreTruncation(flag: Boolean) {
// not applicable
}
override fun setEDNS(version: Int, payloadSize: Int, flags: Int, options: MutableList<EDNSOption>?) {
// not applicable
}
override fun setTSIGKey(key: TSIG?) {
// not applicable
}
override fun setTimeout(timeout: Duration?) {
// not applicable
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.security.KeyChain
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.net.Socket
import java.security.Principal
import javax.net.ssl.X509ExtendedKeyManager
/**
* KeyManager that provides a client certificate and private key from the Android KeyChain.
*
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
*/
class ClientCertKeyManager @AssistedInject constructor(
@Assisted private val alias: String,
@ApplicationContext private val context: Context
): X509ExtendedKeyManager() {
@AssistedFactory
interface Factory {
fun create(alias: String): ClientCertKeyManager
}
val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) = arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) = alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}

View file

@ -0,0 +1,166 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Lookup
import org.xbill.DNS.Record
import org.xbill.DNS.Resolver
import org.xbill.DNS.ResolverConfig
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.TXTRecord
import java.net.InetAddress
import java.util.LinkedList
import java.util.TreeMap
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.random.Random
/**
* Allows to resolve SRV/TXT records. Chooses the correct resolver, DNS servers etc.
*/
class DnsRecordResolver @Inject constructor(
@ApplicationContext val context: Context,
private val logger: Logger
) {
// resolving
/**
* Fallback DNS server that will be used when other DNS are not known or working.
* `9.9.9.9` belongs to Cloudflare who promise good privacy.
*/
private val DNS_FALLBACK = InetAddress.getByAddress(byteArrayOf(9,9,9,9))
private val resolver by lazy { chooseResolver() }
init {
// empty initialization for dnsjava because we set the servers for each request
ResolverConfig.setConfigProviders(listOf())
}
/**
* Creates a matching Resolver, depending on the Android version:
*
* Android 10+: Android10Resolver, which uses the raw DNS resolver that comes with Android
* Android <10: ExtendedResolver, which uses the known DNS servers to resolve DNS queries
*/
private fun chooseResolver(): Resolver =
if (Build.VERSION.SDK_INT >= 29) {
/* Since Android 10, there's a native DnsResolver API that allows to send SRV queries without
knowing which DNS servers have to be used. DNS over TLS is now also supported. */
logger.fine("Using Android 10+ DnsResolver")
Android10Resolver()
} else {
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
The current version of dnsjava relies on these properties to find the default name servers,
so we have to add the servers explicitly (fortunately, there's an Android API to
get the DNS servers of the network connections). */
val dnsServers = LinkedList<InetAddress>()
val connectivity = context.getSystemService<ConnectivityManager>()!!
@Suppress("DEPRECATION")
connectivity.allNetworks.forEach { network ->
val active = connectivity.getNetworkInfo(network)?.isConnected == true
connectivity.getLinkProperties(network)?.let { link ->
if (active)
// active connection, insert at top of list
dnsServers.addAll(0, link.dnsServers)
else
// inactive connection, insert at end of list
dnsServers.addAll(link.dnsServers)
}
}
// fallback: add Quad9 DNS in case that no other DNS works
dnsServers.add(DNS_FALLBACK)
val uniqueDnsServers = LinkedHashSet<InetAddress>(dnsServers)
val simpleResolvers = uniqueDnsServers.map { dns ->
logger.fine("Adding DNS server ${dns.hostAddress}")
SimpleResolver(dns)
}
// combine SimpleResolvers which query one DNS server each to an ExtendedResolver
ExtendedResolver(simpleResolvers.toTypedArray())
}
fun resolve(query: String, type: Int): Array<out Record> {
val lookup = Lookup(query, type)
lookup.setResolver(resolver)
return lookup.run().orEmpty()
}
// record selection
/**
* Selects the best SRV record from a list of records, based on algorithm from RFC 2782.
*
* @param records the records to choose from
* @param randomGenerator a random number generator to use for random selection
* @return the best SRV record, or `null` if no SRV record is available
*/
fun bestSRVRecord(records: Array<out Record>, randomGenerator: Random = Random.Default): SRVRecord? {
val srvRecords = records.filterIsInstance<SRVRecord>()
if (srvRecords.size <= 1)
return srvRecords.firstOrNull()
/* RFC 2782
Priority
The priority of this target host. A client MUST attempt to
contact the target host with the lowest-numbered priority it can
reach; target hosts with the same priority SHOULD be tried in an
order defined by the weight field. [...]
Weight
A server selection mechanism. The weight field specifies a
relative weight for entries with the same priority. [...]
To select a target to be contacted next, arrange all SRV RRs
(that have not been ordered yet) in any order, except that all
those with weight 0 are placed at the beginning of the list.
Compute the sum of the weights of those RRs, and with each RR
associate the running sum in the selected order. Then choose a
uniform random number between 0 and the sum computed
(inclusive), and select the RR whose running sum value is the
first in the selected order which is greater than or equal to
the random number selected. The target host specified in the
selected SRV RR is the next one to be contacted by the client.
*/
// Select records which have the minimum priority
val minPriority = srvRecords.minOfOrNull { it.priority }
val usableRecords = srvRecords.filter { it.priority == minPriority }
.sortedBy { it.weight != 0 } // and put those with weight 0 first
val map = TreeMap<Int, SRVRecord>()
var runningWeight = 0
for (record in usableRecords) {
val weight = record.weight
runningWeight += weight
map[runningWeight] = record
}
val selector = (0..runningWeight).random(randomGenerator)
return map.ceilingEntry(selector)!!.value
}
fun pathsFromTXTRecords(records: Array<out Record>): List<String> {
val paths = LinkedList<String>()
records.filterIsInstance<TXTRecord>().forEach { txt ->
for (segment in txt.strings as List<String>)
if (segment.startsWith("path="))
paths.add(segment.substring(5))
}
return paths
}
}

View file

@ -0,0 +1,315 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.accounts.Account
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.ForegroundTracker
import com.google.common.net.HttpHeaders
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import okhttp3.Authenticator
import okhttp3.Cache
import okhttp3.ConnectionSpec
import okhttp3.CookieJar
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
class HttpClient(
val okHttpClient: OkHttpClient
): AutoCloseable {
override fun close() {
okHttpClient.cache?.close()
}
// builder
/**
* Builder for the [HttpClient].
*
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
* there's only one [Builder] object and setting properties from one location would influence the others.
*
* To generate multiple clients, inject and use `Provider<HttpClient.Builder>` instead.
*/
class Builder @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext private val context: Context,
defaultLogger: Logger,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val keyManagerFactory: ClientCertKeyManager.Factory,
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
private val settingsManager: SettingsManager
) {
// property setters/getters
private var logger: Logger = defaultLogger
fun setLogger(logger: Logger): Builder {
this.logger = logger
return this
}
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder {
loggerInterceptorLevel = level
return this
}
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
private var cookieStore: CookieJar = MemoryCookieStore()
fun setCookieStore(cookieStore: CookieJar): Builder {
this.cookieStore = cookieStore
return this
}
private var authenticationInterceptor: Interceptor? = null
private var authenticator: Authenticator? = null
private var certificateAlias: String? = null
fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): Builder {
val credentials = getCredentials()
if (credentials.authState != null) {
// OAuth
authenticationInterceptor = oAuthInterceptorFactory.create(
readAuthState = {
// We don't use the "credentials" object from above because it may contain an outdated access token
// when readAuthState is called. Instead, we fetch the up-to-date auth-state.
getCredentials().authState
},
writeAuthState = { authState ->
updateAuthState?.invoke(authState)
}
)
} else if (credentials.username != null && credentials.password != null) {
// basic/digest auth
val authHandler = BasicDigestAuthHandler(
domain = UrlUtils.hostToDomain(host),
username = credentials.username,
password = credentials.password.asCharArray(),
insecurePreemptive = true
)
authenticationInterceptor = authHandler
authenticator = authHandler
}
// client certificate
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
return this
}
private var followRedirects = false
fun followRedirects(follow: Boolean): Builder {
followRedirects = follow
return this
}
private var cache: Cache? = null
@Suppress("unused")
fun withDiskCache(maxSize: Long = 10*1024*1024): Builder {
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
logger.fine("Using disk cache: $cacheDir")
cache = Cache(cacheDir, maxSize)
break
}
}
return this
}
// convenience builders from other classes
/**
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
*
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
*
* @param account the account to take authentication from
* @param onlyHost if set: only authenticate for this host name
*
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
*/
@WorkerThread
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
val accountSettings = accountSettingsFactory.create(account)
authenticate(
host = onlyHost,
getCredentials = {
accountSettings.credentials()
},
updateAuthState = { authState ->
accountSettings.updateAuthState(authState)
}
)
return this
}
/**
* Same as [fromAccount], but can be called on any thread.
*
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
*/
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
fromAccount(account, onlyHost)
}
// actual builder
fun build(): HttpClient {
val okBuilder = OkHttpClient.Builder()
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
// traffic within a minute, a sync will be cancelled.
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
// don't allow redirects by default because it would break PROPFIND handling
.followRedirects(followRedirects)
// add User-Agent to every request
.addInterceptor(UserAgentInterceptor)
// connection-private cookie store
.cookieJar(cookieStore)
// allow cleartext and TLS 1.2+
.connectionSpecs(listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.MODERN_TLS
))
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
.addInterceptor(BrotliInterceptor)
// add cache, if requested
.cache(cache)
// app-wide custom proxy support
buildProxy(okBuilder)
// add authentication
buildAuthentication(okBuilder)
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
loggingInterceptor.redactHeader(HttpHeaders.AUTHORIZATION)
loggingInterceptor.redactHeader(HttpHeaders.COOKIE)
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE)
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2)
loggingInterceptor.level = loggerInterceptorLevel
okBuilder.addNetworkInterceptor(loggingInterceptor)
}
return HttpClient(okBuilder.build())
}
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
// basic/digest auth and OAuth
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
authenticator?.let { okBuilder.authenticator(it) }
// client certificate
val keyManager: KeyManager? = certificateAlias?.let { alias ->
try {
val manager = keyManagerFactory.create(alias)
logger.fine("Using certificate $alias for authentication")
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
manager
} catch (e: IllegalArgumentException) {
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
null
}
}
// cert4android integration
val certManager = CustomCertManager(
context = context,
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
appInForeground = if (BuildConfig.customCertsUI)
ForegroundTracker.inForeground // interactive mode
else
null // non-interactive mode
)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
/* km = */ if (keyManager != null) arrayOf(keyManager) else null,
/* tm = */ arrayOf(certManager),
/* random = */ null
)
okBuilder
.sslSocketFactory(sslContext.socketFactory, certManager)
.hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier))
}
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
try {
val proxyTypeValue = settingsManager.getInt(Settings.PROXY_TYPE)
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
// we set our own proxy
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
InetSocketAddress(
settingsManager.getString(Settings.PROXY_HOST),
settingsManager.getInt(Settings.PROXY_PORT)
)
}
val proxy =
when (proxyTypeValue) {
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
else -> throw IllegalArgumentException("Invalid proxy type")
}
okBuilder.proxy(proxy)
logger.log(Level.INFO, "Using proxy setting", proxy)
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
}
}
}

View file

@ -0,0 +1,81 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.annotation.VisibleForTesting
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import java.util.LinkedList
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
class MemoryCookieStore : CookieJar {
data class StorageKey(
val domain: String,
val path: String,
val name: String
)
private val storage = mutableMapOf<StorageKey, Cookie>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
/* [RFC 6265 5.3 Storage Model]
11. If the cookie store contains a cookie with the same name,
domain, and path as the newly created cookie:
1. Let old-cookie be the existing cookie with the same name,
domain, and path as the newly created cookie. (Notice that
this algorithm maintains the invariant that there is at most
one such cookie.)
2. If the newly created cookie was received from a "non-HTTP"
API and the old-cookie's http-only-flag is set, abort these
steps and ignore the newly created cookie entirely.
3. Update the creation-time of the newly created cookie to
match the creation-time of the old-cookie.
4. Remove the old-cookie from the cookie store.
*/
synchronized(storage) {
storage.putAll(cookies.map {
StorageKey(
domain = it.domain,
path = it.path,
name = it.name
) to it
})
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookies = LinkedList<Cookie>()
synchronized(storage) {
val iter = storage.iterator()
while (iter.hasNext()) {
val (_, cookie) = iter.next()
// remove expired cookies
if (cookie.expiresAt <= System.currentTimeMillis()) {
iter.remove()
continue
}
// add applicable cookies to result
if (cookie.matches(url))
cookies += cookie
}
}
return cookies
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import javax.inject.Inject
/**
* Implements Nextcloud Login Flow v2.
*
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class NextcloudLoginFlow @Inject constructor(
httpClientBuilder: HttpClient.Builder
): AutoCloseable {
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"
/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
const val DAV_PATH = "remote.php/dav"
}
val httpClient = httpClientBuilder
.build()
override fun close() {
httpClient.close()
}
// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var token: String? = null
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = null
pollUrl = null
token = null
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
return loginUrl
}
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath
if (path.endsWith(FLOW_V2_PATH))
// already a Login Flow v2 URL
return baseUrl
if (path.endsWith(FLOW_V1_PATH))
// Login Flow v1 URL, rewrite to v2
return baseUrl.newBuilder()
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
.build()
// other URL, make it a Login Flow v2 URL
return baseUrl.newBuilder()
.addPathSegments(FLOW_V2_PATH)
.build()
}
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = json.getString("server").withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword").toSensitiveString()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.okHttpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import java.net.URI
object OAuthFastmail {
// DAVx5 Client ID (issued by Fastmail)
private const val CLIENT_ID = "34ce41ae"
private val SCOPES = arrayOf(
"https://www.fastmail.com/dev/protocol-caldav", // CalDAV
"https://www.fastmail.com/dev/protocol-carddav" // CardDAV
)
/**
* The base URL for Fastmail. Note that this URL is used for both CalDAV and CardDAV;
* the SRV records of the domain are checked to determine the respective service base URL.
*/
val baseUri: URI = URI.create("https://fastmail.com/")
private val serviceConfig = AuthorizationServiceConfiguration(
"https://api.fastmail.com/oauth/authorize".toUri(),
"https://api.fastmail.com/oauth/refresh".toUri()
)
fun signIn(email: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
serviceConfig,
CLIENT_ID,
ResponseTypeValues.CODE,
OAuthIntegration.redirectUri
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import java.net.URI
object OAuthGoogle {
// davx5integration@gmail.com (for davx5-ose)
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
private val SCOPES = arrayOf(
"https://www.googleapis.com/auth/calendar", // CalDAV
"https://www.googleapis.com/auth/carddav" // CardDAV
)
/**
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
* _calid_ of the primary calendar is the account name.
*
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
* calendars.
*/
fun baseUri(googleAccount: String): URI =
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
private val serviceConfig = AuthorizationServiceConfiguration(
"https://accounts.google.com/o/oauth2/v2/auth".toUri(),
"https://oauth2.googleapis.com/token".toUri()
)
fun signIn(email: String?, customClientId: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
serviceConfig,
customClientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
OAuthIntegration.redirectUri
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.network.OAuthIntegration.redirectUri
import kotlinx.coroutines.CompletableDeferred
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.TokenResponse
/**
* Integration with OpenID AppAuth (Android)
*/
object OAuthIntegration {
/** redirect URI, must be registered in Manifest */
val redirectUri =
(BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri()
/**
* Called by the authorization service when the login is finished and [redirectUri] is launched.
*
* @param authService authorization service
* @param authResponse response from the server (coming over the Intent from the browser / [AuthorizationContract])
*/
suspend fun authenticate(authService: AuthorizationService, authResponse: AuthorizationResponse): AuthState {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val authStateFuture = CompletableDeferred<AuthState>()
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
authStateFuture.complete(authState)
} else if (refreshTokenException != null)
authStateFuture.completeExceptionally(refreshTokenException)
}
return authStateFuture.await()
}
class AuthorizationContract(
private val authService: AuthorizationService
) : ActivityResultContract<AuthorizationRequest, AuthorizationResponse?>() {
override fun createIntent(context: Context, input: AuthorizationRequest) =
authService.getAuthorizationRequestIntent(input)
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? =
intent?.let { AuthorizationResponse.fromIntent(it) }
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import at.bitfire.davdroid.BuildConfig
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
/**
* Sends an OAuth Bearer token authorization as described in RFC 6750.
*
* @param readAuthState callback that fetches an up-to-date authorization state
* @param writeAuthState callback that persists a new authorization state
*/
class OAuthInterceptor @AssistedInject constructor(
@Assisted private val readAuthState: () -> AuthState?,
@Assisted private val writeAuthState: (AuthState) -> Unit,
private val authServiceProvider: Provider<AuthorizationService>,
private val logger: Logger
): Interceptor {
@AssistedFactory
interface Factory {
fun create(readAuthState: () -> AuthState?, writeAuthState: (AuthState) -> Unit): OAuthInterceptor
}
override fun intercept(chain: Interceptor.Chain): Response {
val rq = chain.request().newBuilder()
/** Syntax for the "Authorization" header [RFC 6750 2.1]:
*
* b64token = 1*( ALPHA / DIGIT /
* "-" / "." / "_" / "~" / "+" / "/" ) *"="
* credentials = "Bearer" 1*SP b64token
*/
val accessToken = provideAccessToken()
if (accessToken != null)
rq.header("Authorization", "Bearer $accessToken")
else
logger.severe("No access token available, won't authenticate")
return chain.proceed(rq.build())
}
/**
* Provides a fresh access token for authorization. Uses the current one if it's still valid,
* or requests a new one if necessary.
*
* This method is synchronized / thread-safe so that it can be called for multiple HTTP requests at the same time.
*
* @return access token or `null` if no valid access token is available (usually because of an error during refresh)
*/
fun provideAccessToken(): String? = synchronized(javaClass) {
// if possible, use cached access token
val authState = readAuthState() ?: return null
if (authState.isAuthorized && authState.accessToken != null && !authState.needsTokenRefresh) {
if (BuildConfig.DEBUG) // log sensitive information (refresh/access token) only in debug builds
logger.log(Level.FINEST, "Using cached AuthState", authState.jsonSerializeString())
return authState.accessToken
}
// request fresh access token
logger.fine("Requesting fresh access token")
val accessTokenFuture = CompletableFuture<String>()
val authService = authServiceProvider.get()
try {
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
// appauth internally fetches the new token over HttpURLConnection in an AsyncTask
if (BuildConfig.DEBUG)
logger.log(Level.FINEST, "Got new AuthState", authState.jsonSerializeString())
// persist updated AuthState
writeAuthState(authState)
if (ex != null)
accessTokenFuture.completeExceptionally(ex)
else if (accessToken != null)
accessTokenFuture.complete(accessToken)
}
accessTokenFuture.join()
} catch (e: CompletionException) {
logger.log(Level.SEVERE, "Couldn't obtain access token", e.cause)
null
} finally {
authService.dispose()
}
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.openid.appauth.AppAuthConfiguration
import net.openid.appauth.AuthorizationService
import java.net.HttpURLConnection
import java.net.URL
@Module
@InstallIn(SingletonComponent::class)
object OAuthModule {
/**
* Make sure to call [AuthorizationService.dispose] when obtaining an instance.
*
* Creating an instance is expensive (involves CustomTabsManager), so don't create an
* instance if not necessary (use Provider/Lazy).
*/
@Provides
fun authorizationService(@ApplicationContext context: Context): AuthorizationService =
AuthorizationService(context,
AppAuthConfiguration.Builder()
.setConnectionBuilder { uri ->
val url = URL(uri.toString())
(url.openConnection() as HttpURLConnection).apply {
setRequestProperty("User-Agent", UserAgentInterceptor.userAgent)
}
}.build()
)
}

View file

@ -0,0 +1,33 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.os.Build
import at.bitfire.davdroid.BuildConfig
import okhttp3.Interceptor
import okhttp3.OkHttp
import okhttp3.Response
import java.util.Locale
import java.util.logging.Logger
object UserAgentInterceptor: Interceptor {
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {
Logger.getGlobal().info("Will set User-Agent: $userAgent")
}
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}

View file

@ -0,0 +1,119 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.XmlReader
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.Lazy
import org.unifiedpush.android.connector.data.PushMessage
import org.xmlpull.v1.XmlPullParserException
import java.io.StringReader
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import at.bitfire.dav4jvm.property.push.PushMessage as DavPushMessage
/**
* Handles incoming WebDAV-Push messages.
*/
class PushMessageHandler @Inject constructor(
private val accountRepository: AccountRepository,
private val collectionRepository: DavCollectionRepository,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
) {
suspend fun processMessage(message: PushMessage, instance: String) {
if (!message.decrypted) {
logger.severe("Received a push message that could not be decrypted.")
return
}
val messageXml = message.content.toString(Charsets.UTF_8)
logger.log(Level.INFO, "Received push message", messageXml)
// parse push notification
val topic = parse(messageXml)
// sync affected collection
if (topic != null) {
logger.info("Got push notification for topic $topic")
// Sync all authorities of account that the collection belongs to
// Later: only sync affected collection and authorities
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
val syncDataTypes = mutableSetOf<SyncDataType>()
// If the type is an address book, add the contacts type
if (collection.type == TYPE_ADDRESSBOOK)
syncDataTypes += SyncDataType.CONTACTS
// If the collection supports events, add the events type
if (collection.supportsVEVENT != false)
syncDataTypes += SyncDataType.EVENTS
// If the collection supports tasks, make sure there's a provider installed,
// and add the tasks type
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
if (tasksAppManager.get().currentProvider() != null)
syncDataTypes += SyncDataType.TASKS
// Schedule sync for all the types identified
val account = accountRepository.fromName(service.accountName)
for (syncDataType in syncDataTypes)
syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true)
}
}
} else {
// fallback when no known topic is present (shouldn't happen)
val service = instance.toLongOrNull()?.let { serviceRepository.getBlocking(it) }
if (service != null) {
logger.warning("Got push message without topic and service, syncing all accounts")
val account = accountRepository.fromName(service.accountName)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
} else {
logger.warning("Got push message without topic, syncing all accounts")
for (account in accountRepository.getAll())
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
}
}
}
/**
* Parses a WebDAV-Push message and returns the `topic` that the message is about.
*
* @return topic of the modified collection, or `null` if the topic couldn't be determined
*/
@VisibleForTesting
internal fun parse(message: String): String? {
var topic: String? = null
val parser = XmlUtils.newPullParser()
try {
parser.setInput(StringReader(message))
XmlReader(parser).processTag(DavPushMessage.NAME) {
val pushMessage = DavPushMessage.Factory.create(parser)
topic = pushMessage.topic?.topic
}
} catch (e: XmlPullParserException) {
logger.log(Level.WARNING, "Couldn't parse push message", e)
}
return topic
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.accounts.Account
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.ui.account.AccountActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class PushNotificationManager @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationRegistry: NotificationRegistry
) {
/**
* Generates the notification ID for a push notification.
*/
private fun notificationId(account: Account, dataType: SyncDataType): Int {
return account.name.hashCode() + account.type.hashCode() + dataType.hashCode()
}
/**
* Sends a notification to inform the user that a push notification has been received, the
* sync has been scheduled, but it still has not run.
*/
fun notify(account: Account, dataType: SyncDataType) {
notificationRegistry.notifyIfPossible(notificationId(account, dataType)) {
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_sync)
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
.setContentText(context.getString(R.string.sync_notification_pending_push_message))
.setSubText(account.name)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(
TaskStackBuilder.create(context)
.addNextIntentWithParentStack(
Intent(context, AccountActivity::class.java).apply {
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.build()
}
}
/**
* Once the sync has been started, the notification is no longer needed and can be dismissed.
* It's safe to call this method even if the notification has not been shown.
*/
fun dismiss(account: Account, dataType: SyncDataType) {
NotificationManagerCompat.from(context)
.cancel(notificationId(account, dataType))
}
}

View file

@ -0,0 +1,375 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.push.AuthSecret
import at.bitfire.dav4jvm.property.push.PushRegister
import at.bitfire.dav4jvm.property.push.PushResource
import at.bitfire.dav4jvm.property.push.Subscription
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
import at.bitfire.dav4jvm.property.push.WebPushSubscription
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.account.InvalidAccountException
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.PushEndpoint
import java.io.StringWriter
import java.time.Duration
import java.time.Instant
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
/**
* Manages push registrations and subscriptions.
*
* To update push registrations and subscriptions (for instance after collections have been changed), call [update].
*
* Public API calls are protected by [mutex] so that there won't be multiple subscribe/unsubscribe operations at the same time.
* If you call other methods than [update], make sure that they don't interfere with other operations.
*/
class PushRegistrationManager @Inject constructor(
private val accountRepository: Lazy<AccountRepository>,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext private val context: Context,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
) {
/**
* Sets or removes (disable push) the distributor and updates the subscriptions + worker.
*
* Uses [update] which is protected by [mutex] so creating/deleting subscriptions doesn't
* interfere with other operations.
*
* @param pushDistributor new distributor or `null` to disable Push
*/
suspend fun setPushDistributor(pushDistributor: String?) {
// Disable UnifiedPush and remove all subscriptions
UnifiedPush.removeDistributor(context)
update()
if (pushDistributor != null) {
// If a distributor was passed, store it and create/register subscriptions
UnifiedPush.saveDistributor(context, pushDistributor)
update()
}
}
fun getCurrentDistributor() = UnifiedPush.getSavedDistributor(context)
fun getDistributors() = UnifiedPush.getDistributors(context)
/**
* Updates all push registrations and subscriptions so that if Push is available, it's up-to-date and
* working for all database services. If Push is not available, existing subscriptions are unregistered.
*
* Also makes sure that the [PushRegistrationWorker] is enabled if there's a Push-enabled collection.
*
* Acquires [mutex] so that this method can't be called twice at the same time, or at the same time
* with [update(serviceId)].
*/
suspend fun update() = mutex.withLock {
for (service in serviceRepository.getAll())
updateService(service.id)
updatePeriodicWorker()
}
/**
* Same as [update], but for a specific database service.
*
* Acquires [mutex] so that this method can't be called twice at the same time, or at the same time
* as [update()].
*/
suspend fun update(serviceId: Long) = mutex.withLock {
updateService(serviceId)
updatePeriodicWorker()
}
/**
* Registers or unregisters subscriptions depending on whether there is a distributor available.
*/
private suspend fun updateService(serviceId: Long) {
val service = serviceRepository.get(serviceId) ?: return
// use service ID from database as UnifiedPush instance name
val instance = serviceId.toString()
val distributorAvailable = getCurrentDistributor() != null
if (distributorAvailable)
try {
val vapid = collectionRepository.getVapidKey(serviceId)
logger.fine("Registering UnifiedPush instance $serviceId (${service.accountName})")
// message for distributor
val message = "${service.accountName} (${service.type})"
UnifiedPush.register(context, instance, message, vapid)
} catch (e: UnifiedPush.VapidNotValidException) {
logger.log(Level.WARNING, "Couldn't register invalid VAPID key for service $serviceId", e)
}
else {
logger.fine("Unregistering UnifiedPush instance $serviceId (${service.accountName})")
UnifiedPush.unregister(context, instance) // doesn't call UnifiedPushService.onUnregistered
unsubscribeAll(service)
}
// UnifiedPush has now been called. It will do its work and then asynchronously call back to UnifiedPushService, which
// will then call processSubscription or removeSubscription.
}
/**
* Called by [UnifiedPushService] when a subscription (endpoint) is available for the given service.
*
* Uses the subscription to subscribe to syncable collections, and then unsubscribes from non-syncable collections.
*/
suspend fun processSubscription(serviceId: Long, endpoint: PushEndpoint) = mutex.withLock {
val service = serviceRepository.get(serviceId) ?: return
try {
// subscribe to collections which are selected for synchronization
subscribeSyncable(service, endpoint)
// unsubscribe from collections which are not selected for synchronization
unsubscribeCollections(service, collectionRepository.getPushRegisteredAndNotSyncable(service.id))
} catch (_: InvalidAccountException) {
// couldn't create authenticating HTTP client because account is not available
}
}
private suspend fun subscribeSyncable(service: Service, endpoint: PushEndpoint) {
val subscribeTo = collectionRepository.getPushCapableAndSyncable(service.id)
if (subscribeTo.isEmpty())
return
val account = accountRepository.get().fromName(service.accountName)
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
.use { httpClient ->
for (collection in subscribeTo)
try {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond)
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
else {
// no existing subscription or expiring soon
logger.fine("Registering push subscription for ${collection.url}")
subscribe(httpClient, collection, endpoint)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
}
}
}
/**
* Called when no subscription is available (anymore) for the given service.
*
* Unsubscribes from all subscribed collections.
*/
suspend fun removeSubscription(serviceId: Long) = mutex.withLock {
val service = serviceRepository.get(serviceId) ?: return
unsubscribeAll(service)
}
private suspend fun unsubscribeAll(service: Service) {
val unsubscribeFrom = collectionRepository.getPushRegistered(service.id)
try {
unsubscribeCollections(service, unsubscribeFrom)
} catch (_: InvalidAccountException) {
// couldn't create authenticating HTTP client because account is not available
}
}
/**
* Registers the subscription to a given collection ("subscribe to a collection").
*
* @param httpClient HTTP client to use
* @param collection collection to subscribe to
* @param endpoint subscription to register
*/
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.insertTag(PushRegister.NAME) {
serializer.insertTag(Subscription.NAME) {
// subscription URL
serializer.insertTag(WebPushSubscription.NAME) {
serializer.insertTag(PushResource.NAME) {
text(endpoint.url)
}
endpoint.pubKeySet?.let { pubKeySet ->
serializer.insertTag(SubscriptionPublicKey.NAME) {
attribute(null, "type", "p256dh")
text(pubKeySet.pubKey)
}
serializer.insertTag(AuthSecret.NAME) {
text(pubKeySet.auth)
}
}
}
}
// requested expiration
serializer.insertTag(PushRegister.EXPIRES) {
text(HttpUtils.formatDate(requestedExpiration))
}
}
serializer.endDocument()
runInterruptible(ioDispatcher) {
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
// update subscription URL and expiration in DB
val subscriptionUrl = response.header("Location")
val expires = response.header("Expires")?.let { expiresDate ->
HttpUtils.parseDate(expiresDate)
} ?: requestedExpiration
runBlocking {
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
}
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
}
}
/**
* Unsubscribe from the given collections.
*/
private suspend fun unsubscribeCollections(service: Service, from: List<Collection>) {
if (from.isEmpty())
return
val account = accountRepository.get().fromName(service.accountName)
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
.use { httpClient ->
for (collection in from)
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
}
}
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
try {
runInterruptible(ioDispatcher) {
DavResource(httpClient.okHttpClient, url).delete {
// deleted
}
}
} catch (e: DavException) {
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
}
// remove registration URL from DB in any case
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = null,
expires = null
)
}
/**
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
*
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
* A potentially existing worker is replaced, so that the first run should be soon.
*
* Otherwise, a potentially existing worker is cancelled.
*/
private suspend fun updatePeriodicWorker() {
val workerNeeded = collectionRepository.anyPushCapable()
val workManager = WorkManager.getInstance(context)
if (workerNeeded) {
logger.info("Enqueuing periodic PushRegistrationWorker")
workManager.enqueueUniquePeriodicWork(
WORKER_UNIQUE_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, WORKER_INTERVAL_DAYS, TimeUnit.DAYS)
.setInitialDelay(5, TimeUnit.SECONDS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
)
} else {
logger.info("Cancelling periodic PushRegistrationWorker")
workManager.cancelUniqueWork(WORKER_UNIQUE_NAME)
}
}
companion object {
private const val WORKER_UNIQUE_NAME = "push-registration"
const val WORKER_INTERVAL_DAYS = 1L
/**
* Mutex to synchronize (un)subscription.
*/
val mutex = Mutex()
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.util.logging.Logger
/**
* Worker that runs regularly and initiates push registration updates for all collections.
*
* Managed by [PushRegistrationManager].
*/
@Suppress("unused")
@HiltWorker
class PushRegistrationWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParameters: WorkerParameters,
private val logger: Logger,
private val pushRegistrationManager: PushRegistrationManager
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result {
logger.info("Running push registration worker")
// update registrations for all services
pushRegistrationManager.update()
return Result.success()
}
}

View file

@ -0,0 +1,80 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import at.bitfire.davdroid.di.ApplicationScope
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import java.util.logging.Logger
import javax.inject.Inject
/**
* Entry point for UnifiedPush.
*
* Calls [PushRegistrationManager] for most tasks, except incoming push messages,
* which are handled directly.
*/
@AndroidEntryPoint
class UnifiedPushService : PushService() {
/* Scope to run the requests asynchronously. UnifiedPush binds the service,
* sends the message and unbinds one second later. Our operations may take longer,
* so the scope should not be bound to the service lifecycle. */
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var logger: Logger
@Inject
lateinit var pushMessageHandler: Lazy<PushMessageHandler>
@Inject
lateinit var pushRegistrationManager: Lazy<PushRegistrationManager>
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
val serviceId = instance.toLongOrNull() ?: return
logger.warning("Got UnifiedPush endpoint for service $serviceId: ${endpoint.url}")
// register new endpoint at CalDAV/CardDAV servers
applicationScope.launch {
pushRegistrationManager.get().processSubscription(serviceId, endpoint)
}
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
val serviceId = instance.toLongOrNull() ?: return
logger.warning("UnifiedPush registration failed for service $serviceId: $reason")
// unregister subscriptions
applicationScope.launch {
pushRegistrationManager.get().removeSubscription(serviceId)
}
}
override fun onUnregistered(instance: String) {
val serviceId = instance.toLongOrNull() ?: return
logger.warning("UnifiedPush unregistered for service $serviceId")
applicationScope.launch {
pushRegistrationManager.get().removeSubscription(serviceId)
}
}
override fun onMessage(message: PushMessage, instance: String) {
applicationScope.launch {
pushMessageHandler.get().processMessage(message, instance)
}
}
}

View file

@ -0,0 +1,269 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
import at.bitfire.davdroid.di.DefaultDispatcher
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
/**
* Repository for managing CalDAV/CardDAV accounts.
*
* *Note:* This class is not related to address book accounts, which are managed by
* [at.bitfire.davdroid.resource.LocalAddressBook].
*/
class AccountRepository @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
@ApplicationContext private val context: Context,
private val collectionRepository: DavCollectionRepository,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
private val homeSetRepository: DavHomeSetRepository,
private val localCalendarStore: Lazy<LocalCalendarStore>,
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: Lazy<SyncWorkerManager>,
private val tasksAppManager: Lazy<TasksAppManager>
) {
private val accountType = context.getString(R.string.account_type)
private val accountManager = AccountManager.get(context)
/**
* Creates a new account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param accountName name of the account
* @param credentials server credentials
* @param config discovered server capabilities for syncable authorities
* @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
@WorkerThread
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = fromName(accountName)
// create Android account
val userData = AccountSettings.initialUserData(credentials)
logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to database
logger.log(Level.INFO, "Writing account configuration to database", config)
try {
if (config.cardDAV != null) {
// insert CardDAV service
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// set initial CardDAV account settings and set sync intervals (enables automatic sync)
val accountSettings = accountSettingsFactory.create(account)
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
}
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
}
// set up automatic sync (processes inserted services)
automaticSyncManager.get().updateAutomaticSync(account)
} catch(e: InvalidAccountException) {
logger.log(Level.SEVERE, "Couldn't access account settings", e)
return null
}
return account
}
suspend fun delete(accountName: String): Boolean {
val account = fromName(accountName)
// remove account directly (bypassing the authenticator, which is our own)
return try {
accountManager.removeAccountExplicitly(account)
// delete address books (= address book accounts)
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
collectionRepository.getByService(service.id).forEach { collection ->
localAddressBookStore.get().deleteByCollectionId(collection.id)
}
}
// delete from database
serviceRepository.deleteByAccount(accountName)
true
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't remove account $accountName", e)
false
}
}
fun exists(accountName: String): Boolean =
if (accountName.isEmpty())
false
else
accountManager
.getAccountsByType(accountType)
.any { it.name == accountName }
fun fromName(accountName: String) =
Account(accountName, accountType)
fun getAll(): Array<Account> = accountManager.getAccountsByType(accountType)
fun getAllFlow() = callbackFlow<Set<Account>> {
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
withContext(defaultDispatcher) { // causes disk I/O
accountManager.addOnAccountsUpdatedListener(listener, null, true)
}
awaitClose {
accountManager.removeOnAccountsUpdatedListener(listener)
}
}
/**
* Renames an account.
*
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
* a consistent state.
*
* @param oldName current name of the account
* @param newName new name the account shall be re named to
*
* @throws InvalidAccountException if the account does not exist
* @throws IllegalArgumentException if the new account name already exists
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String): Unit = withContext(defaultDispatcher) {
val oldAccount = fromName(oldName)
val newAccount = fromName(newName)
// check whether new account name already exists
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
throw IllegalArgumentException("Account with name \"$newName\" already exists")
// rename account
try {
/* https://github.com/bitfireAT/davx5/issues/135
Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account
because this can cause problems when:
1. The account is renamed.
2. The AccountsCleanupWorker is called BEFORE the services table is updated.
AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore
3. Now the services would be renamed, but they're not here anymore. */
AccountsCleanupWorker.lockAccountsCleanup()
// rename account (also moves AccountSettings)
val future = accountManager.renameAccount(oldAccount, newName, null, null)
// wait for operation to complete (blocks calling thread)
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
// account renamed, cancel maybe running synchronization of old account
syncWorkerManager.get().cancelAllWork(oldAccount)
// disable periodic syncs for old account
for (dataType in SyncDataType.entries)
syncWorkerManager.get().disablePeriodic(oldAccount, dataType)
// update account name references in database
serviceRepository.renameAccount(oldName, newName)
try {
// update address books
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
}
try {
// update calendar events
localCalendarStore.get().updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
}
try {
// update account_name of local tasks
val dataStore = tasksAppManager.get().getDataStore()
dataStore?.updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
}
// update automatic sync
automaticSyncManager.get().updateAutomaticSync(newAccount)
} finally {
// release AccountsCleanupWorker mutex at the end of this async coroutine
AccountsCleanupWorker.unlockAccountsCleanup()
}
}
// helpers
private fun insertService(accountName: String, @ServiceType type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = serviceRepository.insertOrReplaceBlocking(service)
// insert home sets
for (homeSet in info.homeSets)
homeSetRepository.insertOrUpdateByUrlBlocking(HomeSet(0, serviceId, true, homeSet))
// insert collections
for (collection in info.collections.values) {
collectionRepository.insertOrUpdateByUrl(collection.copy(serviceId = serviceId))
}
return serviceId
}
}

View file

@ -0,0 +1,425 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.GoneException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.CollectionType
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.util.DavUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
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.TimeZoneRegistryFactory
import net.fortuna.ical4j.model.component.VTimeZone
import net.fortuna.ical4j.model.property.Version
import okhttp3.HttpUrl
import java.io.StringWriter
import java.util.UUID
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
/**
* Repository for managing collections.
*/
class DavCollectionRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val logger: Logger,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceRepository: DavServiceRepository
) {
private val dao = db.collectionDao()
/**
* Whether there are any collections that are registered for push.
*/
suspend fun anyPushCapable() = dao.anyPushCapable()
/**
* Creates address book collection on server and locally
*/
suspend fun createAddressBook(
account: Account,
homeSet: HomeSet,
displayName: String,
description: String?
) {
val folderName = UUID.randomUUID().toString()
val url = homeSet.url.newBuilder()
.addPathSegment(folderName)
.addPathSegment("") // trailing slash
.build()
// create collection on server
createOnServer(
account = account,
url = url,
method = "MKCOL",
xmlBody = generateMkColXml(
addressBook = true,
displayName = displayName,
description = description
)
)
// no HTTP error -> create collection locally
val collection = Collection(
serviceId = homeSet.serviceId,
homeSetId = homeSet.id,
url = url,
type = Collection.TYPE_ADDRESSBOOK,
displayName = displayName,
description = description
)
dao.insertAsync(collection)
}
/**
* Create calendar collection on server and locally
*/
suspend fun createCalendar(
account: Account,
homeSet: HomeSet,
color: Int?,
displayName: String,
description: String?,
timeZoneId: String?,
supportVEVENT: Boolean,
supportVTODO: Boolean,
supportVJOURNAL: Boolean
) {
val folderName = UUID.randomUUID().toString()
val url = homeSet.url.newBuilder()
.addPathSegment(folderName)
.addPathSegment("") // trailing slash
.build()
// create collection on server
createOnServer(
account = account,
url = url,
method = "MKCALENDAR",
xmlBody = generateMkColXml(
addressBook = false,
displayName = displayName,
description = description,
color = color,
timezoneId = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
)
)
// no HTTP error -> create collection locally
val collection = Collection(
serviceId = homeSet.serviceId,
homeSetId = homeSet.id,
url = url,
type = Collection.TYPE_CALENDAR,
displayName = displayName,
description = description,
color = color,
timezoneId = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
)
dao.insertAsync(collection)
// Trigger service detection (because the collection may actually have other properties than the ones we have inserted).
// Some servers are known to change the supported components (VEVENT, …) after creation.
RefreshCollectionsWorker.enqueue(context, homeSet.serviceId)
}
/** Deletes the given collection from the server and the database. */
suspend fun deleteRemote(collection: Collection) {
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, context.getString(R.string.account_type))
httpClientBuilder.get().fromAccount(account).build().use { httpClient ->
runInterruptible(ioDispatcher) {
try {
DavResource(httpClient.okHttpClient, collection.url).delete {
// success, otherwise an exception would have been thrown → delete locally, too
delete(collection)
}
} catch (e: HttpException) {
if (e is NotFoundException || e is GoneException) {
// HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too
logger.info("Collection ${collection.url} not found on server, deleting locally")
delete(collection)
} else
throw e
}
}
}
}
suspend fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
fun get(id: Long) = dao.get(id)
suspend fun getAsync(id: Long) = dao.getAsync(id)
fun getFlow(id: Long) = dao.getFlow(id)
suspend fun getByService(serviceId: Long) = dao.getByService(serviceId)
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
fun getByServiceAndSync(serviceId: Long) = dao.getByServiceAndSync(serviceId)
fun getSyncCalendars(serviceId: Long) = dao.getSyncCalendars(serviceId)
fun getSyncJtxCollections(serviceId: Long) = dao.getSyncJtxCollections(serviceId)
fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId)
/** Returns all collections that are both selected for synchronization and push-capable. */
suspend fun getPushCapableAndSyncable(serviceId: Long) = dao.getPushCapableSyncCollections(serviceId)
suspend fun getPushRegistered(serviceId: Long) = dao.getPushRegistered(serviceId)
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long) = dao.getPushRegisteredAndNotSyncable(serviceId)
suspend fun getVapidKey(serviceId: Long) = dao.getFirstVapidKey(serviceId)
/**
* Inserts or updates the collection.
*
* On update, it will _not_ update the flags
* - [Collection.sync] and
* - [Collection.forceReadOnly],
* but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlRememberSync(newCollection: Collection) {
db.runInTransaction {
// remember locally set flags
val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())
val newCollectionWithFlags =
if (oldCollection != null)
newCollection.copy(sync = oldCollection.sync, forceReadOnly = oldCollection.forceReadOnly)
else
newCollection
// commit new collection to database
insertOrUpdateByUrl(newCollectionWithFlags)
}
}
/**
* Creates or updates the existing collection if it exists (URL)
*/
fun insertOrUpdateByUrl(collection: Collection) {
dao.insertOrUpdateByUrl(collection)
}
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String) =
dao.pageByServiceAndType(serviceId, type)
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String) =
dao.pagePersonalByServiceAndType(serviceId, type)
/**
* Sets the flag for whether read-only should be enforced on the local collection
*/
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
dao.updateForceReadOnly(id, forceReadOnly)
}
/**
* Whether or not the local collection should be synced with the server
*/
suspend fun setSync(id: Long, forceReadOnly: Boolean) {
dao.updateSync(id, forceReadOnly)
}
suspend fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
dao.updatePushSubscription(
id = id,
pushSubscription = subscriptionUrl,
pushSubscriptionExpires = expires
)
}
/**
* Deletes the collection locally
*/
fun delete(collection: Collection) {
dao.delete(collection)
}
// helpers
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
httpClientBuilder.get()
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible(ioDispatcher) {
DavResource(httpClient.okHttpClient, url).mkCol(
xmlBody = xmlBody,
method = method
) {
// success, otherwise an exception would have been thrown
}
}
}
}
private fun generateMkColXml(
addressBook: Boolean,
displayName: String?,
description: String?,
color: Int? = null,
timezoneId: String? = null,
supportsVEVENT: Boolean = true,
supportsVTODO: Boolean = true,
supportsVJOURNAL: Boolean = true
): String {
val writer = StringWriter()
val serializer = XmlUtils.newSerializer()
serializer.apply {
setOutput(writer)
startDocument("UTF-8", null)
setPrefix("", NS_WEBDAV)
setPrefix("CAL", NS_CALDAV)
setPrefix("CARD", NS_CARDDAV)
if (addressBook)
startTag(NS_WEBDAV, "mkcol")
else
startTag(NS_CALDAV, "mkcalendar")
insertTag(DavResource.SET) {
insertTag(DavResource.PROP) {
insertTag(ResourceType.NAME) {
insertTag(ResourceType.COLLECTION)
if (addressBook)
insertTag(ResourceType.ADDRESSBOOK)
else
insertTag(ResourceType.CALENDAR)
}
displayName?.let {
insertTag(DisplayName.NAME) {
text(it)
}
}
if (addressBook) {
// addressbook-specific properties
description?.let {
insertTag(AddressbookDescription.NAME) {
text(it)
}
}
} else {
// calendar-specific properties
description?.let {
insertTag(CalendarDescription.NAME) {
text(it)
}
}
color?.let {
insertTag(CalendarColor.NAME) {
text(DavUtils.ARGBtoCalDAVColor(it))
}
}
timezoneId?.let { id ->
insertTag(CalendarTimezoneId.NAME) {
text(id)
}
getVTimeZone(id)?.let { vTimezone ->
insertTag(CalendarTimezone.NAME) {
text(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(
PropertyList<Property>().apply {
add(Version.VERSION_2_0)
add(Constants.iCalProdId)
},
ComponentList(
listOf(vTimezone)
)
).toString()
)
}
}
}
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
insertTag(SupportedCalendarComponentSet.NAME) {
// Only if there's at least one not explicitly supported calendar component set,
// otherwise don't include the property, which means "supports everything".
if (supportsVEVENT)
insertTag(SupportedCalendarComponentSet.COMP) {
attribute(null, "name", Component.VEVENT)
}
if (supportsVTODO)
insertTag(SupportedCalendarComponentSet.COMP) {
attribute(null, "name", Component.VTODO)
}
if (supportsVJOURNAL)
insertTag(SupportedCalendarComponentSet.COMP) {
attribute(null, "name", Component.VJOURNAL)
}
}
}
}
}
}
if (addressBook)
endTag(NS_WEBDAV, "mkcol")
else
endTag(NS_CALDAV, "mkcalendar")
endDocument()
}
return writer.toString()
}
private fun getVTimeZone(tzId: String): VTimeZone? {
val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()
return tzRegistry.getTimeZone(tzId)?.vTimeZone
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import javax.inject.Inject
class DavHomeSetRepository @Inject constructor(
db: AppDatabase
) {
private val dao = db.homeSetDao()
fun getAddressBookHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV)
fun getBindableByServiceFlow(serviceId: Long) = dao.getBindableByServiceFlow(serviceId)
fun getByIdBlocking(id: Long) = dao.getById(id)
fun getByServiceBlocking(serviceId: Long) = dao.getByService(serviceId)
fun getCalendarHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
dao.insertOrUpdateByUrlBlocking(homeSet)
fun deleteBlocking(homeSet: HomeSet) = dao.delete(homeSet)
}

View file

@ -0,0 +1,52 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
import javax.inject.Inject
class DavServiceRepository @Inject constructor(
db: AppDatabase
) {
private val dao = db.serviceDao()
// Read
fun getBlocking(id: Long): Service? = dao.get(id)
suspend fun get(id: Long): Service? = dao.getAsync(id)
suspend fun getAll(): List<Service> = dao.getAll()
suspend fun getByAccountAndType(name: String, @ServiceType serviceType: String): Service? =
dao.getByAccountAndType(name, serviceType)
fun getCalDavServiceFlow(accountName: String) =
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CALDAV)
fun getCardDavServiceFlow(accountName: String) =
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CARDDAV)
// Create & update
fun insertOrReplaceBlocking(service: Service) =
dao.insertOrReplace(service)
suspend fun renameAccount(oldName: String, newName: String) =
dao.renameAccount(oldName, newName)
// Delete
fun deleteAllBlocking() = dao.deleteAll()
suspend fun deleteByAccount(accountName: String) =
dao.deleteByAccount(accountName)
}

View file

@ -0,0 +1,50 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.content.Context
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncStats
import at.bitfire.davdroid.sync.SyncDataType
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.text.Collator
import javax.inject.Inject
class DavSyncStatsRepository @Inject constructor(
@ApplicationContext val context: Context,
db: AppDatabase
) {
private val dao = db.syncStatsDao()
data class LastSynced(
val dataType: String,
val lastSynced: Long
)
fun getLastSyncedFlow(collectionId: Long): Flow<List<LastSynced>> =
dao.getByCollectionIdFlow(collectionId).map { list ->
val collator = Collator.getInstance()
list.map { stats ->
LastSynced(
dataType = stats.dataType,
lastSynced = stats.lastSync
)
}.sortedWith { a, b ->
collator.compare(a.dataType, b.dataType)
}
}
suspend fun logSyncTime(collectionId: Long, dataType: SyncDataType, lastSync: Long = System.currentTimeMillis()) {
dao.insertOrReplace(SyncStats(
id = 0,
collectionId = collectionId,
dataType = dataType.name,
lastSync = lastSync
))
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
/**
* Repository to access preferences. Preferences are stored in a shared preferences file
* and reflect settings that are very low-level and are therefore not covered by
* [at.bitfire.davdroid.settings.SettingsManager].
*/
class PreferenceRepository @Inject constructor(
@ApplicationContext context: Context
) {
companion object {
const val LOG_TO_FILE = "log_to_file"
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
/**
* Updates the "log to file" (verbose logging") preference.
*/
fun logToFile(logToFile: Boolean) {
preferences.edit {
putBoolean(LOG_TO_FILE, logToFile)
}
}
/**
* Gets the "log to file" (verbose logging) preference.
*/
fun logToFile(): Boolean =
preferences.getBoolean(LOG_TO_FILE, false)
/**
* Gets the "log to file" (verbose logging) preference as a live value.
*/
fun logToFileFlow(): Flow<Boolean> = observeAsFlow(LOG_TO_FILE) {
logToFile()
}
// helpers
private fun<T> observeAsFlow(keyToObserve: String, getValue: () -> T): Flow<T> =
callbackFlow {
val listener = OnSharedPreferenceChangeListener { _, key ->
if (key == keyToObserve) {
trySend(getValue())
}
}
preferences.registerOnSharedPreferenceChangeListener(listener)
// Emit the initial value
trySend(getValue())
awaitClose {
preferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Principal
import javax.inject.Inject
class PrincipalRepository @Inject constructor(
db: AppDatabase
) {
private val dao = db.principalDao()
fun getBlocking(id: Long): Principal = dao.get(id)
}

View file

@ -0,0 +1,9 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import at.bitfire.vcard4android.Contact
interface LocalAddress: LocalResource<Contact>

View file

@ -0,0 +1,360 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.os.Bundle
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import androidx.annotation.OpenForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ONLY
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.LinkedList
import java.util.Optional
import java.util.logging.Level
import java.util.logging.Logger
/**
* A local address book. Requires its own Android account, because Android manages contacts per
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book.
*
* @param account DAVx5 account which "owns" this address book
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
* the new name will only be available in [addressBookAccount], so usually that one should be used.
* @param provider Content provider needed to access and modify the address book
*/
@OpenForTesting
open class LocalAddressBook @AssistedInject constructor(
@Assisted("account") val account: Account,
@Assisted("addressBookAccount") _addressBookAccount: Account,
@Assisted provider: ContentProviderClient,
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext private val context: Context,
internal val dirtyVerifier: Optional<ContactDirtyVerifier>,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncFramework: SyncFrameworkIntegration
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
@AssistedFactory
interface Factory {
fun create(
@Assisted("account") account: Account,
@Assisted("addressBookAccount") addressBookAccount: Account,
provider: ContentProviderClient
): LocalAddressBook
}
override val tag: String
get() = "contacts-${addressBookAccount.name}"
override val title
get() = addressBookAccount.name
private val accountManager by lazy { AccountManager.get(context) }
/**
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
*
* For instance, if groupMethod is GROUP_VCARDS, [findDirty] will find only dirty [LocalContact]s,
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
open val groupMethod: GroupMethod by lazy {
val account = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
}
if (account == null)
throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account")
val accountSettings = accountSettingsFactory.create(account)
accountSettings.getGroupMethod()
}
val includeGroups
get() = groupMethod == GroupMethod.GROUP_VCARDS
override var dbCollectionId: Long?
get() = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()
set(id) {
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, id.toString())
}
/**
* Read-only flag for the address book itself.
*
* Setting this flag:
*
* - stores the new value in [USER_DATA_READ_ONLY] and
* - sets the read-only flag for all contacts and groups in the address book in the content provider, which will
* prevent non-sync-adapter apps from modifying them. However new entries can still be created, so the address book
* is not really read-only.
*
* Reading this flag returns the stored value from [USER_DATA_READ_ONLY].
*/
override var readOnly: Boolean
get() = accountManager.getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
set(readOnly) {
// set read-only flag for address book itself
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
// update raw contacts
val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update data rows
val dataValues = contentValuesOf(ContactsContract.Data.IS_READ_ONLY to if (readOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update group rows
val groupValues = contentValuesOf(Groups.GROUP_IS_READ_ONLY to if (readOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
}
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
set(state) {
syncState = state?.toString()?.toByteArray()
}
/* operations on the collection (address book) itself */
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(LocalContact.COLUMN_FLAGS to flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider!!.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
}
return number
}
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider!!.delete(groupsSyncUri(),
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
/**
* Renames an address book account and moves the contacts and groups (without making them dirty).
* Does not keep user data of the old account, so these have to be set again.
*
* On success, [addressBookAccount] will be updated to the new account name.
*
* _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never
* a moment when both accounts are available.
*
* @param newName the new account name (account type is taken from [addressBookAccount])
*
* @return whether the account was renamed successfully
*/
internal fun renameAccount(newName: String): Boolean {
val oldAccount = addressBookAccount
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
// create new account
val newAccount = Account(newName, oldAccount.type)
if (!SystemAccountUtils.createAccount(context, newAccount, Bundle()))
return false
// move contacts and groups to new account
val batch = ContactsBatchOperation(provider!!)
batch += BatchOperation.CpoBuilder
.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
addressBookAccount = newAccount
// delete old account
accountManager.removeAccountExplicitly(oldAccount)
return true
}
/**
* Enables or disables sync on content changes for the address book account based on the current sync
* interval account setting.
*/
fun updateSyncFrameworkSettings() {
val accountSettings = accountSettingsFactory.create(account)
val syncInterval = accountSettings.getSyncInterval(SyncDataType.CONTACTS)
// Enable/Disable content triggered syncs for the address book account.
if (syncInterval != null)
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
else
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
}
/* operations on members (contacts/groups) */
override fun findByName(name: String): LocalAddress? {
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
return if (includeGroups)
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
else
result
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
* @throws RemoteException on content provider errors
*/
override fun findDeleted() =
if (includeGroups)
findDeletedContacts() + findDeletedGroups()
else
findDeletedContacts()
fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null)
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
* @throws RemoteException on content provider errors
*/
override fun findDirty() =
if (includeGroups)
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
override fun forgetETags() {
if (includeGroups) {
val values = contentValuesOf(AndroidGroup.COLUMN_ETAG to null)
provider!!.update(groupsSyncUri(), values, null, null)
}
val values = contentValuesOf(AndroidContact.COLUMN_ETAG to null)
provider!!.update(rawContactsSyncUri(), values, null, null)
}
fun getContactIdsByGroupMembership(groupId: Long): List<Long> {
val ids = LinkedList<Long>()
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID),
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor ->
while (cursor.moveToNext())
ids += cursor.getLong(0)
}
return ids
}
fun getContactUidFromId(contactId: Long): String? {
provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID),
"${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getString(0)
}
return null
}
/* special group operations */
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws RemoteException on content provider errors
*/
fun findOrCreateGroup(title: String): Long {
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getLong(0)
}
val values = contentValuesOf(Groups.TITLE to title)
val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
return ContentUris.parseId(uri)
}
fun removeEmptyGroups() {
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
logger.log(Level.FINE, "Deleting group", group)
group.delete()
}
}
companion object {
const val USER_DATA_ACCOUNT_NAME = "account_name"
const val USER_DATA_ACCOUNT_TYPE = "account_type"
/**
* ID of the corresponding database [at.bitfire.davdroid.db.Collection].
*
* User data of the address book account (Long).
*/
const val USER_DATA_COLLECTION_ID = "collection_id"
/**
* Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag).
*
* User data of the address book account (Boolean).
*/
const val USER_DATA_READ_ONLY = "read_only"
}
}

View file

@ -0,0 +1,269 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.ContentProviderClient
import android.content.Context
import android.provider.ContactsContract
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import androidx.core.os.bundleOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.DavUtils.lastSegment
import com.google.common.base.CharMatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class LocalAddressBookStore @Inject constructor(
@ApplicationContext private val context: Context,
private val localAddressBookFactory: LocalAddressBook.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val settings: SettingsManager
): LocalDataStore<LocalAddressBook> {
override val authority: String
get() = ContactsContract.AUTHORITY
/** whether a (usually managed) setting wants all address-books to be read-only **/
val forceAllReadOnly: Boolean
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
/**
* Assembles a name for the address book (account) from its corresponding database [Collection].
*
* The address book account name contains
*
* - the collection display name or last URL path segment (filtered for dangerous special characters)
* - the actual account name
* - the collection ID, to make it unique.
*
* @param info Collection to take info from
*/
fun accountName(info: Collection): String {
// Name of address book is given collection display name, otherwise the last URL path segment
var name = info.displayName.takeIf { !it.isNullOrEmpty() } ?: info.url.lastSegment
// Remove ISO control characters + SQL problematic characters
name = CharMatcher
.javaIsoControl()
.or(CharMatcher.anyOf("`'\""))
.removeFrom(name)
// Add the actual account name to the address book account name
val sb = StringBuilder(name)
serviceRepository.getBlocking(info.serviceId)?.let { service ->
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
sb.append(" #${info.id}")
return sb.toString()
}
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
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))
val name = accountName(fromCollection)
val addressBookAccount = createAddressBookAccount(
account = account,
name = name,
id = fromCollection.id
) ?: return null
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
// update settings
addressBook.updateSyncFrameworkSettings()
addressBook.settings = contactsProviderSettings
addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
return addressBook
}
@OpenForTesting
internal fun createAddressBookAccount(account: Account, name: String, id: Long): Account? {
// create address book account with reference to account, collection ID and URL
val addressBookAccount = Account(name, context.getString(R.string.account_type_address_book))
val userData = bundleOf(
LocalAddressBook.USER_DATA_ACCOUNT_NAME to account.name,
LocalAddressBook.USER_DATA_ACCOUNT_TYPE to account.type,
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString()
)
if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) {
logger.warning("Couldn't create address book account: $addressBookAccount")
return null
}
return addressBookAccount
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
getAddressBookAccounts(account).map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
// Update the account name
val newAccountName = accountName(fromCollection)
if (currentAccount.name != newAccountName) {
// rename, move contacts/groups and update [AndroidAddressBook.]account
localCollection.renameAccount(newAccountName)
currentAccount = Account(newAccountName, currentAccount.type)
}
// Update the account user data
val accountManager = AccountManager.get(context)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, localCollection.account.name)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, localCollection.account.type)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, fromCollection.id.toString())
// Set contacts provider settings
localCollection.settings = contactsProviderSettings
// Update force read only
val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
if (nowReadOnly != localCollection.readOnly) {
logger.info("Address book has changed to read-only = $nowReadOnly")
localCollection.readOnly = nowReadOnly
}
// Update automatic synchronization
localCollection.updateSyncFrameworkSettings()
}
/**
* Updates address books which are assigned to [oldAccount] so that they're assigned to [newAccount] instead.
*
* @param oldAccount The old account
* @param newAccount The new account
*/
override fun updateAccount(oldAccount: Account, newAccount: Account) {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == oldAccount.name &&
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == oldAccount.type
}
.forEach { addressBookAccount ->
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, newAccount.name)
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, newAccount.type)
}
}
override fun delete(localCollection: LocalAddressBook) {
val accountManager = AccountManager.get(context)
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)
}
/**
* Deletes a [LocalAddressBook] based on its corresponding database collection.
*
* @param id [Collection.id] to look for
*/
fun deleteByCollectionId(id: Long) {
val accountManager = AccountManager.get(context)
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
if (addressBookAccount != null)
accountManager.removeAccountExplicitly(addressBookAccount)
}
/**
* Returns all address book accounts that belong to the given account.
*
* @param account Account which has the address books.
* @return List of address book accounts.
*/
fun getAddressBookAccounts(account: Account): List<Account> =
AccountManager.get(context).let { accountManager ->
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
account.name == accountManager.getUserData(
addressBookAccount,
LocalAddressBook.USER_DATA_ACCOUNT_NAME
) && account.type == accountManager.getUserData(
addressBookAccount,
LocalAddressBook.USER_DATA_ACCOUNT_TYPE
)
}
}
/**
* Returns all address book accounts that belong to the given account in a flow.
*
* @param account Account which has the address books.
* @return List of address book accounts as flow.
*/
fun getAddressBookAccountsFlow(account: Account): Flow<List<Account>> = callbackFlow {
val accountManager = AccountManager.get(context)
val listener = OnAccountsUpdateListener { accounts ->
trySend(getAddressBookAccounts(account))
}
accountManager.addOnAccountsUpdatedListener(
/* listener = */ listener,
/* handler = */ null,
/* updateImmediately = */ true
)
awaitClose { accountManager.removeOnAccountsUpdatedListener(listener) }
}
companion object {
/**
* Contacts Provider Settings (equal for every address book)
*/
val contactsProviderSettings
get() = contentValuesOf(
// SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable.
ContactsContract.Settings.SHOULD_SYNC to 1,
// UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems).
ContactsContract.Settings.UNGROUPED_VISIBLE to 1
)
/**
* Determines whether the address book should be set to read-only.
*
* @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information
* @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege)
*/
@VisibleForTesting
internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean =
info.readOnly() || forceAllReadOnly
}
}

View file

@ -0,0 +1,235 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.util.LinkedList
import java.util.logging.Logger
/**
* Application-specific subclass of [AndroidCalendar] for local calendars.
*
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalCalendar @AssistedInject constructor(
@Assisted internal val androidCalendar: AndroidCalendar,
private val logger: Logger
) : LocalCollection<LocalEvent> {
@AssistedFactory
interface Factory {
fun create(calendar: AndroidCalendar): LocalCalendar
}
// properties
override val dbCollectionId: Long?
get() = androidCalendar.syncId?.toLongOrNull()
override val tag: String
get() = "events-${androidCalendar.account.name}-${androidCalendar.id}"
override val title: String
get() = androidCalendar.displayName ?: androidCalendar.id.toString()
override val readOnly
get() = androidCalendar.accessLevel <= Calendars.CAL_ACCESS_READ
override var lastSyncState: SyncState?
get() = androidCalendar.readSyncState()?.let {
SyncState.fromString(it)
}
set(state) {
androidCalendar.writeSyncState(state.toString())
}
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
val mapped = LegacyAndroidEventBuilder2(
calendar = androidCalendar,
event = event,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.addEventAndExceptions(mapped)
}
override fun findDeleted(): List<LocalEvent> {
val result = LinkedList<LocalEvent>()
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
}
return result
}
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
/*
* RFC 5545 3.8.7.4. Sequence Number
* 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.
*/
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
}
return dirty
}
override fun findByName(name: String) =
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
LocalEvent(recurringCalendar, it)
}
override fun markNotDirty(flags: Int) =
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
"""
${Events.CALENDAR_ID}=?
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
AND ${Events.ORIGINAL_ID} IS NULL
""".trimIndent(),
arrayOf(androidCalendar.id.toString())
)
override fun removeNotDirtyMarked(flags: Int): Int {
// list all non-dirty events with the given flags and delete every row + its exceptions
val batch = CalendarBatchOperation(androidCalendar.client)
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
"""
${Events.CALENDAR_ID}=?
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
AND ${Events.ORIGINAL_ID} IS NULL
AND ${AndroidEvent2.COLUMN_FLAGS}=?
""".trimIndent(),
arrayOf(androidCalendar.id.toString(), flags.toString())
) { values ->
val id = values.getAsLong(Events._ID)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch += BatchOperation.CpoBuilder
.newDelete(androidCalendar.eventsUri)
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
}
return batch.commit()
}
override fun forgetETags() {
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${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 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 batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent2.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.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${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 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(AndroidEvent2.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(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
}
/**
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
*
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
// Iterate dirty main events without exceptions
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${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 = androidCalendar.numInstances(eventId)
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventId without instances as deleted")
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
}
}
}
}

View file

@ -0,0 +1,154 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
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
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.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
import javax.inject.Inject
class LocalCalendarStore @Inject constructor(
@ApplicationContext private val context: Context,
private val accountSettingsFactory: AccountSettings.Factory,
private val localCalendarFactory: LocalCalendar.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
): LocalDataStore<LocalCalendar> {
override val authority: String
get() = CalendarContract.AUTHORITY
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
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))
// If the collection doesn't have a color, use a default color.
val collectionWithColor =
if (fromCollection.color != null)
fromCollection
else
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
val values = valuesFromCollectionInfo(
info = collectionWithColor,
withColor = true
).apply {
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
put(Calendars.ACCOUNT_NAME, account.name)
put(Calendars.ACCOUNT_TYPE, account.type)
// Email address for scheduling. Used by the calendar provider to determine whether the
// user is ORGANIZER/ATTENDEE for a certain event.
put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & syncable at creation, might be changed by user at any time
put(Calendars.VISIBLE, 1)
put(Calendars.SYNC_EVENTS, 1)
}
logger.log(Level.INFO, "Adding local calendar", values)
val provider = AndroidCalendarProvider(account, client)
return localCalendarFactory.create(provider.createAndGetCalendar(values))
}
override fun getAll(account: Account, client: ContentProviderClient) =
AndroidCalendarProvider(account, client)
.findCalendars("${Calendars.SYNC_EVENTS}!=0", null)
.map { localCalendarFactory.create(it) }
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)
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 = 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)
if (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
} else
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
info.timezoneId?.let { tzId ->
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
}
return values
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalCalendar) {
logger.log(Level.INFO, "Deleting local calendar", localCollection)
localCollection.androidCalendar.delete()
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
interface LocalCollection<out T: LocalResource<*>> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
val tag: String
/** ID of the collection in the database (corresponds to [at.bitfire.davdroid.db.Collection.id]) */
val dbCollectionId: Long?
/** collection title (used for user notifications etc.) **/
val title: String
var lastSyncState: SyncState?
/**
* Whether the collection should be treated as read-only on sync.
* Stops uploading dirty events (Server side changes are still downloaded).
*/
val readOnly: Boolean
/**
* Finds local resources of this collection which have been marked as *deleted* by the user
* or an app acting on their behalf.
*
* @return list of resources marked as *deleted*
*/
fun findDeleted(): List<T>
/**
* Finds local resources of this collection which have been marked as *dirty*, i.e. resources
* which have been modified by the user or an app acting on their behalf.
*
* @return list of resources marked as *dirty*
*/
fun findDirty(): List<T>
/**
* Finds a local resource of this collection with a given file name. (File names are assigned
* by the sync adapter.)
*
* @param name file name to look for
* @return resource with the given name, or null if none
*/
fun findByName(name: String): T?
/**
* Updates the flags value for entries which are not dirty.
*
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
*
* @return number of marked entries
*/
fun markNotDirty(flags: Int): Int
/**
* Removes entries which are not dirty with a given flag combination.
*
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
* all entries with exactly this flag will be removed)
*
* @return number of removed entries
*/
fun removeNotDirtyMarked(flags: Int): Int
/**
* Forgets the ETags of all members so that they will be reloaded from the server during sync.
*/
fun forgetETags()
}

View file

@ -0,0 +1,239 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import android.provider.ContactsContract.RawContacts.getContactLookupUri
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.Ascii
import com.google.common.base.MoreObjects
import java.io.FileNotFoundException
import java.util.Optional
import java.util.UUID
import kotlin.jvm.optionals.getOrNull
class LocalContact: AndroidContact, LocalAddress {
companion object {
const val COLUMN_FLAGS = RawContacts.SYNC4
const val COLUMN_HASHCODE = RawContacts.SYNC3
}
override val addressBook: LocalAddressBook
get() = super.addressBook as LocalAddressBook
internal val cachedGroupMemberships = HashSet<Long>()
internal val groupMemberships = HashSet<Long>()
override val scheduleTag: String?
get() = null
override var flags: Int = 0
constructor(addressBook: LocalAddressBook, values: ContentValues): super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: LocalAddressBook, contact: Contact, fileName: String?, eTag: String?, _flags: Int): super(addressBook, contact, fileName, eTag) {
flags = _flags
}
init {
processor.registerHandler(CachedGroupMembershipHandler(this))
processor.registerHandler(GroupMembershipHandler(this))
processor.registerHandler(UnknownPropertiesHandler)
processor.registerBuilderFactory(GroupMembershipBuilder.Factory(addressBook))
processor.registerBuilderFactory(UnknownPropertiesBuilder.Factory)
}
override fun prepareForUpload(): String {
val contact = getContact()
val uid: String = contact.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in contacts provider
val values = contentValuesOf(COLUMN_UID to newUid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
// update this event
contact.uid = newUid
newUid
}
return "$uid.vcf"
}
/**
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
*/
fun clearCachedContact() {
_contact = null
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
val values = ContentValues(4)
if (fileName.isPresent)
values.put(COLUMN_FILENAME, fileName.get())
values.put(COLUMN_ETAG, eTag)
values.put(RawContacts.DIRTY, 0)
// Android 7 workaround
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = eTag
}
fun resetDirty() {
val values = contentValuesOf(RawContacts.DIRTY to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
// processes this.{fileName, eTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
.add("contact",
try {
Ascii.truncate(getContact().toString(), 1000, "")
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context): Uri? =
id?.let { idNotNull ->
getContactLookupUri(
context.contentResolver,
ContentUris.withAppendedId(RawContacts.CONTENT_URI, idNotNull)
)
}
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
batch += BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
groupMemberships += groupID
batch += BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
cachedGroupMemberships += groupID
}
fun removeGroupMemberships(batch: BatchOperation) {
batch += BatchOperation.CpoBuilder
.newDelete(dataSyncURI())
.withSelection(
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
)
groupMemberships.clear()
cachedGroupMemberships.clear()
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
* whether a membership has been deleted/added when a raw contact is dirty.
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
* @throws FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
fun getCachedGroupMemberships(): Set<Long> {
getContact()
return cachedGroupMemberships
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
fun getGroupMemberships(): Set<Long> {
getContact()
return groupMemberships
}
// data rows
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
LocalContact(addressBook as LocalAddressBook, values)
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
/**
* Represents a local data store for a specific collection type.
* Manages creation, update, and deletion of collections of the given type.
*/
interface LocalDataStore<T: LocalCollection<*>> {
/**
* Content provider authority for the data store.
*/
val authority: String
/**
* Acquires a content provider client for the data store. The result of this call
* should be passed to all other methods of this class.
*
* **The caller is responsible for closing the content provider client!**
*
* @param throwOnMissingPermissions If `true`, the function will throw [SecurityException] if permissions are not granted.
*
* @return the content provider client, or `null` if the content provider could not be acquired (or permissions are not
* granted and [throwOnMissingPermissions] is `false`)
*
* @throws SecurityException on missing permissions
*/
fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient?
/**
* Creates a new local collection from the given (remote) collection info.
*
* @param client the content provider client
* @param fromCollection collection info
*
* @return the new local collection, or `null` if creation failed
*/
fun create(client: ContentProviderClient, fromCollection: Collection): T?
/**
* Returns all local collections of the data store, including those which don't have a corresponding remote
* [Collection] entry.
*
* @param account the account that the data store is associated with
* @param client the content provider client
*
* @return a list of all local collections
*/
fun getAll(account: Account, client: ContentProviderClient): List<T>
/**
* Updates the local collection with the data from the given (remote) collection info.
*
* @param client the content provider client
* @param localCollection the local collection to update
* @param fromCollection collection info
*/
fun update(client: ContentProviderClient, localCollection: T, fromCollection: Collection)
/**
* Deletes the local collection.
*
* @param localCollection the local collection to delete
*/
fun delete(localCollection: T)
/**
* Changes the account assigned to the containing data to another one.
*
* @param oldAccount The old account.
* @param newAccount The new account.
*/
fun updateAccount(oldAccount: Account, newAccount: Account)
}

View file

@ -0,0 +1,197 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.LegacyAndroidCalendar
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.LocalStorageException
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import com.google.common.base.Ascii
import com.google.common.base.MoreObjects
import java.util.Optional
import java.util.UUID
class LocalEvent(
val recurringCalendar: AndroidRecurringCalendar,
val androidEvent: AndroidEvent2
) : LocalResource<Event> {
override val id: Long
get() = androidEvent.id
override val fileName: String?
get() = androidEvent.syncId
override val eTag: String?
get() = androidEvent.eTag
override val scheduleTag: String?
get() = androidEvent.scheduleTag
override val flags: Int
get() = androidEvent.flags
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
val eventAndExceptions = LegacyAndroidEventBuilder2(
calendar = androidEvent.calendar,
event = data,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
}
private var _event: Event? = null
/**
* Retrieves the event from the content provider and converts it to a legacy data object.
*
* Caches the result: the content provider is only queried at the first call and then
* this method always returns the same object.
*
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
*/
@Synchronized
fun getCachedEvent(): Event {
_event?.let { return it }
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
val event = legacyCalendar.getEvent(androidEvent.id)
?: throw LocalStorageException("Event ${androidEvent.id} not found")
_event = event
return event
}
/**
* Generates the [Event] that should actually be uploaded:
*
* 1. Takes the [getCachedEvent].
* 2. Calculates the new SEQUENCE.
*
* _Note: This method currently modifies the object returned by [getCachedEvent], but
* this may change in the future._
*
* @return data object that should be used for uploading
*/
fun eventToUpload(): Event {
val event = getCachedEvent()
val nonGroupScheduled = event.attendees.isEmpty()
val weAreOrganizer = event.isOrganizer == true
// Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence):
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
// - If it's non-null, the event already exists on the server, so increase by one.
val sequence = event.sequence
if (sequence != null && (nonGroupScheduled || weAreOrganizer))
event.sequence = sequence + 1
return event
}
/**
* Updates the SEQUENCE of the event in the content provider.
*
* @param sequence new sequence value
*/
fun updateSequence(sequence: Int?) {
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_SEQUENCE to sequence
))
}
/**
* 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 = getCachedEvent().uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// persist to calendar provider
val values = contentValuesOf(Events.UID_2445 to newUid)
androidEvent.update(values)
// update in cached event data object
getCachedEvent().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: Optional<String>, eTag: String?, scheduleTag: String?) {
val values = contentValuesOf(
Events.DIRTY to 0,
AndroidEvent2.COLUMN_ETAG to eTag,
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
)
if (fileName.isPresent)
values.put(Events._SYNC_ID, fileName.get())
androidEvent.update(values)
}
override fun updateFlags(flags: Int) {
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_FLAGS to flags
))
}
override fun deleteLocal() {
recurringCalendar.deleteEventAndExceptions(id)
}
override fun resetDeleted() {
androidEvent.update(contentValuesOf(
Events.DELETED to 0
))
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.add("event",
try {
Ascii.truncate(getCachedEvent().toString(), 1000, "")
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context) =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
}

View file

@ -0,0 +1,313 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.AndroidGroupFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.MoreObjects
import java.util.LinkedList
import java.util.Optional
import java.util.UUID
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull
class LocalGroup: AndroidGroup, LocalAddress {
companion object {
private val logger: Logger
get() = Logger.getGlobal()
const val COLUMN_FLAGS = Groups.SYNC4
/** List of member UIDs, as sent by server. This list will be used to establish
* the group memberships when all groups and contacts have been synchronized.
* Use [PendingMemberships] to create/read the list. */
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
/**
* Processes all groups with non-null [COLUMN_PENDING_MEMBERS]: the pending memberships
* are applied (if possible) to keep cached memberships in sync.
*
* @param addressBook address book to take groups from
*/
fun applyPendingMemberships(addressBook: LocalAddressBook) {
logger.info("Assigning memberships of contact groups")
addressBook.allGroups { group ->
val groupId = group.id!!
val pendingMemberUids = group.pendingMemberships.toMutableSet()
val batch = ContactsBatchOperation(addressBook.provider!!)
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
// process members which are currently in this group, but shouldn't be
for (currentMemberId in addressBook.getContactIdsByGroupMembership(groupId)) {
val uid = addressBook.getContactUidFromId(currentMemberId) ?: continue
if (!pendingMemberUids.contains(uid)) {
logger.fine("$currentMemberId removed from group $groupId; removing group membership")
val currentMember = addressBook.findContactById(currentMemberId)
currentMember.removeGroupMemberships(batch)
// Android 7 hack
changeContactIDs += currentMemberId
}
// UID is processed, remove from pendingMembers
pendingMemberUids -= uid
}
// now pendingMemberUids contains all UIDs which are not assigned yet
// process members which should be in this group, but aren't
for (missingMemberUid in pendingMemberUids) {
val missingMember = addressBook.findContactByUid(missingMemberUid)
if (missingMember == null) {
logger.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring")
continue
}
logger.fine("Assigning member $missingMember to group $groupId")
missingMember.addToGroup(batch, groupId)
// Android 7 hack
changeContactIDs += missingMember.id!!
}
addressBook.dirtyVerifier.getOrNull()?.let { verifier ->
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
changeContactIDs
.map { id -> addressBook.findContactById(id) }
.forEach { contact ->
verifier.updateHashCode(contact, batch)
}
}
batch.commit()
}
}
}
override var scheduleTag: String?
get() = null
set(_) = throw NotImplementedError()
override var flags: Int = 0
var pendingMemberships = setOf<String>()
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) : super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
values.getAsString(COLUMN_PENDING_MEMBERS)?.let { members ->
pendingMemberships = PendingMemberships.fromString(members).uids
}
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
override fun contentValues(): ContentValues {
val values = super.contentValues()
values.put(COLUMN_FLAGS, flags)
values.put(COLUMN_PENDING_MEMBERS, PendingMemberships(getContact().members).toString())
return values
}
override fun prepareForUpload(): String {
var uid: String? = null
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0).trimToNull()
}
if (uid == null) {
// generate new UID
uid = UUID.randomUUID().toString()
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
_contact?.uid = uid
}
return "$uid.vcf"
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
val id = requireNotNull(id)
val values = ContentValues(3)
if (fileName.isPresent)
values.put(COLUMN_FILENAME, fileName.get())
values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated
values.put(Groups.DIRTY, 0)
update(values)
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = null
// update cached group memberships
val batch = ContactsBatchOperation(addressBook.provider!!)
// delete old cached group memberships
batch += BatchOperation.CpoBuilder
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
)
// insert updated cached group memberships
for (member in getMembers())
batch += BatchOperation.CpoBuilder
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
batch.commit()
}
/**
* Marks all members of the current group as dirty.
*/
fun markMembersDirty() {
val batch = ContactsBatchOperation(addressBook.provider!!)
for (member in getMembers())
batch += BatchOperation.CpoBuilder
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
batch.commit()
}
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
// processes this.{fileName, eTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
.add("contact",
try {
getContact().toString()
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context) = null
// helpers
private fun groupSyncUri(): Uri {
val id = requireNotNull(id)
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws RemoteException on contact provider errors
*/
internal fun getMembers(): List<Long> {
val id = requireNotNull(id)
val members = LinkedList<Long>()
addressBook.provider!!.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(Data.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
null
)?.use { cursor ->
while (cursor.moveToNext())
members += cursor.getLong(0)
}
return members
}
// helper class for COLUMN_PENDING_MEMBERSHIPS blob
class PendingMemberships(
/** list of member UIDs that shall be assigned **/
val uids: Set<String>
) {
companion object {
const val SEPARATOR = '\n'
fun fromString(value: String) =
PendingMemberships(value.split(SEPARATOR).toSet())
}
override fun toString() = uids.joinToString(SEPARATOR.toString())
}
// factory
object Factory: AndroidGroupFactory<LocalGroup> {
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
LocalGroup(addressBook, values)
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxCollectionFactory
import at.bitfire.ical4android.JtxICalObject
/**
* Application-specific implementation for jtx collections.
*
* [at.techbee.jtx.JtxContract.JtxCollection.SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
LocalCollection<LocalJtxICalObject>{
override val readOnly: Boolean
get() = throw NotImplementedError()
override val tag: String
get() = "jtx-${account.name}-$id"
override val dbCollectionId: Long?
get() = syncId
override val title: String
get() = displayname ?: id.toString()
override var lastSyncState: SyncState?
get() = SyncState.fromString(syncstate)
set(value) { syncstate = value.toString() }
override fun findDeleted(): List<LocalJtxICalObject> {
val values = queryDeletedICalObjects()
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
values.forEach {
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
}
return localJtxICalObjects
}
override fun findDirty(): List<LocalJtxICalObject> {
val values = queryDirtyICalObjects()
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
values.forEach {
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
}
return localJtxICalObjects
}
override fun findByName(name: String): LocalJtxICalObject? {
val values = queryByFilename(name) ?: return null
return LocalJtxICalObject.Factory.fromProvider(this, values)
}
/**
* 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 findRecurInstance(uid: String, recurid: String): LocalJtxICalObject? {
val values = queryRecur(uid, recurid) ?: return null
return LocalJtxICalObject.Factory.fromProvider(this, values)
}
override fun markNotDirty(flags: Int)= updateSetFlags(flags)
override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags)
override fun forgetETags() = updateSetETag(null)
object Factory: JtxCollectionFactory<LocalJtxCollection> {
override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id)
}
}

View file

@ -0,0 +1,118 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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 androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.PrincipalRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Logger
import javax.inject.Inject
class LocalJtxCollectionStore @Inject constructor(
@ApplicationContext val context: Context,
val accountSettingsFactory: AccountSettings.Factory,
db: AppDatabase,
val principalRepository: PrincipalRepository
): LocalDataStore<LocalJtxCollection> {
private val serviceDao = db.serviceDao()
override val authority: String
get() = JtxContract.AUTHORITY
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
// If the collection doesn't have a color, use a default color.
val collectionWithColor =
if (fromCollection.color != null)
fromCollection
else
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
val values = valuesFromCollection(
info = collectionWithColor,
account = account,
withColor = true
)
val uri = JtxCollection.create(account, provider, values)
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
}
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
val owner = info.ownerId?.let { principalRepository.getBlocking(it) }
return ContentValues().apply {
put(JtxContract.JtxCollection.SYNC_ID, info.id)
put(JtxContract.JtxCollection.URL, info.url.toString())
put(
JtxContract.JtxCollection.DISPLAYNAME,
info.displayName ?: info.url.lastSegment
)
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
if (owner != null)
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
else
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
if (withColor && info.color != null)
put(JtxContract.JtxCollection.COLOR, info.color)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
}
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.account)
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
localCollection.update(values)
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalJtxCollection) {
localCollection.delete()
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentValues
import android.content.Context
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxICalObject
import at.bitfire.ical4android.JtxICalObjectFactory
import at.techbee.jtx.JtxContract
import com.google.common.base.MoreObjects
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class LocalJtxICalObject(
collection: JtxCollection<*>,
fileName: String?,
eTag: String?,
scheduleTag: String?,
flags: Int
) :
JtxICalObject(collection),
LocalResource<JtxICalObject> {
init {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
this.scheduleTag = scheduleTag
}
object Factory : JtxICalObjectFactory<LocalJtxICalObject> {
override fun fromProvider(
collection: JtxCollection<JtxICalObject>,
values: ContentValues
): LocalJtxICalObject {
val fileName = values.getAsString(JtxContract.JtxICalObject.FILENAME)
val eTag = values.getAsString(JtxContract.JtxICalObject.ETAG)
val scheduleTag = values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG)
val flags = values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?: 0
val localJtxICalObject = LocalJtxICalObject(collection, fileName, eTag, scheduleTag, flags)
localJtxICalObject.populateFromContentValues(values)
return localJtxICalObject
}
}
override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
update(data)
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.toString()
override fun getViewUri(context: Context) = null
}

View file

@ -0,0 +1,115 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.Context
import android.content.Intent
import android.net.Uri
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
import java.util.Optional
/**
* Defines operations that are used by SyncManager for all sync data types.
*/
interface LocalResource<in TData: Any> {
companion object {
/**
* Resource is present on remote server. This flag is used to identify resources
* which are not present on the remote server anymore and can be deleted at the end
* of the synchronization.
*/
const val FLAG_REMOTELY_PRESENT = 1
}
/**
* Unique ID which identifies the resource in the local storage. May be null if the
* resource has not been saved yet.
*/
val id: Long?
/**
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
* (in this case, [fileName] is the remote file name).
*/
val fileName: String?
/** remote ETag for the resource */
val eTag: String?
/** remote Schedule-Tag for the resource */
val scheduleTag: String?
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
val flags: Int
/**
* Prepares the resource for uploading:
*
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
* saved to the content provider. The sync manager is responsible for saving the file name that
* was actually used.
*
* @return suggestion for new file name of the resource (like "<uid>.vcf")
*/
fun prepareForUpload(): String
/**
* Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider.
* Does not affect `this` object itself (which is immutable).
*
* @param fileName If this optional argument is present, [LocalResource.fileName] will be set to its value.
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
* @param scheduleTag CalDAV only: `Schedule-Tag` of the uploaded resource as returned by the server
* (null if not applicable or if the server didn't return one)
*/
fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String? = null)
/**
* Sets (local) flags of the resource in the content provider.
* Does not affect `this` object itself (which is immutable).
*
* At the moment, the only allowed values are 0 and [FLAG_REMOTELY_PRESENT].
*/
fun updateFlags(flags: Int)
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
* Does not affect `this` or the [data] object (which are both immutable).
*
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
/**
* Deletes the data object from the content provider.
*/
fun deleteLocal()
/**
* Undoes deletion of the data object from the content provider.
*/
fun resetDeleted()
/**
* User-readable debug summary of this local resource (used in debug info)
*/
fun getDebugSummary(): String
/**
* Returns the content provider URI that opens the local resource for viewing ([Intent.ACTION_VIEW])
* in its respective app.
*
* For instance, in case of a local raw contact, this method could return the content provider URI
* that identifies the corresponding contact.
*
* @return content provider URI, or `null` if not available
*/
fun getViewUri(context: Context): Uri?
}

View file

@ -0,0 +1,159 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import at.bitfire.synctools.storage.BatchOperation
import com.google.common.base.Ascii
import com.google.common.base.MoreObjects
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.Optional
import java.util.UUID
class LocalTask: DmfsTask, LocalResource<Task> {
companion object {
const val COLUMN_ETAG = Tasks.SYNC1
const val COLUMN_FLAGS = Tasks.SYNC2
}
override var fileName: String? = null
override var scheduleTag: String? = null
override var eTag: String? = null
override var flags = 0
private set
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
id = values.getAsLong(Tasks._ID)
fileName = values.getAsString(Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/* process LocalTask-specific fields */
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
super.buildTask(builder, update)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_FLAGS, flags)
}
/* custom queries */
override fun prepareForUpload(): String {
val uid: String = task!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in tasks provider
val values = contentValuesOf(Tasks._UID to newUid)
taskList.provider.update(taskSyncURI(), values, null, null)
// update this task
task!!.uid = newUid
newUid
}
return "$uid.ics"
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
val values = ContentValues(4)
if (fileName.isPresent)
values.put(Tasks._SYNC_ID, fileName.get())
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
values.put(Tasks._DIRTY, 0)
taskList.provider.update(taskSyncURI(), values, null, null)
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = eTag
}
override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
taskList.provider.update(taskSyncURI(), values, null, null)
}
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.add("task",
try {
Ascii.truncate(task.toString(), 1000, "")
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context): Uri? {
val idNotNull = id ?: return null
if (taskList.providerName == TaskProvider.ProviderName.OpenTasks) {
val contentUri = Tasks.getContentUri(taskList.providerName.authority)
return ContentUris.withAppendedId(contentUri, idNotNull)
}
return null
}
object Factory: DmfsTaskFactory<LocalTask> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}

View file

@ -0,0 +1,129 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import java.util.logging.Logger
/**
* App-specific implementation of a task list.
*
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalTaskList private constructor(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
private val logger = Logger.getGlobal()
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
override val readOnly
get() =
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
override val dbCollectionId: Long?
get() = syncId?.toLongOrNull()
override val tag: String
get() = "tasks-${account.name}-$id"
override val title: String
get() = name ?: id.toString()
override var lastSyncState: SyncState?
get() {
try {
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
provider.update(taskListSyncUri(), values, null, null)
}
override fun populate(values: ContentValues) {
super.populate(values)
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
}
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks(Tasks._DIRTY, null)
for (localTask in tasks) {
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else // task was modified, increase sequence
task.sequence = sequence + 1
} catch(e: Exception) {
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
}
return tasks
}
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
return provider.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}
object Factory: DmfsTaskListFactory<LocalTaskList> {
override fun newInstance(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
) = LocalTaskList(account, provider, providerName, id)
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.net.Uri
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.TaskProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import java.util.logging.Logger
class LocalTaskListStore @AssistedInject constructor(
@Assisted private val providerName: TaskProvider.ProviderName,
val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
val db: AppDatabase,
val logger: Logger
): LocalDataStore<LocalTaskList> {
@AssistedFactory
interface Factory {
fun create(providerName: TaskProvider.ProviderName): LocalTaskListStore
}
private val serviceDao = db.serviceDao()
override val authority: String
get() = providerName.authority
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
logger.log(Level.INFO, "Adding local task list", fromCollection)
val uri = create(account, provider, providerName, fromCollection)
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
}
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
// If the collection doesn't have a color, use a default color.
val collectionWithColor = if (fromCollection.color != null)
fromCollection
else
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
val values = valuesFromCollectionInfo(
info = collectionWithColor,
withColor = true
).apply {
put(TaskLists.OWNER, account.name)
put(TaskLists.SYNC_ENABLED, 1)
put(TaskLists.VISIBLE, 1)
}
return DmfsTaskList.Companion.create(account, provider, providerName, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.id.toString())
values.put(TaskLists.LIST_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor && info.color != null)
values.put(TaskLists.LIST_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly)
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
else
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
return values
}
override fun getAll(account: Account, provider: ContentProviderClient) =
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
val accountSettings = accountSettingsFactory.create(localCollection.account)
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
TaskProvider.acquire(context, providerName)?.use { provider ->
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
val uri = Tasks.getContentUri(providerName.authority)
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalTaskList) {
localCollection.delete()
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import at.bitfire.dav4jvm.property.webdav.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String,
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
) {
companion object {
private const val KEY_TYPE = "type"
private const val KEY_VALUE = "value"
private const val KEY_INITIAL_SYNC = "initialSync"
fun fromString(s: String?): SyncState? {
if (s == null)
return null
return try {
val json = JSONObject(s)
SyncState(
Type.valueOf(json.getString(KEY_TYPE)),
json.getString(KEY_VALUE),
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
)
} catch (e: JSONException) {
null
}
}
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
}
enum class Type { CTAG, SYNC_TOKEN }
override fun toString(): String {
val json = JSONObject()
json.put(KEY_TYPE, type.name)
json.put(KEY_VALUE, value)
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
return json.toString()
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.content.ContentValues
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.contactrow.DataRowHandler
import java.util.logging.Logger
class CachedGroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
override fun forMimeType() = CachedGroupMembership.CONTENT_ITEM_TYPE
override fun handle(values: ContentValues, contact: Contact) {
super.handle(values, contact)
if (localContact.addressBook.groupMethod == GroupMethod.GROUP_VCARDS)
localContact.cachedGroupMemberships += values.getAsLong(CachedGroupMembership.GROUP_ID)
else
Logger.getGlobal().warning("Ignoring cached group membership for group method CATEGORIES")
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.net.Uri
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.contactrow.DataRowBuilder
import java.util.LinkedList
class GroupMembershipBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, val addressBook: LocalAddressBook, readOnly: Boolean)
: DataRowBuilder(Factory.MIME_TYPE, dataRowUri, rawContactId, contact, readOnly) {
override fun build(): List<BatchOperation.CpoBuilder> {
val result = LinkedList<BatchOperation.CpoBuilder>()
if (addressBook.groupMethod == GroupMethod.CATEGORIES)
for (category in contact.categories)
result += newDataRow().withValue(GroupMembership.GROUP_ROW_ID, addressBook.findOrCreateGroup(category))
else {
// GroupMethod.GROUP_VCARDS -> memberships are handled by LocalGroups (and not by the members = LocalContacts, which we are processing here)
// TODO: CATEGORIES <-> unknown properties
}
return result
}
class Factory(val addressBook: LocalAddressBook): DataRowBuilder.Factory<GroupMembershipBuilder> {
companion object {
const val MIME_TYPE = GroupMembership.CONTENT_ITEM_TYPE
}
override fun mimeType() = MIME_TYPE
override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) =
GroupMembershipBuilder(dataRowUri, rawContactId, contact, addressBook, readOnly)
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.content.ContentValues
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.contactrow.DataRowHandler
import java.io.FileNotFoundException
class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE
override fun handle(values: ContentValues, contact: Contact) {
super.handle(values, contact)
val groupId = values.getAsLong(GroupMembership.GROUP_ROW_ID)
localContact.groupMemberships += groupId
if (localContact.addressBook.groupMethod == GroupMethod.CATEGORIES) {
try {
val group = localContact.addressBook.findGroupById(groupId)
group.getContact().displayName.trimToNull()?.let { groupName ->
logger.fine("Adding membership in group $groupName as category")
contact.categories.add(groupName)
}
} catch (ignored: FileNotFoundException) {
logger.warning("Contact is member in group $groupId which doesn't exist anymore")
}
}
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.provider.ContactsContract.RawContacts
object UnknownProperties {
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
const val MIMETYPE = RawContacts.Data.MIMETYPE
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
}

View file

@ -0,0 +1,31 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.net.Uri
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.contactrow.DataRowBuilder
import java.util.LinkedList
class UnknownPropertiesBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean)
: DataRowBuilder(Factory.mimeType(), dataRowUri, rawContactId, contact, readOnly) {
override fun build(): List<BatchOperation.CpoBuilder> {
val result = LinkedList<BatchOperation.CpoBuilder>()
contact.unknownProperties?.let { unknownProperties ->
result += newDataRow().withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
}
return result
}
object Factory: DataRowBuilder.Factory<UnknownPropertiesBuilder> {
override fun mimeType() = UnknownProperties.CONTENT_ITEM_TYPE
override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) =
UnknownPropertiesBuilder(dataRowUri, rawContactId, contact, readOnly)
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.contactrow
import android.content.ContentValues
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.contactrow.DataRowHandler
object UnknownPropertiesHandler: DataRowHandler() {
override fun forMimeType() = UnknownProperties.CONTENT_ITEM_TYPE
override fun handle(values: ContentValues, contact: Contact) {
super.handle(values, contact)
contact.unknownProperties = values.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
}
}

View file

@ -0,0 +1,161 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.workaround
import android.content.ContentValues
import android.os.Build
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalContact.Companion.COLUMN_HASHCODE
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.Optional
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
/**
* Android 7.x introduced a new behavior in the Contacts provider: when metadata of a contact (like the "last contacted" time)
* changes, the contact is marked as "dirty" (i.e. the [android.provider.ContactsContract.RawContacts.DIRTY] flag is set).
* So, under Android 7.x, every time a user calls a contact or writes an SMS to a contact, the contact is marked as dirty.
*
* **This behavior is not present in Android 6.x nor in Android 8.x, where a contact is only marked as dirty
* when its data actually change.**
*
* So, as a dirty workaround for Android 7.x, we need to calculate a hash code from the contact data and group memberships every
* time we change the contact. When then a contact is marked as dirty, we compare the hash code of the current contact data with
* the previous hash code. If the hash code has changed, the contact is "really dirty" and we need to upload it. Otherwise,
* we reset the dirty flag to ignore the meta-data change.
*
* @constructor May only be called on Android 7.x, otherwise an [IllegalStateException] is thrown.
*/
class Android7DirtyVerifier @Inject constructor(
val logger: Logger
): ContactDirtyVerifier {
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("Android7DirtyVerifier must not be used on Android != 7.x")
}
// address-book level functions
override fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean {
val reallyDirty = verifyDirtyContacts(addressBook)
val deleted = addressBook.findDeleted().size
if (isUpload && reallyDirty == 0 && deleted == 0) {
logger.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
return false
}
return true
}
/**
* Queries all contacts with the [android.provider.ContactsContract.RawContacts.DIRTY] flag and checks whether their data
* checksum has changed, i.e. if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
*
* The dirty flag is removed from contacts which are not "really dirty", i.e. from contacts whose contact data
* checksum has not changed.
*
* @return number of "really dirty" contacts
*/
private fun verifyDirtyContacts(addressBook: LocalAddressBook): Int {
var reallyDirty = 0
for (contact in addressBook.findDirtyContacts()) {
val lastHash = getLastHashCode(addressBook, contact)
val currentHash = contactDataHashCode(contact)
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
if (addressBook.includeGroups)
reallyDirty += addressBook.findDirtyGroups().size
return reallyDirty
}
private fun getLastHashCode(addressBook: LocalAddressBook, contact: LocalContact): Int {
addressBook.provider!!.query(contact.rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
}
// contact level functions
/**
* Calculates a hash code from the [at.bitfire.vcard4android.Contact] data and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory!
*
* @return hash code of contact data (including group memberships)
*/
private fun contactDataHashCode(contact: LocalContact): Int {
contact.clearCachedContact()
// groupMemberships is filled by getContact()
val dataHash = contact.getContact().hashCode()
val groupHash = contact.groupMemberships.hashCode()
val combinedHash = dataHash xor groupHash
logger.log(Level.FINE, "Calculated data hash = $dataHash, group memberships hash = $groupHash → combined hash = $combinedHash", contact)
return combinedHash
}
override fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues) {
val hashCode = contactDataHashCode(contact)
toValues.put(COLUMN_HASHCODE, hashCode)
}
override fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact) {
val values = ContentValues(1)
setHashCodeColumn(contact, values)
addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null)
}
override fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation) {
val hashCode = contactDataHashCode(contact)
batch += BatchOperation.CpoBuilder
.newUpdate(contact.rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode)
}
// factory
@Module
@InstallIn(SingletonComponent::class)
object Android7DirtyVerifierModule {
/**
* Provides an [Android7DirtyVerifier] on Android 7.x, or an empty [Optional] on other versions.
*/
@Provides
fun provide(android7DirtyVerifier: Provider<Android7DirtyVerifier>): Optional<ContactDirtyVerifier> =
if (/* Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && */ Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
Optional.of(android7DirtyVerifier.get())
else
Optional.empty()
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.workaround
import android.content.ContentValues
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.synctools.storage.ContactsBatchOperation
/**
* Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8,
* this interface and all calls to it can be removed as well.
*/
interface ContactDirtyVerifier {
// address-book level functions
/**
* Checks whether contacts which are marked as "dirty" are really dirty, i.e. their data has changed.
* If contacts are not really dirty (because only the metadata like "last contacted" changed), the "dirty" flag is removed.
*
* Intended to be called by [at.bitfire.davdroid.sync.ContactsSyncManager.prepare].
*
* @param addressBook the address book
* @param isUpload whether this sync is an upload
*
* @return `true` if the address book should be synced, `false` if the sync is an upload and no contacts have been changed
*/
fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean
// contact level functions
/**
* Sets the [LocalContact.COLUMN_HASHCODE] column in the given [ContentValues] to the hash code of the contact data.
*
* @param contact the contact to calculate the hash code for
* @param toValues set the hash code into these values
*/
fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues)
/**
* Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data directly in the content provider.
*/
fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact)
/**
Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data in a content provider batch operation.
*/
fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation)
}

View file

@ -0,0 +1,73 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
/**
* Logic for refreshing the list of collections (and their related information)
* which do not belong to a home set.
*/
class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val collectionRepository: DavCollectionRepository,
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher
}
/**
* Refreshes collections which don't have a homeset.
*
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
*/
internal fun refreshCollectionsWithoutHomeSet() {
val withoutHomeSet = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for ((url, localCollection) in withoutHomeSet) try {
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
if (!response.isSuccess()) {
collectionRepository.delete(localCollection)
return@propfind
}
// Save or update the collection, if usable, otherwise delete it
Collection.fromDavResponse(response)?.let { collection ->
if (!ServiceDetectionUtils.isUsableCollection(service, collection))
return@let
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
serviceId = localCollection.serviceId, // use same service ID as previous entry
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
))
} ?: collectionRepository.delete(localCollection)
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.statusCode in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
}

View file

@ -0,0 +1,506 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.app.ActivityManager
import android.content.Context
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.common.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.network.DnsRecordResolver
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Credentials
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.xbill.DNS.Type
import java.io.InterruptedIOException
import java.net.SocketTimeoutException
import java.net.URI
import java.net.URISyntaxException
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
/**
* Does initial resource detection when an account is added. It uses the (user given) base URL to find
*
* - services (CalDAV and/or CardDAV),
* - principal,
* - homeset/collections (multistatus responses are handled through dav4jvm).
*
* @param context to build the HTTP client
* @param baseURI user-given base URI (either mailto: URI or http(s):// URL)
* @param credentials optional login credentials (username/password, client certificate, OAuth state)
*/
class DavResourceFinder @AssistedInject constructor(
@Assisted private val baseURI: URI,
@Assisted private val credentials: Credentials? = null,
@ApplicationContext val context: Context,
private val dnsRecordResolver: DnsRecordResolver,
httpClientBuilder: HttpClient.Builder
): AutoCloseable {
@AssistedFactory
interface Factory {
fun create(baseURI: URI, credentials: Credentials?): DavResourceFinder
}
enum class Service(val wellKnownName: String) {
CALDAV("caldav"),
CARDDAV("carddav");
override fun toString() = wellKnownName
}
val log: Logger = Logger.getLogger(javaClass.name)
private val logBuffer: StringHandler = initLogging()
private var encountered401 = false
private val httpClient = httpClientBuilder
.setLogger(log)
.apply {
if (credentials != null)
authenticate(
host = null,
getCredentials = { credentials }
)
}
.build()
override fun close() {
httpClient.close()
}
private fun initLogging(): StringHandler {
// don't use more than 1/4 of the available memory for a log string
val activityManager = context.getSystemService<ActivityManager>()!!
val maxLogSize = activityManager.memoryClass * (1024 * 1024 / 8)
val handler = StringHandler(maxLogSize)
// add StringHandler to logger
log.level = Level.ALL
log.addHandler(handler)
return handler
}
/**
* Finds the initial configuration (= runs the service detection process).
*
* In case of an error, it returns an empty [Configuration] with error logs
* instead of throwing an [Exception].
*
* @return service information if there's neither a CalDAV service nor a CardDAV service,
* service detection was not successful
*/
fun findInitialConfiguration(): Configuration {
var cardDavConfig: Configuration.ServiceInfo? = null
var calDavConfig: Configuration.ServiceInfo? = null
try {
try {
cardDavConfig = findInitialConfiguration(Service.CARDDAV)
} catch (e: Exception) {
log.log(Level.INFO, "CardDAV service detection failed", e)
processException(e)
}
try {
calDavConfig = findInitialConfiguration(Service.CALDAV)
} catch (e: Exception) {
log.log(Level.INFO, "CalDAV service detection failed", e)
processException(e)
}
} catch(_: Exception) {
// we have been interrupted; reset results so that an error message will be shown
cardDavConfig = null
calDavConfig = null
}
return Configuration(
cardDAV = cardDavConfig,
calDAV = calDavConfig,
encountered401 = encountered401,
logs = logBuffer.toString()
)
}
private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
// domain for service discovery
var discoveryFQDN: String? = null
// discovered information goes into this config
val config = Configuration.ServiceInfo()
// Start discovering
log.info("Finding initial ${service.wellKnownName} service configuration")
when (baseURI.scheme.lowercase()) {
"http", "https" ->
baseURI.toHttpUrlOrNull()?.let { baseURL ->
// remember domain for service discovery
if (baseURL.scheme.equals("https", true))
// service discovery will only be tried for https URLs, because only secure service discovery is implemented
discoveryFQDN = baseURL.host
// Actual discovery process
checkBaseURL(baseURL, service, config)
// If principal was not found already, try well known URI
if (config.principal == null)
try {
config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
} catch(e: Exception) {
log.log(Level.FINE, "Well-known URL detection failed", e)
processException(e)
}
}
"mailto" -> {
val mailbox = baseURI.schemeSpecificPart
val posAt = mailbox.lastIndexOf("@")
if (posAt != -1)
discoveryFQDN = mailbox.substring(posAt + 1)
}
}
// Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY)
if (config.principal == null)
discoveryFQDN?.let { fqdn ->
log.info("No principal found at user-given URL, trying to discover for domain $fqdn")
try {
config.principal = discoverPrincipalUrl(fqdn, service)
} catch(e: Exception) {
log.log(Level.FINE, "$service service discovery failed", e)
processException(e)
}
}
// detect email address
if (service == Service.CALDAV)
config.principal?.let { principal ->
config.emails.addAll(queryEmailAddress(principal))
}
// return config or null if config doesn't contain useful information
val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty()
return if (serviceAvailable)
config
else
null
}
/**
* Entry point of the actual discovery process.
*
* Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal
* or whether it is a homeset or collection.
*
* @param baseURL base URL provided by the user
* @param service service to detect configuration for
* @param config found configuration will be written to this object
*/
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
log.info("Checking user-given URL: $baseURL")
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
try {
when (service) {
Service.CARDDAV -> {
davBaseURL.propfind(
0,
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanResponse(ResourceType.ADDRESSBOOK, response, config)
}
}
Service.CALDAV -> {
davBaseURL.propfind(
0,
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanResponse(ResourceType.CALENDAR, response, config)
}
}
}
} catch(e: Exception) {
log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
processException(e)
}
}
/**
* Queries a user's email address using CalDAV scheduling: calendar-user-address-set.
* @param principal principal URL of the user
* @return list of found email addresses (empty if none)
*/
fun queryEmailAddress(principal: HttpUrl): List<String> {
val mailboxes = LinkedList<String>()
try {
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
for (href in addressSet.hrefs)
try {
val uri = URI(href)
if (uri.scheme.equals("mailto", true))
mailboxes.add(uri.schemeSpecificPart)
} catch(e: URISyntaxException) {
log.log(Level.WARNING, "Couldn't parse user address", e)
}
}
}
} catch(e: Exception) {
log.log(Level.WARNING, "Couldn't query user email address", e)
processException(e)
}
return mailboxes
}
/**
* Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references
* - an address book or calendar (actual resource), and/or
* - an "address book home set" or a "calendar home set", and/or
* - whether it's a principal.
*
* Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal].
* Collection URLs will be stored with trailing "/".
*
* @param resourceType type of service to search for in the response
* @param davResponse response whose properties are evaluated
* @param config structure storing the references
*/
fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) {
var principal: HttpUrl? = null
// Type mapping
val homeSetClass: Class<out HrefListProperty>
val serviceType: Service
when (resourceType) {
ResourceType.ADDRESSBOOK -> {
homeSetClass = AddressbookHomeSet::class.java
serviceType = Service.CARDDAV
}
ResourceType.CALENDAR -> {
homeSetClass = CalendarHomeSet::class.java
serviceType = Service.CALDAV
}
else -> throw IllegalArgumentException()
}
// check for current-user-principal
davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal ->
principal = davResponse.requestedUrl.resolve(currentUserPrincipal)
}
davResponse[ResourceType::class.java]?.let {
// Is it a calendar or an address book, ...
if (it.types.contains(resourceType))
Collection.fromDavResponse(davResponse)?.let { info ->
log.info("Found resource of type $resourceType at ${info.url}")
config.collections[info.url] = info
}
// ... and/or a principal?
if (it.types.contains(ResourceType.PRINCIPAL))
principal = davResponse.href
}
// Is it an addressbook-home-set or calendar-home-set?
davResponse[homeSetClass]?.let { homeSet ->
for (href in homeSet.hrefs) {
davResponse.requestedUrl.resolve(href)?.let {
val location = UrlUtils.withTrailingSlash(it)
log.info("Found home-set of type $resourceType at $location")
config.homeSets += location
}
}
}
// Is there a principal too?
principal?.let {
if (providesService(it, serviceType))
config.principal = principal
else
log.warning("Principal $principal doesn't provide $serviceType service")
}
}
/**
* Sends an OPTIONS request to determine whether a URL provides a given service.
*
* @param url URL to check; often a principal URL
* @param service service to check for
*
* @return whether the URL provides the given service
*/
fun providesService(url: HttpUrl, service: Service): Boolean {
var provided = false
try {
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
provided = true
}
} catch(e: Exception) {
log.log(Level.SEVERE, "Couldn't detect services on $url", e)
if (e !is HttpException && e !is DavException)
throw e
}
return provided
}
/**
* Try to find the principal URL by performing service discovery on a given domain name.
* Only secure services (caldavs, carddavs) will be discovered!
*
* @param domain domain name, e.g. "icloud.com"
* @param service service to discover (CALDAV or CARDDAV)
* @return principal URL, or null if none found
*/
fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
val scheme: String
val fqdn: String
var port = 443
val paths = LinkedList<String>() // there may be multiple paths to try
val query = "_${service.wellKnownName}s._tcp.$domain"
log.fine("Looking up SRV records for $query")
val srvRecords = dnsRecordResolver.resolve(query, Type.SRV)
val srv = dnsRecordResolver.bestSRVRecord(srvRecords)
if (srv != null) {
// choose SRV record to use (query may return multiple SRV records)
scheme = "https"
fqdn = srv.target.toString(true)
port = srv.port
log.info("Found $service service at https://$fqdn:$port")
} else {
// no SRV records, try domain name as FQDN
log.info("Didn't find $service service, trying at https://$domain:$port")
scheme = "https"
fqdn = domain
}
// look for TXT record too (for initial context path)
val txtRecords = dnsRecordResolver.resolve(query, Type.TXT)
paths.addAll(dnsRecordResolver.pathsFromTXTRecords(txtRecords))
// in case there's a TXT record, but it's wrong, try well-known
paths.add("/.well-known/" + service.wellKnownName)
// if this fails too, try "/"
paths.add("/")
for (path in paths)
try {
val initialContextPath = HttpUrl.Builder()
.scheme(scheme)
.host(fqdn).port(port)
.encodedPath(path)
.build()
log.info("Trying to determine principal from initial context path=$initialContextPath")
val principal = getCurrentUserPrincipal(initialContextPath, service)
principal?.let { return it }
} catch(e: Exception) {
log.log(Level.WARNING, "No resource found", e)
processException(e)
}
return null
}
/**
* Queries a given URL for current-user-principal
*
* @param url URL to query with PROPFIND (Depth: 0)
* @param service required service (may be null, in which case no service check is done)
* @return current-user-principal URL that provides required service, or null if none
*/
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
var principal: HttpUrl? = null
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
response.requestedUrl.resolve(href)?.let {
log.info("Found current-user-principal: $it")
// service check
if (service != null && !providesService(it, service))
log.warning("Principal $it doesn't provide $service service")
else
principal = it
}
}
}
return principal
}
/**
* Processes a thrown exception like this:
*
* - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*.
* - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation.
*/
private fun processException(e: Exception) {
if (e is UnauthorizedException)
encountered401 = true
else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException)
throw e
}
// data classes
class Configuration(
val cardDAV: ServiceInfo?,
val calDAV: ServiceInfo?,
val encountered401: Boolean,
val logs: String
) {
data class ServiceInfo(
var principal: HttpUrl? = null,
val homeSets: MutableSet<HttpUrl> = HashSet(),
val collections: MutableMap<HttpUrl, Collection> = HashMap(),
val emails: MutableList<String> = LinkedList()
)
override fun toString() =
"DavResourceFinder.Configuration(cardDAV=$cardDAV, calDAV=$calDAV, encountered401=$encountered401, logs=(${logs.length} chars))"
}
}

View file

@ -0,0 +1,162 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
import java.util.logging.Level
import java.util.logging.Logger
/**
* Used to update the list of synchronizable collections
*/
class HomeSetRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val logger: Logger,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
private val settings: SettingsManager
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): HomeSetRefresher
}
/**
* Refreshes home-sets and their collections.
*
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
* or marked as "without home-set" - in case a collection was removed from its home-set.
*
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet].
*/
internal fun refreshHomesetsAndTheirCollections() {
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
for ((homeSetUrl, localHomeset) in homesets) {
logger.fine("Listing home set $homeSetUrl")
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
// is successfully rediscovered. If there are collections left, after processing is done, these are marked as "without home-set".
val localHomesetCollections = db.collectionDao()
.getByServiceAndHomeset(service.id, localHomeset.id)
.associateBy { it.url }
.toMutableMap()
try {
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
// Note: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF)
// this response is about the home set itself
homeSetRepository.insertOrUpdateByUrlBlocking(
localHomeset.copy(
displayName = response[DisplayName::class.java]?.displayName,
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
)
)
// in any case, check whether the response is about a usable collection
var collection = Collection.fromDavResponse(response) ?: return@propfind
collection = collection.copy(
serviceId = service.id,
homeSetId = localHomeset.id,
sync = shouldPreselect(collection, homesets.values),
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
)
logger.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
if (ServiceDetectionUtils.isUsableCollection(service, collection))
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
// Remove this collection from queue - because it was found in the home set
localHomesetCollections.remove(collection.url)
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.statusCode in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
for ((_, collection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlRememberSync(
collection.copy(homeSetId = null)
)
}
}
/**
* Whether to preselect the given collection for synchronisation, according to the
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
*
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
*
* Before a collection is pre-selected, we check whether its URL matches the regexp in
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homeSets list of personal home-sets
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
if (!excludedRegex.isNullOrEmpty())
Regex(excludedRegex).containsMatchIn(collection.url.toString())
else
false
}
return when (shouldPreselect) {
Settings.PRESELECT_COLLECTIONS_ALL ->
// preselect if collection url is not excluded
!excluded
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homeSets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)
&& !excluded
else -> // don't preselect
false
}
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
import java.util.logging.Logger
/**
* Used to update the principals (their current display names) and delete those without collections.
*/
class PrincipalsRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val logger: Logger
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): PrincipalsRefresher
}
/**
* Principal properties to ask the server for.
*/
private val principalProperties = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* Refreshes the principals (get their current display names).
* Also removes principals which do not own any collections anymore.
*/
fun refreshPrincipals() {
// Refresh principals (collection owner urls)
val principals = db.principalDao().getByService(service.id)
for (oldPrincipal in principals) {
val principalUrl = oldPrincipal.url
logger.fine("Querying principal $principalUrl")
try {
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
if (!response.isSuccess())
return@propfind
Principal.fromDavResponse(service.id, response)?.let { principal ->
logger.fine("Got principal: $principal")
db.principalDao().insertOrUpdate(service.id, principal)
}
}
} catch (e: HttpException) {
logger.info("Principal update failed with response code ${e.statusCode}. principalUrl=$principalUrl")
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach { principal ->
db.principalDao().delete(principal)
}
}
}

View file

@ -0,0 +1,251 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.accounts.Account
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.push.PushRegistrationManager
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import java.util.logging.Level
import java.util.logging.Logger
/**
* Refreshes list of home sets and their respective collections of a service type (CardDAV or CalDAV).
* Called from UI, when user wants to refresh all collections of a service.
*
* Input data:
*
* - [ARG_SERVICE_ID]: service ID
*
* It queries all existing homesets and/or collections and then:
* - updates resources with found properties (overwrites without comparing)
* - adds resources if new ones are detected
* - removes resources if not found 40x (delete locally)
*
* Expedited: yes (always initiated by user)
*
* Long-running: no
*
* @throws IllegalArgumentException when there's no service with the given service ID
*/
@HiltWorker
class RefreshCollectionsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
private val httpClientBuilder: HttpClient.Builder,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry,
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
private val pushRegistrationManager: PushRegistrationManager,
private val serviceRefresherFactory: ServiceRefresher.Factory,
serviceRepository: DavServiceRepository
): CoroutineWorker(appContext, workerParams) {
companion object {
const val ARG_SERVICE_ID = "serviceId"
const val WORKER_TAG = "refreshCollectionsWorker"
/**
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
*
* @param serviceId what service (CalDAV/CardDAV) the worker is running for
*/
fun workerName(serviceId: Long): String = "$WORKER_TAG-$serviceId"
/**
* Requests immediate refresh of a given service. If not running already. this will enqueue
* a [RefreshCollectionsWorker].
*
* @param serviceId serviceId which is to be refreshed
* @return Pair with
*
* 1. worker name,
* 2. operation of [WorkManager.enqueueUniqueWork] (can be used to wait for completion)
*
* @throws IllegalArgumentException when there's no service with this ID
*/
fun enqueue(context: Context, serviceId: Long): Pair<String, Operation> {
val name = workerName(serviceId)
val arguments = Data.Builder()
.putLong(ARG_SERVICE_ID, serviceId)
.build()
val workRequest = OneTimeWorkRequestBuilder<RefreshCollectionsWorker>()
.addTag(name)
.setInputData(arguments)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
return Pair(
name,
WorkManager.getInstance(context).enqueueUniqueWork(
name,
ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one
workRequest
)
)
}
/**
* Observes whether a refresh worker with given service id and state exists.
*
* @param workerName name of worker to find
* @param workState state of worker to match
*
* @return flow that emits `true` if worker with matching state was found (otherwise `false`)
*/
fun existsFlow(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(workerName).map { workInfoList ->
workInfoList.any { workInfo -> workInfo.state == workState }
}
}
val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1)
val service = serviceRepository.getBlocking(serviceId)
val account = service?.let { service ->
Account(service.accountName, applicationContext.getString(R.string.account_type))
}
override suspend fun doWork(): Result {
if (service == null || account == null) {
logger.warning("Missing service or account with service ID: $serviceId")
return Result.failure()
}
try {
logger.info("Refreshing ${service.type} collections of service #$service")
// cancel previous notification
NotificationManagerCompat.from(applicationContext)
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
httpClientBuilder
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible {
val httpClient = httpClient.okHttpClient
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
// refresh home set list (from principal url)
service.principal?.let { principalUrl ->
logger.fine("Querying principal $principalUrl for home sets")
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
serviceRefresher.discoverHomesets(principalUrl)
}
// refresh home sets and their member collections
homeSetRefresherFactory.create(service, httpClient)
.refreshHomesetsAndTheirCollections()
// also refresh collections without a home set
refresher.refreshCollectionsWithoutHomeSet()
// Lastly, refresh the principals (collection owners)
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
principalsRefresher.refreshPrincipals()
}
}
} catch(e: InvalidAccountException) {
logger.log(Level.SEVERE, "Invalid account", e)
return Result.failure()
} catch (e: UnauthorizedException) {
logger.log(Level.SEVERE, "Not authorized (anymore)", e)
// notify that we need to re-authenticate in the account settings
val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java)
.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
notifyRefreshError(
applicationContext.getString(R.string.sync_error_authentication_failed),
settingsIntent
)
return Result.failure()
} catch(e: Exception) {
logger.log(Level.SEVERE, "Couldn't refresh collection list", e)
val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext)
.withCause(e)
.withAccount(account)
.build()
notifyRefreshError(
applicationContext.getString(R.string.refresh_collections_worker_refresh_couldnt_refresh),
debugIntent
)
return Result.failure()
}
// update push registrations
pushRegistrationManager.update(serviceId)
// Success
return Result.success()
}
/**
* Used by WorkManager to show a foreground service notification for expedited jobs on Android <12.
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_foreground_notify)
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
.setStyle(NotificationCompat.BigTextStyle())
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
return ForegroundInfo(NotificationRegistry.NOTIFY_SYNC_EXPEDITED, notification)
}
private fun notifyRefreshError(contentText: String, contentIntent: Intent) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS, tag = serviceId.toString()) {
NotificationCompat.Builder(applicationContext, notificationRegistry.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed))
.setContentText(contentText)
.setContentIntent(
TaskStackBuilder.create(applicationContext)
.addNextIntentWithParentStack(contentIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.setSubText(account?.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
}
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.Source
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
object ServiceDetectionUtils {
/**
* WebDAV properties to ask for in a PROPFIND request on a collection.
*/
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
arrayOf( // generic WebDAV properties
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
ResourceType.NAME,
PushTransports.NAME, // WebDAV-Push
Topic.NAME
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookDescription.NAME
)
Service.TYPE_CALDAV -> arrayOf(
CalendarColor.NAME,
CalendarDescription.NAME,
CalendarTimezone.NAME,
CalendarTimezoneId.NAME,
SupportedCalendarComponentSet.NAME,
Source.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Finds out whether given collection is usable for synchronization, by checking that either
*
* - CalDAV/CardDAV: service and collection type match, or
* - WebCal: subscription source URL is not empty.
*/
fun isUsableCollection(service: Service, collection: Collection) =
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
}

View file

@ -0,0 +1,178 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.common.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GroupMembership
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.davdroid.util.DavUtils.parent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.logging.Level
import java.util.logging.Logger
/**
* ServiceRefresher is used to discover and save home sets of a given service.
*/
class ServiceRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val logger: Logger,
private val homeSetRepository: DavHomeSetRepository
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): ServiceRefresher
}
/**
* Home-set class to use depending on the given service type.
*/
private val homeSetClass: Class<out HrefListProperty> =
when (service.type) {
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
else -> throw IllegalArgumentException()
}
/**
* Home-set properties to ask for in a PROPFIND request to the principal URL,
* depending on the given service type.
*/
private val homeSetProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
DisplayName.NAME,
GroupMembership.NAME,
ResourceType.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookHomeSet.NAME,
)
Service.TYPE_CALDAV -> arrayOf(
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
*
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
* @param level Current recursion level (limited to 0, 1 or 2):
* - 0: We assume found home sets belong to the current-user-principal
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
* more than once, which could overwrite the already set "personal" flag with `false`.
*
* @throws java.io.IOException on I/O errors
* @throws HttpException on HTTP errors
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
*/
internal fun discoverHomesets(
principalUrl: HttpUrl,
level: Int = 0,
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
) {
logger.fine("Discovering homesets of $principalUrl")
val relatedResources = mutableSetOf<HttpUrl>()
// Query the URL
val principal = DavResource(httpClient, principalUrl)
val personal = level == 0
try {
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
alreadyQueriedPrincipals += davResponse.href
// If response holds home sets, save them
davResponse[homeSetClass]?.let { homeSets ->
for (homeSetHref in homeSets.hrefs)
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
homeSetRepository.insertOrUpdateByUrlBlocking(
// HomeSet is considered personal if this is the outer recursion call,
// This is because we assume the first call to query the current-user-principal
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
// other principals while still being considered "personal" (belonging to the current-user-principal)
// and an owned home set need not always be personal either.
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
)
alreadySavedHomeSets += resolvedHomeSetUrl
}
}
}
// Add related principals to be queried afterwards
if (personal) {
val relatedResourcesTypes = listOf(
// current resource is a read/write-proxy for other principals
CalendarProxyReadFor::class.java,
CalendarProxyWriteFor::class.java,
// current resource is a member of a group (principal that can also have proxies)
GroupMembership::class.java
)
for (type in relatedResourcesTypes)
davResponse[type]?.let {
for (href in it.hrefs)
principal.location.resolve(href)?.let { url ->
relatedResources += url
}
}
}
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
davResponse[ResourceType::class.java]?.let { resourceType ->
val proxyProperties = arrayOf(
ResourceType.CALENDAR_PROXY_READ,
ResourceType.CALENDAR_PROXY_WRITE,
)
if (proxyProperties.any { resourceType.types.contains(it) })
relatedResources += davResponse.href.parent()
}
}
} catch (e: HttpException) {
if (e.isClientError)
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e
}
// query related resources
if (level <= 1)
for (resource in relatedResources)
if (alreadyQueriedPrincipals.contains(resource))
logger.warning("$resource already queried, skipping")
else
discoverHomesets(
principalUrl = resource,
level = level + 1,
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
alreadySavedHomeSets = alreadySavedHomeSets
)
}
}

View file

@ -0,0 +1,443 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import android.os.Looper
import androidx.annotation.WorkerThread
import androidx.core.os.bundleOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.AccountSettings.Companion.CREDENTIALS_LOCK
import at.bitfire.davdroid.settings.AccountSettings.Companion.CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS
import at.bitfire.davdroid.settings.migration.AccountSettingsMigration
import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.AuthState
import java.util.Collections
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
/**
* Manages settings of an account.
*
* **Must not be called from main thread as it uses blocking I/O and may run migrations.**
*
* @param account account to take settings from
* @param abortOnMissingMigration whether to throw an [IllegalArgumentException] when migrations are missing (useful for testing)
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
* @throws IllegalArgumentException when the account is not a DAVx5 account or migrations are missing and [abortOnMissingMigration] is set
*/
@WorkerThread
class AccountSettings @AssistedInject constructor(
@Assisted val account: Account,
@Assisted val abortOnMissingMigration: Boolean,
private val automaticSyncManager: AutomaticSyncManager,
@ApplicationContext private val context: Context,
private val logger: Logger,
private val migrations: Map<Int, @JvmSuppressWildcards Provider<AccountSettingsMigration>>,
private val settingsManager: SettingsManager
) {
@AssistedFactory
interface Factory {
/**
* **Must not be called on main thread. Throws exceptions!** See [AccountSettings] for details.
*/
@WorkerThread
fun create(account: Account, abortOnMissingMigration: Boolean = false): AccountSettings
}
init {
if (Looper.getMainLooper() == Looper.myLooper())
throw IllegalThreadStateException("AccountSettings may not be used on main thread")
}
val accountManager: AccountManager = AccountManager.get(context)
init {
val allowedAccountTypes = arrayOf(
context.getString(R.string.account_type),
"at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest
)
if (!allowedAccountTypes.contains(account.type))
throw IllegalArgumentException("Invalid account type for AccountSettings(): ${account.type}")
// synchronize because account migration must only be run one time
synchronized(currentlyUpdating) {
if (currentlyUpdating.contains(account))
logger.warning("AccountSettings created during migration of $account not running update()")
else {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
}
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION) {
currentlyUpdating += account
try {
update(version, abortOnMissingMigration)
} finally {
currentlyUpdating -= account
}
}
}
}
}
// authentication settings
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account)?.toSensitiveString(),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
accountManager.getUserData(account, KEY_AUTH_STATE)?.let { json ->
AuthState.jsonDeserialize(json)
}
)
fun credentials(credentials: Credentials) {
// Basic/Digest auth
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
accountManager.setPassword(account, credentials.password?.asString())
// client certificate
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
// OAuth
credentials.authState?.let { authState ->
updateAuthState(authState)
}
}
fun updateAuthState(authState: AuthState) {
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString())
}
/**
* Returns whether users can modify credentials from the account settings screen.
* Checks the value of [CREDENTIALS_LOCK] to be `0` or not equal to [CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS].
*/
fun changingCredentialsAllowed(): Boolean {
val credentialsLock = settingsManager.getIntOrNull(CREDENTIALS_LOCK)
return credentialsLock == null || credentialsLock != CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS
}
// sync. settings
/**
* Gets the currently set sync interval for this account and data type in seconds.
*
* @param dataType data type of desired sync interval
* @return sync interval in seconds, or `null` if not set (not applicable or only manual sync)
*/
fun getSyncInterval(dataType: SyncDataType): Long? {
val key = when (dataType) {
SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
}
val seconds = accountManager.getUserData(account, key)?.toLong()
return when (seconds) {
null -> settingsManager.getLongOrNull(Settings.DEFAULT_SYNC_INTERVAL) // no setting → default value
SYNC_INTERVAL_MANUALLY -> null // manual sync
else -> seconds
}
}
/**
* Sets the sync interval for the given data type and updates the automatic sync.
*
* @param dataType data type of the sync interval to set
* @param seconds sync interval in seconds; _null_ for no periodic sync
*/
fun setSyncInterval(dataType: SyncDataType, seconds: Long?) {
val key = when (dataType) {
SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
}
val newValue = seconds ?: SYNC_INTERVAL_MANUALLY
accountManager.setAndVerifyUserData(account, key, newValue.toString())
automaticSyncManager.updateAutomaticSync(account, dataType)
}
fun getSyncWifiOnly() =
if (settingsManager.containsKey(KEY_WIFI_ONLY))
settingsManager.getBoolean(KEY_WIFI_ONLY)
else
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
automaticSyncManager.updateAutomaticSync(account)
}
fun getSyncWifiOnlySSIDs(): List<String>? =
if (getSyncWifiOnly()) {
val strSsids = if (settingsManager.containsKey(KEY_WIFI_ONLY_SSIDS))
settingsManager.getString(KEY_WIFI_ONLY_SSIDS)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS)
strSsids?.split(',')
} else
null
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY_SSIDS, ssids?.joinToString(",").trimToNull())
fun getIgnoreVpns(): Boolean =
when (accountManager.getUserData(account, KEY_IGNORE_VPNS)) {
null -> settingsManager.getBoolean(KEY_IGNORE_VPNS)
"0" -> false
else -> true
}
fun setIgnoreVpns(ignoreVpns: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0")
// CalDAV settings
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
return if (strDays != null) {
val days = strDays.toInt()
if (days < 0)
null
else
days
} else
DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
/**
* Takes the default alarm setting (in this order) from
*
* 1. the local account settings
* 2. the settings provider (unless the value is -1 there).
*
* @return A default reminder shall be created this number of minutes before the start of every
* non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun getDefaultAlarm() =
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
/**
* Sets the default alarm value in the local account settings, if the new value differs
* from the value of the settings provider. If the new value is the same as the value of
* the settings provider, the local setting will be deleted, so that the settings provider
* value applies.
*
* @param minBefore The number of minutes a default reminder shall be created before the
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun setDefaultAlarm(minBefore: Int?) =
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
null
else
minBefore?.toString())
fun getManageCalendarColors() =
if (settingsManager.containsKey(KEY_MANAGE_CALENDAR_COLORS))
settingsManager.getBoolean(KEY_MANAGE_CALENDAR_COLORS)
else
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
fun setManageCalendarColors(manage: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
fun getEventColors() =
if (settingsManager.containsKey(KEY_EVENT_COLORS))
settingsManager.getBoolean(KEY_EVENT_COLORS)
else
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
fun setEventColors(useColors: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = settingsManager.getString(KEY_CONTACT_GROUP_METHOD) ?:
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
if (name != null)
try {
return GroupMethod.valueOf(name)
}
catch (_: IllegalArgumentException) {
}
return GroupMethod.GROUP_VCARDS
}
fun setGroupMethod(method: GroupMethod) {
accountManager.setAndVerifyUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
}
// UI settings
/**
* Whether to show only personal collections in the UI
*
* @return *true* if only personal collections shall be shown; *false* otherwise
*/
fun getShowOnlyPersonal(): Boolean = when (settingsManager.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
0 -> false
1 -> true
else /* including -1 */ -> accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null
}
/**
* Whether the user shall be able to change the setting (= setting not locked)
*
* @return *true* if the setting is locked; *false* otherwise
*/
fun getShowOnlyPersonalLocked(): Boolean = when (settingsManager.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
0, 1 -> true
else /* including -1 */ -> false
}
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) {
accountManager.setAndVerifyUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null)
}
// update from previous account settings
private fun update(baseVersion: Int, abortOnMissingMigration: Boolean) {
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
val fromVersion = toVersion - 1
logger.info("Updating account ${account.name} settings version $fromVersion$toVersion")
val migration = migrations[toVersion]
if (migration == null) {
logger.severe("No AccountSettings migration $fromVersion$toVersion")
if (abortOnMissingMigration)
throw IllegalArgumentException("Missing AccountSettings migration $fromVersion$toVersion")
} else {
try {
migration.get().migrate(account)
logger.info("Account settings version update to $toVersion successful")
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't run AccountSettings migration $fromVersion$toVersion", e)
}
}
}
}
companion object {
const val CURRENT_VERSION = 20
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
const val CREDENTIALS_LOCK = "login_credentials_lock"
const val CREDENTIALS_LOCK_NO_LOCK = 0
const val CREDENTIALS_LOCK_AT_LOGIN = 1
const val CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS = 2
/** OAuth [AuthState] (serialized as JSON) */
const val KEY_AUTH_STATE = "auth_state"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
/** Time range limitation to the past [in days]. Values:
*
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
* - <0 (typically -1): no limit
* - n>0: entries more than n days in the past won't be synchronized
*/
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/**
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
* Value can be null (no default alarm) or an integer (default alarm shall be created this
* number of minutes before the event/task).
*/
const val KEY_DEFAULT_ALARM = "default_alarm"
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
value = *null* (not existing): true (default);
"0" false */
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/** Whether DAVx5 populates and uses CalendarContract.Colors
value = *null* (not existing) false (default);
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
*null (not existing)* groups as separate vCards (default);
"CATEGORIES" groups are per-contact CATEGORIES
*/
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
/** UI preference: Show only personal collections
value = *null* (not existing) show all collections (default);
"1" show only personal collections */
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
internal const val SYNC_INTERVAL_MANUALLY = -1L
/** Static property to remember which AccountSettings updates/migrations are currently running */
val currentlyUpdating = Collections.synchronizedSet(mutableSetOf<Account>())
fun initialUserData(credentials: Credentials?): Bundle {
val bundle = bundleOf(KEY_SETTINGS_VERSION to CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.username != null)
bundle.putString(KEY_USERNAME, credentials.username)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
if (credentials.authState != null)
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
}
return bundle
}
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.util.SensitiveString
import net.openid.appauth.AuthState
/**
* Represents credentials that are used to authenticate against a CalDAV/CardDAV/WebDAV server.
*
* Note: [authState] can change from request to request, so make sure that you have an up-to-date
* copy when using it.
*/
data class Credentials(
/** username for Basic / Digest auth */
val username: String? = null,
/** password for Basic / Digest auth */
val password: SensitiveString? = null,
/** alias of an client certificate that is present on the system */
val certificateAlias: String? = null,
/** OAuth authorization state */
val authState: AuthState? = null
) {
override fun toString(): String {
val s = mutableListOf<String>()
if (username != null)
s += "userName=$username"
if (password != null)
s += "password=*****"
if (certificateAlias != null)
s += "certificateAlias=$certificateAlias"
if (authState != null) // contains sensitive information (refresh token, access token)
s += "authState=${authState.jsonSerializeString()}"
return "Credentials(" + s.joinToString(", ") + ")"
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.TextTable
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import java.io.Writer
import javax.inject.Inject
class DefaultsProvider @Inject constructor(): SettingsProvider {
val booleanDefaults = mutableMapOf(
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false),
Pair(Settings.IGNORE_VPN_NETWORK_CAPABILITY, true)
)
val intDefaults = mapOf(
Pair(Settings.PRESELECT_COLLECTIONS, Settings.PRESELECT_COLLECTIONS_NONE),
Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM),
Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS
)
val longDefaults = mapOf<String, Long>(
Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */
)
val stringDefaults = mapOf(
Pair(Settings.PROXY_HOST, "localhost"),
Pair(Settings.PRESELECT_COLLECTIONS_EXCLUDED, "/z-app-generated--contactsinteraction--recent/") // Nextcloud "Recently Contacted" address book
)
override fun canWrite() = false
override fun close() {
// no resources to close
}
override fun setOnChangeListener(listener: SettingsProvider.OnChangeListener) {
// default settings never change
}
override fun forceReload() {
// default settings never change
}
override fun contains(key: String) =
booleanDefaults.containsKey(key) ||
intDefaults.containsKey(key) ||
longDefaults.containsKey(key) ||
stringDefaults.containsKey(key)
override fun getBoolean(key: String) = booleanDefaults[key]
override fun getInt(key: String) = intDefaults[key]
override fun getLong(key: String) = longDefaults[key]
override fun getString(key: String) = stringDefaults[key]
override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError()
override fun putInt(key: String, value: Int?) = throw NotImplementedError()
override fun putLong(key: String, value: Long?) = throw NotImplementedError()
override fun putString(key: String, value: String?) = throw NotImplementedError()
override fun remove(key: String) = throw NotImplementedError()
override fun dump(writer: Writer) {
val strValues = mutableMapOf<String, String?>()
strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(stringDefaults)
val table = TextTable("Setting", "Value")
for ((key, value) in strValues.toSortedMap())
table.addLine(key, value)
writer.write(table.toString())
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DefaultsProviderModule {
@Binds
@IntoMap
@IntKey(/* priority */ 0)
abstract fun defaultsProvider(impl: DefaultsProvider): SettingsProvider
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import androidx.appcompat.app.AppCompatDelegate
import at.bitfire.davdroid.settings.Settings.PRESELECT_COLLECTIONS_EXCLUDED
object Settings {
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
const val PROXY_TYPE = "proxy_type" // Integer
const val PROXY_TYPE_SYSTEM = -1
const val PROXY_TYPE_NONE = 0
const val PROXY_TYPE_HTTP = 1
const val PROXY_TYPE_SOCKS = 2
const val PROXY_HOST = "proxy_host" // String
const val PROXY_PORT = "proxy_port" // Integer
/**
* Whether to ignore VPNs at internet connection detection, true by default because VPN connections
* seem to include "VALIDATED" by default even without actual internet connection
*/
const val IGNORE_VPN_NETWORK_CAPABILITY = "ignore_vpns" // Boolean
/**
* Default sync interval (Long), in seconds.
* Used to initialize an account.
*/
const val DEFAULT_SYNC_INTERVAL = "default_sync_interval"
/**
* Preferred theme (light/dark). Value must be one of [AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM]
* (default if setting is missing), [AppCompatDelegate.MODE_NIGHT_NO] or [AppCompatDelegate.MODE_NIGHT_YES].
*/
const val PREFERRED_THEME = "preferred_theme"
const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
/**
* Selected tasks app. When at least one tasks app is installed, this setting is set to its sync authority.
* In case of multiple available tasks app, the user can choose one and this setting will reflect the selected one.
*
* This setting may even be set if the corresponding tasks app is not installed because it only reflects the user's choice.
*/
const val SELECTED_TASKS_PROVIDER = "preferred_tasks_provider"
/** whether collections are automatically selected for synchronization after their initial detection */
const val PRESELECT_COLLECTIONS = "preselect_collections"
/** collections are not automatically selected for synchronization */
const val PRESELECT_COLLECTIONS_NONE = 0
/** all collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */
const val PRESELECT_COLLECTIONS_ALL = 1
/** personal collections (except those matching [PRESELECT_COLLECTIONS_EXCLUDED]) are automatically selected for synchronization */
const val PRESELECT_COLLECTIONS_PERSONAL = 2
/** regular expression to match URLs of collections to be excluded from pre-selection */
const val PRESELECT_COLLECTIONS_EXCLUDED = "preselect_collections_excluded"
/** whether all address books are forced to be read-only */
const val FORCE_READ_ONLY_ADDRESSBOOKS = "force_read_only_addressbooks"
/** max. number of accounts */
const val MAX_ACCOUNTS = "max_accounts"
}

View file

@ -0,0 +1,213 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.util.NoSuchPropertyException
import androidx.annotation.AnyThread
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.settings.SettingsManager.OnChangeListener
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.io.Writer
import java.lang.ref.WeakReference
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
/**
* Settings manager which coordinates [SettingsProvider]s to read/write
* application settings.
*/
@Singleton
class SettingsManager @Inject constructor(
private val logger: Logger,
providerMap: Map<Int, @JvmSuppressWildcards SettingsProvider>
): SettingsProvider.OnChangeListener {
private val providers = LinkedList<SettingsProvider>()
private var writeProvider: SettingsProvider? = null
private val observers = LinkedList<WeakReference<OnChangeListener>>()
init {
providerMap // get providers from Hilt
.toSortedMap() // sort by Int key
.values.reversed() // take reverse-sorted values (because high priority numbers shall be processed first)
.forEach { provider ->
logger.info("Loading settings provider: ${provider.javaClass.name}")
// register for changes
provider.setOnChangeListener(this)
// add to list of available providers
providers += provider
}
// settings will be written to the first writable provider
writeProvider = providers.firstOrNull { it.canWrite() }
logger.info("Changed settings are handled by $writeProvider")
}
/**
* Requests all providers to reload their settings.
*/
@AnyThread
fun forceReload() {
for (provider in providers)
provider.forceReload()
// notify possible listeners
onSettingsChanged(null)
}
/*** OBSERVERS ***/
fun addOnChangeListener(observer: OnChangeListener) {
synchronized(observers) {
observers += WeakReference(observer)
}
}
fun removeOnChangeListener(observer: OnChangeListener) {
synchronized(observers) {
observers.removeAll { it.get() == null || it.get() == observer }
}
}
/**
* Notifies registered listeners about changes in the configuration.
* Called by config providers when settings have changed.
*/
@AnyThread
override fun onSettingsChanged(key: String?) {
synchronized(observers) {
for (observer in observers.mapNotNull { it.get() })
observer.onSettingsChanged()
}
}
/**
* Returns a Flow that
*
* - always emits the initial value of the setting, and then
* - emits the new value whenever the setting changes.
*
* @param getValue used to determine the current value of the setting
*/
@VisibleForTesting
internal fun<T> observerFlow(getValue: () -> T): Flow<T> = callbackFlow {
// emit value on changes
val listener = OnChangeListener {
trySend(getValue())
}
addOnChangeListener(listener)
// get current value and emit it as first state
trySend(getValue())
// wait and clean up
awaitClose { removeOnChangeListener(listener) }
}
/*** SETTINGS ACCESS ***/
fun containsKey(key: String) = providers.any { it.contains(key) }
fun containsKeyFlow(key: String): Flow<Boolean> = observerFlow { containsKey(key) }
private fun<T> getValue(key: String, reader: (SettingsProvider) -> T?): T? {
logger.fine("Looking up setting $key")
val result: T? = null
for (provider in providers)
try {
val value = reader(provider)
logger.finer("${provider::class.java.simpleName}: $key = $value")
if (value != null) {
logger.fine("Looked up setting $key -> $value")
return value
}
} catch(e: Exception) {
logger.log(Level.SEVERE, "Couldn't read setting from $provider", e)
}
logger.fine("Looked up setting $key -> no result")
return result
}
fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) }
fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(key)
fun getBooleanFlow(key: String): Flow<Boolean?> = observerFlow { getBooleanOrNull(key) }
fun getBooleanFlow(key: String, defaultValue: Boolean): Flow<Boolean> = observerFlow { getBooleanOrNull(key) ?: defaultValue }
fun getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) }
fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(key)
fun getIntFlow(key: String): Flow<Int?> = observerFlow { getIntOrNull(key) }
fun getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) }
fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key)
fun getString(key: String) = getValue(key) { provider -> provider.getString(key) }
fun getStringFlow(key: String): Flow<String?> = observerFlow { getString(key) }
fun isWritable(key: String): Boolean {
for (provider in providers) {
if (provider.canWrite())
return true
else if (provider.contains(key))
// non-writeable provider contains this key -> setting will always be provided by this read-only provider
return false
}
return false
}
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Unit) {
logger.fine("Trying to write setting $key = $value")
val provider = writeProvider ?: return
try {
writer(provider)
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't write setting to $writeProvider", e)
}
}
fun putBoolean(key: String, value: Boolean?) =
putValue(key, value) { provider -> provider.putBoolean(key, value) }
fun putInt(key: String, value: Int?) =
putValue(key, value) { provider -> provider.putInt(key, value) }
fun putLong(key: String, value: Long?) =
putValue(key, value) { provider -> provider.putLong(key, value) }
fun putString(key: String, value: String?) =
putValue(key, value) { provider -> provider.putString(key, value) }
fun remove(key: String) = putString(key, null)
/*** HELPERS ***/
fun dump(writer: Writer) {
for ((idx, provider) in providers.withIndex()) {
writer.write("${idx + 1}. ${provider::class.java.simpleName} canWrite=${provider.canWrite()}\n")
provider.dump(writer)
}
}
fun interface OnChangeListener {
/**
* Will be called when something has changed in a [SettingsProvider].
* May run in worker thread!
*/
@AnyThread
fun onSettingsChanged()
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import androidx.annotation.AnyThread
import java.io.Writer
/**
* Defines a settings provider, which provides settings from a certain source
* to the [SettingsManager].
*
* Implementations must be thread-safe and synchronize get/put operations on their own.
*/
interface SettingsProvider {
fun interface OnChangeListener {
/**
* Called when a setting has changed.
*
* @param key The key of the setting that has changed, or null if the key is not
* available. In this case, the listener should reload all settings.
*/
fun onSettingsChanged(key: String?)
}
/**
* Whether this provider can write settings.
*
* If this method returns false, the put...() methods will never be called for this provider.
*
* @return true = this provider provides read/write settings;
* false = this provider provides read-only settings
*/
fun canWrite(): Boolean
/**
* Closes the provider and releases resources.
*/
fun close()
/**
* Sets an on-changed listener. The provider calls the listener whenever a setting
* has changed.
*/
fun setOnChangeListener(listener: OnChangeListener)
@AnyThread
fun forceReload()
fun contains(key: String): Boolean
fun getBoolean(key: String): Boolean?
fun getInt(key: String): Int?
fun getLong(key: String): Long?
fun getString(key: String): String?
fun putBoolean(key: String, value: Boolean?)
fun putInt(key: String, value: Int?)
fun putLong(key: String, value: Long?)
fun putString(key: String, value: String?)
fun remove(key: String)
fun dump(writer: Writer)
}

Some files were not shown because too many files have changed in this diff Show more