Repo created
This commit is contained in:
parent
324070df30
commit
2d33a757bf
644 changed files with 99721 additions and 2 deletions
83
app/src/main/kotlin/at/bitfire/davdroid/App.kt
Normal file
83
app/src/main/kotlin/at/bitfire/davdroid/App.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
23
app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
Normal file
23
app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
Normal 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}"
|
||||
|
||||
}
|
||||
86
app/src/main/kotlin/at/bitfire/davdroid/TextTable.kt
Normal file
86
app/src/main/kotlin/at/bitfire/davdroid/TextTable.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
160
app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
Normal file
160
app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
266
app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt
Normal file
266
app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt
Normal 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
|
||||
|
||||
}
|
||||
132
app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt
Normal file
132
app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt
Normal 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)
|
||||
|
||||
}
|
||||
31
app/src/main/kotlin/at/bitfire/davdroid/db/Converters.kt
Normal file
31
app/src/main/kotlin/at/bitfire/davdroid/db/Converters.kt
Normal 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()
|
||||
|
||||
}
|
||||
43
app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt
Normal file
43
app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt
Normal 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
|
||||
|
||||
}
|
||||
60
app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt
Normal file
60
app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt
Normal 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)
|
||||
|
||||
}
|
||||
70
app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt
Normal file
70
app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt
Normal 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)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
67
app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt
Normal file
67
app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
44
app/src/main/kotlin/at/bitfire/davdroid/db/Service.kt
Normal file
44
app/src/main/kotlin/at/bitfire/davdroid/db/Service.kt
Normal 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"
|
||||
}
|
||||
|
||||
}
|
||||
49
app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt
Normal file
49
app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt
Normal 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)
|
||||
|
||||
}
|
||||
28
app/src/main/kotlin/at/bitfire/davdroid/db/SyncStats.kt
Normal file
28
app/src/main/kotlin/at/bitfire/davdroid/db/SyncStats.kt
Normal 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
|
||||
)
|
||||
22
app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt
Normal file
22
app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt
Normal 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>>
|
||||
|
||||
}
|
||||
140
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocument.kt
Normal file
140
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocument.kt
Normal 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
|
||||
)
|
||||
|
||||
}
|
||||
107
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocumentDao.kt
Normal file
107
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavDocumentDao.kt
Normal 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"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
24
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMount.kt
Normal file
24
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMount.kt
Normal 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
|
||||
|
||||
)
|
||||
42
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt
Normal file
42
app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt
Normal 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>>
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
20
app/src/main/kotlin/at/bitfire/davdroid/di/LoggerModule.kt
Normal file
20
app/src/main/kotlin/at/bitfire/davdroid/di/LoggerModule.kt
Normal 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()
|
||||
|
||||
}
|
||||
177
app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt
Normal file
177
app/src/main/kotlin/at/bitfire/davdroid/log/LogFileHandler.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
90
app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt
Normal file
90
app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt
Normal 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())
|
||||
}
|
||||
|
||||
}
|
||||
57
app/src/main/kotlin/at/bitfire/davdroid/log/StringHandler.kt
Normal file
57
app/src/main/kotlin/at/bitfire/davdroid/log/StringHandler.kt
Normal 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()
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
315
app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt
Normal file
315
app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
}
|
||||
239
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
239
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
197
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
197
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt
Normal 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)
|
||||
|
||||
}
|
||||
313
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
313
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
||||
}
|
||||
159
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
159
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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))"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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(", ") + ")"
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
69
app/src/main/kotlin/at/bitfire/davdroid/settings/Settings.kt
Normal file
69
app/src/main/kotlin/at/bitfire/davdroid/settings/Settings.kt
Normal 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"
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue