Compare commits

..

20 Commits

Author SHA1 Message Date
Sunik Kupfer
7f36e826d8 Cancel syncs for calendar, tasks, and contacts separately 2025-09-04 12:13:18 +02:00
Sunik Kupfer
3737d69397 Add FAB to cancel sync adapter syncs 2025-09-04 11:43:10 +02:00
Sunik Kupfer
2dbd5c02b6 Cancel by request and empty bundle 2025-09-04 11:09:23 +02:00
Sunik Kupfer
c12e9311f7 Stop always returning false for pending sync state of sync adapter framework 2025-09-04 10:58:40 +02:00
Sunik Kupfer
b663912feb Enable forever pending sync workaround by canceling sync adapter framework syncs on Android 14+ 2025-09-04 10:56:54 +02:00
Sunik Kupfer
3c484f253f Use cancelSync directly in migration 2025-09-04 10:53:50 +02:00
Sunik Kupfer
de7f8d2964 Cancel for all authorities and update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
a79a39c25d Cancel only on Android 14+ 2025-09-04 10:53:50 +02:00
Sunik Kupfer
20675ed71b Update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
881588f8e8 Don't infer authority from account type 2025-09-04 10:53:50 +02:00
Sunik Kupfer
0c31758880 Also cancel calendar syncs 2025-09-04 10:53:50 +02:00
Sunik Kupfer
9ffd59cd00 Updating log statement 2025-09-04 10:53:50 +02:00
Sunik Kupfer
c40b2b38bc Update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
3025ea7491 Optimize imports 2025-09-04 10:53:50 +02:00
Sunik Kupfer
b84a812d7a Call cancelSync via integration 2025-09-04 10:53:50 +02:00
Sunik Kupfer
562afc5666 Add and update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
8992859b63 Increase account settings current version 2025-09-04 10:53:50 +02:00
Sunik Kupfer
03013b5576 Add log statement 2025-09-04 10:53:50 +02:00
Sunik Kupfer
0028fc8722 Add application context annotation 2025-09-04 10:53:50 +02:00
Sunik Kupfer
1b4ebde896 Add AccountSettingsMigration21 to cancel pending address book syncs 2025-09-04 10:53:50 +02:00
207 changed files with 3197 additions and 5226 deletions

4
.github/CODEOWNERS vendored
View File

@@ -1,4 +0,0 @@
# See https://docs.github.com/de/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# For combination with "Require review from code owners" for main-ose branch:
* @bitfireAT/app-dev

View File

@@ -12,16 +12,3 @@ updates:
groups:
ci-actions:
patterns: ["*"]
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
groups:
app-dependencies:
patterns: ["*"]
ignore:
# dependencies without semantic versioning
- dependency-name: "com.github.bitfireat:cert4android"
- dependency-name: "com.github.bitfireat:dav4jvm"
- dependency-name: "com.github.bitfireat:synctools"

View File

@@ -3,12 +3,11 @@ name: "CodeQL"
on:
push:
branches: [ main-ose ]
# pull_request:
pull_request:
# The branches below must be a subset of the branches above
# branches: [ main-ose ]
branches: [ main-ose ]
schedule:
- cron: '22 10 * * 1'
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
@@ -29,19 +28,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v5
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true # gradle user home cache is generated by test jobs
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
@@ -51,9 +50,9 @@ jobs:
# uses: github/codeql-action/autobuild@v2
- name: Build
run: ./gradlew --no-daemon app:compileOseDebugSource
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -1,24 +0,0 @@
name: Dependency Submission
on:
push:
branches: [ 'main-ose' ]
permissions:
contents: write
jobs:
dependency-submission:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v5
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'

55
.github/workflows/dependent-issues.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Dependent Issues
on:
issues:
types:
- opened
- edited
- closed
- reopened
pull_request_target:
types:
- opened
- edited
- closed
- reopened
# Makes sure we always add status check for PRs. Useful only if
# this action is required to pass before merging. Otherwise, it
# can be removed.
- synchronize
# Schedule a daily check. Useful if you reference cross-repository
# issues or pull requests. Otherwise, it can be removed.
schedule:
- cron: '19 9 * * *'
permissions: write-all
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: z0al/dependent-issues@v1
env:
# (Required) The token to use to make API calls to GitHub.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# (Optional) The token to use to make API calls to GitHub for remote repos.
GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }}
with:
# (Optional) The label to use to mark dependent issues
# label: dependent
# (Optional) Enable checking for dependencies in issues.
# Enable by setting the value to "on". Default "off"
check_issues: on
# (Optional) A comma-separated list of keywords. Default
# "depends on, blocked by"
keywords: depends on, blocked by
# (Optional) A custom comment body. It supports `{{ dependencies }}` token.
comment: >
This PR/issue depends on:
{{ dependencies }}

View File

@@ -19,12 +19,12 @@ jobs:
discussions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v5
- uses: gradle/actions/setup-gradle@v4
- name: Prepare keystore
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks

View File

@@ -9,125 +9,74 @@ concurrency:
group: test-dev-${{ github.ref }}
cancel-in-progress: true
# We provide a remote gradle build cache. Take the settings from the secrets and enable
# configuration and build cache for all gradle jobs.
env:
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
jobs:
compile:
name: Compile
name: Compile for build cache
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v5
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: false # allow branches to update their configuration cache
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
dependency-graph: generate-and-submit # submit Github Dependency Graph info
- name: Cache Android environment
uses: actions/cache@v5
with:
path: ~/.config/.android # needs to be cached so that configuration cache can work
key: android-${{ hashFiles('app/build.gradle.kts') }}
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
- name: Compile
run: ./gradlew app:compileOseDebugSource
# Cache configurations for the other jobs
- name: Populate configuration cache
run: |
./gradlew --dry-run app:lintOseDebug
./gradlew --dry-run app:testOseDebugUnitTest
./gradlew --dry-run app:virtualOseDebugAndroidTest
unit_tests:
test:
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v5
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Restore Android environment
uses: actions/cache/restore@v5
with:
path: ~/.config/.android
key: android-${{ hashFiles('app/build.gradle.kts') }}
- name: Run lint
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
- name: Lint checks
run: ./gradlew app:lintOseDebug
- name: Unit tests
run: ./gradlew app:testOseDebugUnitTest
instrumented_tests:
test_on_emulator:
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v5
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
- name: Restore Android environment
uses: actions/cache/restore@v5
with:
path: ~/.config/.android
key: android-${{ hashFiles('app/build.gradle.kts') }}
# gradle and Android SDK often take more space than what is available on the default runner.
# We try to free a few GB here to make gradle-managed devices more reliable.
- name: Free some disk space
uses: jlumbroso/free-disk-space@main
with:
android: false # we need the Android SDK
large-packages: false # apt takes too long
swap-storage: false # gradle needs much memory
- name: Restore AVD
id: restore-avd
uses: actions/cache/restore@v5
with:
path: ~/.config/.android/avd # where AVD is stored
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
# Enable virtualization for Android emulator
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Instrumented tests
run: ./gradlew app:virtualOseDebugAndroidTest
- name: Cache AVD
uses: actions/cache/save@v5
if: steps.restore-avd.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: ~/.config/.android/avd # where AVD is stored
path: ~/.config/.android/avd
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

4
.gitignore vendored
View File

@@ -16,6 +16,10 @@
bin/
gen/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties

View File

@@ -1,6 +1,6 @@
[main]
host = https://www.transifex.com
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, pt_BR: pt-rBR, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
[o:bitfireAT:p:davx5:r:app]
file_filter = app/src/main/res/values-<lang>/strings.xml

View File

@@ -7,9 +7,9 @@ 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.android)
alias(libs.plugins.mikepenz.aboutLibraries)
}
// Android configuration
@@ -19,16 +19,15 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 405080000
versionName = "4.5.8-alpha.1"
versionCode = 405040002
versionName = "4.5.4-rc.1"
base.archivesName = "davx5-ose-$versionName"
minSdk = 24 // Android 7.0
targetSdk = 36 // Android 16
// whether the build supports and allows to use custom certificates
buildConfigField("boolean", "allowCustomCerts", "true")
buildConfigField("boolean", "customCertsUI", "true")
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
@@ -124,10 +123,8 @@ ksp {
}
aboutLibraries {
export {
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
excludeFields.add("generated")
}
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
excludeFields = arrayOf("generated")
}
dependencies {
@@ -187,14 +184,10 @@ dependencies {
}
// third-party libs
implementation(libs.conscrypt)
@Suppress("RedundantSuppression")
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.mikepenz.aboutLibraries.m3)
implementation(libs.mikepenz.aboutLibraries)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)
@@ -229,5 +222,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.robolectric)
}

View File

@@ -24,8 +24,3 @@
-dontwarn sun.net.spi.nameservice.NameService
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider
# okhttp
# https://github.com/bitfireAT/davx5/issues/711 / https://github.com/square/okhttp/issues/8574
-keep class okhttp3.internal.idn.IdnaMappingTable { *; }
-keep class okhttp3.internal.idn.IdnaMappingTableInstanceKt{ *; }

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<!-- account management permissions not required for own accounts since API level 22 -->
@@ -8,9 +7,4 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!--
Since Mockk 1.14.7 it's required to use minSdk 26. We use 24, so override for tests.
-->
<uses-sdk tools:overrideLibrary="io.mockk.android,io.mockk.proxy.android" />
</manifest>

View File

@@ -6,15 +6,15 @@ package at.bitfire.davdroid.db
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -29,12 +29,12 @@ import javax.inject.Inject
class CollectionTest {
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
private lateinit var httpClient: OkHttpClient
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
@@ -45,6 +45,11 @@ class CollectionTest {
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun teardown() {
httpClient.close()
}
@Test
@SmallTest
@@ -64,8 +69,8 @@ class CollectionTest {
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient, server.url("/"))
.propfind(0, WebDAV.ResourceType) { response, _ ->
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
@@ -120,8 +125,8 @@ class CollectionTest {
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient, server.url("/"))
.propfind(0, WebDAV.ResourceType) { response, _ ->
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response)!!
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
@@ -156,8 +161,8 @@ class CollectionTest {
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient, server.url("/"))
.propfind(0, WebDAV.ResourceType) { response, _ ->
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response)!!
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
@@ -190,8 +195,8 @@ class CollectionTest {
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient, server.url("/"))
.propfind(0, WebDAV.ResourceType) { response, _ ->
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_WEBCAL, info.type)

View File

@@ -1,96 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import android.database.sqlite.SQLiteConstraintException
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PrincipalDaoTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
private lateinit var principalDao: PrincipalDao
private lateinit var service: Service
private val url = "https://example.com/dav/principal".toHttpUrl()
@Before
fun setUp() {
hiltRule.inject()
principalDao = spyk(db.principalDao())
service = Service(id = 1, accountName = "account", type = "webdav")
db.serviceDao().insertOrReplace(service)
}
@Test
fun insertOrUpdate_insertsIfNotExisting() = runTest {
val principal = Principal(serviceId = service.id, url = url, displayName = "principal")
val id = principalDao.insertOrUpdate(service.id, principal)
assertTrue(id > 0)
val stored = principalDao.get(id)
assertEquals("principal", stored.displayName)
verify(exactly = 0) { principalDao.update(any()) }
}
@Test
fun insertOrUpdate_doesNotUpdateIfDisplayNameIsEqual() = runTest {
val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld")
val idOld = principalDao.insertOrUpdate(service.id, principalOld)
val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalOld")
val idNew = principalDao.insertOrUpdate(service.id, principalNew)
assertEquals(idOld, idNew)
val stored = principalDao.get(idOld)
assertEquals("principalOld", stored.displayName)
verify(exactly = 0) { principalDao.update(any()) }
}
@Test
fun insertOrUpdate_updatesIfDisplayNameIsDifferent() = runTest {
val principalOld = Principal(serviceId = service.id, url = url, displayName = "principalOld")
val idOld = principalDao.insertOrUpdate(service.id, principalOld)
val principalNew = Principal(serviceId = service.id, url = url, displayName = "principalNew")
val idNew = principalDao.insertOrUpdate(service.id, principalNew)
assertEquals(idOld, idNew)
val updated = principalDao.get(idOld)
assertEquals("principalNew", updated.displayName)
verify(exactly = 1) { principalDao.update(any()) }
}
@Test(expected = SQLiteConstraintException::class)
fun insertOrUpdate_throwsForeignKeyConstraintViolationException() = runTest {
// throws on non-existing service
val url = "https://example.com/dav/principal".toHttpUrl()
val principal1 = Principal(serviceId = 999, url = url, displayName = "p1")
principalDao.insertOrUpdate(999, principal1)
}
}

View File

@@ -1,31 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import org.conscrypt.Conscrypt
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.security.Security
class ConscryptIntegrationTest {
val integration = ConscryptIntegration()
@Test
fun testInitialize_InstallsConscrypt() {
uninstallConscrypt()
assertFalse(integration.conscryptInstalled())
integration.initialize()
assertTrue(integration.conscryptInstalled())
}
private fun uninstallConscrypt() {
for (conscrypt in Security.getProviders().filter { Conscrypt.isConscrypt(it) })
Security.removeProvider(conscrypt.name)
}
}

View File

@@ -7,9 +7,6 @@ package at.bitfire.davdroid.network
import android.security.NetworkSecurityPolicy
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.test.runTest
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -22,23 +19,25 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
import javax.inject.Provider
@HiltAndroidTest
class HttpClientBuilderTest {
class HttpClientTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var httpClientBuilder: Provider<HttpClientBuilder>
lateinit var httpClientBuilder: HttpClient.Builder
lateinit var httpClient: HttpClient
lateinit var server: MockWebServer
@Before
fun setUp() {
hiltRule.inject()
httpClient = httpClientBuilder.build()
server = MockWebServer()
server.start(30000)
}
@@ -46,22 +45,10 @@ class HttpClientBuilderTest {
@After
fun tearDown() {
server.shutdown()
httpClient.close()
}
@Test
fun testBuildKtor_CreatesWorkingClient() = runTest {
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("Some Content"))
httpClientBuilder.get().buildKtor().use { client ->
val response = client.get(server.url("/").toString())
assertEquals(200, response.status.value)
assertEquals("Some Content", response.bodyAsText())
}
}
@Test
fun testCookies() {
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
@@ -73,9 +60,7 @@ class HttpClientBuilderTest {
.addHeader("Set-Cookie", "cookie1=1; path=/")
.addHeader("Set-Cookie", "cookie2=2")
.setBody("Cookie set"))
val httpClient = httpClientBuilder.get().build()
httpClient.newCall(Request.Builder()
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertNull(server.takeRequest().getHeader("Cookie"))
@@ -86,7 +71,7 @@ class HttpClientBuilderTest {
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
.addHeader("Set-Cookie", "cookie2=2a")
.setResponseCode(200))
httpClient.newCall(Request.Builder()
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
val header = server.takeRequest().getHeader("Cookie")
@@ -94,7 +79,7 @@ class HttpClientBuilderTest {
server.enqueue(MockResponse()
.setResponseCode(200))
httpClient.newCall(Request.Builder()
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))

View File

@@ -17,7 +17,7 @@ import javax.inject.Inject
class OkhttpClientTest {
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -31,15 +31,16 @@ class OkhttpClientTest {
@Test
@SdkSuppress(maxSdkVersion = 34)
fun testIcloudWithSettings() {
val client = httpClientBuilder.build()
client
.newCall(
Request.Builder()
.get()
.url("https://icloud.com")
.build()
)
.execute()
httpClientBuilder.build().use { client ->
client.okHttpClient
.newCall(
Request.Builder()
.get()
.url("https://icloud.com")
.build()
)
.execute()
}
}
}

View File

@@ -1,214 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalDataStore
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.account.TestAccount
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.clearAllMocks
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountRepositoryTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
// System under test
@Inject
lateinit var accountRepository: AccountRepository
// Real injections
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
// Dependency overrides
@BindValue @MockK(relaxed = true)
lateinit var automaticSyncManager: AutomaticSyncManager
@BindValue @MockK(relaxed = true)
lateinit var localAddressBookStore: LocalAddressBookStore
@BindValue @MockK(relaxed = true)
lateinit var localCalendarStore: LocalCalendarStore
@BindValue @MockK(relaxed = true)
lateinit var serviceRepository: DavServiceRepository
@BindValue @MockK(relaxed = true)
lateinit var syncWorkerManager: SyncWorkerManager
@BindValue @MockK(relaxed = true)
lateinit var tasksAppManager: TasksAppManager
// Account setup
private val newName = "Renamed Account"
lateinit var am: AccountManager
lateinit var accountType: String
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
// Account setup
am = AccountManager.get(context)
accountType = context.getString(R.string.account_type)
account = TestAccount.create()
// AccountsCleanupWorker static mocking
mockkObject(AccountsCleanupWorker)
every { AccountsCleanupWorker.lockAccountsCleanup() } returns Unit
}
@After
fun tearDown() {
am.getAccountsByType(accountType).forEach { account ->
am.removeAccountExplicitly(account)
}
unmockkObject(AccountsCleanupWorker)
clearAllMocks()
}
// testRename
@Test(expected = IllegalArgumentException::class)
fun testRename_checksForAlreadyExisting() = runTest {
val existing = Account("Existing Account", accountType)
am.addAccountExplicitly(existing, null, null)
accountRepository.rename(account.name, existing.name)
}
@Test
fun testRename_locksAccountsCleanup() = runTest {
accountRepository.rename(account.name, newName)
verify { AccountsCleanupWorker.lockAccountsCleanup() }
}
@Test
fun testRename_renamesAccountInAndroid() = runTest {
accountRepository.rename(account.name, newName)
val accountsAfter = am.getAccountsByType(accountType)
assertTrue(accountsAfter.any { it.name == newName })
}
@Test
fun testRename_cancelsRunningSynchronizationOfOldAccount() = runTest {
accountRepository.rename(account.name, newName)
coVerify { syncWorkerManager.cancelAllWork(account) }
}
@Test
fun testRename_disablesPeriodicSyncsForOldAccount() = runTest {
accountRepository.rename(account.name, newName)
for (dataType in SyncDataType.entries)
coVerify(exactly = 1) {
syncWorkerManager.disablePeriodic(account, dataType)
}
}
@Test
fun testRename_updatesAccountNameReferencesInDatabase() = runTest {
accountRepository.rename(account.name, newName)
coVerify { serviceRepository.renameAccount(account.name, newName) }
}
@Test
fun testRename_updatesAddressBooks() = runTest {
accountRepository.rename(account.name, newName)
val newAccount = accountRepository.fromName(newName)
coVerify { localAddressBookStore.updateAccount(account, newAccount, any()) }
}
@Test
fun testRename_updatesCalendarEvents() = runTest {
accountRepository.rename(account.name, newName)
val newAccount = accountRepository.fromName(newName)
coVerify { localCalendarStore.updateAccount(account, newAccount, any()) }
}
@Test
fun testRename_updatesAccountNameOfLocalTasks() = runTest {
val mockDataStore = mockk<LocalDataStore<*>>(relaxed = true)
every { tasksAppManager.getDataStore() } returns mockDataStore
accountRepository.rename(account.name, newName)
val newAccount = accountRepository.fromName(newName)
coVerify { mockDataStore.updateAccount(account, newAccount, any()) }
}
@Test
fun testRename_updatesAutomaticSync() = runTest {
accountRepository.rename(account.name, newName)
val newAccount = accountRepository.fromName(newName)
coVerify { automaticSyncManager.updateAutomaticSync(newAccount) }
}
@Test
fun testRename_releasesAccountsCleanupWorkerMutex() = runTest {
accountRepository.rename(account.name, newName)
verify { AccountsCleanupWorker.lockAccountsCleanup() }
coVerify { serviceRepository.renameAccount(account.name, newName) }
}
}

View File

@@ -1,112 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.net.Uri
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.test.InitCalendarProviderRule
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
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 org.junit.rules.TestRule
import javax.inject.Inject
@HiltAndroidTest
class LocalCalendarStoreTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localCalendarStore: LocalCalendarStore
private lateinit var provider: ContentProviderClient
private lateinit var account: Account
private lateinit var calendarUri: Uri
@Before
fun setUp() {
hiltRule.inject()
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
account = TestAccount.create(accountName = "InitialAccountName")
calendarUri = createCalendarForAccount(account)
}
@After
fun tearDown() {
provider.delete(calendarUri, null, null)
TestAccount.remove(account)
provider.closeCompat()
}
@Test
fun testUpdateAccount_updatesOwnerAccount() {
// Verify initial state (assume to skip and prevent flaky test failures)
Assume.assumeTrue("InitialAccountName" == getOwnerAccount())
// Rename account
val oldAccount = account
account = TestAccount.rename(account, "ChangedAccountName")
// Update account name in local calendar
localCalendarStore.updateAccount(oldAccount, account, provider)
// Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated
assertEquals("ChangedAccountName", getOwnerAccount())
}
// helpers
private fun createCalendarForAccount(account: Account): Uri =
provider.insert(
Calendars.CONTENT_URI.asSyncAdapter(account),
contentValuesOf(
Calendars.ACCOUNT_NAME to account.name,
Calendars.ACCOUNT_TYPE to account.type,
Calendars.OWNER_ACCOUNT to account.name,
Calendars.VISIBLE to 1,
Calendars.SYNC_EVENTS to 1,
Calendars._SYNC_ID to 999,
Calendars.CALENDAR_DISPLAY_NAME to "displayName",
)
)!!.asSyncAdapter(account)
private fun getOwnerAccount(): String {
provider.query(
calendarUri,
arrayOf(Calendars.OWNER_ACCOUNT),
"${Calendars.ACCOUNT_NAME}=?",
arrayOf(account.name),
null
)!!.use { cursor ->
cursor.moveToNext()
return cursor.getString(0)
}
}
}

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Entity
import android.provider.CalendarContract
@@ -13,15 +14,22 @@ 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.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.EventsContract
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.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
@@ -65,6 +73,93 @@ class LocalCalendarTest {
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
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
// set event as dirty
client.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 now marked as deleted
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(1, cursor.getInt(0))
}
}
@Test
// Needs InitCalendarProviderRule
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
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)
// set event as dirty
client.update(eventUrl, contentValuesOf(
Events.DIRTY to 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 ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
/**
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
* @param contentValues values to set on the event. Required:
@@ -72,16 +167,15 @@ class LocalCalendarTest {
* - [Events.DIRTY]
*/
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
val entity = Entity(
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",
EventsContract.COLUMN_FLAGS to 123
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
)
val id = androidCalendar.addEvent(entity)
))
calendar.removeNotDirtyMarked(123)
@@ -116,13 +210,13 @@ class LocalCalendarTest {
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
EventsContract.COLUMN_FLAGS to 123
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
val updated = calendar.markNotDirty(321)
assertEquals(1, updated)
assertEquals(321, androidCalendar.getEvent(id)?.entityValues?.getAsInteger(EventsContract.COLUMN_FLAGS))
assertEquals(321, androidCalendar.getEvent(id)?.flags)
}
@Test

View File

@@ -0,0 +1,265 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.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.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.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.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
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()))
}
@After
fun tearDown() {
calendar.androidCalendar.delete()
client.closeCompat()
}
@Test
fun testPrepareForUpload_NoUid() {
// create event
val event = Event().apply {
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")!!
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
// throws an exception if fileName is not an UUID
UUID.fromString(fileName)
// UID in calendar storage should be the same as file name
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(fileName, cursor.getString(0))
}
}
@Test
fun testPrepareForUpload_NormalUid() {
// create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
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")!!
// prepare for upload - this should use the UID for the file name
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
assertEquals(event.uid, fileName)
// UID in calendar storage should still be set, too
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(fileName, cursor.getString(0))
}
}
@Test
fun testPrepareForUpload_UidHasDangerousChars() {
// create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
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")!!
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
// throws an exception if fileName is not an UUID
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(event.uid, cursor.getString(0))
}
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
// TODO
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
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!!
// set event as dirty
client.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 now marked as deleted
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(1, cursor.getInt(0))
}
}
@Test
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
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 eventId = localEvent.id!!
// set event as dirty
client.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(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
}

View File

@@ -21,11 +21,15 @@ import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.util.Optional
@@ -34,34 +38,22 @@ import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
lateinit var provider: ContentProviderClient
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Before
fun setUp() {
fun setup() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@After
fun tearDown() {
provider.close()
}
@Test
fun testApplyPendingMemberships_addPendingMembership() {
@@ -154,6 +146,7 @@ class LocalGroupTest {
}
}
@Test
fun testClearDirty_addCachedGroupMembership() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
@@ -221,6 +214,7 @@ class LocalGroupTest {
}
}
@Test
fun testMarkMembersDirty() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
@@ -240,11 +234,17 @@ class LocalGroupTest {
}
}
@Test
fun testUpdate() {
localTestAddressBookProvider.provide(account, provider) { ab ->
fun testPrepareForUpload() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
group.update(Contact(displayName = "New Group Name"), null, null, null, 0)
assertNull(group.getContact().uid)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
}
}
@@ -260,4 +260,27 @@ class LocalGroupTest {
add()
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -8,14 +8,13 @@ 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.HttpClientBuilder
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.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -42,7 +41,7 @@ class CollectionsWithoutHomeSetRefresherTest {
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -54,7 +53,7 @@ class CollectionsWithoutHomeSetRefresherTest {
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: OkHttpClient
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@@ -81,6 +80,7 @@ class CollectionsWithoutHomeSetRefresherTest {
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@@ -102,7 +102,7 @@ class CollectionsWithoutHomeSetRefresherTest {
)
// Refresh
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got updated - with display name and description
assertEquals(
@@ -135,7 +135,7 @@ class CollectionsWithoutHomeSetRefresherTest {
)
// Refresh - should delete collection
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
@@ -157,7 +157,7 @@ class CollectionsWithoutHomeSetRefresherTest {
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)

View File

@@ -5,16 +5,14 @@
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.property.carddav.CardDAV
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -51,7 +49,7 @@ class DavResourceFinderTest {
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -60,7 +58,7 @@ class DavResourceFinderTest {
lateinit var resourceFinderFactory: DavResourceFinder.Factory
private lateinit var server: MockWebServer
private lateinit var client: OkHttpClient
private lateinit var client: HttpClient
private lateinit var finder: DavResourceFinder
@Before
@@ -72,9 +70,9 @@ class DavResourceFinderTest {
start()
}
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
client = httpClientBuilder
.authenticate(domain = null, getCredentials = { credentials })
.authenticate(host = null, getCredentials = { credentials })
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
@@ -84,6 +82,7 @@ class DavResourceFinderTest {
@After
fun tearDown() {
client.close()
server.shutdown()
}
@@ -92,9 +91,9 @@ class DavResourceFinderTest {
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
finder.scanResponse(CardDAV.Addressbook, response, info)
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
}
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
@@ -102,9 +101,9 @@ class DavResourceFinderTest {
// recognize address book
info = ServiceInfo()
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, WebDAV.ResourceType) { response, _ ->
finder.scanResponse(CardDAV.Addressbook, response, info)
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME) { response, _ ->
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
}
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())

View File

@@ -9,7 +9,7 @@ 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.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
@@ -21,7 +21,6 @@ import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -48,7 +47,7 @@ class HomeSetRefresherTest {
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -60,7 +59,7 @@ class HomeSetRefresherTest {
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: OkHttpClient
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@@ -87,6 +86,7 @@ class HomeSetRefresherTest {
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@@ -101,7 +101,7 @@ class HomeSetRefresherTest {
)
// Refresh
homeSetRefresherFactory.create(service, client)
homeSetRefresherFactory.create(service, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
@@ -137,7 +137,7 @@ class HomeSetRefresherTest {
)
// Refresh
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
@@ -174,7 +174,7 @@ class HomeSetRefresherTest {
)
// Refresh
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
@@ -214,7 +214,7 @@ class HomeSetRefresherTest {
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
@@ -241,7 +241,7 @@ class HomeSetRefresherTest {
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
@@ -278,7 +278,7 @@ class HomeSetRefresherTest {
)
)
val refresher = homeSetRefresherFactory.create(service, client)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@@ -303,7 +303,7 @@ class HomeSetRefresherTest {
)
)
val refresher = homeSetRefresherFactory.create(service, client)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@@ -330,7 +330,7 @@ class HomeSetRefresherTest {
)
)
val refresher = homeSetRefresherFactory.create(service, client)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@@ -355,7 +355,7 @@ class HomeSetRefresherTest {
)
)
val refresher = homeSetRefresherFactory.create(service, client)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@@ -380,7 +380,7 @@ class HomeSetRefresherTest {
)
)
val refresher = homeSetRefresherFactory.create(service, client)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@@ -407,7 +407,7 @@ class HomeSetRefresherTest {
)
)
val refresher = homeSetRefresherFactory.create(service, client)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}

View File

@@ -9,7 +9,7 @@ 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.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
@@ -17,7 +17,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertEquals
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -37,7 +36,7 @@ class PrincipalsRefresherTest {
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -55,7 +54,7 @@ class PrincipalsRefresherTest {
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: OkHttpClient
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@@ -82,6 +81,7 @@ class PrincipalsRefresherTest {
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@@ -110,7 +110,7 @@ class PrincipalsRefresherTest {
)
// Refresh principals
principalsRefresher.create(service, client).refreshPrincipals()
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
@@ -143,7 +143,7 @@ class PrincipalsRefresherTest {
)
// Refresh principals
principalsRefresher.create(service, client).refreshPrincipals()
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
@@ -164,7 +164,7 @@ class PrincipalsRefresherTest {
)
// Refresh principals - detecting it does not own collections
principalsRefresher.create(service, client).refreshPrincipals()
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)

View File

@@ -7,10 +7,9 @@ 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.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -34,7 +33,7 @@ class ServiceRefresherTest {
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -42,7 +41,7 @@ class ServiceRefresherTest {
@Inject
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
private lateinit var client: OkHttpClient
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@@ -69,6 +68,7 @@ class ServiceRefresherTest {
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@@ -78,7 +78,7 @@ class ServiceRefresherTest {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
serviceRefresherFactory.create(service, client)
serviceRefresherFactory.create(service, client.okHttpClient)
.discoverHomesets(baseUrl)
// Check home set has been saved correctly to database

View File

@@ -1,149 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assume
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration21Test {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var migration: AccountSettingsMigration21
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
lateinit var account: Account
val authority = CalendarContract.AUTHORITY
private val inPendingState = MutableStateFlow(false)
private var statusChangeListener: Any? = null
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
// Enable sync globally and for the test account
ContentResolver.setIsSyncable(account, authority, 1)
// Start hot flow
registerSyncStateObserver()
}
@After
fun tearDown() {
unregisterSyncStateObserver()
TestAccount.remove(account)
}
@SdkSuppress(minSdkVersion = 34)
@Test
fun testCancelsSyncAndClearsPendingState() = runBlocking {
// Move into forever pending state
ContentResolver.requestSync(syncRequest())
// Wait until we are in forever pending state (with timeout)
withTimeout(10_000) {
inPendingState.first { it }
}
// Assume that we are now in the forever pending state (Skips test otherwise)
Assume.assumeTrue(ContentResolver.isSyncPending(account, authority))
// Run the migration which should cancel the forever pending sync for all accounts
migration.migrate(account)
// Wait for the state to change (with timeout)
withTimeout(10_000) {
inPendingState.first { !it }
}
// Check the sync is now not pending anymore
assertFalse(ContentResolver.isSyncPending(account, authority))
}
// 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()
private fun registerSyncStateObserver() {
// listener pushes updates immediately when sync status changes
statusChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
) {
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
}
// Emit initial state
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
}
private fun unregisterSyncStateObserver() {
statusChangeListener?.let { ContentResolver.removeStatusChangeListener(it) }
}
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

@@ -6,20 +6,22 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.content.SyncStatusObserver
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.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
@@ -31,11 +33,18 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
class AndroidSyncFrameworkTest: SyncStatusObserver {
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
@@ -59,7 +68,7 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
onStatusChanged(0) // record first entry (pending = false, active = false)
stateChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
this
::onStatusChanged
)
}
@@ -88,11 +97,11 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
)
}
/* SHOULD BE FIXED WITH https://github.com/bitfireAT/davx5-ose/issues/1748
/**
* 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 */)
@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
@Test
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
verifySyncStates(
@@ -103,7 +112,7 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
State(pending = true, active = false) // ... and finishes, but stays pending
)
)
}*/
}
// helpers
@@ -120,10 +129,6 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
* Verifies that the given expected states match the recorded states.
*/
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
// Verify that last state is non-optional.
if (expectedStates.last().optional)
throw IllegalArgumentException("Last expected state must not be optional")
// 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.
@@ -138,60 +143,47 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
while (recordedStates.size < expectedStates.size) {
// verify already known states
if (recordedStates.isNotEmpty())
assertStatesEqual(expectedStates, recordedStates, fullMatch = false)
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
delay(500) // avoid busy-waiting
}
assertStatesEqual(expectedStates, recordedStates, fullMatch = true)
assertStatesEqual(expectedStates, recordedStates)
}
}
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean) {
assertTrue("Expected states=$expectedStates, actual=$actualStates", statesMatch(expectedStates, actualStates, fullMatch))
}
/**
* Checks whether [actualStates] have matching [expectedStates], under the condition
* Asserts whether [actualStates] and [expectedStates] are the same, under the condition
* that expected states with the [State.optional] flag can be skipped.
*
* Note: When [fullMatch] is not set, this method can return _true_ even if not all expected states are used.
*
* @param expectedStates expected states (can include optional states which don't have to be present in actual states)
* @param actualStates actual states
* @param fullMatch whether all non-optional expected states must be present in actual states
*/
private fun statesMatch(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean): Boolean {
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())
return false
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())
return false
fail()
expected = expectedIterator.next()
}
// we now have a non-optional expected state and it must match
if (!actual.stateEquals(expected))
return false
fail()
}
// full match: all expected states must have been used
if (fullMatch && expectedIterator.hasNext())
return false
return true
}
// SyncStatusObserver implementation and data class
override fun onStatusChanged(which: Int) {
fun onStatusChanged(which: Int) {
val state = State(
pending = ContentResolver.isSyncPending(account, authority),
active = ContentResolver.isSyncActive(account, authority)

View File

@@ -1,151 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.Entity
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.sync.account.TestAccount
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.EventAndExceptions
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.mockk
import okio.Buffer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class CalendarSyncManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionsRule = GrantPermissionRule.grant(
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
)
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
@Inject
lateinit var syncManagerFactory: CalendarSyncManager.Factory
lateinit var account: Account
lateinit var providerClient: ContentProviderClient
lateinit var androidCalendar: AndroidCalendar
lateinit var localCalendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
providerClient = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
// create LocalCalendar
val androidCalendarProvider = AndroidCalendarProvider(account, providerClient)
androidCalendar = androidCalendarProvider.createAndGetCalendar(contentValuesOf(
Calendars.NAME to "Sample Calendar"
))
localCalendar = localCalendarFactory.create(androidCalendar)
}
@After
fun tearDown() {
localCalendar.androidCalendar.delete()
providerClient.closeCompat()
TestAccount.remove(account)
}
@Test
fun test_generateUpload_existingUid() {
val result = syncManager().generateUpload(LocalEvent(
localCalendar.recurringCalendar,
EventAndExceptions(
main = Entity(contentValuesOf(
Events._ID to 1,
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.UID_2445 to "existing-uid"
)),
exceptions = emptyList()
)
))
assertEquals("existing-uid.ics", result.suggestedFileName)
val iCal = Buffer().also {
result.requestBody.writeTo(it)
}.readString(Charsets.UTF_8)
assertTrue(iCal.contains("UID:existing-uid\r\n"))
}
@Test
fun generateUpload_noUid() {
val result = syncManager().generateUpload(LocalEvent(
localCalendar.recurringCalendar,
EventAndExceptions(
main = Entity(contentValuesOf(
Events._ID to 2,
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis()
)),
exceptions = emptyList()
)
))
assertTrue(result.suggestedFileName.matches(UUID_FILENAME_REGEX))
val uuid = result.suggestedFileName.removeSuffix(".ics")
val iCal = Buffer().also {
result.requestBody.writeTo(it)
}.readString(Charsets.UTF_8)
assertTrue(iCal.contains("UID:$uuid\r\n"))
}
// helpers
private fun syncManager() = syncManagerFactory.calendarSyncManager(
account = account,
httpClient = mockk(),
syncResult = mockk(),
localCalendar = mockk(),
collection = mockk(),
resync = mockk()
)
companion object {
val UUID_FILENAME_REGEX = "^[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}\\.ics$".toRegex()
}
}

View File

@@ -9,7 +9,7 @@ import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
@@ -46,7 +46,7 @@ class JtxSyncManagerTest {
lateinit var context: Context
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var serviceRepository: DavServiceRepository

View File

@@ -4,11 +4,10 @@
package at.bitfire.davdroid.sync
import android.content.Context
import at.bitfire.davdroid.resource.LocalResource
import java.util.Optional
class LocalTestResource: LocalResource {
class LocalTestResource: LocalResource<Any> {
override val id: Long? = null
override var fileName: String? = null
@@ -19,6 +18,8 @@ class LocalTestResource: LocalResource {
var deleted = false
var dirty = false
override fun prepareForUpload() = "generated-file.txt"
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
dirty = false
if (fileName.isPresent)
@@ -31,14 +32,8 @@ class LocalTestResource: LocalResource {
this.flags = flags
}
override fun updateUid(uid: String) { /* no-op */ }
override fun updateSequence(sequence: Int) = throw NotImplementedError()
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
override fun deleteLocal() = throw NotImplementedError()
override fun resetDeleted() = throw NotImplementedError()
override fun getDebugSummary() = "Test Resource"
override fun getViewUri(context: Context) = null
}

View File

@@ -1,109 +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 at.bitfire.dav4jvm.HttpUtils.toKtorUrl
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.ktor.http.HttpHeaders
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.InetAddress
import javax.inject.Inject
@HiltAndroidTest
class ResourceDownloaderTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
lateinit var resourceDownloaderFactory: ResourceDownloader.Factory
lateinit var account: Account
lateinit var server: MockWebServer
@Before
fun setUp() {
hiltRule.inject()
server = MockWebServer().apply {
start()
}
account = TestAccount.create()
// add credentials to test account so that we can check whether they have been sent
val settings = accountSettingsFactory.create(account)
settings.credentials(Credentials("test", "test".toSensitiveString()))
}
@After
fun tearDown() {
TestAccount.remove(account)
server.close()
}
@Test
fun testDownload_ExternalDomain() = runTest {
val baseUrl = server.url("/")
// URL should be http://localhost, replace with http://127.0.0.1 to have other domain
Assume.assumeTrue(baseUrl.host == "localhost")
val baseUrlIp = baseUrl.newBuilder()
.host(InetAddress.getByName(baseUrl.host).hostAddress!!)
.build()
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("TEST"))
val downloader = resourceDownloaderFactory.create(account, baseUrl.host)
val result = downloader.download(baseUrlIp.toKtorUrl())
// authentication was NOT sent because request is not for original domain
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
assertNull(sentAuth)
// and result is OK
assertArrayEquals("TEST".toByteArray(), result)
}
@Test
fun testDownload_SameDomain() = runTest {
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("TEST"))
val baseUrl = server.url("/")
val downloader = resourceDownloaderFactory.create(account, baseUrl.host)
val result = downloader.download(baseUrl.toKtorUrl())
// authentication was sent
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
assertEquals("Basic dGVzdDp0ZXN0", sentAuth)
// and result is OK
assertArrayEquals("TEST".toByteArray(), result)
}
}

View File

@@ -8,14 +8,14 @@ import android.accounts.Account
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
import at.bitfire.dav4jvm.okhttp.PropStat
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.Response.HrefRelation
import at.bitfire.dav4jvm.PropStat
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
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.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
@@ -59,7 +59,7 @@ class SyncManagerTest {
lateinit var context: Context
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var syncManagerFactory: TestSyncManager.Factory

View File

@@ -194,7 +194,7 @@ class SyncerTest {
}
override fun create(
client: ContentProviderClient,
provider: ContentProviderClient,
fromCollection: Collection
): LocalTestCollection? {
throw NotImplementedError()
@@ -202,13 +202,13 @@ class SyncerTest {
override fun getAll(
account: Account,
client: ContentProviderClient
provider: ContentProviderClient
): List<LocalTestCollection> {
throw NotImplementedError()
}
override fun update(
client: ContentProviderClient,
provider: ContentProviderClient,
localCollection: LocalTestCollection,
fromCollection: Collection
) {
@@ -219,7 +219,7 @@ class SyncerTest {
throw NotImplementedError()
}
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
override fun updateAccount(oldAccount: Account, newAccount: Account) {
throw NotImplementedError()
}

View File

@@ -5,13 +5,13 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import at.bitfire.dav4jvm.okhttp.DavCollection
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.DavCollection
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.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
@@ -20,13 +20,13 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Assert.assertEquals
class TestSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted httpClient: OkHttpClient,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted localCollection: LocalTestCollection,
@Assisted collection: Collection,
@@ -46,7 +46,7 @@ class TestSyncManager @AssistedInject constructor(
interface Factory {
fun create(
account: Account,
httpClient: OkHttpClient,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
collection: Collection
@@ -54,7 +54,7 @@ class TestSyncManager @AssistedInject constructor(
}
override fun prepare(): Boolean {
davCollection = DavCollection(httpClient, collection.url)
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
return true
}
@@ -65,7 +65,7 @@ class TestSyncManager @AssistedInject constructor(
didQueryCapabilities = true
var cTag: SyncState? = null
davCollection.propfind(0, CalDAV.GetCTag) { response, rel ->
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
if (rel == Response.HrefRelation.SELF)
response[GetCTag::class.java]?.cTag?.let {
cTag = SyncState(SyncState.Type.CTAG, it)
@@ -76,13 +76,9 @@ class TestSyncManager @AssistedInject constructor(
}
var didGenerateUpload = false
override fun generateUpload(resource: LocalTestResource): GeneratedResource {
override fun generateUpload(resource: LocalTestResource): RequestBody {
didGenerateUpload = true
return GeneratedResource(
suggestedFileName = resource.fileName ?: "generated-file.txt",
requestBody = resource.toString().toRequestBody(),
onSuccessContext = GeneratedResource.OnSuccessContext()
)
return resource.toString().toRequestBody()
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT

View File

@@ -8,8 +8,6 @@ import android.accounts.AccountManager
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount.remove
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
object TestAccount {
@@ -21,9 +19,9 @@ object TestAccount {
*
* Remove it with [remove].
*/
fun create(version: Int = AccountSettings.CURRENT_VERSION, accountName: String = "Test Account"): Account {
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
val accountType = targetContext.getString(R.string.account_type)
val account = Account(accountName, accountType)
val account = Account("Test Account", accountType)
val initialData = AccountSettings.initialUserData(null)
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
@@ -32,16 +30,6 @@ object TestAccount {
return account
}
/**
* Renames a test account in a blocking way (usually what you want in tests)
*/
fun rename(account: Account, newName: String): Account {
val am = AccountManager.get(targetContext)
val newAccount = am.renameAccount(account, newName, null, null).result
assertEquals(newName, newAccount.name)
return newAccount
}
/**
* Removes a test account, usually in the `@After` tearDown of a test.
*/

View File

@@ -19,7 +19,7 @@ class DebugInfoActivityTest {
val expected = StringBuilder(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE)
expected.append(String(ByteArray(DebugInfoActivity.IntentBuilder.MAX_ELEMENT_SIZE - 3) { a }))
expected.append("...")
assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY))
assertEquals(expected.toString(), intent.getStringExtra("localResource"))
}
@Test

View File

@@ -21,7 +21,7 @@ class LoginActivityTest {
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.asString())
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
@@ -34,7 +34,7 @@ class LoginActivityTest {
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.asString())
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
@@ -43,7 +43,7 @@ class LoginActivityTest {
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.asString())
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
@@ -52,7 +52,7 @@ class LoginActivityTest {
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.asString())
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
@@ -61,7 +61,7 @@ class LoginActivityTest {
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals(null, loginInfo.baseUri)
assertEquals("user@example.com", loginInfo.credentials!!.username)
assertEquals(null, loginInfo.credentials.password?.asString())
assertEquals(null, loginInfo.credentials.password?.concatToString())
}
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
@@ -31,8 +30,8 @@ class CredentialsStoreTest {
@Test
fun testSetGetDelete() {
store.setCredentials(0, Credentials(username = "myname", password = "12345".toSensitiveString()))
assertEquals(Credentials(username = "myname", password = "12345".toSensitiveString()), store.getCredentials(0))
store.setCredentials(0, Credentials(username = "myname", password = "12345".toCharArray()))
assertEquals(Credentials(username = "myname", password = "12345".toCharArray()), store.getCredentials(0))
store.setCredentials(0, null)
assertNull(store.getCredentials(0))

View File

@@ -9,14 +9,13 @@ import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
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.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -49,13 +48,13 @@ class QueryChildDocumentsOperationTest {
lateinit var operation: QueryChildDocumentsOperation
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var testDispatcher: TestDispatcher
private lateinit var server: MockWebServer
private lateinit var client: OkHttpClient
private lateinit var client: HttpClient
private lateinit var mount: WebDavMount
private lateinit var rootDocument: WebDavDocument
@@ -85,6 +84,7 @@ class QueryChildDocumentsOperationTest {
@After
fun tearDown() {
client.close()
server.shutdown()
runBlocking {

View File

@@ -100,7 +100,7 @@
<activity
android:name=".ui.DebugInfoActivity"
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="false"
android:exported="true"
android:label="@string/debug_info_title">
<intent-filter>
<action android:name="android.intent.action.BUG_REPORT"/>

View File

@@ -7,14 +7,13 @@ package at.bitfire.davdroid
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import at.bitfire.davdroid.di.DefaultDispatcher
import at.bitfire.davdroid.log.LogManager
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.ui.UiUtils
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.logging.Logger
@@ -32,10 +31,6 @@ class App: Application(), Configuration.Provider {
@Inject
lateinit var logManager: LogManager
@Inject
@DefaultDispatcher
lateinit var defaultDispatcher: CoroutineDispatcher
@Inject
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
@@ -65,7 +60,7 @@ class App: Application(), Configuration.Provider {
// don't block UI for some background checks
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(defaultDispatcher) {
GlobalScope.launch(Dispatchers.Default) {
// clean up orphaned accounts in DB from time to time
AccountsCleanupWorker.enable(this@App)

View File

@@ -5,6 +5,7 @@ package at.bitfire.davdroid
import at.bitfire.synctools.icalendar.ical4jVersion
import ezvcard.Ezvcard
import net.fortuna.ical4j.model.property.ProdId
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
@@ -16,7 +17,7 @@ object Constants {
// product IDs for iCalendar/vCard
val iCalProdId = "DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion"
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
}

View File

@@ -10,9 +10,8 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
@@ -20,7 +19,6 @@ 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.CardDAV
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.push.WebPush
@@ -168,9 +166,9 @@ data class Collection(
val url = UrlUtils.withTrailingSlash(dav.href)
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
when {
resourceType.types.contains(CardDAV.Addressbook) -> TYPE_ADDRESSBOOK
resourceType.types.contains(CalDAV.Calendar) -> TYPE_CALENDAR
resourceType.types.contains(CalDAV.Subscribed) -> TYPE_WEBCAL
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
else -> null
}
} ?: return null

View File

@@ -8,11 +8,10 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.util.trimToNull
import okhttp3.HttpUrl
@@ -47,7 +46,7 @@ data class Principal(
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
// Check if response is a principal
val resourceType = dav[ResourceType::class.java] ?: return null
if (!resourceType.types.contains(WebDAV.Principal))
if (!resourceType.types.contains(ResourceType.PRINCIPAL))
return null
// Try getting the display name of the principal

View File

@@ -48,20 +48,11 @@ interface PrincipalDao {
* @param principal Principal to be inserted or updated
* @return ID of the newly inserted or already existing principal
*/
fun insertOrUpdate(serviceId: Long, principal: Principal): Long {
// Try to get existing principal by URL
val oldPrincipal = getByUrl(serviceId, principal.url)
// Insert new principal if not existing
if (oldPrincipal == null)
return insert(principal)
// Otherwise update the existing principal
if (principal.displayName != oldPrincipal.displayName)
update(principal.copy(id = oldPrincipal.id))
// In any case return the id of the principal
return oldPrincipal.id
}
fun insertOrUpdate(serviceId: Long, principal: Principal): Long =
getByUrl(serviceId, principal.url)?.let { oldPrincipal ->
if (principal.displayName != oldPrincipal.displayName)
update(principal.copy(id = oldPrincipal.id))
return oldPrincipal.id
} ?: insert(principal)
}

View File

@@ -39,10 +39,6 @@ import javax.inject.Singleton
*
* When using the global logger, the class name of the logging calls will still be logged, so there's
* no need to always get a separate logger for each class (only if the class wants to customize it).
*
* Note about choosing log levels: records with [Level.FINE] or higher will always be printed to adb logs
* (regardless of whether verbose logging is active). Records with a lower level will only be
* printed to adb logs when verbose logging is active.
*/
@Singleton
class LogManager @Inject constructor(
@@ -83,10 +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 // include everything (including HTTP interceptor logs) in verbose logs
else
Level.FINE // include detailed information like content provider operations in non-verbose logs
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
// log to file, if requested

View File

@@ -6,31 +6,22 @@ package at.bitfire.davdroid.network
import android.content.Context
import android.security.KeyChain
import android.security.KeyChainException
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.logging.Level
import java.util.logging.Logger
import javax.net.ssl.X509ExtendedKeyManager
/**
* KeyManager that provides a client certificate and private key from the Android [KeyChain].
* KeyManager that provides a client certificate and private key from the Android KeyChain.
*
* Requests for certificates / private keys for other aliases than the specified one
* will be ignored.
*
* @param alias alias of the desired certificate / private key
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
*/
class ClientCertKeyManager @AssistedInject constructor(
@Assisted private val alias: String,
@ApplicationContext private val context: Context,
private val logger: Logger
@ApplicationContext private val context: Context
): X509ExtendedKeyManager() {
@AssistedFactory
@@ -38,42 +29,19 @@ class ClientCertKeyManager @AssistedInject constructor(
fun create(alias: String): ClientCertKeyManager
}
val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) = arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) = alias
override fun getCertificateChain(forAlias: String): Array<X509Certificate>? {
if (forAlias != alias)
return null
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
return try {
KeyChain.getCertificateChain(context, alias).also { result ->
if (result == null)
logger.warning("Couldn't obtain certificate chain for alias $alias")
}
} catch (e: KeyChainException) {
// Android <Q throws an exception instead of returning null
logger.log(Level.WARNING, "Couldn't obtain certificate chain for alias $alias", e)
null
}
}
override fun getPrivateKey(forAlias: String): PrivateKey? {
if (forAlias != alias)
return null
return try {
KeyChain.getPrivateKey(context, alias).also { result ->
if (result == null)
logger.log(Level.WARNING, "Couldn't obtain private key for alias $alias")
}
} catch (e: KeyChainException) {
// Android <Q throws an exception instead of returning null
logger.log(Level.WARNING, "Couldn't obtain private key for alias $alias", e)
null
}
}
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}

View File

@@ -1,52 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.annotation.VisibleForTesting
import org.conscrypt.Conscrypt
import java.security.Security
import java.util.logging.Logger
import javax.net.ssl.SSLContext
/**
* Integration with the Conscrypt library that provides recent TLS versions and ciphers,
* regardless of the device Android version.
*/
class ConscryptIntegration {
private val logger
get() = Logger.getLogger(javaClass.name)
private var initialized = false
/**
* Loads and initializes Conscrypt (if not already done). Safe to be called multiple times.
*/
fun initialize() {
synchronized(ConscryptIntegration::javaClass) {
if (initialized)
return
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
// install Conscrypt as most preferred provider
Security.insertProviderAt(Conscrypt.newProvider(), 1)
val version = Conscrypt.version()
logger.info("Using Conscrypt/${version.major()}.${version.minor()}.${version.patch()} for TLS")
val engine = SSLContext.getDefault().createSSLEngine()
logger.info("Enabled protocols: ${engine.enabledProtocols.joinToString(", ")}")
logger.info("Enabled ciphers: ${engine.enabledCipherSuites.joinToString(", ")}")
}
initialized = true
}
}
@VisibleForTesting
internal fun conscryptInstalled() =
Security.getProviders().any { Conscrypt.isConscrypt(it) }
}

View File

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

View File

@@ -1,415 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.accounts.Account
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.cert4android.CustomCertStore
import at.bitfire.dav4jvm.okhttp.BasicDigestAuthHandler
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.ForegroundTracker
import com.google.common.net.HttpHeaders
import com.google.errorprone.annotations.MustBeClosed
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import okhttp3.Authenticator
import okhttp3.ConnectionSpec
import okhttp3.CookieJar
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.net.InetSocketAddress
import java.net.Proxy
import java.security.KeyStore
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* Builder for the HTTP client.
*
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
* there's only one [HttpClientBuilder] object and setting properties from one location would influence the others.
*
* To generate multiple clients, inject and use `Provider<HttpClientBuilder>` instead.
*/
class HttpClientBuilder @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext private val context: Context,
defaultLogger: Logger,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val keyManagerFactory: ClientCertKeyManager.Factory,
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
private val settingsManager: SettingsManager
) {
companion object {
init {
// make sure Conscrypt is available when the HttpClientBuilder class is loaded the first time
ConscryptIntegration().initialize()
}
}
/**
* Flag to prevent multiple [build] calls
*/
var alreadyBuilt = false
// property setters/getters
private var logger: Logger = defaultLogger
fun setLogger(logger: Logger): HttpClientBuilder {
this.logger = logger
return this
}
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): HttpClientBuilder {
loggerInterceptorLevel = level
return this
}
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
private var cookieStore: CookieJar = MemoryCookieStore()
fun setCookieStore(cookieStore: CookieJar): HttpClientBuilder {
this.cookieStore = cookieStore
return this
}
private var authenticationInterceptor: Interceptor? = null
private var authenticator: Authenticator? = null
private var certificateAlias: String? = null
fun authenticate(domain: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder {
val credentials = getCredentials()
if (credentials.authState != null) {
// OAuth
authenticationInterceptor = oAuthInterceptorFactory.create(
readAuthState = {
// We don't use the "credentials" object from above because it may contain an outdated access token
// when readAuthState is called. Instead, we fetch the up-to-date auth-state.
getCredentials().authState
},
writeAuthState = { authState ->
updateAuthState?.invoke(authState)
}
)
} else if (credentials.username != null && credentials.password != null) {
// basic/digest auth
val authHandler = BasicDigestAuthHandler(
domain = domain,
username = credentials.username,
password = credentials.password.asCharArray(),
insecurePreemptive = true
)
authenticationInterceptor = authHandler
authenticator = authHandler
}
// client certificate
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
return this
}
private var followRedirects = false
fun followRedirects(follow: Boolean): HttpClientBuilder {
followRedirects = follow
return this
}
// convenience builders from other classes
/**
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
*
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
*
* @param account the account to take authentication from
* @param authDomain (optional) Send credentials only for the hosts of the given domain. Can be:
*
* - a full host name (`caldav.example.com`): then credentials are only sent for the domain of that host name (`example.com`), or
* - a domain name (`example.com`): then credentials are only sent for the given domain, or
* - or _null_: then credentials are always sent, regardless of the resource host name.
*
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
*/
@WorkerThread
fun fromAccount(account: Account, authDomain: String? = null): HttpClientBuilder {
val accountSettings = accountSettingsFactory.create(account)
authenticate(
domain = UrlUtils.hostToDomain(authDomain),
getCredentials = {
accountSettings.credentials()
},
updateAuthState = { authState ->
accountSettings.updateAuthState(authState)
}
)
return this
}
/**
* Same as [fromAccount], but can be called on any thread.
*
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
*/
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): HttpClientBuilder = withContext(ioDispatcher) {
fromAccount(account, onlyHost)
}
// okhttp builder
/**
* Builds an [OkHttpClient] with the configured settings.
*
* [build] or [buildKtor] is usually called only once because multiple calls indicate this wrong usage pattern:
*
* ```
* val builder = HttpClientBuilder(/*injected*/)
* val client1 = builder.configure().build()
* val client2 = builder.configureOtherwise().build()
* ```
*
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
* which is usually not desired.
*/
fun build(): OkHttpClient {
if (alreadyBuilt)
logger.warning("build() should only be called once; use Provider<HttpClientBuilder> instead")
val builder = OkHttpClient.Builder()
configureOkHttp(builder)
alreadyBuilt = true
return builder.build()
}
private fun configureOkHttp(builder: OkHttpClient.Builder) {
buildTimeouts(builder)
// don't allow redirects by default because it would break PROPFIND handling
builder.followRedirects(followRedirects)
// add User-Agent to every request
builder.addInterceptor(UserAgentInterceptor)
// connection-private cookie store
builder.cookieJar(cookieStore)
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
builder.addInterceptor(BrotliInterceptor)
// app-wide custom proxy support
buildProxy(builder)
// add connection security (including client certificates) and authentication
buildConnectionSecurity(builder)
buildAuthentication(builder)
// 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
builder.addNetworkInterceptor(loggingInterceptor)
}
}
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
// basic/digest auth and OAuth
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
authenticator?.let { okBuilder.authenticator(it) }
}
private fun buildConnectionSecurity(okBuilder: OkHttpClient.Builder) {
// allow cleartext and TLS 1.2+
okBuilder.connectionSpecs(listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.MODERN_TLS
))
// client certificate
val clientKeyManager: KeyManager? = certificateAlias?.let { alias ->
try {
val manager = keyManagerFactory.create(alias)
logger.fine("Using certificate $alias for authentication")
// HTTP/2 doesn't support client certificates (yet)
// see https://datatracker.ietf.org/doc/draft-ietf-httpbis-secondary-server-certs/
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
manager
} catch (e: IllegalArgumentException) {
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
null
}
}
// select trust manager and hostname verifier depending on whether custom certificates are allowed
val customTrustManager: X509TrustManager?
val customHostnameVerifier: HostnameVerifier?
if (BuildConfig.allowCustomCerts) {
// use cert4android for custom certificate handling
customTrustManager = CustomCertManager(
certStore = CustomCertStore.getInstance(context),
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
appInForeground = ForegroundTracker.inForeground
)
// allow users to accept certificates with wrong host names
customHostnameVerifier = customTrustManager.HostnameVerifier(OkHostnameVerifier)
} else {
// no custom certificates, use default trust manager and hostname verifier
customTrustManager = null
customHostnameVerifier = null
}
// change settings only if we have at least only one custom component
if (clientKeyManager != null || customTrustManager != null) {
val trustManager = customTrustManager ?: defaultTrustManager()
// use trust manager and client key manager (if defined) for TLS connections
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
/* km = */ if (clientKeyManager != null) arrayOf(clientKeyManager) else null,
/* tm = */ arrayOf(trustManager),
/* random = */ null
)
okBuilder.sslSocketFactory(sslContext.socketFactory, trustManager)
}
// also add the custom hostname verifier (if defined)
if (customHostnameVerifier != null)
okBuilder.hostnameVerifier(customHostnameVerifier)
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
try {
val proxyTypeValue = settingsManager.getInt(Settings.PROXY_TYPE)
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
// we set our own proxy
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
InetSocketAddress(
settingsManager.getString(Settings.PROXY_HOST),
settingsManager.getInt(Settings.PROXY_PORT)
)
}
val proxy =
when (proxyTypeValue) {
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
else -> throw IllegalArgumentException("Invalid proxy type")
}
okBuilder.proxy(proxy)
logger.log(Level.INFO, "Using proxy setting", proxy)
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
}
/**
* Set timeouts for the connection.
*
* **Note:** According to [android.content.AbstractThreadedSyncAdapter], when there is no network
* traffic within a minute, a sync will be cancelled.
*/
private fun buildTimeouts(builder: OkHttpClient.Builder) {
builder.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
}
// Ktor builder
/**
* Builds a Ktor [HttpClient] with the configured settings.
*
* [buildKtor] or [build] must be called only once because multiple calls indicate this wrong usage pattern:
*
* ```
* val builder = HttpClientBuilder(/*injected*/)
* val client1 = builder.configure().buildKtor()
* val client2 = builder.configureOtherwise().buildKtor()
* ```
*
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
* which is usually not desired.
*
* @return the new HttpClient (with [OkHttp] engine) which **must be closed by the caller**
*/
@MustBeClosed
fun buildKtor(): HttpClient {
if (alreadyBuilt)
logger.warning("buildKtor() should only be called once; use Provider<HttpClientBuilder> instead")
val client = HttpClient(OkHttp) {
// Ktor-level configuration here
// automatically convert JSON from/into data classes (if requested in respective code)
install(ContentNegotiation) {
json()
}
engine {
// okhttp engine configuration here
config {
// OkHttpClient.Builder configuration here
configureOkHttp(this)
}
}
}
alreadyBuilt = true
return client
}
}

View File

@@ -4,28 +4,26 @@
package at.bitfire.davdroid.network
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.ktor.exception.HttpException
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.appendPathSegments
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.http.path
import kotlinx.serialization.Serializable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import javax.inject.Inject
import javax.inject.Provider
/**
* Implements Nextcloud Login Flow v2.
@@ -33,133 +31,8 @@ import javax.inject.Provider
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class NextcloudLoginFlow @Inject constructor(
private val httpClientBuilder: Provider<HttpClientBuilder>
) {
// Login flow state
var pollUrl: Url? = null
var token: String? = null
/**
* Starts Nextcloud Login Flow v2.
*
* @param baseUrl Nextcloud login flow or base URL
*
* @return URL that should be opened in the browser (login screen)
*
* @throws HttpException on non-successful HTTP status
*/
suspend fun start(baseUrl: Url): Url {
// reset fields in case something goes wrong
pollUrl = null
token = null
// POST to login flow URL in order to receive endpoint data
createClient().use { client ->
val result = client.post(loginFlowUrl(baseUrl))
if (!result.status.isSuccess())
throw HttpException.fromResponse(result)
// save endpoint data for polling
val endpointData: EndpointData = result.body()
pollUrl = Url(endpointData.poll.endpoint)
token = endpointData.poll.token
return Url(endpointData.login)
}
}
@VisibleForTesting
internal fun loginFlowUrl(baseUrl: Url): Url {
return when {
// already a Login Flow v2 URL
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
baseUrl
// Login Flow v1 URL, rewrite to v2
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
// drop "[index.php/login]/flow" from the end and append "/v2"
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
val builder = URLBuilder(baseUrl)
builder.path(*v2Segments.toTypedArray())
builder.build()
}
// other URL, make it a Login Flow v2 URL
else ->
URLBuilder(baseUrl)
.appendPathSegments(FLOW_V2_PATH.split('/'))
.build()
}
}
/**
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
*
* @throws HttpException on non-successful HTTP status
*/
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
createClient().use { client ->
val result = client.post(pollUrl) {
contentType(ContentType.Application.FormUrlEncoded)
setBody("token=$token")
}
if (!result.status.isSuccess())
throw HttpException.fromResponse(result)
// make sure server URL ends with a slash so that DAV_PATH can be appended
val loginData: LoginData = result.body()
val serverUrl = loginData.server.withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = loginData.loginName,
password = loginData.appPassword.toSensitiveString()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
}
/**
* Creates a Ktor HTTP client that follows redirects.
*/
private fun createClient(): HttpClient =
httpClientBuilder.get()
.followRedirects(true)
.buildKtor()
/**
* Represents the JSON response that is returned on the first call to `/login/v2`.
*/
@Serializable
private data class EndpointData(
val poll: Poll,
val login: String
) {
@Serializable
data class Poll(
val token: String,
val endpoint: String
)
}
/**
* Represents the JSON response that is returned by the polling endpoint.
*/
@Serializable
private data class LoginData(
val server: String,
val loginName: String,
val appPassword: String
)
httpClientBuilder: HttpClient.Builder
): AutoCloseable {
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
@@ -169,4 +42,97 @@ class NextcloudLoginFlow @Inject constructor(
const val DAV_PATH = "remote.php/dav"
}
val httpClient = httpClientBuilder
.build()
override fun close() {
httpClient.close()
}
// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var token: String? = null
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = null
pollUrl = null
token = null
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
return loginUrl
}
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath
if (path.endsWith(FLOW_V2_PATH))
// already a Login Flow v2 URL
return baseUrl
if (path.endsWith(FLOW_V1_PATH))
// Login Flow v1 URL, rewrite to v2
return baseUrl.newBuilder()
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
.build()
// other URL, make it a Login Flow v2 URL
return baseUrl.newBuilder()
.addPathSegments(FLOW_V2_PATH)
.build()
}
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = json.getString("server").withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword").toCharArray()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.okHttpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
}
}

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.push
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.XmlReader
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.push.WebDAVPush
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
@@ -106,7 +105,7 @@ class PushMessageHandler @Inject constructor(
try {
parser.setInput(StringReader(message))
XmlReader(parser).processTag(WebDAVPush.PushMessage) {
XmlReader(parser).processTag(DavPushMessage.NAME) {
val pushMessage = DavPushMessage.Factory.create(parser)
topic = pushMessage.topic?.topic
}

View File

@@ -11,17 +11,22 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.okhttp.DavCollection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.property.push.WebDAVPush
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.push.AuthSecret
import at.bitfire.dav4jvm.property.push.PushRegister
import at.bitfire.dav4jvm.property.push.PushResource
import at.bitfire.dav4jvm.property.push.Subscription
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
import at.bitfire.dav4jvm.property.push.WebPushSubscription
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
@@ -36,7 +41,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.PushEndpoint
@@ -61,7 +65,7 @@ class PushRegistrationManager @Inject constructor(
private val accountRepository: Lazy<AccountRepository>,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext private val context: Context,
private val httpClientBuilder: Provider<HttpClientBuilder>,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
@@ -176,23 +180,25 @@ class PushRegistrationManager @Inject constructor(
return
val account = accountRepository.get().fromName(service.accountName)
val httpClient = httpClientBuilder.get()
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
for (collection in subscribeTo)
try {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond)
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
else {
// no existing subscription or expiring soon
logger.fine("Registering push subscription for ${collection.url}")
subscribe(httpClient, collection, endpoint)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
.use { httpClient ->
for (collection in subscribeTo)
try {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond)
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
else {
// no existing subscription or expiring soon
logger.fine("Registering push subscription for ${collection.url}")
subscribe(httpClient, collection, endpoint)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
}
}
}
@@ -224,7 +230,7 @@ class PushRegistrationManager @Inject constructor(
* @param collection collection to subscribe to
* @param endpoint subscription to register
*/
private suspend fun subscribe(httpClient: OkHttpClient, collection: Collection, endpoint: PushEndpoint) {
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
@@ -232,26 +238,26 @@ class PushRegistrationManager @Inject constructor(
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.insertTag(WebDAVPush.PushRegister) {
serializer.insertTag(WebDAVPush.Subscription) {
serializer.insertTag(PushRegister.NAME) {
serializer.insertTag(Subscription.NAME) {
// subscription URL
serializer.insertTag(WebDAVPush.WebPushSubscription) {
serializer.insertTag(WebDAVPush.PushResource) {
serializer.insertTag(WebPushSubscription.NAME) {
serializer.insertTag(PushResource.NAME) {
text(endpoint.url)
}
endpoint.pubKeySet?.let { pubKeySet ->
serializer.insertTag(WebDAVPush.SubscriptionPublicKey) {
serializer.insertTag(SubscriptionPublicKey.NAME) {
attribute(null, "type", "p256dh")
text(pubKeySet.pubKey)
}
serializer.insertTag(WebDAVPush.AuthSecret) {
serializer.insertTag(AuthSecret.NAME) {
text(pubKeySet.auth)
}
}
}
}
// requested expiration
serializer.insertTag(WebDAVPush.Expires) {
serializer.insertTag(PushRegister.EXPIRES) {
text(HttpUtils.formatDate(requestedExpiration))
}
}
@@ -259,7 +265,7 @@ class PushRegistrationManager @Inject constructor(
runInterruptible(ioDispatcher) {
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
// update subscription URL and expiration in DB
val subscriptionUrl = response.header("Location")
@@ -288,20 +294,22 @@ class PushRegistrationManager @Inject constructor(
return
val account = accountRepository.get().fromName(service.accountName)
val httpClient = httpClientBuilder.get()
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
for (collection in from)
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
.use { httpClient ->
for (collection in from)
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
}
}
private suspend fun unsubscribe(httpClient: OkHttpClient, collection: Collection, url: HttpUrl) {
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
try {
runInterruptible(ioDispatcher) {
DavResource(httpClient, url).delete {
DavResource(httpClient.okHttpClient, url).delete {
// deleted
}
}

View File

@@ -8,12 +8,10 @@ import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
import at.bitfire.davdroid.di.DefaultDispatcher
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.servicedetection.DavResourceFinder
@@ -30,7 +28,7 @@ import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
@@ -49,7 +47,6 @@ class AccountRepository @Inject constructor(
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
@ApplicationContext private val context: Context,
private val collectionRepository: DavCollectionRepository,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
private val homeSetRepository: DavHomeSetRepository,
private val localCalendarStore: Lazy<LocalCalendarStore>,
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
@@ -73,7 +70,6 @@ class AccountRepository @Inject constructor(
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
@WorkerThread
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = fromName(accountName)
@@ -157,7 +153,7 @@ class AccountRepository @Inject constructor(
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
withContext(defaultDispatcher) { // causes disk I/O
withContext(Dispatchers.Default) { // causes disk I/O
accountManager.addOnAccountsUpdatedListener(listener, null, true)
}
@@ -169,7 +165,7 @@ class AccountRepository @Inject constructor(
/**
* Renames an account.
*
* **Note**: It is highly advised to re-sync the account after renaming in order to restore
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
* a consistent state.
*
* @param oldName current name of the account
@@ -179,7 +175,7 @@ class AccountRepository @Inject constructor(
* @throws IllegalArgumentException if the new account name already exists
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String): Unit = withContext(defaultDispatcher) {
suspend fun rename(oldName: String, newName: String) {
val oldAccount = fromName(oldName)
val newAccount = fromName(newName)
@@ -201,10 +197,13 @@ class AccountRepository @Inject constructor(
// rename account (also moves AccountSettings)
val future = accountManager.renameAccount(oldAccount, newName, null, null)
// wait for operation to complete (blocks calling thread)
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
}
// account renamed, cancel maybe running synchronization of old account
syncWorkerManager.get().cancelAllWork(oldAccount)
@@ -218,27 +217,22 @@ class AccountRepository @Inject constructor(
try {
// update address books
localAddressBookStore.get().updateAccount(oldAccount, newAccount, null)
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
}
try {
// update calendar events
val store = localCalendarStore.get()
store.acquireContentProvider(true)?.use { client ->
store.updateAccount(oldAccount, newAccount, client)
}
localCalendarStore.get().updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
}
try {
// update account_name of local tasks
val store = tasksAppManager.get().getDataStore()
store?.acquireContentProvider(true)?.use { client ->
store.updateAccount(oldAccount, newAccount, client)
}
val dataStore = tasksAppManager.get().getDataStore()
dataStore?.updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
}

View File

@@ -6,15 +6,23 @@ package at.bitfire.davdroid.repository
import android.accounts.Account
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.GoneException
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.okhttp.exception.NotFoundException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.property.carddav.CardDAV
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.dav4jvm.exception.GoneException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
@@ -22,7 +30,7 @@ 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.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.util.DavUtils
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -35,7 +43,6 @@ 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.ProdId
import net.fortuna.ical4j.model.property.Version
import okhttp3.HttpUrl
import java.io.StringWriter
@@ -51,7 +58,7 @@ class DavCollectionRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val logger: Logger,
private val httpClientBuilder: Provider<HttpClientBuilder>,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceRepository: DavServiceRepository
) {
@@ -165,20 +172,21 @@ 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))
val httpClient = httpClientBuilder.get().fromAccount(account).build()
runInterruptible(ioDispatcher) {
try {
DavResource(httpClient, collection.url).delete {
// success, otherwise an exception would have been thrown → delete locally, too
delete(collection)
httpClientBuilder.get().fromAccount(account).build().use { httpClient ->
runInterruptible(ioDispatcher) {
try {
DavResource(httpClient.okHttpClient, collection.url).delete {
// success, otherwise an exception would have been thrown → delete locally, too
delete(collection)
}
} catch (e: HttpException) {
if (e is NotFoundException || e is GoneException) {
// HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too
logger.info("Collection ${collection.url} not found on server, deleting locally")
delete(collection)
} else
throw e
}
} 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
}
}
}
@@ -281,17 +289,19 @@ class DavCollectionRepository @Inject constructor(
// helpers
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
val httpClient = httpClientBuilder.get()
httpClientBuilder.get()
.fromAccount(account)
.build()
runInterruptible(ioDispatcher) {
DavResource(httpClient, url).mkCol(
xmlBody = xmlBody,
method = method
) {
// success, otherwise an exception would have been thrown
.use { httpClient ->
runInterruptible(ioDispatcher) {
DavResource(httpClient.okHttpClient, url).mkCol(
xmlBody = xmlBody,
method = method
) {
// success, otherwise an exception would have been thrown
}
}
}
}
}
private fun generateMkColXml(
@@ -310,27 +320,27 @@ class DavCollectionRepository @Inject constructor(
setOutput(writer)
startDocument("UTF-8", null)
setPrefix("", WebDAV.NS_WEBDAV)
setPrefix("CAL", CalDAV.NS_CALDAV)
setPrefix("CARD", CardDAV.NS_CARDDAV)
setPrefix("", NS_WEBDAV)
setPrefix("CAL", NS_CALDAV)
setPrefix("CARD", NS_CARDDAV)
if (addressBook)
startTag(WebDAV.NS_WEBDAV, "mkcol")
startTag(NS_WEBDAV, "mkcol")
else
startTag(CalDAV.NS_CALDAV, "mkcalendar")
startTag(NS_CALDAV, "mkcalendar")
insertTag(WebDAV.Set) {
insertTag(WebDAV.Prop) {
insertTag(WebDAV.ResourceType) {
insertTag(WebDAV.Collection)
insertTag(DavResource.SET) {
insertTag(DavResource.PROP) {
insertTag(ResourceType.NAME) {
insertTag(ResourceType.COLLECTION)
if (addressBook)
insertTag(CardDAV.Addressbook)
insertTag(ResourceType.ADDRESSBOOK)
else
insertTag(CalDAV.Calendar)
insertTag(ResourceType.CALENDAR)
}
displayName?.let {
insertTag(WebDAV.DisplayName) {
insertTag(DisplayName.NAME) {
text(it)
}
}
@@ -338,7 +348,7 @@ class DavCollectionRepository @Inject constructor(
if (addressBook) {
// addressbook-specific properties
description?.let {
insertTag(CardDAV.AddressbookDescription) {
insertTag(AddressbookDescription.NAME) {
text(it)
}
}
@@ -346,27 +356,27 @@ class DavCollectionRepository @Inject constructor(
} else {
// calendar-specific properties
description?.let {
insertTag(CalDAV.CalendarDescription) {
insertTag(CalendarDescription.NAME) {
text(it)
}
}
color?.let {
insertTag(CalDAV.CalendarColor) {
insertTag(CalendarColor.NAME) {
text(DavUtils.ARGBtoCalDAVColor(it))
}
}
timezoneId?.let { id ->
insertTag(CalDAV.CalendarTimezoneId) {
insertTag(CalendarTimezoneId.NAME) {
text(id)
}
getVTimeZone(id)?.let { vTimezone ->
insertTag(CalDAV.CalendarTimezone) {
insertTag(CalendarTimezone.NAME) {
text(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(
PropertyList<Property>().apply {
add(Version.VERSION_2_0)
add(ProdId(Constants.iCalProdId))
add(Constants.iCalProdId)
},
ComponentList(
listOf(vTimezone)
@@ -378,19 +388,19 @@ class DavCollectionRepository @Inject constructor(
}
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
insertTag(CalDAV.SupportedCalendarComponentSet) {
insertTag(SupportedCalendarComponentSet.NAME) {
// Only if there's at least one not explicitly supported calendar component set,
// otherwise don't include the property, which means "supports everything".
if (supportsVEVENT)
insertTag(CalDAV.Comp) {
insertTag(SupportedCalendarComponentSet.COMP) {
attribute(null, "name", Component.VEVENT)
}
if (supportsVTODO)
insertTag(CalDAV.Comp) {
insertTag(SupportedCalendarComponentSet.COMP) {
attribute(null, "name", Component.VTODO)
}
if (supportsVJOURNAL)
insertTag(CalDAV.Comp) {
insertTag(SupportedCalendarComponentSet.COMP) {
attribute(null, "name", Component.VJOURNAL)
}
}
@@ -399,9 +409,9 @@ class DavCollectionRepository @Inject constructor(
}
}
if (addressBook)
endTag(WebDAV.NS_WEBDAV, "mkcol")
endTag(NS_WEBDAV, "mkcol")
else
endTag(CalDAV.NS_CALDAV, "mkcalendar")
endTag(NS_CALDAV, "mkcalendar")
endDocument()
}
return writer.toString()

View File

@@ -6,8 +6,4 @@ package at.bitfire.davdroid.resource
import at.bitfire.vcard4android.Contact
interface LocalAddress: LocalResource {
fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
}
interface LocalAddress: LocalResource<Contact>

View File

@@ -87,7 +87,7 @@ class LocalAddressBookStore @Inject constructor(
/* return */ null
}
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
@@ -98,7 +98,7 @@ class LocalAddressBookStore @Inject constructor(
id = fromCollection.id
) ?: return null
val addressBook = localAddressBookFactory.create(account, addressBookAccount, client)
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
// update settings
addressBook.updateSyncFrameworkSettings()
@@ -125,12 +125,12 @@ class LocalAddressBookStore @Inject constructor(
return addressBookAccount
}
override fun getAll(account: Account, client: ContentProviderClient): List<LocalAddressBook> =
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
getAddressBookAccounts(account).map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, client)
localAddressBookFactory.create(account, addressBookAccount, provider)
}
override fun update(client: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
@@ -167,9 +167,8 @@ class LocalAddressBookStore @Inject constructor(
*
* @param oldAccount The old account
* @param newAccount The new account
* @param client content provider client (not needed/does not exist for address books)
*/
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
override fun updateAccount(oldAccount: Account, newAccount: Account) {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->

View File

@@ -4,16 +4,18 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
import at.bitfire.synctools.storage.calendar.EventAndExceptions
import at.bitfire.synctools.storage.calendar.EventsContract
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -58,42 +60,53 @@ class LocalCalendar @AssistedInject constructor(
androidCalendar.writeSyncState(state.toString())
}
@VisibleForTesting
internal val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
fun add(event: EventAndExceptions): Long {
return recurringCalendar.addEventAndExceptions(event)
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 findDeleted(): List<LocalEvent> {
val result = LinkedList<LocalEvent>()
recurringCalendar.iterateEventAndExceptions(
"${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null
) { eventAndExceptions ->
result += LocalEvent(recurringCalendar, eventAndExceptions)
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
}
return result
}
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
recurringCalendar.iterateEventAndExceptions(
"${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null
) { eventAndExceptions ->
dirty += LocalEvent(recurringCalendar, eventAndExceptions)
/*
* RFC 5545 3.8.7.4. Sequence Number
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
* CUA each time the "Organizer" makes a significant revision to the calendar component.
*/
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
}
return dirty
}
override fun findByName(name: String) =
recurringCalendar.findEventAndExceptions("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
LocalEvent(recurringCalendar, it)
}
override fun markNotDirty(flags: Int) =
androidCalendar.updateEventRows(
contentValuesOf(EventsContract.COLUMN_FLAGS to flags),
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
"""
${Events.CALENDAR_ID}=?
@@ -113,7 +126,7 @@ class LocalCalendar @AssistedInject constructor(
${Events.CALENDAR_ID}=?
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
AND ${Events.ORIGINAL_ID} IS NULL
AND ${EventsContract.COLUMN_FLAGS}=?
AND ${AndroidEvent2.COLUMN_FLAGS}=?
""".trimIndent(),
arrayOf(androidCalendar.id.toString(), flags.toString())
) { values ->
@@ -129,9 +142,95 @@ class LocalCalendar @AssistedInject constructor(
override fun forgetETags() {
androidCalendar.updateEventRows(
contentValuesOf(EventsContract.COLUMN_ETAG to null),
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
batch.commit()
}
// process dirty exceptions
logger.info("Processing dirty exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: set original event to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(originalID))
.withValue(Events.DIRTY, 1)
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
}
/**
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
*
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
// Iterate dirty main events without exceptions
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
) { values ->
val eventId = values.getAsLong(Events._ID)
// get number of instances
val numEventInstances = androidCalendar.numInstances(eventId)
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventId without instances as deleted")
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
}
}
}
}

View File

@@ -26,7 +26,6 @@ import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.annotation.WillNotClose
import javax.inject.Inject
class LocalCalendarStore @Inject constructor(
@@ -139,18 +138,12 @@ class LocalCalendarStore @Inject constructor(
return values
}
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
if (client == null)
return
val values = contentValuesOf(
// Account name to be changed
Calendars.ACCOUNT_NAME to newAccount.name,
// Owner account of this calendar to be changed. Used by the calendar
// provider to determine whether the user is ORGANIZER/ATTENDEE (usually an email address) for a certain event.
Calendars.OWNER_ACCOUNT to newAccount.name
)
override fun updateAccount(oldAccount: Account, newAccount: Account) {
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
client.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalCalendar) {

View File

@@ -4,12 +4,7 @@
package at.bitfire.davdroid.resource
/**
* This is an interface between the Syncer/SyncManager and a collection in the local storage.
*
* It defines operations that are used during sync for all sync data types.
*/
interface LocalCollection<out T: LocalResource> {
interface LocalCollection<out T: LocalResource<*>> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
val tag: String

View File

@@ -4,16 +4,11 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import android.provider.ContactsContract.RawContacts.getContactLookupUri
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
@@ -27,16 +22,16 @@ import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.MoreObjects
import java.io.FileNotFoundException
import java.util.Optional
import java.util.UUID
import kotlin.jvm.optionals.getOrNull
class LocalContact: AndroidContact, LocalAddress {
companion object {
const val COLUMN_FLAGS = RawContacts.SYNC4
const val COLUMN_HASHCODE = RawContacts.SYNC3
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
override val addressBook: LocalAddressBook
@@ -68,6 +63,25 @@ class LocalContact: AndroidContact, LocalAddress {
}
override fun prepareForUpload(): String {
val contact = getContact()
val uid: String = contact.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in contacts provider
val values = contentValuesOf(COLUMN_UID to newUid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
// update this event
contact.uid = newUid
newUid
}
return "$uid.vcf"
}
/**
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
*/
@@ -83,7 +97,7 @@ class LocalContact: AndroidContact, LocalAddress {
if (fileName.isPresent)
values.put(COLUMN_FILENAME, fileName.get())
values.put(COLUMN_ETAG, eTag)
values.put(RawContacts.DIRTY, 0)
values.put(ContactsContract.RawContacts.DIRTY, 0)
// Android 7 workaround
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
@@ -96,7 +110,7 @@ class LocalContact: AndroidContact, LocalAddress {
}
fun resetDirty() {
val values = contentValuesOf(RawContacts.DIRTY to 0)
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
@@ -116,13 +130,6 @@ class LocalContact: AndroidContact, LocalAddress {
this.flags = flags
}
override fun updateSequence(sequence: Int) = throw NotImplementedError()
override fun updateUid(uid: String) {
val values = contentValuesOf(COLUMN_UID to uid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun deleteLocal() {
delete()
}
@@ -132,30 +139,6 @@ class LocalContact: AndroidContact, LocalAddress {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
/*.add("contact",
try {
// too dangerous, may contain unknown properties and cause another OOM
Ascii.truncate(getContact().toString(), 1000, "…")
} catch (e: Exception) {
e
}
)*/
.toString()
override fun getViewUri(context: Context): Uri? =
id?.let { idNotNull ->
getContactLookupUri(
context.contentResolver,
ContentUris.withAppendedId(RawContacts.CONTENT_URI, idNotNull)
)
}
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
batch += BatchOperation.CpoBuilder
@@ -216,7 +199,6 @@ class LocalContact: AndroidContact, LocalAddress {
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
import javax.annotation.WillNotClose
/**
* Represents a local data store for a specific collection type.
@@ -77,8 +76,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
*
* @param oldAccount The old account.
* @param newAccount The new account.
* @param client Content provider client for the local data store type or *null* when not needed for that data type.
*/
fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?)
fun updateAccount(oldAccount: Account, newAccount: Account)
}

View File

@@ -4,75 +4,161 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.synctools.storage.calendar.AndroidCalendar
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 at.bitfire.synctools.storage.calendar.EventAndExceptions
import at.bitfire.synctools.storage.calendar.EventsContract
import com.google.common.base.MoreObjects
import java.util.Optional
import java.util.UUID
class LocalEvent(
val recurringCalendar: AndroidRecurringCalendar,
val androidEvent: EventAndExceptions
) : LocalResource {
val calendar: AndroidCalendar
get() = recurringCalendar.calendar
private val mainValues = androidEvent.main.entityValues
val androidEvent: AndroidEvent2
) : LocalResource<Event> {
override val id: Long
get() = mainValues.getAsLong(Events._ID)
get() = androidEvent.id
override val fileName: String?
get() = mainValues.getAsString(Events._SYNC_ID)
get() = androidEvent.syncId
override val eTag: String?
get() = mainValues.getAsString(EventsContract.COLUMN_ETAG)
get() = androidEvent.eTag
override val scheduleTag: String?
get() = mainValues.getAsString(EventsContract.COLUMN_SCHEDULE_TAG)
get() = androidEvent.scheduleTag
override val flags: Int
get() = mainValues.getAsInteger(EventsContract.COLUMN_FLAGS) ?: 0
get() = androidEvent.flags
fun update(data: EventAndExceptions) {
recurringCalendar.updateEventAndExceptions(id, data)
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)
}
private var _event: Event? = null
/**
* Retrieves the event from the content provider and converts it to a legacy data object.
*
* Caches the result: the content provider is only queried at the first call and then
* this method always returns the same object.
*
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
*/
@Synchronized
fun getCachedEvent(): Event {
_event?.let { return it }
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
val event = legacyCalendar.getEvent(androidEvent.id)
?: throw LocalStorageException("Event ${androidEvent.id} not found")
_event = event
return event
}
/**
* Generates the [Event] that should actually be uploaded:
*
* 1. Takes the [getCachedEvent].
* 2. Calculates the new SEQUENCE.
*
* _Note: This method currently modifies the object returned by [getCachedEvent], but
* this may change in the future._
*
* @return data object that should be used for uploading
*/
fun eventToUpload(): Event {
val event = getCachedEvent()
val nonGroupScheduled = event.attendees.isEmpty()
val weAreOrganizer = event.isOrganizer == true
// Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence):
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
// - If it's non-null, the event already exists on the server, so increase by one.
val sequence = event.sequence
if (sequence != null && (nonGroupScheduled || weAreOrganizer))
event.sequence = sequence + 1
return event
}
/**
* Updates the SEQUENCE of the event in the content provider.
*
* @param sequence new sequence value
*/
fun updateSequence(sequence: Int?) {
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_SEQUENCE to sequence
))
}
/**
* Creates and sets a new UID in the calendar provider, if no UID is already set.
* It also returns the desired file name for the event for further processing in the sync algorithm.
*
* @return file name to use at upload
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = getCachedEvent().uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// persist to calendar provider
val values = contentValuesOf(Events.UID_2445 to newUid)
androidEvent.update(values)
// update in cached event data object
getCachedEvent().uid = newUid
newUid
}
val uidIsGoodFilename = uid.all { char ->
// see RFC 2396 2.2
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
).contains(char)
}
return if (uidIsGoodFilename)
"$uid.ics" // use UID as file name
else
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
val values = contentValuesOf(
Events.DIRTY to 0,
EventsContract.COLUMN_ETAG to eTag,
EventsContract.COLUMN_SCHEDULE_TAG to scheduleTag
AndroidEvent2.COLUMN_ETAG to eTag,
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
)
if (fileName.isPresent)
values.put(Events._SYNC_ID, fileName.get())
calendar.updateEventRow(id, values)
androidEvent.update(values)
}
override fun updateFlags(flags: Int) {
calendar.updateEventRow(id, contentValuesOf(
EventsContract.COLUMN_FLAGS to flags
))
}
override fun updateSequence(sequence: Int) {
calendar.updateEventRow(id, contentValuesOf(
EventsContract.COLUMN_SEQUENCE to sequence
))
}
override fun updateUid(uid: String) {
calendar.updateEventRow(id, contentValuesOf(
Events.UID_2445 to uid
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_FLAGS to flags
))
}
@@ -81,28 +167,9 @@ class LocalEvent(
}
override fun resetDeleted() {
calendar.updateEventRow(id, contentValuesOf(
androidEvent.update(contentValuesOf(
Events.DELETED to 0
))
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.add("event",
try {
// only include truncated main event row (won't contain attachments, unknown properties etc.)
androidEvent.main.entityValues.toString().take(1000)
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context) =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
}

View File

@@ -6,7 +6,6 @@ package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
@@ -16,6 +15,7 @@ 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
@@ -24,9 +24,9 @@ import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.AndroidGroupFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.MoreObjects
import java.util.LinkedList
import java.util.Optional
import java.util.UUID
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull
@@ -140,6 +140,26 @@ class LocalGroup: AndroidGroup, LocalAddress {
}
override fun prepareForUpload(): String {
var uid: String? = null
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0).trimToNull()
}
if (uid == null) {
// generate new UID
uid = UUID.randomUUID().toString()
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
_contact?.uid = uid
}
return "$uid.vcf"
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
@@ -195,6 +215,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
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)
@@ -207,13 +228,6 @@ class LocalGroup: AndroidGroup, LocalAddress {
this.flags = flags
}
override fun updateSequence(sequence: Int) = throw NotImplementedError()
override fun updateUid(uid: String) {
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun deleteLocal() {
delete()
}
@@ -223,22 +237,6 @@ class LocalGroup: AndroidGroup, LocalAddress {
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
.add("contact",
try {
getContact().toString()
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context) = null
// helpers

View File

@@ -18,11 +18,11 @@ import at.bitfire.davdroid.repository.PrincipalRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Logger
import javax.annotation.WillNotClose
import javax.inject.Inject
class LocalJtxCollectionStore @Inject constructor(
@@ -46,7 +46,7 @@ class LocalJtxCollectionStore @Inject constructor(
/* return */ null
}
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalJtxCollection {
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
@@ -63,8 +63,8 @@ class LocalJtxCollectionStore @Inject constructor(
withColor = true
)
val uri = JtxCollection.create(account, client, values)
return LocalJtxCollection(account, client, ContentUris.parseId(uri))
val uri = JtxCollection.create(account, provider, values)
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
}
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
@@ -94,21 +94,21 @@ class LocalJtxCollectionStore @Inject constructor(
}
}
override fun getAll(account: Account, client: ContentProviderClient): List<LocalJtxCollection> =
JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
override fun update(client: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.account)
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
localCollection.update(values)
}
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
if (client == null)
return
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
override fun updateAccount(oldAccount: Account, newAccount: Account) {
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalJtxCollection) {

View File

@@ -5,26 +5,23 @@
package at.bitfire.davdroid.resource
import android.content.ContentValues
import android.content.Context
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxICalObject
import at.bitfire.ical4android.JtxICalObjectFactory
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.JtxICalObject.getViewIntentUriFor
import com.google.common.base.MoreObjects
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
/**
* Represents a Journal, Note or Task entry
*/
class LocalJtxICalObject(
collection: JtxCollection<*>,
fileName: String?,
eTag: String?,
scheduleTag: String?,
flags: Int
) : JtxICalObject(collection), LocalResource {
) :
JtxICalObject(collection),
LocalResource<JtxICalObject> {
init {
this.fileName = fileName
@@ -53,7 +50,7 @@ class LocalJtxICalObject(
}
fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
@@ -63,10 +60,6 @@ class LocalJtxICalObject(
update(data)
}
override fun updateSequence(sequence: Int) = throw NotImplementedError()
override fun updateUid(uid: String) = throw NotImplementedError()
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
}
@@ -79,15 +72,4 @@ class LocalJtxICalObject(
throw NotImplementedError()
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.toString()
override fun getViewUri(context: Context) = getViewIntentUriFor(id)
}

View File

@@ -4,18 +4,13 @@
package at.bitfire.davdroid.resource
import android.content.Context
import android.content.Intent
import android.net.Uri
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
import java.util.Optional
/**
* This is an interface between the SyncManager and a resource in the local storage.
*
* It defines operations that are used by SyncManager for all sync data types.
* Defines operations that are used by SyncManager for all sync data types.
*/
interface LocalResource {
interface LocalResource<in TData: Any> {
companion object {
/**
@@ -49,6 +44,18 @@ interface LocalResource {
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
val flags: Int
/**
* Prepares the resource for uploading:
*
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
* saved to the content provider. The sync manager is responsible for saving the file name that
* was actually used.
*
* @return suggestion for new file name of the resource (like "<uid>.vcf")
*/
fun prepareForUpload(): String
/**
* Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider.
* Does not affect `this` object itself (which is immutable).
@@ -69,17 +76,12 @@ interface LocalResource {
fun updateFlags(flags: Int)
/**
* Updates the local UID of the resource in the content provider.
* Usually used to persist a UID that has been created during an upload of a locally created resource.
*/
fun updateUid(uid: String)
/**
* Updates the local SEQUENCE of the resource in the content provider.
* 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).
*
* @throws NotImplementedError if SEQUENCE update is not supported
* @return content URI of the updated row (e.g. event URI)
*/
fun updateSequence(sequence: Int)
fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
/**
* Deletes the data object from the content provider.
@@ -91,20 +93,4 @@ interface LocalResource {
*/
fun resetDeleted()
/**
* User-readable debug summary of this local resource (used in debug info)
*/
fun getDebugSummary(): String
/**
* Returns the content provider URI that opens the local resource for viewing ([Intent.ACTION_VIEW])
* in its respective app.
*
* For instance, in case of a local raw contact, this method could return the content provider URI
* that identifies the corresponding contact.
*
* @return content provider URI, or `null` if not available
*/
fun getViewUri(context: Context): Uri?
}

View File

@@ -4,122 +4,126 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import com.google.common.base.MoreObjects
import at.bitfire.synctools.storage.BatchOperation
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.Optional
import java.util.logging.Logger
import java.util.UUID
/**
* Represents a Dmfs Task (OpenTasks and Tasks.org) entry
*/
class LocalTask(
val dmfsTask: DmfsTask
): LocalResource {
class LocalTask: DmfsTask, LocalResource<Task> {
val logger: Logger = Logger.getLogger(javaClass.name)
companion object {
const val COLUMN_ETAG = Tasks.SYNC1
const val COLUMN_FLAGS = Tasks.SYNC2
}
override var fileName: String? = null
// LocalResource implementation
override val id: Long?
get() = dmfsTask.id
override var fileName: String?
get() = dmfsTask.syncId
set(value) { dmfsTask.syncId = value }
override var eTag: String?
get() = dmfsTask.eTag
set(value) { dmfsTask.eTag = value }
/**
* Note: Schedule-Tag for tasks is not supported
*/
override var scheduleTag: String? = null
override var eTag: String? = null
override val flags: Int
get() = dmfsTask.flags
override var flags = 0
private set
fun add() = dmfsTask.add()
fun update(data: Task) = dmfsTask.update(data)
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
fun delete() = dmfsTask.delete()
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
id = values.getAsLong(Tasks._ID)
fileName = values.getAsString(Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/* process LocalTask-specific fields */
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
super.buildTask(builder, update)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_FLAGS, flags)
}
/* custom queries */
override fun prepareForUpload(): String {
val uid: String = task!!.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in tasks provider
val values = contentValuesOf(Tasks._UID to newUid)
taskList.provider.update(taskSyncURI(), values, null, null)
// update this task
task!!.uid = newUid
newUid
}
return "$uid.ics"
}
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
logger.fine("Schedule-Tag for tasks not supported, won't save")
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
val values = ContentValues(4)
if (fileName.isPresent)
values.put(Tasks._SYNC_ID, fileName.get())
values.put(DmfsTask.COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, dmfsTask.task!!.sequence)
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
values.put(Tasks._DIRTY, 0)
dmfsTask.update(values)
taskList.provider.update(taskSyncURI(), values, null, null)
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
dmfsTask.update(values)
}
dmfsTask.flags = flags
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 updateSequence(sequence: Int) = throw NotImplementedError()
override fun updateFlags(flags: Int) {
if (id != null) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
taskList.provider.update(taskSyncURI(), values, null, null)
}
override fun updateUid(uid: String) {
val values = contentValuesOf(Tasks._UID to uid)
dmfsTask.update(values)
this.flags = flags
}
override fun deleteLocal() {
dmfsTask.delete()
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
/*.add("task",
try {
// too dangerous, may contain unknown properties and cause another OOM
Ascii.truncate(task.toString(), 1000, "…")
} catch (e: Exception) {
e
}
)*/
.toString()
override fun getViewUri(context: Context): Uri? = id?.let { id ->
when (dmfsTask.taskList.providerName) {
TaskProvider.ProviderName.OpenTasks -> {
val contentUri = Tasks.getContentUri(dmfsTask.taskList.providerName.authority)
ContentUris.withAppendedId(contentUri, id)
}
// Tasks.org can't handle view content URIs (missing intent-filter)
// Jtx Board tasks are [LocalJtxICalObject]s
else -> null
}
object Factory: DmfsTaskFactory<LocalTask> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}

View File

@@ -4,10 +4,15 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import java.util.logging.Logger
@@ -17,38 +22,62 @@ import java.util.logging.Logger
*
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalTaskList (
val dmfsTaskList: DmfsTaskList
): LocalCollection<LocalTask> {
class LocalTaskList private constructor(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
private val logger = Logger.getGlobal()
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
override val readOnly
get() = dmfsTaskList.accessLevel?.let {
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
} ?: false
get() =
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
override val dbCollectionId: Long?
get() = dmfsTaskList.syncId?.toLongOrNull()
get() = syncId?.toLongOrNull()
override val tag: String
get() = "tasks-${dmfsTaskList.account.name}-${dmfsTaskList.id}"
get() = "tasks-${account.name}-$id"
override val title: String
get() = dmfsTaskList.name ?: dmfsTaskList.id.toString()
get() = name ?: id.toString()
override var lastSyncState: SyncState?
get() = dmfsTaskList.readSyncState()?.let { SyncState.fromString(it) }
get() {
try {
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
dmfsTaskList.writeSyncState(state.toString())
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
provider.update(taskListSyncUri(), values, null, null)
}
override fun findDeleted() = dmfsTaskList.queryTasks(Tasks._DELETED, null)
.map { LocalTask(it) }
override fun populate(values: ContentValues) {
super.populate(values)
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
}
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
override fun findDirty(): List<LocalTask> {
val dmfsTasks = dmfsTaskList.queryTasks(Tasks._DIRTY, null)
for (localTask in dmfsTasks) {
val tasks = queryTasks(Tasks._DIRTY, null)
for (localTask in tasks) {
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
@@ -60,32 +89,41 @@ class LocalTaskList (
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
}
return dmfsTasks.map { LocalTask(it) }
return tasks
}
override fun findByName(name: String) =
dmfsTaskList.queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
.firstOrNull()?.let {
LocalTask(it)
}
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
return dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values,
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
return provider.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(dmfsTaskList.id.toString()))
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
dmfsTaskList.provider.delete(dmfsTaskList.tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
arrayOf(dmfsTaskList.id.toString(), flags.toString()))
provider.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(dmfsTaskList.id.toString()))
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}
object Factory: DmfsTaskListFactory<LocalTaskList> {
override fun newInstance(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
) = LocalTaskList(account, provider, providerName, id)
}
}

View File

@@ -28,7 +28,6 @@ import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import java.util.logging.Logger
import javax.annotation.WillNotClose
class LocalTaskListStore @AssistedInject constructor(
@Assisted private val providerName: TaskProvider.ProviderName,
@@ -57,13 +56,13 @@ class LocalTaskListStore @AssistedInject constructor(
/* return */ null
}
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
logger.log(Level.INFO, "Adding local task list", fromCollection)
val uri = create(account, client, providerName, fromCollection)
return LocalTaskList(DmfsTaskList.findByID(account, client, providerName, ContentUris.parseId(uri)))
val uri = create(account, provider, providerName, fromCollection)
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
}
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
@@ -81,7 +80,7 @@ class LocalTaskListStore @AssistedInject constructor(
put(TaskLists.SYNC_ENABLED, 1)
put(TaskLists.VISIBLE, 1)
}
return DmfsTaskList.create(account, provider, providerName, values)
return DmfsTaskList.Companion.create(account, provider, providerName, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
@@ -101,26 +100,25 @@ class LocalTaskListStore @AssistedInject constructor(
return values
}
override fun getAll(account: Account, client: ContentProviderClient) =
DmfsTaskList.find(account, client, providerName, null, null)
.map { LocalTaskList(it) }
override fun getAll(account: Account, provider: ContentProviderClient) =
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
val accountSettings = accountSettingsFactory.create(localCollection.dmfsTaskList.account)
localCollection.dmfsTaskList.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
val accountSettings = accountSettingsFactory.create(localCollection.account)
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
}
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
if (client == null)
return
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
val uri = Tasks.getContentUri(providerName.authority)
client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
override fun updateAccount(oldAccount: Account, newAccount: Account) {
TaskProvider.acquire(context, providerName)?.use { provider ->
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
val uri = Tasks.getContentUri(providerName.authority)
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalTaskList) {
localCollection.dmfsTaskList.delete()
localCollection.delete()
}
}

View File

@@ -4,8 +4,8 @@
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.HttpException
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
@@ -63,7 +63,7 @@ class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.statusCode in arrayOf(403, 404, 410))
if (e.code in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e

View File

@@ -6,26 +6,30 @@ package at.bitfire.davdroid.servicedetection
import android.app.ActivityManager
import android.content.Context
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.carddav.CardDAV
import at.bitfire.dav4jvm.property.common.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.network.DnsRecordResolver
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Credentials
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -58,8 +62,8 @@ class DavResourceFinder @AssistedInject constructor(
@Assisted private val credentials: Credentials? = null,
@ApplicationContext val context: Context,
private val dnsRecordResolver: DnsRecordResolver,
httpClientBuilder: HttpClientBuilder
) {
httpClientBuilder: HttpClient.Builder
): AutoCloseable {
@AssistedFactory
interface Factory {
@@ -83,12 +87,16 @@ class DavResourceFinder @AssistedInject constructor(
.apply {
if (credentials != null)
authenticate(
domain = null,
host = null,
getCredentials = { credentials }
)
}
.build()
override fun close() {
httpClient.close()
}
private fun initLogging(): StringHandler {
// don't use more than 1/4 of the available memory for a log string
val activityManager = context.getSystemService<ActivityManager>()!!
@@ -220,29 +228,27 @@ class DavResourceFinder @AssistedInject constructor(
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
log.info("Checking user-given URL: $baseURL")
val davBaseURL = DavResource(httpClient, baseURL, log)
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
try {
when (service) {
Service.CARDDAV -> {
davBaseURL.propfind(
0,
WebDAV.ResourceType, WebDAV.DisplayName,
WebDAV.CurrentUserPrincipal,
CardDAV.AddressbookHomeSet,
CardDAV.AddressbookDescription
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanResponse(CardDAV.Addressbook, response, config)
scanResponse(ResourceType.ADDRESSBOOK, response, config)
}
}
Service.CALDAV -> {
davBaseURL.propfind(
0,
WebDAV.ResourceType, WebDAV.DisplayName,
WebDAV.CurrentUserPrincipal, WebDAV.CurrentUserPrivilegeSet,
CalDAV.CalendarHomeSet,
CalDAV.SupportedCalendarComponentSet, CalDAV.CalendarColor, CalDAV.CalendarDescription, CalDAV.CalendarTimezone
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanResponse(CalDAV.Calendar, response, config)
scanResponse(ResourceType.CALENDAR, response, config)
}
}
}
@@ -260,7 +266,7 @@ class DavResourceFinder @AssistedInject constructor(
fun queryEmailAddress(principal: HttpUrl): List<String> {
val mailboxes = LinkedList<String>()
try {
DavResource(httpClient, principal, log).propfind(0, CalDAV.CalendarUserAddressSet) { response, _ ->
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
for (href in addressSet.hrefs)
try {
@@ -299,11 +305,11 @@ class DavResourceFinder @AssistedInject constructor(
val homeSetClass: Class<out HrefListProperty>
val serviceType: Service
when (resourceType) {
CardDAV.Addressbook -> {
ResourceType.ADDRESSBOOK -> {
homeSetClass = AddressbookHomeSet::class.java
serviceType = Service.CARDDAV
}
CalDAV.Calendar -> {
ResourceType.CALENDAR -> {
homeSetClass = CalendarHomeSet::class.java
serviceType = Service.CALDAV
}
@@ -324,7 +330,7 @@ class DavResourceFinder @AssistedInject constructor(
}
// ... and/or a principal?
if (it.types.contains(WebDAV.Principal))
if (it.types.contains(ResourceType.PRINCIPAL))
principal = davResponse.href
}
@@ -359,7 +365,7 @@ class DavResourceFinder @AssistedInject constructor(
fun providesService(url: HttpUrl, service: Service): Boolean {
var provided = false
try {
DavResource(httpClient, url, log).options { capabilities, _ ->
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
provided = true
@@ -444,7 +450,7 @@ class DavResourceFinder @AssistedInject constructor(
*/
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
var principal: HttpUrl? = null
DavResource(httpClient, url, log).propfind(0, WebDAV.CurrentUserPrincipal) { response, _ ->
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
response.requestedUrl.resolve(href)?.let {
log.info("Found current-user-principal: $it")

View File

@@ -4,9 +4,9 @@
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.exception.HttpException
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
@@ -103,7 +103,7 @@ class HomeSetRefresher @AssistedInject constructor(
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.statusCode in arrayOf(403, 404, 410))
if (e.code in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}

View File

@@ -4,9 +4,10 @@
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.WebDAV
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
@@ -35,8 +36,8 @@ class PrincipalsRefresher @AssistedInject constructor(
* Principal properties to ask the server for.
*/
private val principalProperties = arrayOf(
WebDAV.DisplayName,
WebDAV.ResourceType
DisplayName.NAME,
ResourceType.NAME
)
/**
@@ -59,7 +60,7 @@ class PrincipalsRefresher @AssistedInject constructor(
}
}
} catch (e: HttpException) {
logger.info("Principal update failed with response code ${e.statusCode}. principalUrl=$principalUrl")
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
}
}

View File

@@ -22,9 +22,9 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.push.PushRegistrationManager
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
@@ -64,7 +64,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
@Assisted workerParams: WorkerParameters,
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
private val httpClientBuilder: HttpClientBuilder,
private val httpClientBuilder: HttpClient.Builder,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry,
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
@@ -153,31 +153,34 @@ class RefreshCollectionsWorker @AssistedInject constructor(
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
val httpClient = httpClientBuilder
httpClientBuilder
.fromAccount(account)
.build()
runInterruptible {
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
.use { httpClient ->
runInterruptible {
val httpClient = httpClient.okHttpClient
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
// refresh home set list (from principal url)
service.principal?.let { principalUrl ->
logger.fine("Querying principal $principalUrl for home sets")
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
serviceRefresher.discoverHomesets(principalUrl)
// refresh home set list (from principal url)
service.principal?.let { principalUrl ->
logger.fine("Querying principal $principalUrl for home sets")
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
serviceRefresher.discoverHomesets(principalUrl)
}
// refresh home sets and their member collections
homeSetRefresherFactory.create(service, httpClient)
.refreshHomesetsAndTheirCollections()
// also refresh collections without a home set
refresher.refreshCollectionsWithoutHomeSet()
// Lastly, refresh the principals (collection owners)
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
principalsRefresher.refreshPrincipals()
}
}
// refresh home sets and their member collections
homeSetRefresherFactory.create(service, httpClient)
.refreshHomesetsAndTheirCollections()
// also refresh collections without a home set
refresher.refreshCollectionsWithoutHomeSet()
// Lastly, refresh the principals (collection owners)
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
principalsRefresher.refreshPrincipals()
}
} catch(e: InvalidAccountException) {
logger.log(Level.SEVERE, "Invalid account", e)
return Result.failure()

View File

@@ -5,10 +5,19 @@
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.property.carddav.CardDAV
import at.bitfire.dav4jvm.property.push.WebDAVPush
import at.bitfire.dav4jvm.property.webdav.WebDAV
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
@@ -20,24 +29,24 @@ object ServiceDetectionUtils {
*/
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
arrayOf( // generic WebDAV properties
WebDAV.CurrentUserPrivilegeSet,
WebDAV.DisplayName,
WebDAV.Owner,
WebDAV.ResourceType,
WebDAVPush.Transports,
WebDAVPush.Topic
) + when (serviceType) { // service-specific CalDAV/CardDAV 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(
CardDAV.AddressbookDescription
AddressbookDescription.NAME
)
Service.TYPE_CALDAV -> arrayOf(
CalDAV.CalendarColor,
CalDAV.CalendarDescription,
CalDAV.CalendarTimezone,
CalDAV.CalendarTimezoneId,
CalDAV.SupportedCalendarComponentSet,
CalDAV.Source
CalendarColor.NAME,
CalendarDescription.NAME,
CalendarTimezone.NAME,
CalendarTimezoneId.NAME,
SupportedCalendarComponentSet.NAME,
Source.NAME
)
else -> throw IllegalArgumentException()

View File

@@ -4,20 +4,18 @@
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.UrlUtils
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.property.caldav.CalDAV
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.carddav.CardDAV
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.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavHomeSetRepository
@@ -61,18 +59,18 @@ class ServiceRefresher @AssistedInject constructor(
*/
private val homeSetProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
WebDAV.DisplayName,
WebDAV.GroupMembership,
WebDAV.ResourceType
DisplayName.NAME,
GroupMembership.NAME,
ResourceType.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
CardDAV.AddressbookHomeSet,
AddressbookHomeSet.NAME,
)
Service.TYPE_CALDAV -> arrayOf(
CalDAV.CalendarHomeSet,
CalDAV.CalendarProxyReadFor,
CalDAV.CalendarProxyWriteFor
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME
)
else -> throw IllegalArgumentException()
@@ -149,15 +147,15 @@ class ServiceRefresher @AssistedInject constructor(
// 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(
CalDAV.CalendarProxyRead,
CalDAV.CalendarProxyWrite
ResourceType.CALENDAR_PROXY_READ,
ResourceType.CALENDAR_PROXY_WRITE,
)
if (proxyProperties.any { resourceType.types.contains(it) })
relatedResources += davResponse.href.parent()
}
}
} catch (e: HttpException) {
if (e.isClientError)
if (e.code / 100 == 4)
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e

View File

@@ -18,7 +18,6 @@ import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
@@ -107,7 +106,7 @@ class AccountSettings @AssistedInject constructor(
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account)?.toSensitiveString(),
accountManager.getPassword(account)?.toCharArray(),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
@@ -119,7 +118,7 @@ class AccountSettings @AssistedInject constructor(
fun credentials(credentials: Credentials) {
// Basic/Digest auth
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
accountManager.setPassword(account, credentials.password?.asString())
accountManager.setPassword(account, credentials.password?.concatToString())
// client certificate
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.util.SensitiveString
import net.openid.appauth.AuthState
/**
@@ -17,7 +16,7 @@ data class Credentials(
/** username for Basic / Digest auth */
val username: String? = null,
/** password for Basic / Digest auth */
val password: SensitiveString? = null,
val password: CharArray? = null,
/** alias of an client certificate that is present on the system */
val certificateAlias: String? = null,
@@ -43,4 +42,27 @@ data class Credentials(
return "Credentials(" + s.joinToString(", ") + ")"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Credentials
if (username != other.username) return false
if (!password.contentEquals(other.password)) return false
if (certificateAlias != other.certificateAlias) return false
if (authState != other.authState) return false
return true
}
override fun hashCode(): Int {
var result = username?.hashCode() ?: 0
result = 31 * result + (password?.contentHashCode() ?: 0)
result = 31 * result + (certificateAlias?.hashCode() ?: 0)
result = 31 * result + (authState?.hashCode() ?: 0)
return result
}
}

View File

@@ -12,7 +12,7 @@ import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Reminders
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.DmfsTask
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Binds
@@ -39,7 +39,7 @@ class AccountSettingsMigration10 @Inject constructor(
override fun migrate(account: Account) {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
val tasksUri = provider.tasksUri().asSyncAdapter(account)
val emptyETag = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
val emptyETag = contentValuesOf(LocalTask.COLUMN_ETAG to null)
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
}

View File

@@ -13,7 +13,7 @@ import android.util.Base64
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.synctools.storage.calendar.EventsContract
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Binds
import dagger.Module
@@ -69,7 +69,7 @@ class AccountSettingsMigration12 @Inject constructor(
val property = UnknownProperty.fromJsonString(rawValue)
if (property is Url) { // rewrite to MIMETYPE_URL
val newValues = contentValuesOf(
CalendarContract.ExtendedProperties.NAME to EventsContract.EXTNAME_URL,
CalendarContract.ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL,
CalendarContract.ExtendedProperties.VALUE to property.value
)
provider.update(uri, newValues, null, null)
@@ -77,7 +77,7 @@ class AccountSettingsMigration12 @Inject constructor(
} catch (e: Exception) {
logger.log(
Level.WARNING,
"Couldn't rewrite URL from unknown property to ${EventsContract.EXTNAME_URL}",
"Couldn't rewrite URL from unknown property to ${AndroidEvent2.EXTNAME_URL}",
e
)
}

View File

@@ -98,9 +98,9 @@ class AccountSettingsMigration20 @Inject constructor(
for (taskList in taskListStore.getAll(account, provider)) {
when (taskList) {
is LocalTaskList -> { // tasks.org, OpenTasks
val url = taskList.dmfsTaskList.syncId ?: continue
val url = taskList.syncId ?: continue
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
taskList.dmfsTaskList.update(contentValuesOf(
taskList.update(contentValuesOf(
TaskLists._SYNC_ID to collection.id.toString()
))
}

View File

@@ -5,15 +5,18 @@
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.content.ContentResolver
import android.accounts.AccountManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
@@ -31,46 +34,36 @@ import javax.inject.Inject
* (+tasks) account syncs.
*/
class AccountSettingsMigration21 @Inject constructor(
private val localAddressBookStore: LocalAddressBookStore,
@ApplicationContext private val context: Context,
private val syncFrameworkIntegration: SyncFrameworkIntegration,
private val logger: Logger
): AccountSettingsMigration {
/**
* Cancel any possibly forever pending account syncs of the different authorities
*/
private val accountManager = AccountManager.get(context)
private val calendarAccountType = context.getString(R.string.account_type)
private val addressBookAccountType = context.getString(R.string.account_type_address_book)
override fun migrate(account: Account) {
if (Build.VERSION.SDK_INT >= 34) {
// Request new dummy syncs (yes, seems like this is needed)
val extras = Bundle().apply {
putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
}
// Cancel any (after an update) possibly forever pending calendar (+tasks) account syncs
cancelSyncs(calendarAccountType, CalendarContract.AUTHORITY)
// Request calendar and tasks syncs and cancel all syncs account wide
val possibleAuthorities = SyncDataType.EVENTS.possibleAuthorities() +
SyncDataType.TASKS.possibleAuthorities()
for (authority in possibleAuthorities) {
ContentResolver.requestSync(account, authority, extras)
logger.info("Android 14+: Canceling all (possibly forever pending) sync adapter syncs for $authority and $account")
// Ensure the sync framework processes the request right away
ContentResolver.isSyncPending(account, authority)
// Cancel the sync
ContentResolver.cancelSync(account, null) // Ignores possibly set sync extras
}
// Request contacts sync (per address book account) and cancel all syncs address book account wide
val addressBookAccounts = localAddressBookStore.getAddressBookAccounts(account) + account
for (addressBookAccount in addressBookAccounts) {
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, extras)
logger.info("Android 14+: Canceling all (possibly forever pending) sync adapter syncs for $addressBookAccount")
// Ensure the sync framework processes the request right away
ContentResolver.isSyncPending(account, ContactsContract.AUTHORITY)
// Cancel the sync
ContentResolver.cancelSync(addressBookAccount, null) // Ignores possibly set sync extras
}
// Cancel any (after an update) possibly forever pending address book account syncs
cancelSyncs(addressBookAccountType, ContactsContract.AUTHORITY)
}
}
/**
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
* authorities.
*/
private fun cancelSyncs(accountType: String, authority: String) {
accountManager.getAccountsByType(accountType).forEach { account ->
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
syncFrameworkIntegration.cancelSync(account, authority, Bundle())
}
}
@Module
@InstallIn(SingletonComponent::class)

View File

@@ -18,6 +18,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.tasks.contract.TaskContract.CommonSyncColumns
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@@ -49,7 +50,7 @@ class AccountSettingsMigration8 @Inject constructor(
TaskContract.Tasks.SYNC1 to null,
TaskContract.Tasks.SYNC2 to null
)
logger.log(Level.FINE, "Updating task $id", values)
logger.log(Level.FINER, "Updating task $id", values)
provider.client.update(
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
values, null, null)

View File

@@ -10,6 +10,7 @@ import android.content.ContentProviderClient
import android.provider.ContactsContract
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.settings.AccountSettings
@@ -18,7 +19,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import java.util.logging.Level
/**
@@ -58,7 +58,7 @@ class AddressBookSyncer @AssistedInject constructor(
syncAddressBook(
account = account,
addressBook = localCollection,
provideHttpClient = { httpClient },
httpClient = httpClient,
provider = provider,
syncResult = syncResult,
collection = remoteCollection
@@ -68,16 +68,15 @@ class AddressBookSyncer @AssistedInject constructor(
/**
* Synchronizes an address book
*
* @param addressBook local address book
* @param provideHttpClient returns HTTP client on demand
* @param provider content provider to access android contacts
* @param syncResult stores hard and soft sync errors
* @param collection the database collection associated with this address book
* @param addressBook local address book
* @param provider Content provider to access android contacts
* @param syncResult Stores hard and soft sync errors
* @param collection The database collection associated with this address book
*/
private fun syncAddressBook(
account: Account,
addressBook: LocalAddressBook,
provideHttpClient: () -> OkHttpClient,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult,
collection: Collection
@@ -104,7 +103,7 @@ class AddressBookSyncer @AssistedInject constructor(
val syncManager = contactsSyncManagerFactory.contactsSyncManager(
account,
provideHttpClient(),
httpClient.value,
syncResult,
provider,
addressBook,

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBookStore
@@ -60,7 +59,6 @@ class AutomaticSyncManager @Inject constructor(
* @param account the account to synchronize
* @param dataType the data type to synchronize
*/
@WorkerThread
private fun enableAutomaticSync(
account: Account,
dataType: SyncDataType
@@ -115,7 +113,6 @@ class AutomaticSyncManager @Inject constructor(
*
* @param account account for which automatic synchronization shall be updated
*/
@WorkerThread
fun updateAutomaticSync(account: Account) {
for (dataType in SyncDataType.entries)
updateAutomaticSync(account, dataType)
@@ -131,7 +128,6 @@ class AutomaticSyncManager @Inject constructor(
* @param account account for which automatic synchronization shall be updated
* @param dataType sync data type for which automatic synchronization shall be updated
*/
@WorkerThread
fun updateAutomaticSync(account: Account, dataType: SyncDataType) {
val serviceType = when (dataType) {
SyncDataType.CONTACTS -> Service.TYPE_CARDDAV

View File

@@ -6,49 +6,48 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.text.format.Formatter
import at.bitfire.dav4jvm.okhttp.DavCalendar
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalendarData
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.di.SyncDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.icalendar.CalendarUidSplitter
import at.bitfire.synctools.icalendar.ICalendarGenerator
import at.bitfire.synctools.icalendar.ICalendarParser
import at.bitfire.synctools.mapping.calendar.AndroidEventBuilder
import at.bitfire.synctools.mapping.calendar.AndroidEventHandler
import at.bitfire.synctools.mapping.calendar.DefaultProdIdGenerator
import at.bitfire.synctools.mapping.calendar.SequenceUpdater
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.EventReader
import at.bitfire.ical4android.EventWriter
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.synctools.exception.InvalidRemoteResourceException
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.component.VEvent
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.property.Action
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.Reader
import java.io.StringReader
import java.io.StringWriter
import java.time.Duration
import java.time.ZonedDateTime
import java.util.Optional
import java.util.logging.Level
@@ -58,7 +57,7 @@ import java.util.logging.Level
*/
class CalendarSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted httpClient: OkHttpClient,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted localCalendar: LocalCalendar,
@Assisted collection: Collection,
@@ -80,7 +79,7 @@ class CalendarSyncManager @AssistedInject constructor(
interface Factory {
fun calendarSyncManager(
account: Account,
httpClient: OkHttpClient,
httpClient: HttpClient,
syncResult: SyncResult,
localCalendar: LocalCalendar,
collection: Collection,
@@ -92,15 +91,13 @@ class CalendarSyncManager @AssistedInject constructor(
override fun prepare(): Boolean {
davCollection = DavCalendar(httpClient, collection.url)
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
// if there are dirty exceptions for events, mark their master events as dirty, too
val recurringCalendar = localCollection.recurringCalendar
recurringCalendar.processDeletedExceptions()
recurringCalendar.processDirtyExceptions()
localCollection.processDirtyExceptions()
// now find dirty events that have no instances and set them to deleted
localCollection.androidCalendar.deleteDirtyEventsWithoutInstances()
localCollection.deleteDirtyEventsWithoutInstances()
return true
}
@@ -109,20 +106,14 @@ class CalendarSyncManager @AssistedInject constructor(
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
var syncState: SyncState? = null
runInterruptible {
davCollection.propfind(
0,
CalDAV.MaxResourceSize,
WebDAV.SupportedReportSet,
CalDAV.GetCTag,
WebDAV.SyncToken
) { response, relation ->
davCollection.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
logger.info("Calendar accepts events up to ${Formatter.formatFileSize(context, maxSize)}")
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
@@ -187,38 +178,24 @@ class CalendarSyncManager @AssistedInject constructor(
return modified or superModified
}
override fun generateUpload(resource: LocalEvent): GeneratedResource {
val localEvent = resource.androidEvent
logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent)
override fun onSuccessfulUpload(local: LocalEvent, newFileName: String, eTag: String?, scheduleTag: String?) {
super.onSuccessfulUpload(local, newFileName, eTag, scheduleTag)
// increase SEQUENCE of main event and remember value
val updatedSequence = SequenceUpdater().increaseSequence(localEvent.main)
// map Android event to iCalendar (also generates UID, if necessary)
val handler = AndroidEventHandler(
accountName = resource.recurringCalendar.calendar.account.name,
prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId)
)
val mappedEvents = handler.mapToVEvents(localEvent)
// persist UID if it was generated
if (mappedEvents.generatedUid)
resource.updateUid(mappedEvents.uid)
// generate iCalendar and convert to request body
val iCalWriter = StringWriter()
ICalendarGenerator().write(mappedEvents.associatedEvents, iCalWriter)
val requestBody = iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"),
requestBody = requestBody,
onSuccessContext = GeneratedResource.OnSuccessContext(
sequence = updatedSequence
)
)
// update local SEQUENCE to new value after successful upload
local.updateSequence(local.getCachedEvent().sequence)
}
override fun generateUpload(resource: LocalEvent): RequestBody =
SyncException.wrapWithLocalResource(resource) {
val event = resource.eventToUpload()
logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
// write iCalendar to string and convert to request body
val iCalWriter = StringWriter()
EventWriter(Constants.iCalProdId).write(event, iCalWriter)
iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
}
override suspend fun listAllRemote(callback: MultiResponseCallback) {
// calculate time range limits
val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays ->
@@ -267,11 +244,11 @@ class CalendarSyncManager @AssistedInject constructor(
?: throw DavException("Received multi-get response without ETag")
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
processICalendar(
fileName = response.href.lastSegment,
eTag = eTag,
scheduleTag = scheduleTag,
reader = StringReader(iCal)
processVEvent(
response.href.lastSegment,
eTag,
scheduleTag,
StringReader(iCal)
)
}
}
@@ -284,50 +261,56 @@ class CalendarSyncManager @AssistedInject constructor(
// helpers
private fun processICalendar(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
val calendar =
try {
ICalendarParser().parse(reader)
} catch (e: InvalidICalendarException) {
logger.log(Level.WARNING, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
val uidsAndEvents = CalendarUidSplitter<VEvent>().associateByUid(calendar, Component.VEVENT)
if (uidsAndEvents.size != 1) {
logger.warning("Received iCalendar with not exactly one UID; ignoring $fileName")
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
val events: List<Event>
try {
events = EventReader().readEvents(reader)
} catch (e: InvalidRemoteResourceException) {
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
// Event: main VEVENT and potentially attached exceptions (further VEVENTs with RECURRENCE-ID)
val event = uidsAndEvents.values.first()
// map AssociatedEvents (VEVENTs) to EventAndExceptions (Android events)
val androidEvent = AndroidEventBuilder(
calendar = localCollection.androidCalendar,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
).build(event)
if (events.size == 1) {
val event = events.first()
// add default reminder (if desired)
accountSettings.getDefaultAlarm()?.let { minBefore ->
logger.log(Level.INFO, "Adding default alarm ($minBefore min before)", event)
DefaultReminderBuilder(minBefore = minBefore).add(to = androidEvent)
}
// create/update local event in calendar provider
val local = localCollection.findByName(fileName)
if (local != null) {
SyncException.wrapWithLocalResource(local) {
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
local.update(androidEvent)
// set default reminder for non-full-day events, if requested
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
// Needed for calendars to actually show a notification.
properties += Action.DISPLAY
}
logger.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
event.alarms += alarm
}
} else {
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
localCollection.add(androidEvent)
}
// update local event, if it exists
val local = localCollection.findByName(fileName)
SyncException.wrapWithLocalResource(local) {
if (local != null) {
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
local.update(
data = event,
fileName = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
} else {
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
localCollection.add(
event = event,
fileName = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
}
}
} else
logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
}
override fun notifyInvalidResourceTitle(): String =

View File

@@ -60,7 +60,7 @@ class CalendarSyncer @AssistedInject constructor(
val syncManager = calendarSyncManagerFactory.calendarSyncManager(
account,
httpClient,
httpClient.value,
syncResult,
localCollection,
remoteCollection,

View File

@@ -7,24 +7,24 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.text.format.Formatter
import at.bitfire.dav4jvm.ktor.toUrlOrNull
import at.bitfire.dav4jvm.okhttp.DavAddressBook
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.DavAddressBook
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.carddav.AddressData
import at.bitfire.dav4jvm.property.carddav.CardDAV
import at.bitfire.dav4jvm.property.carddav.MaxResourceSize
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
import at.bitfire.dav4jvm.property.webdav.GetContentType
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.di.SyncDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddress
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
@@ -46,14 +46,15 @@ import dagger.assisted.AssistedInject
import ezvcard.VCardVersion
import ezvcard.io.CannotParseException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.Reader
import java.io.StringReader
import java.util.Optional
@@ -99,7 +100,7 @@ import kotlin.jvm.optionals.getOrNull
*/
class ContactsSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted httpClient: OkHttpClient,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted val provider: ContentProviderClient,
@Assisted localAddressBook: LocalAddressBook,
@@ -108,7 +109,7 @@ class ContactsSyncManager @AssistedInject constructor(
@Assisted val syncFrameworkUpload: Boolean,
val dirtyVerifier: Optional<ContactDirtyVerifier>,
accountSettingsFactory: AccountSettings.Factory,
private val resourceDownloaderFactory: ResourceDownloader.Factory,
private val httpClientBuilder: HttpClient.Builder,
@SyncDispatcher syncDispatcher: CoroutineDispatcher
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
account,
@@ -125,7 +126,7 @@ class ContactsSyncManager @AssistedInject constructor(
interface Factory {
fun contactsSyncManager(
account: Account,
httpClient: OkHttpClient,
httpClient: HttpClient,
syncResult: SyncResult,
provider: ContentProviderClient,
localAddressBook: LocalAddressBook,
@@ -148,6 +149,11 @@ class ContactsSyncManager @AssistedInject constructor(
GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook)
}
/**
* Used to download images which are referenced by URL
*/
private lateinit var resourceDownloader: ResourceDownloader
override fun prepare(): Boolean {
if (dirtyVerifier.isPresent) {
@@ -156,7 +162,8 @@ class ContactsSyncManager @AssistedInject constructor(
return false
}
davCollection = DavAddressBook(httpClient, collection.url)
davCollection = DavAddressBook(httpClient.okHttpClient, collection.url)
resourceDownloader = ResourceDownloader(davCollection.location)
logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
return true
@@ -166,14 +173,7 @@ class ContactsSyncManager @AssistedInject constructor(
return SyncException.wrapWithRemoteResourceSuspending(collection.url) {
var syncState: SyncState? = null
runInterruptible {
davCollection.propfind(
0,
CardDAV.MaxResourceSize,
CardDAV.SupportedAddressData,
WebDAV.SupportedReportSet,
CalDAV.GetCTag,
WebDAV.SyncToken
) { response, relation ->
davCollection.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
logger.info("Address book accepts vCards up to ${Formatter.formatFileSize(context, maxSize)}")
@@ -186,7 +186,7 @@ class ContactsSyncManager @AssistedInject constructor(
// hasJCard = supported.hasJCard()
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
@@ -272,50 +272,40 @@ class ContactsSyncManager @AssistedInject constructor(
return modified or superModified
}
override fun generateUpload(resource: LocalAddress): GeneratedResource {
val contact: Contact = when (resource) {
is LocalContact -> resource.getContact()
is LocalGroup -> resource.getContact()
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
}
logger.log(Level.FINE, "Preparing upload of vCard #${resource.id}", contact)
// get/create UID
val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(contact.uid)
if (uidIsGenerated) {
// modify in Contact and persist to contacts provider
contact.uid = uid
resource.updateUid(uid)
}
// generate vCard and convert to request body
val os = ByteArrayOutputStream()
val mimeType: MediaType
when {
hasJCard -> {
mimeType = DavAddressBook.MIME_JCARD
contact.writeJCard(os, Constants.vCardProdId)
override fun generateUpload(resource: LocalAddress): RequestBody =
SyncException.wrapWithLocalResource(resource) {
val contact: Contact = when (resource) {
is LocalContact -> resource.getContact()
is LocalGroup -> resource.getContact()
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
}
hasVCard4 -> {
mimeType = DavAddressBook.MIME_VCARD4
contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId)
}
else -> {
mimeType = DavAddressBook.MIME_VCARD3_UTF8
contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId)
}
}
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(uid, "vcf"),
requestBody = os.toByteArray().toRequestBody(mimeType)
)
}
logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact)
val os = ByteArrayOutputStream()
val mimeType: MediaType
when {
hasJCard -> {
mimeType = DavAddressBook.MIME_JCARD
contact.writeJCard(os, Constants.vCardProdId)
}
hasVCard4 -> {
mimeType = DavAddressBook.MIME_VCARD4
contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId)
}
else -> {
mimeType = DavAddressBook.MIME_VCARD3_UTF8
contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId)
}
}
return@wrapWithLocalResource os.toByteArray().toRequestBody(mimeType)
}
override suspend fun listAllRemote(callback: MultiResponseCallback) =
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
runInterruptible {
davCollection.propfind(1, WebDAV.ResourceType, WebDAV.GetETag, callback = callback)
davCollection.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
}
}
@@ -357,25 +347,16 @@ class ContactsSyncManager @AssistedInject constructor(
?: throw DavException("Received multi-get response without ETag")
var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it)
response[GetContentType::class.java]?.type?.toMediaTypeOrNull()?.let { type ->
response[GetContentType::class.java]?.type?.let { type ->
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
}
processCard(
fileName = response.href.lastSegment,
eTag = eTag,
reader = StringReader(card),
jCard = isJCard,
downloader = object : Contact.Downloader {
override fun download(url: String, accepts: String): ByteArray? {
// download external resource (like a photo) from an URL
val httpUrl = url.toUrlOrNull() ?: return null
val downloader = resourceDownloaderFactory.create(account, davCollection.location.host)
return runBlocking(syncDispatcher) {
downloader.download(httpUrl)
}
}
}
response.href.lastSegment,
eTag,
StringReader(card),
isJCard,
resourceDownloader
)
}
}
@@ -481,6 +462,44 @@ class ContactsSyncManager @AssistedInject constructor(
}
// downloader helper class
private inner class ResourceDownloader(
val baseUrl: HttpUrl
): Contact.Downloader {
override fun download(url: String, accepts: String): ByteArray? {
val httpUrl = url.toHttpUrlOrNull()
if (httpUrl == null) {
logger.log(Level.SEVERE, "Invalid external resource URL", url)
return null
}
// authenticate only against a certain host, and only upon request
httpClientBuilder
.fromAccount(account, onlyHost = baseUrl.host)
.followRedirects(true) // allow redirects
.build()
.use { httpClient ->
try {
val response = httpClient.okHttpClient.newCall(Request.Builder()
.get()
.url(httpUrl)
.build()).execute()
if (response.isSuccessful)
return response.body.bytes()
else
logger.warning("Couldn't download external resource")
} catch(e: IOException) {
logger.log(Level.SEVERE, "Couldn't download external resource", e)
}
}
return null
}
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_contact)

View File

@@ -1,60 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.content.Entity
import android.provider.CalendarContract.Events
import android.provider.CalendarContract.Reminders
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.synctools.storage.calendar.EventAndExceptions
/**
* Builder for default reminders / alarms that can be added to events
* if this is enabled in app settings.
*
* @param minBefore how many minutes before the entry the alarm should be added (usually taken from app settings)
*/
class DefaultReminderBuilder(
private val minBefore: Int
) {
/**
* Adds a default alarm ([minBefore] minutes before) to
*
* - the main event and
* - each exception event,
*
* except for those events which
*
* - are all-day, or
* - already have another reminder.
*/
fun add(to: EventAndExceptions) {
// add default reminder to main event and exceptions
val events = mutableListOf(to.main)
events += to.exceptions
for (event in events)
addToEvent(to = event)
}
@VisibleForTesting
internal fun addToEvent(to: Entity) {
// don't add default reminder if there's already another reminder
if (to.subValues.any { it.uri == Reminders.CONTENT_URI })
return
// don't add default reminder to all-day events
if (to.entityValues.getAsInteger(Events.ALL_DAY) == 1)
return
to.addSubValue(Reminders.CONTENT_URI, contentValuesOf(
Reminders.MINUTES to minBefore,
Reminders.METHOD to Reminders.METHOD_ALERT // will trigger an alarm on the Android device
))
}
}

View File

@@ -1,32 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import okhttp3.RequestBody
/**
* Represents a resource that has been generated for the purpose of being uploaded.
*
* @param suggestedFileName file name that can be used for uploading if there's no existing name
* @param requestBody resource body (including MIME type)
* @param onSuccessContext context that must be passed to [SyncManager.onSuccessfulUpload]
* on successful upload in order to persist the changes made during mapping
*/
class GeneratedResource(
val suggestedFileName: String,
val requestBody: RequestBody,
val onSuccessContext: OnSuccessContext? = null
) {
/**
* Contains information that has been created for a [GeneratedResource], but has not been saved yet.
*
* @param sequence new SEQUENCE to persist on successful upload (*null*: SEQUENCE not modified)
*/
data class OnSuccessContext(
val sequence: Int? = null
)
}

View File

@@ -7,35 +7,34 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.text.format.Formatter
import androidx.annotation.OpenForTesting
import at.bitfire.dav4jvm.okhttp.DavCalendar
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.caldav.CalendarData
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.di.SyncDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.resource.LocalJtxICalObject
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.JtxICalObject
import at.bitfire.synctools.exception.InvalidICalendarException
import at.bitfire.synctools.exception.InvalidRemoteResourceException
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
import net.fortuna.ical4j.model.property.ProdId
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.Reader
@@ -44,7 +43,7 @@ import java.util.logging.Level
class JtxSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted httpClient: OkHttpClient,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted localCollection: LocalJtxCollection,
@Assisted collection: Collection,
@@ -65,7 +64,7 @@ class JtxSyncManager @AssistedInject constructor(
interface Factory {
fun jtxSyncManager(
account: Account,
httpClient: OkHttpClient,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalJtxCollection,
collection: Collection,
@@ -75,7 +74,7 @@ class JtxSyncManager @AssistedInject constructor(
override fun prepare(): Boolean {
davCollection = DavCalendar(httpClient, collection.url)
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
return true
}
@@ -84,7 +83,7 @@ class JtxSyncManager @AssistedInject constructor(
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
var syncState: SyncState? = null
runInterruptible {
davCollection.propfind(0, CalDAV.GetCTag, CalDAV.MaxResourceSize, WebDAV.SyncToken) { response, relation ->
davCollection.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
logger.info("Collection accepts resources up to ${Formatter.formatFileSize(context, maxSize)}")
@@ -97,17 +96,13 @@ class JtxSyncManager @AssistedInject constructor(
syncState
}
override fun generateUpload(resource: LocalJtxICalObject): GeneratedResource {
logger.log(Level.FINE, "Preparing upload of icalobject #${resource.id}")
val os = ByteArrayOutputStream()
resource.write(os, ProdId(Constants.iCalProdId))
return GeneratedResource(
suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"),
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
)
}
override fun generateUpload(resource: LocalJtxICalObject): RequestBody =
SyncException.wrapWithLocalResource(resource) {
logger.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource)
val os = ByteArrayOutputStream()
resource.write(os, Constants.iCalProdId)
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
@@ -172,7 +167,7 @@ class JtxSyncManager @AssistedInject constructor(
try {
// parse the reader content and return the list of ICalObjects
icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection))
} catch (e: InvalidICalendarException) {
} catch (e: InvalidRemoteResourceException) {
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return

View File

@@ -71,7 +71,7 @@ class JtxSyncer @AssistedInject constructor(
val syncManager = jtxSyncManagerFactory.jtxSyncManager(
account,
httpClient,
httpClient.value,
syncResult,
localCollection,
remoteCollection,

View File

@@ -1,74 +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 at.bitfire.davdroid.network.HttpClientBuilder
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import io.ktor.http.Url
import io.ktor.http.isSuccess
import java.io.IOException
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
/**
* Downloads a separate resource that is referenced during synchronization, for instance in
* a vCard with `PHOTO:<external URL>`.
*
* The [ResourceDownloader] only sends authentication for URLs on the same domain as the
* original URL. For instance, if the vCard that references a photo is taken from
* `example.com` ([originalHost]), then [download] will send authentication
* when downloading `https://example.com/photo.jpg`, but not for `https://external-hoster.com/photo.jpg`.
*
* @param account account to build authentication from
* @param originalHost client only authenticates for the domain of this host
*/
class ResourceDownloader @AssistedInject constructor(
@Assisted private val account: Account,
@Assisted private val originalHost: String,
private val httpClientBuilder: Provider<HttpClientBuilder>,
private val logger: Logger
) {
@AssistedFactory
interface Factory {
fun create(account: Account, originalHost: String): ResourceDownloader
}
/**
* Downloads the given resource and returns it as an in-memory blob.
*
* Authentication is handled as described in [ResourceDownloader].
*
* @param url URL of the resource to download
*
* @return blob of requested resource, or `null` on error
*/
suspend fun download(url: Url): ByteArray? {
httpClientBuilder
.get()
.fromAccount(account, authDomain = originalHost) // restricts authentication to original domain
.followRedirects(true) // allow redirects
.buildKtor()
.use { httpClient ->
try {
val response = httpClient.get(url)
if (response.status.isSuccess())
return response.bodyAsBytes()
else
logger.warning("Couldn't download external resource (${response.status})")
} catch(e: IOException) {
logger.log(Level.SEVERE, "Couldn't download external resource", e)
}
}
return null
}
}

View File

@@ -18,12 +18,12 @@ class SyncException(cause: Throwable) : Exception(cause) {
// provide lambda wrappers for setting the local/remote resource
fun <T> wrapWithLocalResource(localResource: LocalResource?, body: () -> T): T =
fun <T> wrapWithLocalResource(localResource: LocalResource<*>?, body: () -> T): T =
runBlocking {
wrapWithLocalResourceSuspending(localResource, body)
}
suspend fun <T> wrapWithLocalResourceSuspending(localResource: LocalResource?, body: suspend () -> T): T {
suspend fun <T> wrapWithLocalResourceSuspending(localResource: LocalResource<*>?, body: suspend () -> T): T {
try {
return body()
} catch (e: SyncException) {
@@ -68,12 +68,12 @@ class SyncException(cause: Throwable) : Exception(cause) {
}
var localResource: LocalResource? = null
var localResource: LocalResource<*>? = null
private set
var remoteResource: HttpUrl? = null
private set
fun setLocalResourceIfNull(local: LocalResource): SyncException {
fun setLocalResourceIfNull(local: LocalResource<*>): SyncException {
if (localResource == null)
localResource = local

View File

@@ -8,31 +8,28 @@ import android.accounts.Account
import android.content.Context
import android.os.DeadObjectException
import android.os.RemoteException
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Error
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.QuotedStringUtils
import at.bitfire.dav4jvm.okhttp.DavCollection
import at.bitfire.dav4jvm.okhttp.DavResource
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
import at.bitfire.dav4jvm.okhttp.Response
import at.bitfire.dav4jvm.okhttp.exception.ConflictException
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.okhttp.exception.ForbiddenException
import at.bitfire.dav4jvm.okhttp.exception.GoneException
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import at.bitfire.dav4jvm.okhttp.exception.NotFoundException
import at.bitfire.dav4jvm.okhttp.exception.PreconditionFailedException
import at.bitfire.dav4jvm.okhttp.exception.ServiceUnavailableException
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalDAV
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.ConflictException
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.ForbiddenException
import at.bitfire.dav4jvm.exception.GoneException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.exception.PreconditionFailedException
import at.bitfire.dav4jvm.exception.ServiceUnavailableException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.dav4jvm.property.webdav.SyncToken
import at.bitfire.dav4jvm.property.webdav.WebDAV
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
@@ -49,7 +46,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import java.io.IOException
import java.net.HttpURLConnection
import java.security.cert.CertificateException
@@ -65,21 +62,21 @@ import javax.net.ssl.SSLHandshakeException
/**
* Synchronizes a local collection with a remote collection.
*
* @param LocalType type of local resources
* @param ResourceType type of local resources
* @param CollectionType type of local collection
* @param RemoteType type of remote collection
*
* @param account account to synchronize
* @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account]
* @param dataType data type to synchronize
* @param syncResult receiver for result of the synchronization (will be updated by [performSync])
* @param localCollection local collection to synchronize (interface to content provider)
* @param collection collection info in the database
* @param resync whether re-synchronization is requested
* @param account account to synchronize
* @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account]
* @param dataType data type to synchronize
* @param syncResult receiver for result of the synchronization (will be updated by [performSync])
* @param localCollection local collection to synchronize (interface to content provider)
* @param collection collection info in the database
* @param resync whether re-synchronization is requested
*/
abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCollection<LocalType>, RemoteType: DavCollection>(
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
val account: Account,
val httpClient: OkHttpClient,
val httpClient: HttpClient,
val dataType: SyncDataType,
val syncResult: SyncResult,
val localCollection: CollectionType,
@@ -136,7 +133,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
suspend fun performSync() = withContext(syncDispatcher) {
// dismiss previous error notifications
syncNotificationManager.dismissCollectionError(localCollectionTag = localCollection.tag)
syncNotificationManager.dismissInvalidResource(localCollectionTag = localCollection.tag)
try {
logger.info("Preparing synchronization")
@@ -212,7 +209,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
syncState = SyncState.fromSyncToken(result.first, initialSync)
furtherChanges = result.second
} catch (e: HttpException) {
if (e.errors.contains(Error(WebDAV.ValidSyncToken))) {
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
logger.info("Sync token invalid, performing initial sync")
initialSync = true
resetPresentRemotely()
@@ -250,7 +247,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
logger.info("Remote collection didn't change, no reason to sync")
} catch (potentiallyWrappedException: Throwable) {
var local: LocalResource? = null
var local: LocalResource<*>? = null
var remote: HttpUrl? = null
val e = SyncException.unwrap(potentiallyWrappedException) {
@@ -259,10 +256,9 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
}
when (e) {
/* LocalStorageException with cause DeadObjectException may occur when syncing takes too long
and process is demoted to cached. In this case, we re-throw to the base Syncer which will
treat it as a soft error and re-schedule the sync process. */
is LocalStorageException if e.cause is DeadObjectException ->
// DeadObjectException (may occur when syncing takes too long and process is demoted to cached):
// re-throw to base Syncer → will cause soft error and restart the sync process
is DeadObjectException ->
throw e
// sync was cancelled or account has been removed: re-throw to Syncer
@@ -335,7 +331,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
logger.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)")
val url = collection.url.newBuilder().addPathSegment(fileName).build()
val remote = DavResource(httpClient, url)
val remote = DavResource(httpClient.okHttpClient, url)
SyncException.wrapWithRemoteResourceSuspending(url) {
try {
runInterruptible {
@@ -390,26 +386,32 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
* @param forceAsNew whether the ETag (and Schedule-Tag) of [local] are ignored and the resource
* is created as a new resource on the server
*/
protected open suspend fun uploadDirty(local: LocalType, forceAsNew: Boolean = false) {
protected open suspend fun uploadDirty(local: ResourceType, forceAsNew: Boolean = false) {
val existingFileName = local.fileName
val fileName = if (existingFileName != null) {
// prepare upload (for UID etc), but ignore returned file name suggestion
local.prepareForUpload()
existingFileName
} else {
// prepare upload and use returned file name suggestion as new file name
local.prepareForUpload()
}
val upload = generateUpload(local)
val fileName = existingFileName ?: upload.suggestedFileName
val uploadUrl = collection.url.newBuilder().addPathSegment(fileName).build()
val remote = DavResource(httpClient, uploadUrl)
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
try {
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
if (existingFileName == null || forceAsNew) {
// create new resource on server
logger.info("Uploading new resource ${local.id} -> $fileName")
val bodyToUpload = generateUpload(local)
var newETag: String? = null
var newScheduleTag: String? = null
runInterruptible {
remote.put(
upload.requestBody,
bodyToUpload,
ifNoneMatch = true, // fails if there's already a resource with that name
callback = { response ->
newETag = GetETag.fromResponse(response)?.eTag
@@ -419,8 +421,10 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
)
}
logger.fine("Upload successful; new ETag=$newETag / Schedule-Tag=$newScheduleTag")
// success (no exception thrown)
onSuccessfulUpload(local, fileName, newETag, newScheduleTag, upload.onSuccessContext)
onSuccessfulUpload(local, fileName, newETag, newScheduleTag)
} else {
// update resource on server
@@ -428,12 +432,13 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
val ifETag = if (ifScheduleTag == null) local.eTag else null
logger.info("Uploading modified resource ${local.id} -> $fileName (if ETag=$ifETag / Schedule-Tag=$ifScheduleTag)")
val bodyToUpload = generateUpload(local)
var updatedETag: String? = null
var updatedScheduleTag: String? = null
runInterruptible {
remote.put(
upload.requestBody,
bodyToUpload,
ifETag = ifETag,
ifScheduleTag = ifScheduleTag,
callback = { response ->
@@ -444,8 +449,10 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
)
}
logger.fine("Upload successful; updated ETag=$updatedETag / Schedule-Tag=$updatedScheduleTag")
// success (no exception thrown)
onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag, upload.onSuccessContext)
onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag)
}
}
@@ -454,7 +461,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
is ForbiddenException -> {
// HTTP 403 Forbidden
// If and only if the upload failed because of missing permissions, treat it like 412.
if (ex.errors.contains(Error(WebDAV.NeedPrivileges)))
if (ex.errors.contains(Error.NEED_PRIVILEGES))
logger.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex)
else
throw e
@@ -485,6 +492,16 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
}
}
/**
* Called after a successful upload (either of a new or an updated resource) so that the local
* _dirty_ state can be reset.
*
* Note: [CalendarSyncManager] overrides this method to additionally store the updated SEQUENCE.
*/
protected open fun onSuccessfulUpload(local: ResourceType, newFileName: String, eTag: String?, scheduleTag: String?) {
local.clearDirty(Optional.of(newFileName), eTag, scheduleTag)
}
/**
* Generates the request body (iCalendar or vCard) from a local resource.
*
@@ -492,40 +509,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
*
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
*/
@VisibleForTesting
internal abstract fun generateUpload(resource: LocalType): GeneratedResource
/**
* Called after a successful upload (either of a new or an updated resource) so that the local
* _dirty_ state can be reset. Also updates some other local properties.
*
* @param local local resource that has been uploaded successfully
* @param newFileName file name that has been used for uploading
* @param eTag resulting `ETag` of the upload (from the server)
* @param scheduleTag resulting `Schedule-Tag` of the upload (from the server)
* @param context properties that have been generated before the upload and that shall be persisted by this method
*/
private fun onSuccessfulUpload(
local: LocalType,
newFileName: String,
eTag: String?,
scheduleTag: String?,
context: GeneratedResource.OnSuccessContext?
) {
logger.log(Level.FINE, "Upload successful", arrayOf(
"File name = $newFileName",
"ETag = $eTag",
"Schedule-Tag = $scheduleTag",
"context = $context"
))
// update SEQUENCE, if necessary
if (context?.sequence != null)
local.updateSequence(context.sequence)
// clear dirty flag and update ETag/Schedule-Tag
local.clearDirty(Optional.of(newFileName), eTag, scheduleTag)
}
protected abstract fun generateUpload(resource: ResourceType): RequestBody
/**
@@ -615,7 +599,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
return@listRemote
// ignore collections
if (response[ResourceType::class.java]?.types?.contains(WebDAV.Collection) == true)
if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true)
return@listRemote
val name = response.hrefName()
@@ -673,7 +657,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
davCollection.reportChanges(
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
false, null,
WebDAV.GetETag
GetETag.NAME
) { response, relation ->
when (relation) {
Response.HrefRelation.SELF ->
@@ -750,7 +734,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
private suspend fun querySyncState(): SyncState? {
var state: SyncState? = null
runInterruptible {
davCollection.propfind(0, CalDAV.GetCTag, WebDAV.SyncToken) { response, relation ->
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF)
state = syncState(response)
}
@@ -761,7 +745,7 @@ abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCo
/**
* Logs the exception, updates sync result and shows a notification to the user.
*/
private fun handleException(e: Throwable, local: LocalResource?, remote: HttpUrl?) {
private fun handleException(e: Throwable, local: LocalResource<*>?, remote: HttpUrl?) {
var message: String
when (e) {
is IOException -> {

View File

@@ -7,6 +7,7 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
@@ -15,19 +16,25 @@ import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
import at.bitfire.ical4android.TaskProvider
import com.google.common.base.Ascii
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.io.IOException
import java.util.logging.Level
import java.util.logging.Logger
@@ -111,10 +118,11 @@ class SyncNotificationManager @AssistedInject constructor(
message: String,
localCollection: LocalCollection<*>,
e: Throwable,
local: LocalResource?,
local: LocalResource<*>?,
remote: HttpUrl?
) = notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_SYNC_ERROR, tag = notificationTag) {
val contentIntent: Intent
var viewItemAction: NotificationCompat.Action? = null
if (e is UnauthorizedException) {
contentIntent = Intent(context, AccountSettingsActivity::class.java)
contentIntent.putExtra(
@@ -123,6 +131,8 @@ class SyncNotificationManager @AssistedInject constructor(
)
} else {
contentIntent = buildDebugInfoIntent(syncDataType, e, local, remote)
if (local != null)
viewItemAction = buildViewItemActionForLocalResource(local)
}
// to make the PendingIntent unique
@@ -152,6 +162,7 @@ class SyncNotificationManager @AssistedInject constructor(
)
.setPriority(priority)
.setCategory(NotificationCompat.CATEGORY_ERROR)
viewItemAction?.let { builder.addAction(it) }
builder.build()
}
@@ -159,7 +170,7 @@ class SyncNotificationManager @AssistedInject constructor(
/**
* Sends a notification to inform the user that a push notification has been received, the
* sync has been scheduled, but it still has not run.
* Use [dismissCollectionError] to dismiss the notification.
* Use [dismissInvalidResource] to dismiss the notification.
*
* @param dataType The type of data which was synced.
* @param notificationTag The tag to use for the notification.
@@ -200,7 +211,7 @@ class SyncNotificationManager @AssistedInject constructor(
*
* @param localCollectionTag The tag of the local collection which is used as notification tag also.
*/
fun dismissCollectionError(localCollectionTag: String) =
fun dismissInvalidResource(localCollectionTag: String) =
dismissNotification(localCollectionTag)
@@ -218,7 +229,7 @@ class SyncNotificationManager @AssistedInject constructor(
private fun buildDebugInfoIntent(
dataType: SyncDataType,
e: Throwable,
local: LocalResource?,
local: LocalResource<*>?,
remote: HttpUrl?
): Intent {
val builder = DebugInfoActivity.IntentBuilder(context)
@@ -228,13 +239,10 @@ class SyncNotificationManager @AssistedInject constructor(
if (local != null)
try {
// Add local resource summary, if available
builder.withLocalResource(local.getDebugSummary())
// Add URI to view local resource, if available
builder.withLocalResourceUri(local.getViewUri(context))
} catch (_: Throwable) {
// Ignore all potential exceptions that arise from providing information about the local resource
// Truncate the string to avoid the Intent to be > 1 MB, which doesn't work (IPC limit)
builder.withLocalResource(Ascii.truncate(local.toString(), 10000, "[…]"))
} catch (_: OutOfMemoryError) {
// For instance because of a huge contact photo; maybe we're lucky and can catch it
}
if (remote != null)
@@ -243,4 +251,33 @@ class SyncNotificationManager @AssistedInject constructor(
return builder.build()
}
/**
* Builds view action for notification, based on the given local resource.
*/
private fun buildViewItemActionForLocalResource(local: LocalResource<*>): NotificationCompat.Action? {
logger.log(Level.FINE, "Adding view action for local resource", local)
val intent = local.id?.let { id ->
when (local) {
is LocalContact ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
is LocalEvent ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
is LocalTask ->
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
else ->
null
}
}
return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
NotificationCompat.Action(
android.R.drawable.ic_menu_view,
context.getString(R.string.sync_error_view_item),
TaskStackBuilder.create(context)
.addNextIntent(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
else
null
}
}

View File

@@ -11,13 +11,12 @@ import android.os.DeadObjectException
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.ServiceType
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalDataStore
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.synctools.storage.LocalStorageException
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking
import java.util.logging.Level
@@ -48,7 +47,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
lateinit var collectionRepository: DavCollectionRepository
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -66,7 +65,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
syncNotificationManagerFactory.create(account)
}
val httpClient by lazy {
val httpClient = lazy {
httpClientBuilder.fromAccount(account).build()
}
@@ -260,30 +259,22 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
if (runSync)
sync(provider)
Unit
} catch (e: DeadObjectException) {
/* May happen when the remote process dies or (since Android 14) when IPC (for instance with the calendar provider)
is suddenly forbidden because our sync process was demoted from a "service process" to a "cached process". */
logger.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e)
syncResult.numDeadObjectExceptions++
} catch (e: InvalidAccountException) {
logger.log(Level.WARNING, "Account was removed during synchronization", e)
} catch (e: Exception) {
/* Handle sync exceptions. Note that most exceptions that occur during synchronization of a specific
collection are already handled in SyncManager. The exceptions here usually
- have occurred during Syncer operation (for instance when creating/deleting local collections),
- or have been re-thrown from SyncManager (like the wrapped DeadObjectException). */
when (e) {
/* May happen when the remote process dies or (since Android 14) when IPC (for instance with the calendar provider)
is suddenly forbidden because our sync process was demoted from a "service process" to a "cached process". */
is LocalStorageException if e.cause is DeadObjectException -> {
logger.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e)
syncResult.numDeadObjectExceptions++
}
is InvalidAccountException ->
logger.log(Level.WARNING, "Account was removed during synchronization", e)
else -> {
logger.log(Level.SEVERE, "Couldn't sync ${dataStore.authority}", e)
syncResult.numUnclassifiedErrors++ // Hard sync error
}
}
logger.log(Level.SEVERE, "Couldn't sync ${dataStore.authority}", e)
syncResult.numUnclassifiedErrors++ // Hard sync error
} finally {
if (httpClient.isInitialized())
httpClient.value.close()
logger.info("${dataStore.authority} sync of $account finished")
}
}

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