Compare commits

..

14 Commits

Author SHA1 Message Date
Arnau Mora
e9fb031d0a Upgrade dependencies
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-04 19:55:31 +02:00
Arnau Mora
d1c3548ccc Upgrade dependencies
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-04 19:54:46 +02:00
Arnau Mora
762095c7ce Merge branch 'main-ose' into nav3-migration
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
#	app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt
2025-06-04 19:53:43 +02:00
Arnau Mora
d9b36a0e34 Fix predictive back for drawer
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-27 15:21:23 +02:00
Arnau Mora
514623c0f2 Use ComponentActivity instead of AppCompatActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-27 15:14:07 +02:00
Arnau Mora
9978850594 Moved nav back stack holding to viewmodel
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 20:07:50 +02:00
Arnau Mora
e1f5b2e3c1 Upgrade Activity Compose
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 20:06:49 +02:00
Arnau Mora
ad0cdb5c0c Use SDK 36
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:43:30 +02:00
Arnau Mora
de9d58bc20 Migrated AccountsActivity to MainActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:36:11 +02:00
Arnau Mora
a6238a4131 Enable back invoked callback
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:35:27 +02:00
Arnau Mora
bbc7fbfa1e Added missing plugin
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:35:11 +02:00
Arnau Mora
3ba4dfb157 Upgrade Kotlin and KSP
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:35:01 +02:00
Arnau Mora
4544cd9b5c Fixed snapshot dependencies repository
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:34:05 +02:00
Arnau Mora
24026edad0 Added dependencies
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:07:13 +02:00
239 changed files with 5633 additions and 7498 deletions

View File

@@ -8,7 +8,4 @@ updates:
schedule:
interval: "weekly"
commit-message:
prefix: "[CI] "
groups:
ci-actions:
patterns: ["*"]
prefix: "[CI] "

View File

@@ -28,8 +28,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
- uses: actions/setup-java@v5
uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
@@ -50,7 +50,7 @@ jobs:
# uses: github/codeql-action/autobuild@v2
- name: Build
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View File

@@ -19,8 +19,8 @@ jobs:
discussions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
@@ -30,8 +30,8 @@ jobs:
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
- name: Build signed package
# Use build cache to speed up building of build variants, but clean caches from previous tests before
run: ./gradlew --build-cache --configuration-cache --no-daemon app:clean app:assembleRelease
# Make sure that caches are disabled to generate reproducible release builds
run: ./gradlew --no-build-cache --no-configuration-cache --no-daemon app:assembleRelease
env:
ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }}
@@ -45,3 +45,4 @@ jobs:
files: app/build/outputs/apk/ose/release/*.apk
fail_on_unmatched_files: true
generate_release_notes: true
discussion_category_name: Announcements

View File

@@ -15,8 +15,8 @@ jobs:
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
@@ -27,7 +27,7 @@ jobs:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph: generate-and-submit # submit Github Dependency Graph info
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
test:
needs: compile
@@ -35,8 +35,8 @@ jobs:
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
@@ -46,9 +46,9 @@ jobs:
cache-read-only: true
- name: Run lint
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
test_on_emulator:
needs: compile
@@ -56,8 +56,8 @@ jobs:
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
@@ -79,4 +79,4 @@ jobs:
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
- name: Run device tests
run: ./gradlew --build-cache --configuration-cache app:virtualCheck
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck

View File

@@ -26,7 +26,8 @@ Parts of DAVx⁵ have been outsourced into these libraries:
* [cert4android](https://github.com/bitfireAT/cert4android) custom certificate management
* [dav4jvm](https://github.com/bitfireAT/dav4jvm) WebDAV/CalDav/CardDAV framework
* [synctools](https://github.com/bitfireAT/synctools) iCalendar/vCard/Tasks processing and content provider access
* [ical4android](https://github.com/bitfireAT/ical4android) iCalendar processing and Calendar Provider access
* [vcard4android](https://github.com/bitfireAT/vcard4android) vCard processing and Contacts Provider access
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
or [purchasing it](https://www.davx5.com/download).**

View File

@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries)
@@ -19,16 +20,14 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 405040002
versionName = "4.5.4-rc.1"
versionCode = 404110004
versionName = "4.4.11"
base.archivesName = "davx5-ose-$versionName"
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 36 // Android 16
buildConfigField("boolean", "customCertsUI", "true")
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
@@ -108,9 +107,7 @@ android {
localDevices {
create("virtual") {
device = "Pixel 3"
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
apiLevel = 34
apiLevel = 35
systemImageSource = "aosp-atd"
}
}
@@ -148,6 +145,9 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.base)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.paging)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.preference)
@@ -158,6 +158,7 @@ dependencies {
implementation(libs.compose.accompanist.permissions)
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
implementation(libs.compose.material3.navigation3)
implementation(libs.compose.materialIconsExtended)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.toolingPreview)
@@ -178,16 +179,19 @@ dependencies {
exclude(group="junit")
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
}
implementation(libs.bitfire.synctools) {
exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code
exclude(group = "junit")
}
implementation(libs.bitfire.ical4android)
implementation(libs.bitfire.vcard4android)
// Serialization (for navigation)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
// third-party libs
@Suppress("RedundantSuppression")
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.mikepenz.aboutLibraries)
implementation(libs.nsk90.kstatemachine)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)

View File

@@ -0,0 +1,37 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import kotlin.reflect.KClass
/**
* Use this custom rule to ignore exceptions thrown by another rule.
*
* @param innerRule The rule to wrap.
* @param exceptionsToIgnore The exceptions to ignore.
*/
class CatchExceptionsRule(
private val innerRule: TestRule,
private vararg val exceptionsToIgnore: KClass<out Throwable>
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
try {
innerRule.apply(base, description).evaluate()
} catch (e: Throwable) {
val shouldIgnore = exceptionsToIgnore.any { it.isInstance(e) }
if (shouldIgnore)
base.evaluate()
else
throw e
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.util.Xml
import at.bitfire.dav4jvm.XmlUtils
import org.junit.Assert.assertTrue
import org.junit.Test
class Dav4jvm {
@Test
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
val parser = XmlUtils.newPullParser()
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.util.Xml
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalLibrariesTest {
@Test
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
val parser = XmlUtils.newPullParser()
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
}
@Test
fun testOkhttpHttpUrl_PublicSuffixList() {
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
// In Android, loading the PublicSuffixList is done over AndroidX startup.
// This test verifies that everything is working.
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
}
}

View File

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

View File

@@ -0,0 +1,123 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import org.junit.Assert.assertNotNull
import org.junit.rules.ExternalResource
import org.junit.rules.RuleChain
import java.util.logging.Logger
/**
* JUnit ClassRule which initializes the AOSP CalendarProvider.
*
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
* maybe by some wrongly synchronized database initialization. So things like querying the instances
* fails in this case.
*
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
* is used the very first time (especially in CI tests / a fresh emulator).
*
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
*/
class InitCalendarProviderRule private constructor(): ExternalResource() {
companion object {
private var isInitialized = false
private val logger = Logger.getLogger(InitCalendarProviderRule::javaClass.name)
fun getInstance(): RuleChain = RuleChain
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
.around(InitCalendarProviderRule())
}
override fun before() {
if (!isInitialized) {
logger.info("Initializing calendar provider")
if (Build.VERSION.SDK_INT < 31)
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
assertNotNull("Couldn't acquire calendar provider", client)
client!!.use {
initCalendarProvider(client)
isInitialized = true
}
}
}
private fun initCalendarProvider(provider: ContentProviderClient) {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
var calendarOrNull: LocalCalendar? = null
for (i in 0..50) {
calendarOrNull = createAndVerifyCalendar(account, provider)
if (calendarOrNull != null)
break
else
Thread.sleep(100)
}
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
try {
// single event init
val normalEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
normalLocalEvent.add()
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
// recurring event init
val recurringEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
}
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
localRecurringEvent.add()
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
} finally {
calendar.delete()
}
}
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
val uri = AndroidCalendar.create(account, provider, ContentValues())
return try {
AndroidCalendar.findByID(
account,
provider,
LocalCalendar.Factory,
ContentUris.parseId(uri)
)
} catch (e: Exception) {
logger.warning("Couldn't find calendar after creation: $e")
null
}
}
}

View File

@@ -2,9 +2,9 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
package at.bitfire.davdroid
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -29,16 +29,13 @@ class OkhttpClientTest {
@Test
@SdkSuppress(maxSdkVersion = 34)
fun testIcloudWithSettings() {
httpClientBuilder.build().use { client ->
client.okHttpClient
.newCall(
Request.Builder()
.get()
.url("https://icloud.com")
.build()
)
.newCall(Request.Builder()
.get()
.url("https://icloud.com")
.build())
.execute()
}
}

View File

@@ -1,97 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class HomeSetDaoTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
lateinit var dao: HomeSetDao
var serviceId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
dao = db.homeSetDao()
serviceId = db.serviceDao().insertOrReplace(
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
)
}
@After
fun tearDown() {
db.serviceDao().deleteAll()
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.copy(id = 1L), dao.getById(1))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = dao.insertOrUpdateByUrlBlocking(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.copy(id = 1L), dao.getById(1))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = dao.insertOrUpdateByUrlBlocking(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.copy(id = 2L), dao.getById(2))
}
@Test
fun testInsertOrUpdate_TransactionSafe() {
runBlocking(Dispatchers.IO) {
for (i in 0..9999)
launch {
dao.insertOrUpdateByUrlBlocking(
HomeSet(
id = 0,
serviceId = serviceId,
url = "https://example.com/".toHttpUrl(),
personal = true
)
)
}
}
assertEquals(1, dao.getByService(serviceId).size)
}
@Test
fun testDelete() {
// should delete row with given primary key (id)
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, dao.getById(1L))
dao.delete(entry1)
assertEquals(null, dao.getById(1L))
}
}

View File

@@ -1,77 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.sqlite.SQLiteException
import at.bitfire.davdroid.sync.SyncDataType
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncStatsDaoTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
var collectionId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
val serviceId = db.serviceDao().insertOrReplace(Service(
id = 0,
accountName = "test@example.com",
type = Service.TYPE_CALDAV
))
collectionId = db.collectionDao().insert(Collection(
id = 0,
serviceId = serviceId,
type = Collection.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
))
}
@After
fun tearDown() {
db.serviceDao().deleteAll()
}
@Test
fun testInsertOrReplace_ExistingForeignKey() = runTest {
val dao = db.syncStatsDao()
dao.insertOrReplace(
SyncStats(
id = 0,
collectionId = collectionId,
dataType = SyncDataType.CONTACTS.toString(),
lastSync = System.currentTimeMillis()
)
)
}
@Test(expected = SQLiteException::class)
fun testInsertOrReplace_MissingForeignKey() = runTest {
val dao = db.syncStatsDao()
dao.insertOrReplace(
SyncStats(
id = 0,
collectionId = 12345,
dataType = SyncDataType.CONTACTS.toString(),
lastSync = System.currentTimeMillis()
)
)
}
}

View File

@@ -44,11 +44,6 @@ abstract class DatabaseMigrationTest(
/**
* Used for testing the migration process from [toVersion]-1 to [toVersion].
*
* Note: SQLite's foreign key constraint enforcement is not enabled in tests. We need
* to enable it ourselves using setting "PRAGMA foreign_keys=ON" directly after opening
* a new database connection (works per connection). In tests it's usually more practical
* not to do so, however. In production database connections room enables it for us.
*
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
*/
@@ -66,8 +61,6 @@ abstract class DatabaseMigrationTest(
// Prepare the database with the initial version.
val dbName = "test"
helper.createDatabase(dbName, version = toVersion - 1).apply {
// We could enable foreign key constraint enforcement here
// by setting "PRAGMA foreign_keys=ON".
prepare(this)
close()
}

View File

@@ -1,20 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.sync.FakeSyncAdapter
import at.bitfire.davdroid.sync.adapter.SyncAdapter
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
abstract class FakeSyncAdapterModule {
@Binds
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
}

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class DavHomeSetRepositoryTest {
@Inject
lateinit var repository: DavHomeSetRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@get:Rule
var hiltRule = HiltAndroidRule(this)
var serviceId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
serviceId = serviceRepository.insertOrReplaceBlocking(
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
)
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.copy(id = 1L), repository.getByIdBlocking(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = repository.insertOrUpdateByUrlBlocking(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.copy(id = 1L), repository.getByIdBlocking(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = repository.insertOrUpdateByUrlBlocking(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.copy(id = 2L), repository.getByIdBlocking(2L))
}
@Test
fun testDeleteBlocking() {
// should delete row with given primary key (id)
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, repository.getByIdBlocking(1L))
repository.deleteBlocking(entry1)
assertEquals(null, repository.getByIdBlocking(1L))
}
}

View File

@@ -29,7 +29,6 @@ import org.junit.Rule
import org.junit.Test
import java.io.FileNotFoundException
import java.util.LinkedList
import java.util.Optional
import javax.inject.Inject
@HiltAndroidTest
@@ -99,7 +98,7 @@ class LocalAddressBookTest {
val id = ContentUris.parseId(uri)
// make sure it's not dirty
localGroup.clearDirty(Optional.empty(), null, null)
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
// rename address book
@@ -128,7 +127,7 @@ class LocalAddressBookTest {
*/
fun isContactDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
val uri = ContentUris.withAppendedId(adddressBook.rawContactsSyncUri(), id)
provider.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
@@ -144,7 +143,7 @@ class LocalAddressBookTest {
*/
fun isGroupDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id)
provider.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}

View File

@@ -8,68 +8,65 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Entity
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.test.InitCalendarProviderRule
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
import javax.inject.Inject
@HiltAndroidTest
class LocalCalendarTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
companion object {
@get:Rule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var androidCalendar: AndroidCalendar
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
androidCalendar = provider.createAndGetCalendar(ContentValues())
calendar = localCalendarFactory.create(androidCalendar)
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun tearDown() {
androidCalendar.delete()
client.closeCompat()
calendar.delete()
}
@@ -99,18 +96,12 @@ class LocalCalendarTest {
status = Status.VEVENT_CANCELLED
})
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -118,7 +109,7 @@ class LocalCalendarTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -135,102 +126,26 @@ class LocalCalendarTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventUrl = androidCalendar.eventUri(localEvent.id)
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(eventUrl, contentValuesOf(
Events.DIRTY to 1
), null, null)
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
/**
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events._ID]
* - [Events.DIRTY]
*/
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
calendar.removeNotDirtyMarked(123)
assertNull(androidCalendar.getEvent(id))
}
@Test
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
)
@Test
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
)
@Test
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
)
/**
* Verifies that [LocalCalendar.markNotDirty] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events.DIRTY]
*/
private fun testMarkNotDirty(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events._ID to 1,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
val updated = calendar.markNotDirty(321)
assertEquals(1, updated)
assertEquals(321, androidCalendar.getEvent(id)?.flags)
}
@Test
fun test_markNotDirty_DirtyIs0() = testMarkNotDirty(
contentValuesOf(
Events.DIRTY to 0
)
)
@Test
fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty(
contentValuesOf(
Events.DIRTY to null
)
)
}

View File

@@ -4,65 +4,292 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
import java.util.UUID
import javax.inject.Inject
@HiltAndroidTest
class LocalEventTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun tearDown() {
calendar.androidCalendar.delete()
client.closeCompat()
fun removeCalendar() {
calendar.delete()
}
@Test
fun testNumDirectInstances_SingleInstance() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_Recurring() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_Recurring_Endless() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event without end"
rRules.add(RRule("FREQ=DAILY"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_Recurring_LateEnd() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 53 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
else
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_Recurring_ManyInstances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 2 years"
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!)
// Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct),
// but we are satisfied with either result for now
assertTrue(number == 365*2 || number == 365*2+1)
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_RecurringWithExdate() {
val event = Event().apply {
dtStart = DtStart(Date("20220120T010203Z"))
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME)))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_RecurringWithExceptions() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T130203Z")
summary = "Exception on 3rd day"
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220124T010203Z")
dtStart = DtStart("20220122T160203Z")
summary = "Exception on 5th day"
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
localEvent.add()
assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_SingleInstance() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_Recurring() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_Recurring_Endless() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with infinite instances"
rRules.add(RRule("FREQ=YEARLY"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_Recurring_LateEnd() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!))
else
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_Recurring_ManyInstances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over two years"
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
365*2 // Android <10: does not include UNTIL (incorrect!)
else
365*2 + 1, // Android ≥10: includes UNTIL (correct)
LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_RecurringWithExceptions() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 6 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=6"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T130203Z")
summary = "Exception on 3rd day"
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220124T010203Z")
dtStart = DtStart("20220122T160203Z")
summary = "Exception on 5th day"
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
localEvent.add()
calendar.findById(localEvent.id!!)
assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testMarkEventAsDeleted() {
// Create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "A fine event"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
// Delete event
LocalEvent.markAsDeleted(provider, account, localEvent.id!!)
// Get the status of whether the event is deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.DELETED),
null,
null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(1, cursor.getInt(0))
}
}
@@ -73,15 +300,8 @@ class LocalEventTest {
dtStart = DtStart("20220120T010203Z")
summary = "Event without uid"
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add() // save it to calendar storage
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
@@ -91,7 +311,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage should be the same as file name
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -108,14 +328,8 @@ class LocalEventTest {
summary = "Event with normal uid"
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add() // save it to calendar storage
// prepare for upload - this should use the UID for the file name
val fileNameWithSuffix = localEvent.prepareForUpload()
@@ -124,7 +338,7 @@ class LocalEventTest {
assertEquals(event.uid, fileName)
// UID in calendar storage should still be set, too
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -141,14 +355,8 @@ class LocalEventTest {
summary = "Event with funny uid"
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add() // save it to calendar storage
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
@@ -158,7 +366,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -199,18 +407,12 @@ class LocalEventTest {
status = Status.VEVENT_CANCELLED
})
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -218,7 +420,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -234,18 +436,12 @@ class LocalEventTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -253,7 +449,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
client.query(
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -262,4 +458,28 @@ class LocalEventTest {
}
}
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
}

View File

@@ -14,7 +14,7 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -32,7 +32,6 @@ import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.util.Optional
import javax.inject.Inject
@HiltAndroidTest
@@ -116,7 +115,7 @@ class LocalGroupTest {
val group = newGroup(ab)
// add contact1 to group
val batch = ContactsBatchOperation(ab.provider!!)
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
@@ -165,7 +164,7 @@ class LocalGroupTest {
}
)
group.clearDirty(Optional.empty(), null)
group.clearDirty(null, null)
// check cached group membership
ab.provider!!.query(
@@ -201,7 +200,7 @@ class LocalGroupTest {
}
)
group.clearDirty(Optional.empty(), null)
group.clearDirty(null, null)
// cached group membership should be gone
ab.provider!!.query(
@@ -224,7 +223,7 @@ class LocalGroupTest {
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val batch = ContactsBatchOperation(ab.provider!!)
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()

View File

@@ -10,7 +10,7 @@ import android.content.Context
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory

View File

@@ -0,0 +1,749 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
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.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class CollectionListRefresherTest {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun testDiscoverHomesets() {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
// Check home set has been saved correctly to database
val savedHomesets = db.homeSetDao().getByService(service.id)
assertEquals(2, savedHomesets.size)
// Home set from current-user-principal
val personalHomeset = savedHomesets[1]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
assertEquals(service.id, personalHomeset.serviceId)
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
assertEquals(true, personalHomeset.personal)
// Home set found in a group principal
val groupHomeset = savedHomesets[0]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
assertEquals(service.id, groupHomeset.serviceId)
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
assertEquals(false, groupHomeset.personal)
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().getByService(service.id).first()
)
}
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshHomelessCollections
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomelessCollections_deletesInaccessibleCollections() {
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshPrincipals
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
assertEquals(null, principals[0].displayName)
}
@Test
fun refreshPrincipals_updatesPrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
assertEquals(0, principals.size)
}
// Others
@Test
fun shouldPreselect_none() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val url = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
companion object {
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
"<current-user-principal>" +
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
"</current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
"</CARD:addressbook-home-set>" +
"<group-membership>" +
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>All address books</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>Freds Contacts (not mine)</displayname>" +
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
"</owner>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
"<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
" <href>mailto:email1@example.com</href>" +
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
var responseBody = ""
var responseCode = 207
when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
responseCode = 404
else ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
}
logger.info("Queried: $path")
logger.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,222 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class CollectionsWithoutHomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshCollectionsWithoutHomeSet
@Test
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
logger.info("${request.method} on $path")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
else -> ""
}
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -8,9 +8,9 @@ import android.security.NetworkSecurityPolicy
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.Credentials
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
@@ -72,7 +72,7 @@ class DavResourceFinderTest {
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
client = httpClientBuilder
.authenticate(host = null, getCredentials = { credentials })
.authenticate(host = null, credentials = credentials)
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)

View File

@@ -1,473 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class HomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().getByService(service.id).first()
)
}
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// other
@Test
fun shouldPreselect_none() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val url = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
logger.info("Queried: $path")
return MockResponse()
.setResponseCode(207)
.setBody(
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,236 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
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.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertEquals
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class PrincipalsRefresherTest {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var principalsRefresher: PrincipalsRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
assertEquals(null, principals[0].displayName)
}
@Test
fun refreshPrincipals_updatesPrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
assertEquals(0, principals.size)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" + "<CARD:addressbook-home-set>" + " <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" + "</CARD:addressbook-home-set>" + "<group-membership>" + " <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
logger.info("Queried: $path")
return MockResponse()
.setResponseCode(207)
.setBody(
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -1,163 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class ServiceRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun testDiscoverHomesets() {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
serviceRefresherFactory.create(service, client.okHttpClient)
.discoverHomesets(baseUrl)
// Check home set has been saved correctly to database
val savedHomesets = db.homeSetDao().getByService(service.id)
assertEquals(2, savedHomesets.size)
// Home set from current-user-principal
val personalHomeset = savedHomesets[1]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
assertEquals(service.id, personalHomeset.serviceId)
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
assertEquals(true, personalHomeset.personal)
// Home set found in a group principal
val groupHomeset = savedHomesets[0]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
assertEquals(service.id, groupHomeset.serviceId)
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
assertEquals(false, groupHomeset.personal)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
logger.info("Query: ${request.method} on $path ")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
"</CARD:addressbook-home-set>" +
"<group-membership>" +
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>All address books</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
"</CARD:addressbook-home-set>"
else -> ""
}
return MockResponse()
.setResponseCode(207)
.setBody(
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

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

View File

@@ -1,230 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.os.Bundle
import android.provider.CalendarContract
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import junit.framework.AssertionFailedError
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.Collections
import java.util.LinkedList
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
class AndroidSyncFrameworkTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
lateinit var account: Account
val authority = CalendarContract.AUTHORITY
private lateinit var stateChangeListener: Any
private val recordedStates = Collections.synchronizedList(LinkedList<State>())
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
// Enable sync globally and for the test account
ContentResolver.setIsSyncable(account, authority, 1)
// Remember states the sync framework reports as pairs of (sync pending, sync active).
recordedStates.clear()
onStatusChanged(0) // record first entry (pending = false, active = false)
stateChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
::onStatusChanged
)
}
@After
fun tearDown() {
ContentResolver.removeStatusChangeListener(stateChangeListener)
TestAccount.remove(account)
}
/**
* Correct behaviour of the sync framework on Android 13 and below.
* Pending state is correctly reflected
*/
@SdkSuppress(maxSdkVersion = 33)
@Test
fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
verifySyncStates(
listOf(
State(pending = false, active = false), // no sync pending or active
State(pending = true, active = false, optional = true), // sync becomes pending
State(pending = true, active = true), // ... and pending and active at the same time
State(pending = false, active = true), // ... and then only active
State(pending = false, active = false) // sync finished
)
)
}
/**
* Wrong behaviour of the sync framework on Android 14+.
* Pending state stays true forever (after initial run), active state behaves correctly
*/
@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
@Test
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
verifySyncStates(
listOf(
State(pending = false, active = false), // no sync pending or active
State(pending = true, active = false, optional = true), // sync becomes pending
State(pending = true, active = true), // ... and pending and active at the same time
State(pending = true, active = false) // ... and finishes, but stays pending
)
)
}
// helpers
private fun syncRequest() = SyncRequest.Builder()
.setSyncAdapter(account, authority)
.syncOnce()
.setExtras(Bundle()) // needed for Android 9
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
.build()
/**
* Verifies that the given expected states match the recorded states.
*/
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
// We use runBlocking for these tests because it uses the default dispatcher
// which does not auto-advance virtual time and we need real system time to
// test the sync framework behavior.
ContentResolver.requestSync(syncRequest())
// Even though the always-pending-bug is present on Android 14+, the sync active
// state behaves correctly, so we can record the state changes as pairs (pending,
// active) and expect a certain sequence of state pairs to verify the presence or
// absence of the bug on different Android versions.
withTimeout(60.seconds) { // Usually takes less than 30 seconds
while (recordedStates.size < expectedStates.size) {
// verify already known states
if (recordedStates.isNotEmpty())
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
delay(500) // avoid busy-waiting
}
assertStatesEqual(expectedStates, recordedStates)
}
}
/**
* Asserts whether [actualStates] and [expectedStates] are the same, under the condition
* that expected states with the [State.optional] flag can be skipped.
*/
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>) {
fun fail() {
throw AssertionFailedError("Expected states=$expectedStates, actual=$actualStates")
}
// iterate through entries
val expectedIterator = expectedStates.iterator()
for (actual in actualStates) {
if (!expectedIterator.hasNext())
fail()
var expected = expectedIterator.next()
// skip optional expected entries if they don't match the actual entry
while (!actual.stateEquals(expected) && expected.optional) {
if (!expectedIterator.hasNext())
fail()
expected = expectedIterator.next()
}
if (!actual.stateEquals(expected))
fail()
}
}
// SyncStatusObserver implementation and data class
fun onStatusChanged(which: Int) {
val state = State(
pending = ContentResolver.isSyncPending(account, authority),
active = ContentResolver.isSyncActive(account, authority)
)
synchronized(recordedStates) {
if (recordedStates.lastOrNull() != state) {
logger.info("$account syncState = $state")
recordedStates += state
}
}
}
data class State(
val pending: Boolean,
val active: Boolean,
val optional: Boolean = false
) {
fun stateEquals(other: State) =
pending == other.pending && active == other.active
}
companion object {
var globalAutoSyncBeforeTest = false
@BeforeClass
@JvmStatic
fun before() {
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
ContentResolver.setMasterSyncAutomatically(false)
}
@AfterClass
@JvmStatic
fun after() {
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
}
}
}

View File

@@ -1,51 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.os.IBinder
import at.bitfire.davdroid.sync.adapter.SyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class FakeSyncAdapter @Inject constructor(
@ApplicationContext context: Context,
private val logger: Logger
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
init {
logger.info("FakeSyncAdapter created")
}
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
logger.log(
Level.INFO,
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
)
// fake 5 sec sync
try {
Thread.sleep(5000)
} catch (_: InterruptedException) {
logger.info("onPerformSync($account) cancelled")
}
logger.info("onPerformSync($account) finished")
}
// SyncAdapter implementation and Hilt module
override fun getBinder(): IBinder = syncAdapterBinder
}

View File

@@ -4,9 +4,10 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.CatchExceptionsRule
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
@@ -17,7 +18,6 @@ import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.test.GrantPermissionOrSkipRule
import at.techbee.jtx.JtxContract
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
@@ -25,7 +25,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeNotNull
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
@@ -61,9 +60,12 @@ class JtxSyncManagerTest {
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionOrSkipRule(TaskProvider.PERMISSIONS_JTX.toSet())
val permissionRule = CatchExceptionsRule(
GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX),
SecurityException::class
)
lateinit var account: Account
private val account = TestAccount.create()
private lateinit var provider: ContentProviderClient
private lateinit var syncManager: JtxSyncManager
@@ -77,11 +79,7 @@ class JtxSyncManagerTest {
assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX))
// Acquire the jtx content provider
val providerOrNull = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
assumeNotNull(providerOrNull)
provider = providerOrNull!!
account = TestAccount.create()
provider = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)!!
// Create dummy dependencies
val service = Service(0, account.name, Service.TYPE_CALDAV, null)
@@ -108,12 +106,9 @@ class JtxSyncManagerTest {
if (this::localJtxCollection.isInitialized)
localJtxCollectionStore.delete(localJtxCollection)
serviceRepository.deleteAllBlocking()
if (this::provider.isInitialized)
provider.closeCompat()
if (this::account.isInitialized)
TestAccount.remove(account)
TestAccount.remove(account)
}

View File

@@ -4,8 +4,8 @@
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.SyncState
class LocalTestCollection(
override val dbCollectionId: Long = 0L

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.resource.LocalResource
import java.util.Optional
class LocalTestResource: LocalResource<Any> {
@@ -20,10 +19,10 @@ class LocalTestResource: LocalResource<Any> {
override fun prepareForUpload() = "generated-file.txt"
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
dirty = false
if (fileName.isPresent)
this.fileName = fileName.get()
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
@@ -32,8 +31,9 @@ class LocalTestResource: LocalResource<Any> {
this.flags = flags
}
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
override fun deleteLocal() = throw NotImplementedError()
override fun add() = throw NotImplementedError()
override fun update(data: Any) = throw NotImplementedError()
override fun delete() = throw NotImplementedError()
override fun resetDeleted() = throw NotImplementedError()
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
@@ -14,17 +13,17 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
@@ -40,12 +39,36 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.cancellation.CancellationException
@HiltAndroidTest
class SyncAdapterImplTest {
class SyncAdapterServicesTest {
lateinit var account: Account
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var syncConditionsFactory: SyncConditions.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -53,21 +76,10 @@ class SyncAdapterImplTest {
@get:Rule
val mockkRule = MockKRule(this)
@Inject @ApplicationContext
lateinit var context: Context
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
@get:Rule
val timeoutRule: Timeout = Timeout.seconds(5)
@Inject
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
@BindValue @MockK
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()
@Before
fun setUp() {
@@ -75,23 +87,33 @@ class SyncAdapterImplTest {
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
ContentResolver.setMasterSyncAutomatically(true)
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
}
@After
fun tearDown() {
ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest)
TestAccount.remove(account)
}
private fun syncAdapter(
syncWorkerManager: SyncWorkerManager
): SyncAdapterService.SyncAdapter =
SyncAdapterService.SyncAdapter(
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
serviceRepository = serviceRepository,
context = context,
logger = logger,
syncConditionsFactory = syncConditionsFactory,
syncWorkerManager = syncWorkerManager
)
@Test
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -114,8 +136,9 @@ class SyncAdapterImplTest {
@Test
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -135,8 +158,9 @@ class SyncAdapterImplTest {
@Test
fun testSyncAdapter_onPerformSync_runsInTime() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -155,4 +179,4 @@ class SyncAdapterImplTest {
}
}
}
}

View File

@@ -15,9 +15,9 @@ import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.assertWithin
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext

View File

@@ -189,7 +189,7 @@ class SyncerTest {
override val authority: String
get() = throw NotImplementedError()
override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? {
override fun acquireContentProvider(): ContentProviderClient? {
throw NotImplementedError()
}

View File

@@ -10,10 +10,10 @@ import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.di.SyncDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory

View File

@@ -4,7 +4,7 @@
package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.db.Credentials
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals

View File

@@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
@@ -14,8 +14,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.CookieJar
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -30,7 +30,29 @@ import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class QueryChildDocumentsOperationTest {
class DavDocumentsProviderTest {
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var credentialsStore: CredentialsStore
@Inject
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var testDispatcher: TestDispatcher
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -38,32 +60,13 @@ class QueryChildDocumentsOperationTest {
@get:Rule
val mockkRule = MockKRule(this)
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var operation: QueryChildDocumentsOperation
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var testDispatcher: TestDispatcher
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var mount: WebDavMount
private lateinit var rootDocument: WebDavDocument
@Before
fun setUp() {
hiltRule.inject()
// create server and client
server = MockWebServer().apply {
dispatcher = testDispatcher
start()
@@ -73,49 +76,50 @@ class QueryChildDocumentsOperationTest {
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// create WebDAV mount and root document in DB
runBlocking {
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
mount = db.webDavMountDao().getById(mountId)
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
}
}
@After
fun tearDown() {
client.close()
server.shutdown()
runBlocking {
db.webDavMountDao().deleteAsync(mount)
}
}
@Test
fun testDoQueryChildren_insert() = runTest {
// Create parent and root in database
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(id)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Query
operation.queryChildren(rootDocument)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
// Assert new children were inserted into db
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
}
@Test
fun testDoQueryChildren_update() = runTest {
// Create parent and root in database
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(
0,
mount.id,
rootDocument.id,
mountId,
parent.id,
"My_Books",
true,
"My Books",
@@ -125,25 +129,38 @@ class QueryChildDocumentsOperationTest {
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
// Query - should update the parent displayname and folder name
operation.queryChildren(rootDocument)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
// Assert parent and children were updated in database
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
}
@Test
fun testDoQueryChildren_delete() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
)
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
operation.queryChildren(rootDocument)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
// Assert folder got deleted
assertEquals(null, db.webDavDocumentDao().get(folderId))
@@ -151,17 +168,26 @@ class QueryChildDocumentsOperationTest {
@Test
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
// Create root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Create two directories
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true))
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
val parent2 = db.webDavDocumentDao().get(parent2Id)!!
assertEquals("parent1", parent1.name)
assertEquals("parent2", parent2.name)
// Query - find children of two nodes simultaneously
operation.queryChildren(parent1)
operation.queryChildren(parent2)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent1)
actor.queryChildren(parent2)
// Assert the two folders names have changed
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
@@ -189,7 +215,7 @@ class QueryChildDocumentsOperationTest {
PATH_WEBDAV_ROOT to arrayOf(
Resource("",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Cats WebDAV</displayname>"
"<displayname>Cats WebDAV</displayname>"
),
Resource("Secret_Document.pages",
"<displayname>Secret_Document.pages</displayname>",
@@ -199,7 +225,7 @@ class QueryChildDocumentsOperationTest {
),
Resource("Library",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Library</displayname>"
"<displayname>Library</displayname>"
)
),
@@ -218,15 +244,15 @@ class QueryChildDocumentsOperationTest {
val responses = propsMap[requestPath]?.joinToString { resource ->
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
resource.props +
"</prop></propstat></response>"
"</prop></propstat></response>"
}
val multistatus =
"<multistatus xmlns='DAV:' " +
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
responses +
"</multistatus>"
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
responses +
"</multistatus>"
logger.info("Response: $multistatus")
return MockResponse()
@@ -239,9 +265,4 @@ class QueryChildDocumentsOperationTest {
}
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
}

View File

@@ -6,11 +6,11 @@ package at.bitfire.davdroid.webdav
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -36,7 +36,7 @@ class WebDavMountRepositoryTest {
@Test
fun testHasWebDav_NoDavHeader() = runTest {
web.enqueue(MockResponse().setResponseCode(200))
assertNull(repository.hasWebDav(url, null))
assertFalse(repository.hasWebDav(url, null))
}
@Test
@@ -44,7 +44,7 @@ class WebDavMountRepositoryTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1"))
assertEquals(url, repository.hasWebDav(url, null))
assertTrue(repository.hasWebDav(url, null))
}
@Test
@@ -52,7 +52,7 @@ class WebDavMountRepositoryTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1, 2"))
assertEquals(url,repository.hasWebDav(url, null))
assertTrue(repository.hasWebDav(url, null))
}
@Test
@@ -60,7 +60,7 @@ class WebDavMountRepositoryTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1, 3"))
assertEquals(url,repository.hasWebDav(url, null))
assertTrue(repository.hasWebDav(url, null))
}
}

View File

@@ -45,6 +45,7 @@
<application
android:name=".App"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -53,19 +54,11 @@
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
<!-- Required for Hilt/WorkManager integration. See
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
<!-- required for Hilt/WorkManager integration -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
tools:node="remove">
</provider>
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
@@ -74,7 +67,7 @@
<activity android:name=".ui.intro.IntroActivity" />
<activity
android:name=".ui.AccountsActivity"
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -85,12 +78,12 @@
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:parentActivityName=".ui.AccountsActivity"/>
android:parentActivityName=".ui.MainActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
@@ -118,7 +111,7 @@
<activity
android:name=".ui.setup.LoginActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@@ -146,7 +139,7 @@
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
</activity>
<activity
@@ -168,7 +161,7 @@
<activity
android:name=".ui.webdav.WebdavMountsActivity"
android:exported="true"
android:parentActivityName=".ui.AccountsActivity" />
android:parentActivityName=".ui.MainActivity" />
<activity
android:name=".ui.webdav.AddWebdavMountActivity"
android:parentActivityName=".ui.webdav.WebdavMountsActivity"
@@ -186,7 +179,7 @@
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".sync.adapter.CalendarsSyncAdapterService"
android:name=".sync.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -197,7 +190,7 @@
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".sync.adapter.JtxSyncAdapterService"
android:name=".sync.JtxSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -208,7 +201,7 @@
android:resource="@xml/sync_notes"/>
</service>
<service
android:name=".sync.adapter.OpenTasksSyncAdapterService"
android:name=".sync.OpenTasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -219,7 +212,7 @@
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".sync.adapter.TasksOrgSyncAdapterService"
android:name=".sync.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -254,7 +247,7 @@
android:resource="@xml/account_authenticator_address_book"/>
</service>
<service
android:name=".sync.adapter.ContactsSyncAdapterService"
android:name=".sync.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>

View File

@@ -3,9 +3,8 @@
*/
package at.bitfire.davdroid
import at.bitfire.synctools.icalendar.ical4jVersion
import ezvcard.Ezvcard
import net.fortuna.ical4j.model.property.ProdId
import android.net.Uri
import androidx.core.net.toUri
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
@@ -14,10 +13,48 @@ object Constants {
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
val HOMEPAGE_URL = "https://www.davx5.com".toUri()
const val HOMEPAGE_PATH_FAQ = "faq"
const val HOMEPAGE_PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected"
const val HOMEPAGE_PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission"
const val HOMEPAGE_PATH_OPEN_SOURCE = "donate"
const val HOMEPAGE_PATH_PRIVACY = "privacy"
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
// product IDs for iCalendar/vCard
val MANUAL_URL = "https://manual.davx5.com".toUri()
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
const val MANUAL_PATH_INTRODUCTION = "introduction.html"
const val MANUAL_FRAGMENT_AUTHENTICATION_METHODS = "authentication-methods"
const val MANUAL_PATH_SETTINGS = "settings.html"
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
const val MANUAL_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
const val MANUAL_PATH_WEBDAV_PUSH = "webdav_push.html"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
val FEDIVERSE_HANDLE = "@davx5app@fosstodon.org"
val FEDIVERSE_URL = "https://fosstodon.org/@davx5app".toUri()
/**
* Appends query parameters for anonymized usage statistics (app ID, version).
* Can be used by the called Website to get an idea of which versions etc. are currently used.
*
* @param context optional info about from where the URL was opened (like a specific Activity)
*/
fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder {
appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
if (context != null)
appendQueryParameter("pk_kwd", context)
return this
}
}

View File

@@ -25,7 +25,7 @@ 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.MainActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
import dagger.Provides
@@ -35,13 +35,6 @@ 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,
@@ -86,7 +79,7 @@ abstract class AppDatabase: RoomDatabase() {
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
val launcherIntent = Intent(context, MainActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))

View File

@@ -38,10 +38,6 @@ interface CollectionDao {
@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?

View File

@@ -14,7 +14,7 @@ class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
url?.toString()
@TypeConverter
fun mediaTypeToString(mediaType: MediaType?) =

View File

@@ -2,26 +2,16 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
package at.bitfire.davdroid.db
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: CharArray? = null,
/** alias of an client certificate that is present on the system */
val certificateAlias: String? = null,
/** OAuth authorization state */
val authState: AuthState? = null
) {
@@ -36,7 +26,7 @@ data class Credentials(
if (certificateAlias != null)
s += "certificateAlias=$certificateAlias"
if (authState != null) // contains sensitive information (refresh token, access token)
if (authState != null)
s += "authState=${authState.jsonSerializeString()}"
return "Credentials(" + s.joinToString(", ") + ")"

View File

@@ -8,7 +8,6 @@ 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
@@ -36,24 +35,6 @@ interface HomeSetDao {
@Update
fun update(homeset: HomeSet)
/**
* If a homeset with the given service ID and URL already exists, it is updated with the other fields.
* Otherwise, a new homeset is inserted.
*
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @param homeSet home set to insert/update
*
* @return ID of the row that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
getByUrl(homeSet.serviceId, homeSet.url.toString())?.let { existingHomeset ->
update(homeSet.copy(id = existingHomeset.id))
existingHomeset.id
} ?: insert(homeSet)
@Delete
fun delete(homeset: HomeSet)

View File

@@ -2,21 +2,21 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
package at.bitfire.davdroid.db
import at.bitfire.dav4jvm.property.webdav.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String,
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
/**
* 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 {

View File

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

View File

@@ -8,7 +8,6 @@ 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
@@ -80,7 +79,7 @@ class LogManager @Inject constructor(
// root logger: set default log level and always log to logcat
val rootLogger = Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
rootLogger.addHandler(LogcatHandler())
// log to file, if requested
if (logToFile)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
import okhttp3.Interceptor
import okhttp3.Response
import java.util.logging.Level
import java.util.logging.Logger
/**
* Sends an OAuth Bearer token authorization as described in RFC 6750.
*/
class BearerAuthInterceptor(
private val accessToken: String
): Interceptor {
companion object {
val logger: Logger
get() = Logger.getGlobal()
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
return runBlocking {
val accessTokenFuture = CompletableDeferred<String>()
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
if (accessToken != null) {
// persist updated AuthState
callback?.onUpdate(authState)
// emit access token
accessTokenFuture.complete(accessToken)
}
else {
logger.log(Level.WARNING, "Couldn't obtain access token", ex)
accessTokenFuture.cancel()
}
}
// return value
try {
BearerAuthInterceptor(accessTokenFuture.await())
} catch (ignored: CancellationException) {
null
}
}
}
}
override fun intercept(chain: Interceptor.Chain): Response {
logger.finer("Authenticating request with access token")
val rq = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(rq)
}
fun interface AuthStateUpdateCallback {
fun onUpdate(authState: AuthState)
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import java.net.URI
import java.util.logging.Logger
class GoogleLogin(
val authService: AuthorizationService
) {
private val logger: Logger = Logger.getGlobal()
companion object {
// 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 googleBaseUri(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,
(BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri()
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
suspend fun authenticate(authResponse: AuthorizationResponse): Credentials {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val credentials = CompletableDeferred<Credentials>()
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
logger.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
credentials.complete(Credentials(authState = authState))
}
}
}
return credentials.await()
}
}

View File

@@ -10,18 +10,17 @@ 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.db.Credentials
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 net.openid.appauth.AuthorizationService
import okhttp3.Authenticator
import okhttp3.Cache
import okhttp3.ConnectionSpec
@@ -39,14 +38,17 @@ import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
class HttpClient(
val okHttpClient: OkHttpClient
val okHttpClient: OkHttpClient,
private val authorizationService: AuthorizationService? = null
): AutoCloseable {
override fun close() {
authorizationService?.dispose()
okHttpClient.cache?.close()
}
@@ -63,11 +65,11 @@ class HttpClient(
*/
class Builder @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val authorizationServiceProvider: Provider<AuthorizationService>,
@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
) {
@@ -94,22 +96,14 @@ class HttpClient(
private var authenticationInterceptor: Interceptor? = null
private var authenticator: Authenticator? = null
private var authorizationService: AuthorizationService? = null
private var certificateAlias: String? = null
fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): Builder {
val credentials = getCredentials()
fun authenticate(host: String?, credentials: Credentials, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
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)
}
)
val authService = authorizationServiceProvider.get()
authenticationInterceptor = BearerAuthInterceptor.fromAuthState(authService, credentials.authState, authStateCallback)
authorizationService = authService
} else if (credentials.username != null && credentials.password != null) {
// basic/digest auth
@@ -169,11 +163,9 @@ class HttpClient(
val accountSettings = accountSettingsFactory.create(account)
authenticate(
host = onlyHost,
getCredentials = {
accountSettings.credentials()
},
updateAuthState = { authState ->
accountSettings.updateAuthState(authState)
credentials = accountSettings.credentials(),
authStateCallback = { authState: AuthState ->
accountSettings.credentials(Credentials(authState = authState))
}
)
return this
@@ -230,15 +222,14 @@ class HttpClient(
// 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())
return HttpClient(
okHttpClient = okBuilder.build(),
authorizationService = authorizationService
)
}
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
@@ -267,7 +258,7 @@ class HttpClient(
val certManager = CustomCertManager(
context = context,
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
appInForeground = if (BuildConfig.customCertsUI)
appInForeground = if (/* davx5-ose */ true)
ForegroundTracker.inForeground // interactive mode
else
null // non-interactive mode

View File

@@ -6,7 +6,7 @@ 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.db.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
@@ -125,7 +125,7 @@ class NextcloudLoginFlow @Inject constructor(
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body.use { body ->
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)")
@@ -133,6 +133,8 @@ class NextcloudLoginFlow @Inject constructor(
// decode JSON
return@withContext JSONObject(body.string())
}
throw DavException("Invalid Login Flow response (no body)")
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,12 +19,6 @@ import java.net.URL
@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,

View File

@@ -9,6 +9,7 @@ import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
@@ -17,7 +18,6 @@ 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

View File

@@ -9,9 +9,6 @@ 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
@@ -23,31 +20,29 @@ 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 at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.util.DateUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
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
@@ -57,9 +52,7 @@ import javax.inject.Provider
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
) {
@@ -172,23 +165,17 @@ class DavCollectionRepository @Inject constructor(
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 {
httpClientBuilder.get()
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible(Dispatchers.IO) {
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)
@@ -228,7 +215,7 @@ class DavCollectionRepository @Inject constructor(
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlRememberSync(newCollection: Collection) {
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
db.runInTransaction {
// remember locally set flags
val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())
@@ -293,7 +280,7 @@ class DavCollectionRepository @Inject constructor(
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible(ioDispatcher) {
runInterruptible(Dispatchers.IO) {
DavResource(httpClient.okHttpClient, url).mkCol(
xmlBody = xmlBody,
method = method
@@ -375,8 +362,8 @@ class DavCollectionRepository @Inject constructor(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(
PropertyList<Property>().apply {
add(ICalendar.prodId)
add(Version.VERSION_2_0)
add(Constants.iCalProdId)
},
ComponentList(
listOf(vTimezone)
@@ -417,9 +404,6 @@ class DavCollectionRepository @Inject constructor(
return writer.toString()
}
private fun getVTimeZone(tzId: String): VTimeZone? {
val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()
return tzRegistry.getTimeZone(tzId)?.vTimeZone
}
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
}

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.repository
import android.accounts.Account
import androidx.room.Transaction
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
@@ -28,8 +29,21 @@ class DavHomeSetRepository @Inject constructor(
fun getCalendarHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
dao.insertOrUpdateByUrlBlocking(homeSet)
/**
* 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 insertOrUpdateByUrlBlocking(homeset: HomeSet): Long =
dao.getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
dao.update(homeset.copy(id = existingHomeset.id))
existingHomeset.id
} ?: dao.insert(homeset)
fun deleteBlocking(homeSet: HomeSet) = dao.delete(homeSet)

View File

@@ -17,20 +17,19 @@ import android.provider.ContactsContract.RawContacts
import androidx.annotation.OpenForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
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.SyncFrameworkIntegration
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.BatchOperation
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -199,17 +198,17 @@ open class LocalAddressBook @AssistedInject constructor(
return false
// move contacts and groups to new account
val batch = ContactsBatchOperation(provider!!)
batch += BatchOperation.CpoBuilder
val batch = BatchOperation(provider!!)
batch.enqueue(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
)
batch.enqueue(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
@@ -223,18 +222,15 @@ open class LocalAddressBook @AssistedInject constructor(
/**
* Enables or disables sync on content changes for the address book account based on the current sync
* interval account setting.
* Makes contacts of this address book available to be synced and activates synchronization upon
* contact data changes.
*/
fun updateSyncFrameworkSettings() {
val accountSettings = accountSettingsFactory.create(account)
val syncInterval = accountSettings.getSyncInterval(SyncDataType.CONTACTS)
// Enable sync-ability of contacts
syncFramework.enableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
// Enable/Disable content triggered syncs for the address book account.
if (syncInterval != null)
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
else
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
// Changes in contact data should trigger syncs
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
}

View File

@@ -6,7 +6,6 @@ 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
@@ -24,9 +23,6 @@ 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
@@ -78,14 +74,8 @@ class LocalAddressBookStore @Inject constructor(
return sb.toString()
}
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
override fun acquireContentProvider() =
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")
@@ -125,10 +115,17 @@ class LocalAddressBookStore @Inject constructor(
return addressBookAccount
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
getAddressBookAccounts(account).map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
return accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == account.name &&
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == account.type
}
.map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
}
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
@@ -158,7 +155,7 @@ class LocalAddressBookStore @Inject constructor(
localCollection.readOnly = nowReadOnly
}
// Update automatic synchronization
// make sure it will still be synchronized when contacts are updated
localCollection.updateSyncFrameworkSettings()
}
@@ -200,45 +197,6 @@ class LocalAddressBookStore @Inject constructor(
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 {

View File

@@ -4,22 +4,20 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Event
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
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.Level
import java.util.logging.Logger
/**
@@ -27,62 +25,55 @@ import java.util.logging.Logger
*
* [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> {
class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
private val logger: Logger
get() = Logger.getGlobal()
@AssistedFactory
interface Factory {
fun create(calendar: AndroidCalendar): LocalCalendar
}
// properties
override val dbCollectionId: Long?
get() = androidCalendar.syncId?.toLongOrNull()
get() = syncId?.toLongOrNull()
override val tag: String
get() = "events-${androidCalendar.account.name}-${androidCalendar.id}"
get() = "events-${account.name}-$id"
override val title: String
get() = androidCalendar.displayName ?: androidCalendar.id.toString()
get() = displayName ?: id.toString()
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
override val readOnly
get() = androidCalendar.accessLevel <= Calendars.CAL_ACCESS_READ
get() = accessLevel <= Calendars.CAL_ACCESS_READ
override var lastSyncState: SyncState?
get() = androidCalendar.readSyncState()?.let {
SyncState.fromString(it)
}
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
set(state) {
androidCalendar.writeSyncState(state.toString())
val values = contentValuesOf(COLUMN_SYNC_STATE to state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
val mapped = LegacyAndroidEventBuilder2(
calendar = androidCalendar,
event = event,
id = null,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.addEventAndExceptions(mapped)
override fun populate(info: ContentValues) {
super.populate(info)
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
}
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 findDeleted() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
@@ -92,119 +83,129 @@ class LocalCalendar @AssistedInject constructor(
* 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))
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
try {
val event = requireNotNull(localEvent.event)
val nonGroupScheduled = event.attendees.isEmpty()
val weAreOrganizer = localEvent.weAreOrganizer
val sequence = event.sequence
if (sequence == null)
// sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
event.sequence = sequence + 1
} catch(e: Exception) {
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
dirty += localEvent
}
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)
}
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
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 markNotDirty(flags: Int): Int {
val values = contentValuesOf(LocalEvent.COLUMN_FLAGS to flags)
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int): Int {
var deleted = 0
// 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()))
provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
val batch = BatchOperation(provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(Events.CONTENT_URI.asSyncAdapter(account))
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
}
deleted = batch.commit()
}
return batch.commit()
return deleted
}
override fun forgetETags() {
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.toString()))
}
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
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 batch = BatchOperation(provider)
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
// get original event's SEQUENCE
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
val batch = CalendarBatchOperation(androidCalendar.client)
// re-schedule original event and set it to DIRTY
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1))
}
}
// 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()
// completely remove deleted exception
batch.enqueue(BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(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")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
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()
val batch = BatchOperation(provider)
// original event to DIRTY
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(Events.DIRTY, 1))
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0))
batch.commit()
}
}
}
@@ -214,23 +215,33 @@ class LocalCalendar @AssistedInject constructor(
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
// Iterate dirty main events without exceptions
androidCalendar.iterateEventRows(
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
) { values ->
val eventId = values.getAsLong(Events._ID)
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events)
null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val eventID = cursor.getLong(0)
// get number of instances
val numEventInstances = androidCalendar.numInstances(eventId)
// get number of instances
val numEventInstances = LocalEvent.numInstances(provider, account, 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))
// delete event if there are no instances
if (numEventInstances == 0) {
logger.info("Marking event #$eventID without instances as deleted")
LocalEvent.markAsDeleted(provider, account, eventID)
}
}
}
}
object Factory: AndroidCalendarFactory<LocalCalendar> {
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
LocalCalendar(account, provider, id)
}
}

View File

@@ -6,13 +6,11 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Attendees
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import android.provider.CalendarContract.Reminders
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
@@ -20,9 +18,10 @@ import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
@@ -31,7 +30,6 @@ 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> {
@@ -39,16 +37,10 @@ class LocalCalendarStore @Inject constructor(
override val authority: String
get() = CalendarContract.AUTHORITY
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
override fun acquireContentProvider() =
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
override fun create(provider: 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))
@@ -77,49 +69,26 @@ class LocalCalendarStore @Inject constructor(
}
logger.log(Level.INFO, "Adding local calendar", values)
val provider = AndroidCalendarProvider(account, client)
return localCalendarFactory.create(provider.createAndGetCalendar(values))
val uri = AndroidCalendar.create(account, provider, values)
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
override fun getAll(account: Account, client: ContentProviderClient) =
AndroidCalendarProvider(account, client)
.findCalendars("${Calendars.SYNC_EVENTS}!=0", null)
.map { localCalendarFactory.create(it) }
override fun getAll(account: Account, provider: ContentProviderClient) =
AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${Calendars.SYNC_EVENTS}!=0", null)
override fun update(client: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.androidCalendar.account)
override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.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)
localCollection.update(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() },
)
val values = ContentValues()
values.put(Calendars._SYNC_ID, info.id)
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
@@ -135,6 +104,9 @@ class LocalCalendarStore @Inject constructor(
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
}
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}
@@ -148,7 +120,7 @@ class LocalCalendarStore @Inject constructor(
override fun delete(localCollection: LocalCalendar) {
logger.log(Level.INFO, "Deleting local calendar", localCollection)
localCollection.androidCalendar.delete()
localCollection.delete()
}
}
}

View File

@@ -4,6 +4,8 @@
package at.bitfire.davdroid.resource
import at.bitfire.davdroid.db.SyncState
interface LocalCollection<out T: LocalResource<*>> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
@@ -48,8 +50,10 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
fun findByName(name: String): T?
/**
* Updates the flags value for entries which are not dirty.
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
* and have an [Events.ORIGINAL_ID] of null.
*
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
*
@@ -58,7 +62,8 @@ interface LocalCollection<out T: LocalResource<*>> {
fun markNotDirty(flags: Int): Int
/**
* Removes entries which are not dirty with a given flag combination.
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) 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)
@@ -73,4 +78,4 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
fun forgetETags()
}
}

View File

@@ -10,26 +10,31 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.BuildConfig
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.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.Optional
import java.util.UUID
import kotlin.jvm.optionals.getOrNull
class LocalContact: AndroidContact, LocalAddress {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
}
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
@@ -40,8 +45,9 @@ class LocalContact: AndroidContact, LocalAddress {
internal val cachedGroupMemberships = HashSet<Long>()
internal val groupMemberships = HashSet<Long>()
override val scheduleTag: String?
override var scheduleTag: String?
get() = null
set(_) = throw NotImplementedError()
override var flags: Int = 0
@@ -89,13 +95,13 @@ class LocalContact: AndroidContact, LocalAddress {
_contact = null
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
override fun clearDirty(fileName: 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())
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
@@ -104,25 +110,21 @@ class LocalContact: AndroidContact, LocalAddress {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
if (fileName.isPresent)
this.fileName = fileName.get()
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
}
override fun resetDeleted() {
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
fun resetDirty() {
val values = contentValuesOf(ContactsContract.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)
@@ -130,39 +132,31 @@ class LocalContact: AndroidContact, LocalAddress {
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)
}
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)
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(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)
batch.enqueue(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)
)
batch.enqueue(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()
}

View File

@@ -25,44 +25,41 @@ interface LocalDataStore<T: LocalCollection<*>> {
*
* **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`)
* @return the content provider client, or `null` if the content provider could not be acquired
*
* @throws SecurityException on missing permissions
*/
fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient?
fun acquireContentProvider(): ContentProviderClient?
/**
* Creates a new local collection from the given (remote) collection info.
*
* @param client the content provider client
* @param provider 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?
fun create(provider: 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
* @param provider the content provider client
*
* @return a list of all local collections
*/
fun getAll(account: Account, client: ContentProviderClient): List<T>
fun getAll(account: Account, provider: ContentProviderClient): List<T>
/**
* Updates the local collection with the data from the given (remote) collection info.
*
* @param client the content provider client
* @param provider the content provider client
* @param localCollection the local collection to update
* @param fromCollection collection info
*/
fun update(client: ContentProviderClient, localCollection: T, fromCollection: Collection)
fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection)
/**
* Deletes the local collection.

View File

@@ -4,109 +4,195 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.resource.LocalEvent.Companion.numInstances
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.AndroidEventFactory
import at.bitfire.ical4android.BatchOperation
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 java.util.Optional
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.ical4jVersion
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import net.fortuna.ical4j.model.property.ProdId
import java.util.UUID
class LocalEvent(
val recurringCalendar: AndroidRecurringCalendar,
val androidEvent: AndroidEvent2
) : LocalResource<Event> {
class LocalEvent: AndroidEvent, LocalResource<Event> {
override val id: Long
get() = androidEvent.id
companion object {
init {
ICalendar.prodId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/" + ical4jVersion)
}
override val fileName: String?
get() = androidEvent.syncId
const val COLUMN_ETAG = Events.SYNC_DATA1
const val COLUMN_FLAGS = Events.SYNC_DATA2
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
override val eTag: String?
get() = androidEvent.eTag
override val scheduleTag: String?
get() = androidEvent.scheduleTag
override val flags: Int
get() = androidEvent.flags
/**
* Marks the event as deleted
* @param eventID
*/
fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) {
provider.update(
ContentUris.withAppendedId(
Events.CONTENT_URI,
eventID
).asSyncAdapter(account),
contentValuesOf(Events.DELETED to 1),
null, null
)
}
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
val eventAndExceptions = LegacyAndroidEventBuilder2(
calendar = androidEvent.calendar,
event = data,
id = id,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
/**
* Finds the amount of direct instances this event has (without exceptions); used by [numInstances]
* to find the number of instances of exceptions.
*
* The number of returned instances may vary with the Android version.
*
* @return number of direct event instances (not counting instances of exceptions); *null* if
* the number can't be determined or if the event has no last date (recurring event without last instance)
*/
fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
// query event to get first and last instance
var first: Long? = null
var last: Long? = null
provider.query(
ContentUris.withAppendedId(
Events.CONTENT_URI,
eventID
),
arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null
)?.use { cursor ->
cursor.moveToNext()
if (!cursor.isNull(0))
first = cursor.getLong(0)
if (!cursor.isNull(1))
last = cursor.getLong(1)
}
// if this event doesn't have a last occurence, it's endless and always has instances
if (first == null || last == null)
return null
/* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances
on the fly and it doesn't accept those values. So we use the first/last actual occurence
of the event (calculated by Android). */
val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account)
.buildUpon()
.appendPath(first.toString()) // begin timestamp
.appendPath(last.toString()) // end timestamp
.build()
var numInstances = 0
provider.query(
instancesUri, null,
"${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()),
null
)?.use { cursor ->
numInstances += cursor.count
}
return numInstances
}
/**
* Finds the total number of instances this event has (including instances of exceptions)
*
* The number of returned instances may vary with the Android version.
*
* @return number of direct event instances (not counting instances of exceptions); *null* if
* the number can't be determined or if the event has no last date (recurring event without last instance)
*/
fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
// num instances of the main event
var numInstances = numDirectInstances(provider, account, eventID) ?: return null
// add the number of instances of every main event's exception
provider.query(
Events.CONTENT_URI,
arrayOf(Events._ID),
"${Events.ORIGINAL_ID}=?", // get exception events of the main event
arrayOf("$eventID"), null
)?.use { exceptionsEventCursor ->
while (exceptionsEventCursor.moveToNext()) {
val exceptionEventID = exceptionsEventCursor.getLong(0)
val exceptionInstances = numDirectInstances(provider, account, exceptionEventID)
if (exceptionInstances == null)
// number of instances of exception can't be determined; so the total number of instances is also unclear
return null
numInstances += exceptionInstances
}
}
return numInstances
}
}
override var fileName: String? = null
private set
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 }
override var eTag: String? = null
override var scheduleTag: String? = null
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
val event = legacyCalendar.getEvent(androidEvent.id)
?: throw LocalStorageException("Event ${androidEvent.id} not found")
override var flags: Int = 0
private set
_event = event
return event
var weAreOrganizer = false
private set
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
}
/**
* 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
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/**
* 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
))
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
val event = requireNotNull(event)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
super.populateEvent(row, groupScheduled)
}
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
super.buildEvent(recurrence, builder)
}
@@ -118,25 +204,25 @@ class LocalEvent(
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = getCachedEvent().uid ?: run {
val uid: String = event!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// persist to calendar provider
// update in calendar provider
val values = contentValuesOf(Events.UID_2445 to newUid)
androidEvent.update(values)
calendar.provider.update(eventSyncURI(), values, null, null)
// update in cached event data object
getCachedEvent().uid = newUid
// update this event
event?.uid = newUid
newUid
}
val uidIsGoodFilename = uid.all { char ->
// see RFC 2396 2.2
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?'
'-','_','.','!','~','*','\'','(',')' // allow unreserved characters
).contains(char)
}
return if (uidIsGoodFilename)
@@ -145,31 +231,39 @@ class LocalEvent(
"${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 clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
val values = ContentValues(5)
if (fileName != null)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
values.put(Events.DIRTY, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
override fun updateFlags(flags: Int) {
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_FLAGS to flags
))
}
val values = contentValuesOf(COLUMN_FLAGS to flags)
calendar.provider.update(eventSyncURI(), values, null, null)
override fun deleteLocal() {
recurringCalendar.deleteEventAndExceptions(id)
this.flags = flags
}
override fun resetDeleted() {
androidEvent.update(contentValuesOf(
Events.DELETED to 0
))
val values = contentValuesOf(Events.DELETED to 0)
calendar.provider.update(eventSyncURI(), values, null, null)
}
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
}

View File

@@ -14,18 +14,15 @@ 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.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import java.util.LinkedList
import java.util.Optional
import java.util.UUID
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull
@@ -56,7 +53,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
addressBook.allGroups { group ->
val groupId = group.id!!
val pendingMemberUids = group.pendingMemberships.toMutableSet()
val batch = ContactsBatchOperation(addressBook.provider!!)
val batch = BatchOperation(addressBook.provider!!)
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
@@ -112,7 +109,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
override var scheduleTag: String?
get() = null
set(_) = throw NotImplementedError()
set(value) = throw NotImplementedError()
override var flags: Int = 0
@@ -160,40 +157,40 @@ class LocalGroup: AndroidGroup, LocalAddress {
return "$uid.vcf"
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
override fun clearDirty(fileName: 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())
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
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()
if (fileName != null)
this.fileName = fileName
this.eTag = null
// update cached group memberships
val batch = ContactsBatchOperation(addressBook.provider!!)
val batch = BatchOperation(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())
)
batch.enqueue(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.enqueue(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()
}
@@ -202,23 +199,19 @@ class LocalGroup: AndroidGroup, LocalAddress {
* Marks all members of the current group as dirty.
*/
fun markMembersDirty() {
val batch = ContactsBatchOperation(addressBook.provider!!)
val batch = BatchOperation(addressBook.provider!!)
for (member in getMembers())
batch += BatchOperation.CpoBuilder
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
batch.enqueue(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
this.scheduleTag = scheduleTag
// processes this.{fileName, eTag, flags} and resets DIRTY flag
update(data)
override fun resetDeleted() {
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun updateFlags(flags: Int) {
@@ -228,15 +221,6 @@ class LocalGroup: AndroidGroup, LocalAddress {
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
// helpers

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxCollectionFactory
import at.bitfire.ical4android.JtxICalObject

View File

@@ -37,14 +37,8 @@ class LocalJtxCollectionStore @Inject constructor(
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 acquireContentProvider() =
context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")

View File

@@ -9,8 +9,6 @@ import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxICalObject
import at.bitfire.ical4android.JtxICalObjectFactory
import at.techbee.jtx.JtxContract
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class LocalJtxICalObject(
collection: JtxCollection<*>,
@@ -50,24 +48,6 @@ class 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()
}

View File

@@ -4,12 +4,8 @@
package at.bitfire.davdroid.resource
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
import java.util.Optional
import android.net.Uri
/**
* Defines operations that are used by SyncManager for all sync data types.
*/
interface LocalResource<in TData: Any> {
companion object {
@@ -36,10 +32,10 @@ interface LocalResource<in TData: Any> {
val fileName: String?
/** remote ETag for the resource */
val eTag: String?
var eTag: String?
/** remote Schedule-Tag for the resource */
val scheduleTag: String?
var scheduleTag: String?
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
val flags: Int
@@ -52,41 +48,47 @@ interface LocalResource<in TData: Any> {
* 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")
* @return 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).
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
* locally modified resource.
*
* @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)
* @param fileName If this argument is not *null*, [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 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)
fun clearDirty(fileName: 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].
* Sets (local) flags of the resource. At the moment, the only allowed values are
* 0 and [FLAG_REMOTELY_PRESENT].
*/
fun updateFlags(flags: Int)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the created row (e.g. event URI)
*/
fun add(): Uri
/**
* 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)
fun update(data: TData): Uri
/**
* Deletes the data object from the content provider.
*
* @return number of affected rows
*/
fun deleteLocal()
fun delete(): Int
/**
* Undoes deletion of the data object from the content provider.

View File

@@ -6,13 +6,12 @@ package at.bitfire.davdroid.resource
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.synctools.storage.BatchOperation
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.Optional
import java.util.UUID
class LocalTask: DmfsTask, LocalResource<Task> {
@@ -77,33 +76,23 @@ class LocalTask: DmfsTask, LocalResource<Task> {
return "$uid.ics"
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
override fun clearDirty(fileName: 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())
if (fileName != null)
values.put(Tasks._SYNC_ID, fileName)
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()
if (fileName != null)
this.fileName = fileName
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)
@@ -113,10 +102,6 @@ class LocalTask: DmfsTask, LocalResource<Task> {
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}
@@ -126,4 +111,4 @@ class LocalTask: DmfsTask, LocalResource<Task> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}
}

View File

@@ -8,6 +8,7 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
@@ -109,7 +110,7 @@ class LocalTaskList private constructor(
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}

View File

@@ -47,14 +47,8 @@ class LocalTaskListStore @AssistedInject constructor(
override val authority: String
get() = providerName.authority
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
override fun acquireContentProvider() =
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")

View File

@@ -7,7 +7,7 @@ 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.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.contactrow.DataRowBuilder

View File

@@ -5,7 +5,7 @@
package at.bitfire.davdroid.resource.contactrow
import android.net.Uri
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.contactrow.DataRowBuilder
import java.util.LinkedList

View File

@@ -9,8 +9,7 @@ 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 at.bitfire.vcard4android.BatchOperation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -130,12 +129,12 @@ class Android7DirtyVerifier @Inject constructor(
addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null)
}
override fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation) {
override fun updateHashCode(contact: LocalContact, batch: BatchOperation) {
val hashCode = contactDataHashCode(contact)
batch += BatchOperation.CpoBuilder
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(contact.rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode)
.withValue(COLUMN_HASHCODE, hashCode))
}

View File

@@ -7,7 +7,7 @@ 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
import at.bitfire.vcard4android.BatchOperation
/**
* Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8,
@@ -49,6 +49,6 @@ interface ContactDirtyVerifier {
/**
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)
fun updateHashCode(contact: LocalContact, batch: BatchOperation)
}

View File

@@ -0,0 +1,421 @@
/*
* 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.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
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.CalendarProxyReadFor
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
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.carddav.AddressbookHomeSet
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.GroupMembership
import at.bitfire.dav4jvm.property.webdav.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.dav4jvm.property.webdav.ResourceType
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 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
/**
* Logic for refreshing the list of collections and home-sets and related information.
*/
class CollectionListRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
private val logger: Logger,
private val settings: SettingsManager
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
}
/**
* Principal properties to ask the server for.
*/
private val principalProperties = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* 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()
}
/**
* Collection properties to ask for in a PROPFIND request on a collection.
*/
private val collectionProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
ResourceType.NAME,
PushTransports.NAME, // WebDAV-Push
Topic.NAME
) + when (service.type) { // 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()
}
/**
* 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.code/100 == 4)
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
)
}
/**
* Refreshes home-sets and their collections.
*
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
* or marked as homeless - 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 [refreshHomelessCollections].
*/
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 homeless.
val localHomesetCollections = db.collectionDao()
.getByServiceAndHomeset(service.id, localHomeset.id)
.associateBy { it.url }
.toMutableMap()
try {
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 (isUsableCollection(collection))
collectionRepository.insertOrUpdateByUrlAndRememberFlags(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.code in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
for ((_, homelessCollection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlAndRememberFlags(
homelessCollection.copy(homeSetId = null)
)
}
}
/**
* 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 refreshHomelessCollections() {
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for((url, localCollection) in homelessCollections) try {
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 (!isUsableCollection(collection))
return@let
collectionRepository.insertOrUpdateByUrlAndRememberFlags(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.code in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
/**
* Refreshes the principals (get their current display names).
* Also removes principals which do not own any collections anymore.
*/
internal 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.code}. principalUrl=$principalUrl")
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach {principal ->
db.principalDao().delete(principal)
}
}
/**
* Finds out whether given collection is usable, by checking that either
* - CalDAV/CardDAV: service and collection type match, or
* - WebCal: subscription source URL is not empty
*/
private fun isUsableCollection(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)
/**
* Whether to preselect the given collection for synchronisation, according to the
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
*
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
*
* Before a collection is pre-selected, we check whether its URL matches the regexp in
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homeSets list of personal home-sets
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
if (!excludedRegex.isNullOrEmpty())
Regex(excludedRegex).containsMatchIn(collection.url.toString())
else
false
}
return when (shouldPreselect) {
Settings.PRESELECT_COLLECTIONS_ALL ->
// preselect if collection url is not excluded
!excluded
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homeSets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)
&& !excluded
else -> // don't preselect
false
}
}
}

View File

@@ -1,73 +0,0 @@
/*
* 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.code in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
}

View File

@@ -21,16 +21,16 @@ 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.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Credentials
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
@@ -88,7 +88,7 @@ class DavResourceFinder @AssistedInject constructor(
if (credentials != null)
authenticate(
host = null,
getCredentials = { credentials }
credentials = credentials
)
}
.build()

View File

@@ -1,162 +0,0 @@
/*
* 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.code in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
for ((_, collection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlRememberSync(
collection.copy(homeSetId = null)
)
}
}
/**
* Whether to preselect the given collection for synchronisation, according to the
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
*
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
*
* Before a collection is pre-selected, we check whether its URL matches the regexp in
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homeSets list of personal home-sets
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
if (!excludedRegex.isNullOrEmpty())
Regex(excludedRegex).containsMatchIn(collection.url.toString())
else
false
}
return when (shouldPreselect) {
Settings.PRESELECT_COLLECTIONS_ALL ->
// preselect if collection url is not excluded
!excluded
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homeSets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)
&& !excluded
else -> // don't preselect
false
}
}
}

View File

@@ -1,73 +0,0 @@
/*
* 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.code}. principalUrl=$principalUrl")
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach { principal ->
db.principalDao().delete(principal)
}
}
}

View File

@@ -62,14 +62,11 @@ import java.util.logging.Logger
class RefreshCollectionsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
private val collectionListRefresherFactory: CollectionListRefresher.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) {
@@ -159,25 +156,22 @@ class RefreshCollectionsWorker @AssistedInject constructor(
.use { httpClient ->
runInterruptible {
val httpClient = httpClient.okHttpClient
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
val refresher = collectionListRefresherFactory.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)
refresher.discoverHomesets(principalUrl)
}
// refresh home sets and their member collections
homeSetRefresherFactory.create(service, httpClient)
.refreshHomesetsAndTheirCollections()
refresher.refreshHomesetsAndTheirCollections()
// also refresh collections without a home set
refresher.refreshCollectionsWithoutHomeSet()
refresher.refreshHomelessCollections()
// Lastly, refresh the principals (collection owners)
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
principalsRefresher.refreshPrincipals()
refresher.refreshPrincipals()
}
}

View File

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

View File

@@ -1,178 +0,0 @@
/*
* 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.code / 100 == 4)
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e
}
// query related resources
if (level <= 1)
for (resource in relatedResources)
if (alreadyQueriedPrincipals.contains(resource))
logger.warning("$resource already queried, skipping")
else
discoverHomesets(
principalUrl = resource,
level = level + 1,
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
alreadySavedHomeSets = alreadySavedHomeSets
)
}
}

View File

@@ -11,6 +11,7 @@ import android.os.Looper
import androidx.annotation.WorkerThread
import androidx.core.os.bundleOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
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
@@ -124,13 +125,7 @@ class AccountSettings @AssistedInject constructor(
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())
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString())
}
/**
@@ -177,7 +172,7 @@ class AccountSettings @AssistedInject constructor(
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
}
val newValue = seconds ?: SYNC_INTERVAL_MANUALLY
val newValue = if (seconds == null) SYNC_INTERVAL_MANUALLY else seconds
accountManager.setAndVerifyUserData(account, key, newValue.toString())
automaticSyncManager.updateAutomaticSync(account, dataType)
@@ -354,12 +349,7 @@ class AccountSettings @AssistedInject constructor(
companion object {
/**
* Current (usually the newest) account settings version. It's used to
* determine whether a migration ([AccountSettingsMigration])
* should be performed.
*/
const val CURRENT_VERSION = 21
const val CURRENT_VERSION = 20
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.settings
import androidx.appcompat.app.AppCompatDelegate
import at.bitfire.davdroid.settings.Settings.PRESELECT_COLLECTIONS_EXCLUDED
object Settings {
@@ -61,9 +60,5 @@ object Settings {
/** whether all address books are forced to be read-only */
const val FORCE_READ_ONLY_ADDRESSBOOKS = "force_read_only_addressbooks"
/** max. number of accounts */
const val MAX_ACCOUNTS = "max_accounts"
}
}

View File

@@ -10,8 +10,7 @@ import at.bitfire.davdroid.settings.AccountSettings
interface AccountSettingsMigration {
/**
* Migrate the account settings from the old version to the new version which
* is set in [AccountSettings.CURRENT_VERSION].
* Migrate the account settings from the old version to the new version.
*
* **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].**
*

View File

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

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