mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
Merge branch 'main-ose' into move-gplay-variant-to-ose
# Conflicts: # gradle/libs.versions.toml
This commit is contained in:
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -21,11 +21,7 @@ updates:
|
||||
app-dependencies:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# kotlin and ksp must be aligned and should only be updated together and manually
|
||||
- dependency-name: "org.jetbrains.kotlin:kotlin-stdlib"
|
||||
- dependency-name: "org.jetbrains.kotlin.plugin.compose"
|
||||
- dependency-name: "org.jetbrains.kotlin.android"
|
||||
- dependency-name: "com.google.devtools.ksp"
|
||||
# dependencies without semantic versioning
|
||||
- dependency-name: "com.github.bitfireat:cert4android"
|
||||
- dependency-name: "com.github.bitfireat:dav4jvm"
|
||||
- dependency-name: "com.github.bitfireat:synctools"
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
|
||||
55
.github/workflows/dependent-issues.yml
vendored
55
.github/workflows/dependent-issues.yml
vendored
@@ -1,55 +0,0 @@
|
||||
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 }}
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
|
||||
6
.github/workflows/test-dev.yml
vendored
6
.github/workflows/test-dev.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
|
||||
@@ -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, 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, pt_BR: pt-rBR, 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
|
||||
|
||||
@@ -19,15 +19,16 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 405050002
|
||||
versionName = "4.5.5-beta.1"
|
||||
versionCode = 405060100
|
||||
versionName = "4.5.6.1"
|
||||
|
||||
base.archivesName = "davx5-ose-$versionName"
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 36 // Android 16
|
||||
|
||||
buildConfigField("boolean", "customCertsUI", "true")
|
||||
// whether the build supports and allows to use custom certificates
|
||||
buildConfigField("boolean", "allowCustomCerts", "true")
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
@@ -198,9 +199,11 @@ dependencies {
|
||||
}
|
||||
|
||||
// third-party libs
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
@@ -236,6 +239,7 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
||||
// build variants (flavors)
|
||||
|
||||
@@ -24,3 +24,8 @@
|
||||
-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{ *; }
|
||||
|
||||
@@ -6,15 +6,15 @@ package at.bitfire.davdroid.db
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private lateinit var httpClient: OkHttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
@@ -45,11 +45,6 @@ class CollectionTest {
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
@@ -69,8 +64,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
@@ -125,8 +120,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -161,8 +156,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -195,8 +190,8 @@ class CollectionTest {
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,9 @@ 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
|
||||
@@ -19,25 +22,23 @@ import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@HiltAndroidTest
|
||||
class HttpClientTest {
|
||||
class HttpClientBuilderTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: Provider<HttpClientBuilder>
|
||||
|
||||
lateinit var httpClient: HttpClient
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
httpClient = httpClientBuilder.build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
}
|
||||
@@ -45,10 +46,22 @@ class HttpClientTest {
|
||||
@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)
|
||||
@@ -60,7 +73,9 @@ class HttpClientTest {
|
||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||
.addHeader("Set-Cookie", "cookie2=2")
|
||||
.setBody("Cookie set"))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
|
||||
val httpClient = httpClientBuilder.get().build()
|
||||
httpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
@@ -71,7 +86,7 @@ class HttpClientTest {
|
||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||
.addHeader("Set-Cookie", "cookie2=2a")
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
httpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
val header = server.takeRequest().getHeader("Cookie")
|
||||
@@ -79,7 +94,7 @@ class HttpClientTest {
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
httpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||
@@ -17,7 +17,7 @@ import javax.inject.Inject
|
||||
class OkhttpClientTest {
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -31,8 +31,8 @@ class OkhttpClientTest {
|
||||
@Test
|
||||
@SdkSuppress(maxSdkVersion = 34)
|
||||
fun testIcloudWithSettings() {
|
||||
httpClientBuilder.build().use { client ->
|
||||
client.okHttpClient
|
||||
val client = httpClientBuilder.build()
|
||||
client
|
||||
.newCall(
|
||||
Request.Builder()
|
||||
.get()
|
||||
@@ -41,6 +41,5 @@ class OkhttpClientTest {
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -174,7 +174,7 @@ class AccountRepositoryTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localAddressBookStore.updateAccount(account, newAccount) }
|
||||
coVerify { localAddressBookStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -182,7 +182,7 @@ class AccountRepositoryTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localCalendarStore.updateAccount(account, newAccount) }
|
||||
coVerify { localCalendarStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -191,7 +191,8 @@ class AccountRepositoryTest {
|
||||
every { tasksAppManager.getDataStore() } returns mockDataStore
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { mockDataStore.updateAccount(account, accountRepository.fromName(newName)) }
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { mockDataStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.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
|
||||
verifyOwnerAccountIs(provider, "InitialAccountName")
|
||||
|
||||
// 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
|
||||
verifyOwnerAccountIs(provider, "ChangedAccountName")
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 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 verifyOwnerAccountIs(provider: ContentProviderClient, expectedOwnerAccount: String) {
|
||||
provider.query(
|
||||
calendarUri,
|
||||
arrayOf(Calendars.OWNER_ACCOUNT),
|
||||
"${Calendars.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.name),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
val ownerAccount = cursor.getString(0)
|
||||
assertEquals(expectedOwnerAccount, ownerAccount)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -14,22 +13,15 @@ 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.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
||||
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
|
||||
@@ -73,93 +65,6 @@ 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:
|
||||
@@ -167,15 +72,16 @@ class LocalCalendarTest {
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
|
||||
val id = androidCalendar.addEvent(Entity(
|
||||
val entity = Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
AndroidEvent2.COLUMN_FLAGS to 123
|
||||
EventsContract.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
)
|
||||
val id = androidCalendar.addEvent(entity)
|
||||
|
||||
calendar.removeNotDirtyMarked(123)
|
||||
|
||||
@@ -210,13 +116,13 @@ class LocalCalendarTest {
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
AndroidEvent2.COLUMN_FLAGS to 123
|
||||
EventsContract.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
|
||||
val updated = calendar.markNotDirty(321)
|
||||
assertEquals(1, updated)
|
||||
assertEquals(321, androidCalendar.getEvent(id)?.flags)
|
||||
assertEquals(321, androidCalendar.getEvent(id)?.entityValues?.getAsInteger(EventsContract.COLUMN_FLAGS))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,8 +24,6 @@ import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
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.Rule
|
||||
@@ -242,19 +240,6 @@ class LocalGroupTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
|
||||
val group = newGroup(ab)
|
||||
assertNull(group.getContact().uid)
|
||||
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdate() {
|
||||
localTestAddressBookProvider.provide(account, provider) { ab ->
|
||||
|
||||
@@ -8,13 +8,14 @@ import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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
|
||||
@@ -41,7 +42,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -53,7 +54,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@@ -80,7 +81,6 @@ class CollectionsWithoutHomeSetRefresherTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class CollectionsWithoutHomeSetRefresherTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
refresherFactory.create(service, client).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.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
refresherFactory.create(service, client).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.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
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.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
|
||||
@@ -50,7 +51,7 @@ class DavResourceFinderTest {
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -59,7 +60,7 @@ class DavResourceFinderTest {
|
||||
lateinit var resourceFinderFactory: DavResourceFinder.Factory
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var finder: DavResourceFinder
|
||||
|
||||
@Before
|
||||
@@ -73,7 +74,7 @@ class DavResourceFinderTest {
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
||||
client = httpClientBuilder
|
||||
.authenticate(host = null, getCredentials = { credentials })
|
||||
.authenticate(domain = null, getCredentials = { credentials })
|
||||
.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
@@ -83,7 +84,6 @@ class DavResourceFinderTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@@ -92,9 +92,9 @@ class DavResourceFinderTest {
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
@@ -102,9 +102,9 @@ class DavResourceFinderTest {
|
||||
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
|
||||
@@ -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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -47,7 +48,7 @@ class HomeSetRefresherTest {
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -59,7 +60,7 @@ class HomeSetRefresherTest {
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@@ -86,7 +87,6 @@ class HomeSetRefresherTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
homeSetRefresherFactory.create(service, client)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
@@ -137,7 +137,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
@@ -174,7 +174,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
homeSetRefresherFactory.create(service, client).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.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
homeSetRefresherFactory.create(service, client).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.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
homeSetRefresherFactory.create(service, client).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.okHttpClient)
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ class HomeSetRefresherTest {
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
val refresher = homeSetRefresherFactory.create(service, client)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
@@ -36,7 +37,7 @@ class PrincipalsRefresherTest {
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -54,7 +55,7 @@ class PrincipalsRefresherTest {
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@@ -81,7 +82,6 @@ class PrincipalsRefresherTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ class PrincipalsRefresherTest {
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
principalsRefresher.create(service, client).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.okHttpClient).refreshPrincipals()
|
||||
principalsRefresher.create(service, client).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.okHttpClient).refreshPrincipals()
|
||||
principalsRefresher.create(service, client).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
|
||||
@@ -7,9 +7,10 @@ package at.bitfire.davdroid.servicedetection
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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
|
||||
@@ -33,7 +34,7 @@ class ServiceRefresherTest {
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -41,7 +42,7 @@ class ServiceRefresherTest {
|
||||
@Inject
|
||||
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@@ -68,7 +69,6 @@ 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.okHttpClient)
|
||||
serviceRefresherFactory.create(service, client)
|
||||
.discoverHomesets(baseUrl)
|
||||
|
||||
// Check home set has been saved correctly to database
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.Assert.assertTrue
|
||||
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 }
|
||||
}
|
||||
|
||||
// Assert again that we are now in the forever pending state
|
||||
assertTrue(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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -88,11 +88,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 +103,7 @@ class AndroidSyncFrameworkTest: SyncStatusObserver {
|
||||
State(pending = true, active = false) // ... and finishes, but stays pending
|
||||
)
|
||||
)
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import java.util.Optional
|
||||
|
||||
class LocalTestResource: LocalResource<Any> {
|
||||
class LocalTestResource: LocalResource {
|
||||
|
||||
override val id: Long? = null
|
||||
override var fileName: String? = null
|
||||
@@ -18,8 +19,6 @@ class LocalTestResource: LocalResource<Any> {
|
||||
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)
|
||||
@@ -32,8 +31,14 @@ class LocalTestResource: LocalResource<Any> {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
|
||||
override fun updateUid(uid: String) { /* no-op */ }
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun deleteLocal() = throw NotImplementedError()
|
||||
override fun resetDeleted() = throw NotImplementedError()
|
||||
|
||||
override fun getDebugSummary() = "Test Resource"
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.okhttp.PropStat
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var syncManagerFactory: TestSyncManager.Factory
|
||||
|
||||
@@ -194,7 +194,7 @@ class SyncerTest {
|
||||
}
|
||||
|
||||
override fun create(
|
||||
provider: ContentProviderClient,
|
||||
client: ContentProviderClient,
|
||||
fromCollection: Collection
|
||||
): LocalTestCollection? {
|
||||
throw NotImplementedError()
|
||||
@@ -202,13 +202,13 @@ class SyncerTest {
|
||||
|
||||
override fun getAll(
|
||||
account: Account,
|
||||
provider: ContentProviderClient
|
||||
client: ContentProviderClient
|
||||
): List<LocalTestCollection> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun update(
|
||||
provider: ContentProviderClient,
|
||||
client: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
fromCollection: Collection
|
||||
) {
|
||||
@@ -219,7 +219,7 @@ class SyncerTest {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
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.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.RequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class TestSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTestCollection,
|
||||
@Assisted collection: Collection,
|
||||
@@ -46,7 +46,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
interface Factory {
|
||||
fun create(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
collection: Collection
|
||||
@@ -54,7 +54,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
|
||||
davCollection = DavCollection(httpClient, collection.url)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
didQueryCapabilities = true
|
||||
|
||||
var cTag: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag) { response, rel ->
|
||||
if (rel == Response.HrefRelation.SELF)
|
||||
response[GetCTag::class.java]?.cTag?.let {
|
||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||
@@ -76,9 +76,13 @@ class TestSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
var didGenerateUpload = false
|
||||
override fun generateUpload(resource: LocalTestResource): RequestBody {
|
||||
override fun generateUpload(resource: LocalTestResource): GeneratedResource {
|
||||
didGenerateUpload = true
|
||||
return resource.toString().toRequestBody()
|
||||
return GeneratedResource(
|
||||
suggestedFileName = resource.fileName ?: "generated-file.txt",
|
||||
requestBody = resource.toString().toRequestBody(),
|
||||
onSuccessContext = GeneratedResource.OnSuccessContext()
|
||||
)
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 {
|
||||
@@ -30,6 +32,16 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -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("localResource"))
|
||||
assertEquals(expected.toString(), intent.getStringExtra(DebugInfoActivity.EXTRA_LOCAL_RESOURCE_SUMMARY))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -9,13 +9,14 @@ 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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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
|
||||
@@ -48,13 +49,13 @@ class QueryChildDocumentsOperationTest {
|
||||
lateinit var operation: QueryChildDocumentsOperation
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
private lateinit var mount: WebDavMount
|
||||
private lateinit var rootDocument: WebDavDocument
|
||||
@@ -84,7 +85,6 @@ class QueryChildDocumentsOperationTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
|
||||
runBlocking {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:exported="false"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
|
||||
@@ -5,7 +5,6 @@ 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.
|
||||
@@ -17,7 +16,7 @@ object Constants {
|
||||
|
||||
// product IDs for iCalendar/vCard
|
||||
|
||||
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
|
||||
val iCalProdId = "DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion"
|
||||
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
|
||||
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
@@ -19,6 +20,7 @@ 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
|
||||
@@ -166,9 +168,9 @@ data class Collection(
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
resourceType.types.contains(CardDAV.Addressbook) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(CalDAV.Calendar) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(CalDAV.Subscribed) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
@@ -8,10 +8,11 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.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
|
||||
|
||||
@@ -46,7 +47,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(ResourceType.PRINCIPAL))
|
||||
if (!resourceType.types.contains(WebDAV.Principal))
|
||||
return null
|
||||
|
||||
// Try getting the display name of the principal
|
||||
|
||||
@@ -39,6 +39,10 @@ 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(
|
||||
@@ -79,7 +83,10 @@ class LogManager @Inject constructor(
|
||||
|
||||
// root logger: set default log level and always log to logcat
|
||||
val rootLogger = Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
rootLogger.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.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
|
||||
|
||||
// log to file, if requested
|
||||
|
||||
@@ -6,22 +6,31 @@ 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].
|
||||
*
|
||||
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
|
||||
* Requests for certificates / private keys for other aliases than the specified one
|
||||
* will be ignored.
|
||||
*
|
||||
* @param alias alias of the desired certificate / private key
|
||||
*/
|
||||
class ClientCertKeyManager @AssistedInject constructor(
|
||||
@Assisted private val alias: String,
|
||||
@ApplicationContext private val context: Context
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): X509ExtendedKeyManager() {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -29,19 +38,42 @@ 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?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
override fun getCertificateChain(forAlias: String): Array<X509Certificate>? {
|
||||
if (forAlias != alias)
|
||||
return null
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
val alreadyInstalled = conscryptInstalled()
|
||||
if (!alreadyInstalled) {
|
||||
// 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) }
|
||||
|
||||
}
|
||||
@@ -1,315 +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.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.ForegroundTracker
|
||||
import com.google.common.net.HttpHeaders
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Cache
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
|
||||
class HttpClient(
|
||||
val okHttpClient: OkHttpClient
|
||||
): AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
|
||||
|
||||
// builder
|
||||
|
||||
/**
|
||||
* Builder for the [HttpClient].
|
||||
*
|
||||
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
|
||||
* there's only one [Builder] object and setting properties from one location would influence the others.
|
||||
*
|
||||
* To generate multiple clients, inject and use `Provider<HttpClient.Builder>` instead.
|
||||
*/
|
||||
class Builder @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext private val context: Context,
|
||||
defaultLogger: Logger,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val keyManagerFactory: ClientCertKeyManager.Factory,
|
||||
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
// property setters/getters
|
||||
|
||||
private var logger: Logger = defaultLogger
|
||||
fun setLogger(logger: Logger): Builder {
|
||||
this.logger = logger
|
||||
return this
|
||||
}
|
||||
|
||||
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder {
|
||||
loggerInterceptorLevel = level
|
||||
return this
|
||||
}
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar = MemoryCookieStore()
|
||||
fun setCookieStore(cookieStore: CookieJar): Builder {
|
||||
this.cookieStore = cookieStore
|
||||
return this
|
||||
}
|
||||
|
||||
private var authenticationInterceptor: Interceptor? = null
|
||||
private var authenticator: Authenticator? = null
|
||||
private var certificateAlias: String? = null
|
||||
fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): Builder {
|
||||
val credentials = getCredentials()
|
||||
if (credentials.authState != null) {
|
||||
// OAuth
|
||||
authenticationInterceptor = oAuthInterceptorFactory.create(
|
||||
readAuthState = {
|
||||
// We don't use the "credentials" object from above because it may contain an outdated access token
|
||||
// when readAuthState is called. Instead, we fetch the up-to-date auth-state.
|
||||
getCredentials().authState
|
||||
},
|
||||
writeAuthState = { authState ->
|
||||
updateAuthState?.invoke(authState)
|
||||
}
|
||||
|
||||
)
|
||||
|
||||
} else if (credentials.username != null && credentials.password != null) {
|
||||
// basic/digest auth
|
||||
val authHandler = BasicDigestAuthHandler(
|
||||
domain = UrlUtils.hostToDomain(host),
|
||||
username = credentials.username,
|
||||
password = credentials.password.asCharArray(),
|
||||
insecurePreemptive = true
|
||||
)
|
||||
authenticationInterceptor = authHandler
|
||||
authenticator = authHandler
|
||||
}
|
||||
|
||||
// client certificate
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private var followRedirects = false
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
followRedirects = follow
|
||||
return this
|
||||
}
|
||||
|
||||
private var cache: Cache? = null
|
||||
@Suppress("unused")
|
||||
fun withDiskCache(maxSize: Long = 10*1024*1024): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
logger.fine("Using disk cache: $cacheDir")
|
||||
cache = Cache(cacheDir, maxSize)
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
// convenience builders from other classes
|
||||
|
||||
/**
|
||||
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
|
||||
*
|
||||
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
|
||||
*
|
||||
* @param account the account to take authentication from
|
||||
* @param onlyHost if set: only authenticate for this host name
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
@WorkerThread
|
||||
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
authenticate(
|
||||
host = onlyHost,
|
||||
getCredentials = {
|
||||
accountSettings.credentials()
|
||||
},
|
||||
updateAuthState = { authState ->
|
||||
accountSettings.updateAuthState(authState)
|
||||
}
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [fromAccount], but can be called on any thread.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
|
||||
fromAccount(account, onlyHost)
|
||||
}
|
||||
|
||||
|
||||
// actual builder
|
||||
|
||||
fun build(): HttpClient {
|
||||
val okBuilder = OkHttpClient.Builder()
|
||||
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
|
||||
// traffic within a minute, a sync will be cancelled.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
|
||||
// don't allow redirects by default because it would break PROPFIND handling
|
||||
.followRedirects(followRedirects)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addInterceptor(UserAgentInterceptor)
|
||||
|
||||
// connection-private cookie store
|
||||
.cookieJar(cookieStore)
|
||||
|
||||
// allow cleartext and TLS 1.2+
|
||||
.connectionSpecs(listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.MODERN_TLS
|
||||
))
|
||||
|
||||
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
|
||||
.addInterceptor(BrotliInterceptor)
|
||||
|
||||
// add cache, if requested
|
||||
.cache(cache)
|
||||
|
||||
// app-wide custom proxy support
|
||||
buildProxy(okBuilder)
|
||||
|
||||
// add authentication
|
||||
buildAuthentication(okBuilder)
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.redactHeader(HttpHeaders.AUTHORIZATION)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.COOKIE)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE)
|
||||
loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2)
|
||||
loggingInterceptor.level = loggerInterceptorLevel
|
||||
okBuilder.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
return HttpClient(okBuilder.build())
|
||||
}
|
||||
|
||||
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
|
||||
// basic/digest auth and OAuth
|
||||
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
|
||||
authenticator?.let { okBuilder.authenticator(it) }
|
||||
|
||||
// client certificate
|
||||
val keyManager: KeyManager? = certificateAlias?.let { alias ->
|
||||
try {
|
||||
val manager = keyManagerFactory.create(alias)
|
||||
logger.fine("Using certificate $alias for authentication")
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
|
||||
|
||||
manager
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// cert4android integration
|
||||
val certManager = CustomCertManager(
|
||||
context = context,
|
||||
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
|
||||
appInForeground = if (BuildConfig.customCertsUI)
|
||||
ForegroundTracker.inForeground // interactive mode
|
||||
else
|
||||
null // non-interactive mode
|
||||
)
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
/* km = */ if (keyManager != null) arrayOf(keyManager) else null,
|
||||
/* tm = */ arrayOf(certManager),
|
||||
/* random = */ null
|
||||
)
|
||||
okBuilder
|
||||
.sslSocketFactory(sslContext.socketFactory, certManager)
|
||||
.hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier))
|
||||
}
|
||||
|
||||
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
|
||||
try {
|
||||
val proxyTypeValue = settingsManager.getInt(Settings.PROXY_TYPE)
|
||||
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
|
||||
// we set our own proxy
|
||||
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
|
||||
InetSocketAddress(
|
||||
settingsManager.getString(Settings.PROXY_HOST),
|
||||
settingsManager.getInt(Settings.PROXY_PORT)
|
||||
)
|
||||
}
|
||||
val proxy =
|
||||
when (proxyTypeValue) {
|
||||
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
|
||||
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
|
||||
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
|
||||
else -> throw IllegalArgumentException("Invalid proxy type")
|
||||
}
|
||||
okBuilder.proxy(proxy)
|
||||
logger.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* 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 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
|
||||
|
||||
engine {
|
||||
// okhttp engine configuration here
|
||||
|
||||
config {
|
||||
// OkHttpClient.Builder configuration here
|
||||
configureOkHttp(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alreadyBuilt = true
|
||||
return client
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
@@ -32,8 +32,8 @@ import javax.inject.Inject
|
||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
*/
|
||||
class NextcloudLoginFlow @Inject constructor(
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): AutoCloseable {
|
||||
httpClientBuilder: HttpClientBuilder
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val FLOW_V1_PATH = "index.php/login/flow"
|
||||
@@ -43,12 +43,7 @@ class NextcloudLoginFlow @Inject constructor(
|
||||
const val DAV_PATH = "remote.php/dav"
|
||||
}
|
||||
|
||||
val httpClient = httpClientBuilder
|
||||
.build()
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
val httpClient = httpClientBuilder.build()
|
||||
|
||||
|
||||
// Login flow state
|
||||
@@ -120,7 +115,7 @@ class NextcloudLoginFlow @Inject constructor(
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val response = runInterruptible {
|
||||
httpClient.okHttpClient.newCall(postRq).execute()
|
||||
httpClient.newCall(postRq).execute()
|
||||
}
|
||||
|
||||
if (response.code != HttpURLConnection.HTTP_OK)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -105,7 +106,7 @@ class PushMessageHandler @Inject constructor(
|
||||
try {
|
||||
parser.setInput(StringReader(message))
|
||||
|
||||
XmlReader(parser).processTag(DavPushMessage.NAME) {
|
||||
XmlReader(parser).processTag(WebDAVPush.PushMessage) {
|
||||
val pushMessage = DavPushMessage.Factory.create(parser)
|
||||
topic = pushMessage.topic?.topic
|
||||
}
|
||||
|
||||
@@ -11,22 +11,17 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.AuthSecret
|
||||
import at.bitfire.dav4jvm.property.push.PushRegister
|
||||
import at.bitfire.dav4jvm.property.push.PushResource
|
||||
import at.bitfire.dav4jvm.property.push.Subscription
|
||||
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
|
||||
import at.bitfire.dav4jvm.property.push.WebPushSubscription
|
||||
import at.bitfire.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.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
@@ -41,6 +36,7 @@ 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
|
||||
@@ -65,7 +61,7 @@ class PushRegistrationManager @Inject constructor(
|
||||
private val accountRepository: Lazy<AccountRepository>,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
@@ -180,10 +176,9 @@ class PushRegistrationManager @Inject constructor(
|
||||
return
|
||||
|
||||
val account = accountRepository.get().fromName(service.accountName)
|
||||
httpClientBuilder.get()
|
||||
val httpClient = httpClientBuilder.get()
|
||||
.fromAccountAsync(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
for (collection in subscribeTo)
|
||||
try {
|
||||
val expires = collection.pushSubscriptionExpires
|
||||
@@ -200,7 +195,6 @@ class PushRegistrationManager @Inject constructor(
|
||||
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when no subscription is available (anymore) for the given service.
|
||||
@@ -230,7 +224,7 @@ class PushRegistrationManager @Inject constructor(
|
||||
* @param collection collection to subscribe to
|
||||
* @param endpoint subscription to register
|
||||
*/
|
||||
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
|
||||
private suspend fun subscribe(httpClient: OkHttpClient, collection: Collection, endpoint: PushEndpoint) {
|
||||
// requested expiration time: 3 days
|
||||
val requestedExpiration = Instant.now() + Duration.ofDays(3)
|
||||
|
||||
@@ -238,26 +232,26 @@ class PushRegistrationManager @Inject constructor(
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", true)
|
||||
serializer.insertTag(PushRegister.NAME) {
|
||||
serializer.insertTag(Subscription.NAME) {
|
||||
serializer.insertTag(WebDAVPush.PushRegister) {
|
||||
serializer.insertTag(WebDAVPush.Subscription) {
|
||||
// subscription URL
|
||||
serializer.insertTag(WebPushSubscription.NAME) {
|
||||
serializer.insertTag(PushResource.NAME) {
|
||||
serializer.insertTag(WebDAVPush.WebPushSubscription) {
|
||||
serializer.insertTag(WebDAVPush.PushResource) {
|
||||
text(endpoint.url)
|
||||
}
|
||||
endpoint.pubKeySet?.let { pubKeySet ->
|
||||
serializer.insertTag(SubscriptionPublicKey.NAME) {
|
||||
serializer.insertTag(WebDAVPush.SubscriptionPublicKey) {
|
||||
attribute(null, "type", "p256dh")
|
||||
text(pubKeySet.pubKey)
|
||||
}
|
||||
serializer.insertTag(AuthSecret.NAME) {
|
||||
serializer.insertTag(WebDAVPush.AuthSecret) {
|
||||
text(pubKeySet.auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// requested expiration
|
||||
serializer.insertTag(PushRegister.EXPIRES) {
|
||||
serializer.insertTag(WebDAVPush.Expires) {
|
||||
text(HttpUtils.formatDate(requestedExpiration))
|
||||
}
|
||||
}
|
||||
@@ -265,7 +259,7 @@ class PushRegistrationManager @Inject constructor(
|
||||
|
||||
runInterruptible(ioDispatcher) {
|
||||
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
|
||||
DavCollection(httpClient, collection.url).post(xml) { response ->
|
||||
if (response.isSuccessful) {
|
||||
// update subscription URL and expiration in DB
|
||||
val subscriptionUrl = response.header("Location")
|
||||
@@ -294,22 +288,20 @@ class PushRegistrationManager @Inject constructor(
|
||||
return
|
||||
|
||||
val account = accountRepository.get().fromName(service.accountName)
|
||||
httpClientBuilder.get()
|
||||
val httpClient = httpClientBuilder.get()
|
||||
.fromAccountAsync(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
for (collection in from)
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
logger.info("Unsubscribing Push from ${collection.url}")
|
||||
unsubscribe(httpClient, collection, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
|
||||
private suspend fun unsubscribe(httpClient: OkHttpClient, collection: Collection, url: HttpUrl) {
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
DavResource(httpClient.okHttpClient, url).delete {
|
||||
DavResource(httpClient, url).delete {
|
||||
// deleted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ class AccountRepository @Inject constructor(
|
||||
/**
|
||||
* Renames an account.
|
||||
*
|
||||
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
|
||||
* **Note**: 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
|
||||
@@ -218,22 +218,27 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
try {
|
||||
// update address books
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount, null)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update calendar events
|
||||
localCalendarStore.get().updateAccount(oldAccount, newAccount)
|
||||
val store = localCalendarStore.get()
|
||||
store.acquireContentProvider(true)?.use { client ->
|
||||
store.updateAccount(oldAccount, newAccount, client)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update account_name of local tasks
|
||||
val dataStore = tasksAppManager.get().getDataStore()
|
||||
dataStore?.updateAccount(oldAccount, newAccount)
|
||||
val store = tasksAppManager.get().getDataStore()
|
||||
store?.acquireContentProvider(true)?.use { client ->
|
||||
store.updateAccount(oldAccount, newAccount, client)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
|
||||
}
|
||||
|
||||
@@ -6,23 +6,15 @@ package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.GoneException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.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.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
@@ -30,7 +22,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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -43,6 +35,7 @@ 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
|
||||
@@ -58,7 +51,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
) {
|
||||
@@ -172,10 +165,10 @@ class DavCollectionRepository @Inject constructor(
|
||||
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
httpClientBuilder.get().fromAccount(account).build().use { httpClient ->
|
||||
val httpClient = httpClientBuilder.get().fromAccount(account).build()
|
||||
runInterruptible(ioDispatcher) {
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, collection.url).delete {
|
||||
DavResource(httpClient, collection.url).delete {
|
||||
// success, otherwise an exception would have been thrown → delete locally, too
|
||||
delete(collection)
|
||||
}
|
||||
@@ -189,7 +182,6 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
|
||||
|
||||
@@ -289,12 +281,11 @@ class DavCollectionRepository @Inject constructor(
|
||||
// helpers
|
||||
|
||||
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
||||
httpClientBuilder.get()
|
||||
val httpClient = httpClientBuilder.get()
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible(ioDispatcher) {
|
||||
DavResource(httpClient.okHttpClient, url).mkCol(
|
||||
DavResource(httpClient, url).mkCol(
|
||||
xmlBody = xmlBody,
|
||||
method = method
|
||||
) {
|
||||
@@ -302,7 +293,6 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateMkColXml(
|
||||
addressBook: Boolean,
|
||||
@@ -320,27 +310,27 @@ class DavCollectionRepository @Inject constructor(
|
||||
setOutput(writer)
|
||||
|
||||
startDocument("UTF-8", null)
|
||||
setPrefix("", NS_WEBDAV)
|
||||
setPrefix("CAL", NS_CALDAV)
|
||||
setPrefix("CARD", NS_CARDDAV)
|
||||
setPrefix("", WebDAV.NS_WEBDAV)
|
||||
setPrefix("CAL", CalDAV.NS_CALDAV)
|
||||
setPrefix("CARD", CardDAV.NS_CARDDAV)
|
||||
|
||||
if (addressBook)
|
||||
startTag(NS_WEBDAV, "mkcol")
|
||||
startTag(WebDAV.NS_WEBDAV, "mkcol")
|
||||
else
|
||||
startTag(NS_CALDAV, "mkcalendar")
|
||||
startTag(CalDAV.NS_CALDAV, "mkcalendar")
|
||||
|
||||
insertTag(DavResource.SET) {
|
||||
insertTag(DavResource.PROP) {
|
||||
insertTag(ResourceType.NAME) {
|
||||
insertTag(ResourceType.COLLECTION)
|
||||
insertTag(WebDAV.Set) {
|
||||
insertTag(WebDAV.Prop) {
|
||||
insertTag(WebDAV.ResourceType) {
|
||||
insertTag(WebDAV.Collection)
|
||||
if (addressBook)
|
||||
insertTag(ResourceType.ADDRESSBOOK)
|
||||
insertTag(CardDAV.Addressbook)
|
||||
else
|
||||
insertTag(ResourceType.CALENDAR)
|
||||
insertTag(CalDAV.Calendar)
|
||||
}
|
||||
|
||||
displayName?.let {
|
||||
insertTag(DisplayName.NAME) {
|
||||
insertTag(WebDAV.DisplayName) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
@@ -348,7 +338,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
if (addressBook) {
|
||||
// addressbook-specific properties
|
||||
description?.let {
|
||||
insertTag(AddressbookDescription.NAME) {
|
||||
insertTag(CardDAV.AddressbookDescription) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
@@ -356,27 +346,27 @@ class DavCollectionRepository @Inject constructor(
|
||||
} else {
|
||||
// calendar-specific properties
|
||||
description?.let {
|
||||
insertTag(CalendarDescription.NAME) {
|
||||
insertTag(CalDAV.CalendarDescription) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
color?.let {
|
||||
insertTag(CalendarColor.NAME) {
|
||||
insertTag(CalDAV.CalendarColor) {
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
}
|
||||
}
|
||||
timezoneId?.let { id ->
|
||||
insertTag(CalendarTimezoneId.NAME) {
|
||||
insertTag(CalDAV.CalendarTimezoneId) {
|
||||
text(id)
|
||||
}
|
||||
getVTimeZone(id)?.let { vTimezone ->
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
insertTag(CalDAV.CalendarTimezone) {
|
||||
text(
|
||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||
Calendar(
|
||||
PropertyList<Property>().apply {
|
||||
add(Version.VERSION_2_0)
|
||||
add(Constants.iCalProdId)
|
||||
add(ProdId(Constants.iCalProdId))
|
||||
},
|
||||
ComponentList(
|
||||
listOf(vTimezone)
|
||||
@@ -388,19 +378,19 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
|
||||
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
||||
insertTag(SupportedCalendarComponentSet.NAME) {
|
||||
insertTag(CalDAV.SupportedCalendarComponentSet) {
|
||||
// Only if there's at least one not explicitly supported calendar component set,
|
||||
// otherwise don't include the property, which means "supports everything".
|
||||
if (supportsVEVENT)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VEVENT)
|
||||
}
|
||||
if (supportsVTODO)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VTODO)
|
||||
}
|
||||
if (supportsVJOURNAL)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VJOURNAL)
|
||||
}
|
||||
}
|
||||
@@ -409,9 +399,9 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
if (addressBook)
|
||||
endTag(NS_WEBDAV, "mkcol")
|
||||
endTag(WebDAV.NS_WEBDAV, "mkcol")
|
||||
else
|
||||
endTag(NS_CALDAV, "mkcalendar")
|
||||
endTag(CalDAV.NS_CALDAV, "mkcalendar")
|
||||
endDocument()
|
||||
}
|
||||
return writer.toString()
|
||||
|
||||
@@ -6,4 +6,8 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.vcard4android.Contact
|
||||
|
||||
interface LocalAddress: LocalResource<Contact>
|
||||
interface LocalAddress: LocalResource {
|
||||
|
||||
fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
||||
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class LocalAddressBookStore @Inject constructor(
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
override fun create(client: 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, provider)
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, client)
|
||||
|
||||
// update settings
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
@@ -125,12 +125,12 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return addressBookAccount
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalAddressBook> =
|
||||
getAddressBookAccounts(account).map { addressBookAccount ->
|
||||
localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
localAddressBookFactory.create(account, addressBookAccount, client)
|
||||
}
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
var currentAccount = localCollection.addressBookAccount
|
||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||
|
||||
@@ -167,8 +167,9 @@ 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) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { addressBookAccount ->
|
||||
|
||||
@@ -4,18 +4,16 @@
|
||||
|
||||
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
|
||||
@@ -60,52 +58,42 @@ class LocalCalendar @AssistedInject constructor(
|
||||
androidCalendar.writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
||||
@VisibleForTesting
|
||||
internal val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
||||
|
||||
|
||||
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
val mapped = LegacyAndroidEventBuilder2(
|
||||
calendar = androidCalendar,
|
||||
event = event,
|
||||
syncId = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = flags
|
||||
).build()
|
||||
recurringCalendar.addEventAndExceptions(mapped)
|
||||
fun add(event: EventAndExceptions): Long {
|
||||
return recurringCalendar.addEventAndExceptions(event)
|
||||
}
|
||||
|
||||
override fun findDeleted(): List<LocalEvent> {
|
||||
val result = LinkedList<LocalEvent>()
|
||||
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
|
||||
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
|
||||
recurringCalendar.iterateEventAndExceptions(
|
||||
"${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null
|
||||
) { eventAndExceptions ->
|
||||
result += LocalEvent(recurringCalendar, eventAndExceptions)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
|
||||
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
|
||||
recurringCalendar.iterateEventAndExceptions(
|
||||
"${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null
|
||||
) { eventAndExceptions ->
|
||||
dirty += LocalEvent(recurringCalendar, eventAndExceptions)
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
||||
recurringCalendar.findEventAndExceptions("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
||||
LocalEvent(recurringCalendar, it)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int) =
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
|
||||
contentValuesOf(EventsContract.COLUMN_FLAGS to flags),
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
@@ -125,7 +113,7 @@ class LocalCalendar @AssistedInject constructor(
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
AND ${AndroidEvent2.COLUMN_FLAGS}=?
|
||||
AND ${EventsContract.COLUMN_FLAGS}=?
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString(), flags.toString())
|
||||
) { values ->
|
||||
@@ -141,95 +129,9 @@ class LocalCalendar @AssistedInject constructor(
|
||||
|
||||
override fun forgetETags() {
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
|
||||
contentValuesOf(EventsContract.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,6 +26,7 @@ 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(
|
||||
@@ -138,12 +139,18 @@ class LocalCalendarStore @Inject constructor(
|
||||
return values
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
|
||||
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
|
||||
)
|
||||
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
|
||||
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
client.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalCalendar) {
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
/**
|
||||
* 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> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
|
||||
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
|
||||
@@ -22,16 +27,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 = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
const val COLUMN_FLAGS = RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = RawContacts.SYNC3
|
||||
}
|
||||
|
||||
override val addressBook: LocalAddressBook
|
||||
@@ -63,25 +68,6 @@ 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.
|
||||
*/
|
||||
@@ -97,7 +83,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
values.put(RawContacts.DIRTY, 0)
|
||||
|
||||
// Android 7 workaround
|
||||
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
||||
@@ -110,7 +96,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
|
||||
val values = contentValuesOf(RawContacts.DIRTY to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
@@ -130,6 +116,13 @@ 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()
|
||||
}
|
||||
@@ -139,6 +132,30 @@ 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
|
||||
@@ -199,6 +216,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.
|
||||
@@ -76,7 +77,8 @@ 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)
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?)
|
||||
|
||||
}
|
||||
@@ -4,160 +4,75 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.LegacyAndroidCalendar
|
||||
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||
import at.bitfire.synctools.storage.LocalStorageException
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
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: AndroidEvent2
|
||||
) : LocalResource<Event> {
|
||||
val androidEvent: EventAndExceptions
|
||||
) : LocalResource {
|
||||
|
||||
val calendar: AndroidCalendar
|
||||
get() = recurringCalendar.calendar
|
||||
|
||||
private val mainValues = androidEvent.main.entityValues
|
||||
|
||||
override val id: Long
|
||||
get() = androidEvent.id
|
||||
get() = mainValues.getAsLong(Events._ID)
|
||||
|
||||
override val fileName: String?
|
||||
get() = androidEvent.syncId
|
||||
get() = mainValues.getAsString(Events._SYNC_ID)
|
||||
|
||||
override val eTag: String?
|
||||
get() = androidEvent.eTag
|
||||
get() = mainValues.getAsString(EventsContract.COLUMN_ETAG)
|
||||
|
||||
override val scheduleTag: String?
|
||||
get() = androidEvent.scheduleTag
|
||||
get() = mainValues.getAsString(EventsContract.COLUMN_SCHEDULE_TAG)
|
||||
|
||||
override val flags: Int
|
||||
get() = androidEvent.flags
|
||||
get() = mainValues.getAsInteger(EventsContract.COLUMN_FLAGS) ?: 0
|
||||
|
||||
|
||||
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
val eventAndExceptions = LegacyAndroidEventBuilder2(
|
||||
calendar = androidEvent.calendar,
|
||||
event = data,
|
||||
syncId = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = flags
|
||||
).build()
|
||||
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
|
||||
fun update(data: EventAndExceptions) {
|
||||
recurringCalendar.updateEventAndExceptions(id, data)
|
||||
}
|
||||
|
||||
|
||||
private var _event: Event? = null
|
||||
/**
|
||||
* Retrieves the event from the content provider and converts it to a legacy data object.
|
||||
*
|
||||
* Caches the result: the content provider is only queried at the first call and then
|
||||
* this method always returns the same object.
|
||||
*
|
||||
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
|
||||
*/
|
||||
@Synchronized
|
||||
fun getCachedEvent(): Event {
|
||||
_event?.let { return it }
|
||||
|
||||
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
|
||||
val event = legacyCalendar.getEvent(androidEvent.id)
|
||||
?: throw LocalStorageException("Event ${androidEvent.id} not found")
|
||||
|
||||
_event = event
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the [Event] that should actually be uploaded:
|
||||
*
|
||||
* 1. Takes the [getCachedEvent].
|
||||
* 2. Calculates the new SEQUENCE.
|
||||
*
|
||||
* _Note: This method currently modifies the object returned by [getCachedEvent], but
|
||||
* this may change in the future._
|
||||
*
|
||||
* @return data object that should be used for uploading
|
||||
*/
|
||||
fun eventToUpload(): Event {
|
||||
val event = getCachedEvent()
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = event.isOrganizer == true
|
||||
|
||||
// Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence):
|
||||
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
|
||||
// - If it's non-null, the event already exists on the server, so increase by one.
|
||||
val sequence = event.sequence
|
||||
if (sequence != null && (nonGroupScheduled || weAreOrganizer))
|
||||
event.sequence = sequence + 1
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the SEQUENCE of the event in the content provider.
|
||||
*
|
||||
* @param sequence new sequence value
|
||||
*/
|
||||
fun updateSequence(sequence: Int?) {
|
||||
androidEvent.update(contentValuesOf(
|
||||
AndroidEvent2.COLUMN_SEQUENCE to sequence
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates and sets a new UID in the calendar provider, if no UID is already set.
|
||||
* It also returns the desired file name for the event for further processing in the sync algorithm.
|
||||
*
|
||||
* @return file name to use at upload
|
||||
*/
|
||||
override fun prepareForUpload(): String {
|
||||
// make sure that UID is set
|
||||
val uid: String = getCachedEvent().uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// persist to calendar provider
|
||||
val values = contentValuesOf(Events.UID_2445 to newUid)
|
||||
androidEvent.update(values)
|
||||
|
||||
// update in cached event data object
|
||||
getCachedEvent().uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
val uidIsGoodFilename = uid.all { char ->
|
||||
// see RFC 2396 2.2
|
||||
char.isLetterOrDigit() || arrayOf( // allow letters and digits
|
||||
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
|
||||
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
|
||||
).contains(char)
|
||||
}
|
||||
return if (uidIsGoodFilename)
|
||||
"$uid.ics" // use UID as file name
|
||||
else
|
||||
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
val values = contentValuesOf(
|
||||
Events.DIRTY to 0,
|
||||
AndroidEvent2.COLUMN_ETAG to eTag,
|
||||
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
|
||||
EventsContract.COLUMN_ETAG to eTag,
|
||||
EventsContract.COLUMN_SCHEDULE_TAG to scheduleTag
|
||||
)
|
||||
if (fileName.isPresent)
|
||||
values.put(Events._SYNC_ID, fileName.get())
|
||||
androidEvent.update(values)
|
||||
calendar.updateEventRow(id, values)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
androidEvent.update(contentValuesOf(
|
||||
AndroidEvent2.COLUMN_FLAGS to flags
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -166,9 +81,28 @@ class LocalEvent(
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
androidEvent.update(contentValuesOf(
|
||||
calendar.updateEventRow(id, 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)
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -15,7 +16,6 @@ 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,26 +140,6 @@ 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")
|
||||
@@ -227,6 +207,13 @@ 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()
|
||||
}
|
||||
@@ -236,6 +223,22 @@ 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
|
||||
|
||||
|
||||
@@ -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(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||
override fun create(client: 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, provider, values)
|
||||
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
|
||||
val uri = JtxCollection.create(account, client, values)
|
||||
return LocalJtxCollection(account, client, 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, provider: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
|
||||
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)
|
||||
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalJtxCollection) {
|
||||
|
||||
@@ -5,23 +5,26 @@
|
||||
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> {
|
||||
|
||||
) : JtxICalObject(collection), LocalResource {
|
||||
|
||||
init {
|
||||
this.fileName = fileName
|
||||
@@ -50,7 +53,7 @@ class LocalJtxICalObject(
|
||||
|
||||
}
|
||||
|
||||
override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
@@ -60,6 +63,10 @@ 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)
|
||||
}
|
||||
@@ -72,4 +79,15 @@ 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)
|
||||
|
||||
}
|
||||
@@ -4,13 +4,18 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Defines operations that are used by SyncManager for all sync data types.
|
||||
* 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.
|
||||
*/
|
||||
interface LocalResource<in TData: Any> {
|
||||
interface LocalResource {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
@@ -44,18 +49,6 @@ interface LocalResource<in TData: Any> {
|
||||
/** 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).
|
||||
@@ -76,12 +69,17 @@ interface LocalResource<in TData: Any> {
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
* Does not affect `this` or the [data] object (which are both immutable).
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
* 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 update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
||||
fun updateUid(uid: String)
|
||||
|
||||
/**
|
||||
* Updates the local SEQUENCE of the resource in the content provider.
|
||||
*
|
||||
* @throws NotImplementedError if SEQUENCE update is not supported
|
||||
*/
|
||||
fun updateSequence(sequence: Int)
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
@@ -93,4 +91,20 @@ interface LocalResource<in TData: Any> {
|
||||
*/
|
||||
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?
|
||||
|
||||
}
|
||||
@@ -4,82 +4,45 @@
|
||||
|
||||
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.synctools.storage.BatchOperation
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import com.google.common.base.MoreObjects
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
/**
|
||||
* Represents a Dmfs Task (OpenTasks and Tasks.org) entry
|
||||
*/
|
||||
class LocalTask: DmfsTask, LocalResource {
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
/**
|
||||
* Note: Schedule-Tag for tasks is not supported
|
||||
*/
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
: super(taskList, task, fileName, eTag, flags)
|
||||
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues)
|
||||
: super(taskList, values)
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val uid: String = task!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in tasks provider
|
||||
val values = contentValuesOf(Tasks._UID to newUid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
task!!.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
logger.fine("Schedule-Tag for tasks not supported, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName.isPresent)
|
||||
@@ -94,10 +57,12 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
if (scheduleTag != null)
|
||||
logger.fine("Schedule-Tag for tasks not supported, won't save")
|
||||
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||
@@ -113,6 +78,13 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun updateUid(uid: String) {
|
||||
val values = contentValuesOf(Tasks._UID to uid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
@@ -121,9 +93,38 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
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 (taskList.providerName) {
|
||||
TaskProvider.ProviderName.OpenTasks -> {
|
||||
val contentUri = Tasks.getContentUri(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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,13 +6,12 @@ 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
|
||||
@@ -31,11 +30,10 @@ class LocalTaskList private constructor(
|
||||
|
||||
private val logger = Logger.getGlobal()
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
override val readOnly
|
||||
get() =
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
get() = accessLevel?.let {
|
||||
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
} ?: false
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
@@ -47,32 +45,11 @@ class LocalTaskList private constructor(
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
get() = readSyncState()?.let { SyncState.fromString(it) }
|
||||
set(state) {
|
||||
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
|
||||
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> {
|
||||
@@ -97,7 +74,7 @@ class LocalTaskList private constructor(
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
@@ -105,11 +82,11 @@ class LocalTaskList private constructor(
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,
|
||||
@@ -56,13 +57,13 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||
val uri = create(account, provider, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
val uri = create(account, client, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, client, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
|
||||
@@ -100,21 +101,21 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
return values
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||
override fun getAll(account: Account, client: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, client, providerName, null, null)
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, providerName)?.use { provider ->
|
||||
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)
|
||||
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalTaskList) {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
|
||||
@@ -6,30 +6,26 @@ package at.bitfire.davdroid.servicedetection
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.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.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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
@@ -62,8 +58,8 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
@Assisted private val credentials: Credentials? = null,
|
||||
@ApplicationContext val context: Context,
|
||||
private val dnsRecordResolver: DnsRecordResolver,
|
||||
httpClientBuilder: HttpClient.Builder
|
||||
): AutoCloseable {
|
||||
httpClientBuilder: HttpClientBuilder
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -87,16 +83,12 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
.apply {
|
||||
if (credentials != null)
|
||||
authenticate(
|
||||
host = null,
|
||||
domain = 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>()!!
|
||||
@@ -228,27 +220,29 @@ 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.okHttpClient, baseURL, log)
|
||||
val davBaseURL = DavResource(httpClient, baseURL, log)
|
||||
try {
|
||||
when (service) {
|
||||
Service.CARDDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||
AddressbookHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
||||
WebDAV.CurrentUserPrincipal,
|
||||
CardDAV.AddressbookHomeSet,
|
||||
CardDAV.AddressbookDescription
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.ADDRESSBOOK, response, config)
|
||||
scanResponse(CardDAV.Addressbook, response, config)
|
||||
}
|
||||
}
|
||||
Service.CALDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
||||
WebDAV.CurrentUserPrincipal, WebDAV.CurrentUserPrivilegeSet,
|
||||
CalDAV.CalendarHomeSet,
|
||||
CalDAV.SupportedCalendarComponentSet, CalDAV.CalendarColor, CalDAV.CalendarDescription, CalDAV.CalendarTimezone
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.CALENDAR, response, config)
|
||||
scanResponse(CalDAV.Calendar, response, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,7 +260,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
||||
val mailboxes = LinkedList<String>()
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
|
||||
DavResource(httpClient, principal, log).propfind(0, CalDAV.CalendarUserAddressSet) { response, _ ->
|
||||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||||
for (href in addressSet.hrefs)
|
||||
try {
|
||||
@@ -305,11 +299,11 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val serviceType: Service
|
||||
when (resourceType) {
|
||||
ResourceType.ADDRESSBOOK -> {
|
||||
CardDAV.Addressbook -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
serviceType = Service.CARDDAV
|
||||
}
|
||||
ResourceType.CALENDAR -> {
|
||||
CalDAV.Calendar -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
serviceType = Service.CALDAV
|
||||
}
|
||||
@@ -330,7 +324,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
}
|
||||
|
||||
// ... and/or a principal?
|
||||
if (it.types.contains(ResourceType.PRINCIPAL))
|
||||
if (it.types.contains(WebDAV.Principal))
|
||||
principal = davResponse.href
|
||||
}
|
||||
|
||||
@@ -365,7 +359,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
fun providesService(url: HttpUrl, service: Service): Boolean {
|
||||
var provided = false
|
||||
try {
|
||||
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
|
||||
DavResource(httpClient, url, log).options { capabilities, _ ->
|
||||
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
||||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
||||
provided = true
|
||||
@@ -450,7 +444,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
*/
|
||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||||
var principal: HttpUrl? = null
|
||||
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||
DavResource(httpClient, url, log).propfind(0, WebDAV.CurrentUserPrincipal) { response, _ ->
|
||||
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
||||
response.requestedUrl.resolve(href)?.let {
|
||||
log.info("Found current-user-principal: $it")
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -36,8 +35,8 @@ class PrincipalsRefresher @AssistedInject constructor(
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.ResourceType
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,9 +22,9 @@ import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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: HttpClient.Builder,
|
||||
private val httpClientBuilder: HttpClientBuilder,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry,
|
||||
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
|
||||
@@ -153,12 +153,10 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
httpClientBuilder
|
||||
val httpClient = httpClientBuilder
|
||||
.fromAccount(account)
|
||||
.build()
|
||||
.use { httpClient ->
|
||||
runInterruptible {
|
||||
val httpClient = httpClient.okHttpClient
|
||||
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
|
||||
|
||||
// refresh home set list (from principal url)
|
||||
@@ -179,7 +177,6 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
|
||||
principalsRefresher.refreshPrincipals()
|
||||
}
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
logger.log(Level.SEVERE, "Invalid account", e)
|
||||
|
||||
@@ -5,19 +5,10 @@
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.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.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
@@ -29,24 +20,24 @@ object ServiceDetectionUtils {
|
||||
*/
|
||||
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
WebDAV.CurrentUserPrivilegeSet,
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.Owner,
|
||||
WebDAV.ResourceType,
|
||||
WebDAVPush.Transports,
|
||||
WebDAVPush.Topic
|
||||
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
CardDAV.AddressbookDescription
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
CalDAV.CalendarColor,
|
||||
CalDAV.CalendarDescription,
|
||||
CalDAV.CalendarTimezone,
|
||||
CalDAV.CalendarTimezoneId,
|
||||
CalDAV.SupportedCalendarComponentSet,
|
||||
CalDAV.Source
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.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.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
|
||||
@@ -59,18 +61,18 @@ class ServiceRefresher @AssistedInject constructor(
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.GroupMembership,
|
||||
WebDAV.ResourceType
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
CardDAV.AddressbookHomeSet,
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
CalDAV.CalendarHomeSet,
|
||||
CalDAV.CalendarProxyReadFor,
|
||||
CalDAV.CalendarProxyWriteFor
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
@@ -147,8 +149,8 @@ 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(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
CalDAV.CalendarProxyRead,
|
||||
CalDAV.CalendarProxyWrite
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
|
||||
@@ -355,7 +355,12 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 20
|
||||
/**
|
||||
* Current (usually the newest) account settings version. It's used to
|
||||
* determine whether a migration ([AccountSettingsMigration])
|
||||
* should be performed.
|
||||
*/
|
||||
const val CURRENT_VERSION = 21
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
|
||||
@@ -10,7 +10,8 @@ import at.bitfire.davdroid.settings.AccountSettings
|
||||
interface AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* Migrate the account settings from the old version to the new version.
|
||||
* Migrate the account settings from the old version to the new version which
|
||||
* is set in [AccountSettings.CURRENT_VERSION].
|
||||
*
|
||||
* **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].**
|
||||
*
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.provider.CalendarContract.Reminders
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
@@ -39,7 +40,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(LocalTask.COLUMN_ETAG to null)
|
||||
val emptyETag = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.EventsContract
|
||||
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 AndroidEvent2.EXTNAME_URL,
|
||||
CalendarContract.ExtendedProperties.NAME to EventsContract.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 ${AndroidEvent2.EXTNAME_URL}",
|
||||
"Couldn't rewrite URL from unknown property to ${EventsContract.EXTNAME_URL}",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* On Android 14+ the pending sync state of the Sync Adapter Framework is not handled correctly.
|
||||
* As a workaround we cancel incoming sync requests (clears pending flag) after enqueuing our own
|
||||
* sync worker (work manager). With version 4.5.3 we started cancelling pending syncs for DAVx5
|
||||
* accounts, but forgot to do that for address book accounts. With version 4.5.4 we also cancel
|
||||
* those, but only when contact data of an address book has been edited.
|
||||
*
|
||||
* This migration cancels (once only) any possibly still wrongly pending address book and calendar
|
||||
* (+tasks) account syncs.
|
||||
*/
|
||||
class AccountSettingsMigration21 @Inject constructor(
|
||||
private val localAddressBookStore: LocalAddressBookStore,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* Cancel any possibly forever pending account syncs of the different authorities
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(21)
|
||||
abstract fun provide(impl: AccountSettingsMigration21): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -50,7 +49,7 @@ class AccountSettingsMigration8 @Inject constructor(
|
||||
TaskContract.Tasks.SYNC1 to null,
|
||||
TaskContract.Tasks.SYNC2 to null
|
||||
)
|
||||
logger.log(Level.FINER, "Updating task $id", values)
|
||||
logger.log(Level.FINE, "Updating task $id", values)
|
||||
provider.client.update(
|
||||
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
|
||||
values, null, null)
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -19,6 +18,7 @@ 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,
|
||||
httpClient = httpClient,
|
||||
provideHttpClient = { httpClient },
|
||||
provider = provider,
|
||||
syncResult = syncResult,
|
||||
collection = remoteCollection
|
||||
@@ -69,14 +69,15 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
* Synchronizes an 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
|
||||
* @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
|
||||
*/
|
||||
private fun syncAddressBook(
|
||||
account: Account,
|
||||
addressBook: LocalAddressBook,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provideHttpClient: () -> OkHttpClient,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult,
|
||||
collection: Collection
|
||||
@@ -103,7 +104,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
|
||||
val syncManager = contactsSyncManagerFactory.contactsSyncManager(
|
||||
account,
|
||||
httpClient.value,
|
||||
provideHttpClient(),
|
||||
syncResult,
|
||||
provider,
|
||||
addressBook,
|
||||
|
||||
@@ -6,48 +6,49 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.text.format.Formatter
|
||||
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.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.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.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
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.ical4android.Event
|
||||
import at.bitfire.ical4android.EventReader
|
||||
import at.bitfire.ical4android.EventWriter
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
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 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.VAlarm
|
||||
import net.fortuna.ical4j.model.property.Action
|
||||
import net.fortuna.ical4j.model.component.VEvent
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
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
|
||||
@@ -57,7 +58,7 @@ import java.util.logging.Level
|
||||
*/
|
||||
class CalendarSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCalendar: LocalCalendar,
|
||||
@Assisted collection: Collection,
|
||||
@@ -79,7 +80,7 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
interface Factory {
|
||||
fun calendarSyncManager(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar,
|
||||
collection: Collection,
|
||||
@@ -91,13 +92,15 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||
davCollection = DavCalendar(httpClient, collection.url)
|
||||
|
||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||
localCollection.processDirtyExceptions()
|
||||
val recurringCalendar = localCollection.recurringCalendar
|
||||
recurringCalendar.processDeletedExceptions()
|
||||
recurringCalendar.processDirtyExceptions()
|
||||
|
||||
// now find dirty events that have no instances and set them to deleted
|
||||
localCollection.deleteDirtyEventsWithoutInstances()
|
||||
localCollection.androidCalendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -106,14 +109,20 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(
|
||||
0,
|
||||
CalDAV.MaxResourceSize,
|
||||
WebDAV.SupportedReportSet,
|
||||
CalDAV.GetCTag,
|
||||
WebDAV.SyncToken
|
||||
) { 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(SupportedReportSet.SYNC_COLLECTION)
|
||||
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
@@ -178,22 +187,36 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
return modified or superModified
|
||||
}
|
||||
|
||||
override fun onSuccessfulUpload(local: LocalEvent, newFileName: String, eTag: String?, scheduleTag: String?) {
|
||||
super.onSuccessfulUpload(local, newFileName, eTag, scheduleTag)
|
||||
override fun generateUpload(resource: LocalEvent): GeneratedResource {
|
||||
val localEvent = resource.androidEvent
|
||||
logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent)
|
||||
|
||||
// update local SEQUENCE to new value after successful upload
|
||||
local.updateSequence(local.getCachedEvent().sequence)
|
||||
}
|
||||
// increase SEQUENCE of main event and remember value
|
||||
val updatedSequence = SequenceUpdater().increaseSequence(localEvent.main)
|
||||
|
||||
override fun generateUpload(resource: LocalEvent): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
val event = resource.eventToUpload()
|
||||
logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
// 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)
|
||||
|
||||
// write iCalendar to string and convert to request body
|
||||
// persist UID if it was generated
|
||||
if (mappedEvents.generatedUid)
|
||||
resource.updateUid(mappedEvents.uid)
|
||||
|
||||
// generate iCalendar and convert to request body
|
||||
val iCalWriter = StringWriter()
|
||||
EventWriter(Constants.iCalProdId).write(event, iCalWriter)
|
||||
iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
||||
@@ -244,11 +267,11 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
||||
|
||||
processVEvent(
|
||||
response.href.lastSegment,
|
||||
eTag,
|
||||
scheduleTag,
|
||||
StringReader(iCal)
|
||||
processICalendar(
|
||||
fileName = response.href.lastSegment,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
reader = StringReader(iCal)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -261,57 +284,51 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
||||
val events: List<Event>
|
||||
private fun processICalendar(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
||||
val calendar =
|
||||
try {
|
||||
events = EventReader().readEvents(reader)
|
||||
ICalendarParser().parse(reader)
|
||||
} catch (e: InvalidICalendarException) {
|
||||
logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
logger.log(Level.WARNING, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (events.size == 1) {
|
||||
val event = events.first()
|
||||
|
||||
// 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
|
||||
val uidsAndEvents = CalendarUidSplitter<VEvent>().associateByUid(calendar, Component.VEVENT)
|
||||
if (uidsAndEvents.size != 1) {
|
||||
logger.warning("Received iCalendar with not exactly one UID; ignoring $fileName")
|
||||
return
|
||||
}
|
||||
// Event: main VEVENT and potentially attached exceptions (further VEVENTs with RECURRENCE-ID)
|
||||
val event = uidsAndEvents.values.first()
|
||||
|
||||
// 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,
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
} 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
|
||||
)
|
||||
localCollection.add(androidEvent)
|
||||
}
|
||||
}
|
||||
} else
|
||||
logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_event)
|
||||
|
||||
@@ -60,7 +60,7 @@ class CalendarSyncer @AssistedInject constructor(
|
||||
|
||||
val syncManager = calendarSyncManagerFactory.calendarSyncManager(
|
||||
account,
|
||||
httpClient.value,
|
||||
httpClient,
|
||||
syncResult,
|
||||
localCollection,
|
||||
remoteCollection,
|
||||
|
||||
@@ -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.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.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.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.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
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,15 +46,14 @@ 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.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
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
|
||||
@@ -100,7 +99,7 @@ import kotlin.jvm.optionals.getOrNull
|
||||
*/
|
||||
class ContactsSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted val provider: ContentProviderClient,
|
||||
@Assisted localAddressBook: LocalAddressBook,
|
||||
@@ -109,7 +108,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
@Assisted val syncFrameworkUpload: Boolean,
|
||||
val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
private val httpClientBuilder: HttpClient.Builder,
|
||||
private val resourceDownloaderFactory: ResourceDownloader.Factory,
|
||||
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
|
||||
account,
|
||||
@@ -126,7 +125,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
interface Factory {
|
||||
fun contactsSyncManager(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
provider: ContentProviderClient,
|
||||
localAddressBook: LocalAddressBook,
|
||||
@@ -149,11 +148,6 @@ 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) {
|
||||
@@ -162,8 +156,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
return false
|
||||
}
|
||||
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collection.url)
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
davCollection = DavAddressBook(httpClient, collection.url)
|
||||
|
||||
logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
|
||||
return true
|
||||
@@ -173,7 +166,14 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
return SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(
|
||||
0,
|
||||
CardDAV.MaxResourceSize,
|
||||
CardDAV.SupportedAddressData,
|
||||
WebDAV.SupportedReportSet,
|
||||
CalDAV.GetCTag,
|
||||
WebDAV.SyncToken
|
||||
) { 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(SupportedReportSet.SYNC_COLLECTION)
|
||||
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
@@ -272,16 +272,23 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
return modified or superModified
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalAddress): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
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)
|
||||
|
||||
logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", 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 {
|
||||
@@ -299,13 +306,16 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
return@wrapWithLocalResource os.toByteArray().toRequestBody(mimeType)
|
||||
return GeneratedResource(
|
||||
suggestedFileName = DavUtils.fileNameFromUid(uid, "vcf"),
|
||||
requestBody = os.toByteArray().toRequestBody(mimeType)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) =
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
runInterruptible {
|
||||
davCollection.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||
davCollection.propfind(1, WebDAV.ResourceType, WebDAV.GetETag, callback = callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,16 +357,25 @@ 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?.let { type ->
|
||||
response[GetContentType::class.java]?.type?.toMediaTypeOrNull()?.let { type ->
|
||||
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
|
||||
}
|
||||
|
||||
processCard(
|
||||
response.href.lastSegment,
|
||||
eTag,
|
||||
StringReader(card),
|
||||
isJCard,
|
||||
resourceDownloader
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -462,44 +481,6 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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
|
||||
))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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
|
||||
)
|
||||
|
||||
}
|
||||
@@ -7,24 +7,24 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.OpenForTesting
|
||||
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.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.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.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
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
|
||||
@@ -33,8 +33,9 @@ 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.RequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
@@ -43,7 +44,7 @@ import java.util.logging.Level
|
||||
|
||||
class JtxSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalJtxCollection,
|
||||
@Assisted collection: Collection,
|
||||
@@ -64,7 +65,7 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
interface Factory {
|
||||
fun jtxSyncManager(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalJtxCollection,
|
||||
collection: Collection,
|
||||
@@ -74,7 +75,7 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||
davCollection = DavCalendar(httpClient, collection.url)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -83,7 +84,7 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag, CalDAV.MaxResourceSize, WebDAV.SyncToken) { 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)}")
|
||||
@@ -96,12 +97,16 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalJtxICalObject): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
logger.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource)
|
||||
override fun generateUpload(resource: LocalJtxICalObject): GeneratedResource {
|
||||
logger.log(Level.FINE, "Preparing upload of icalobject #${resource.id}")
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
resource.write(os, Constants.iCalProdId)
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
resource.write(os, ProdId(Constants.iCalProdId))
|
||||
|
||||
return GeneratedResource(
|
||||
suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"),
|
||||
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
)
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
@@ -71,7 +71,7 @@ class JtxSyncer @AssistedInject constructor(
|
||||
|
||||
val syncManager = jtxSyncManagerFactory.jtxSyncManager(
|
||||
account,
|
||||
httpClient.value,
|
||||
httpClient,
|
||||
syncResult,
|
||||
localCollection,
|
||||
remoteCollection,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,28 +8,31 @@ import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.os.DeadObjectException
|
||||
import android.os.RemoteException
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.dav4jvm.Error
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.QuotedStringUtils
|
||||
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.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.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
|
||||
@@ -46,7 +49,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.cert.CertificateException
|
||||
@@ -62,7 +65,7 @@ import javax.net.ssl.SSLHandshakeException
|
||||
/**
|
||||
* Synchronizes a local collection with a remote collection.
|
||||
*
|
||||
* @param ResourceType type of local resources
|
||||
* @param LocalType type of local resources
|
||||
* @param CollectionType type of local collection
|
||||
* @param RemoteType type of remote collection
|
||||
*
|
||||
@@ -74,9 +77,9 @@ import javax.net.ssl.SSLHandshakeException
|
||||
* @param collection collection info in the database
|
||||
* @param resync whether re-synchronization is requested
|
||||
*/
|
||||
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCollection<LocalType>, RemoteType: DavCollection>(
|
||||
val account: Account,
|
||||
val httpClient: HttpClient,
|
||||
val httpClient: OkHttpClient,
|
||||
val dataType: SyncDataType,
|
||||
val syncResult: SyncResult,
|
||||
val localCollection: CollectionType,
|
||||
@@ -209,7 +212,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} catch (e: HttpException) {
|
||||
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
|
||||
if (e.errors.contains(Error(WebDAV.ValidSyncToken))) {
|
||||
logger.info("Sync token invalid, performing initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
@@ -247,7 +250,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
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) {
|
||||
@@ -256,9 +259,10 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
|
||||
when (e) {
|
||||
// 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 ->
|
||||
/* 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 ->
|
||||
throw e
|
||||
|
||||
// sync was cancelled or account has been removed: re-throw to Syncer
|
||||
@@ -331,7 +335,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
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.okHttpClient, url)
|
||||
val remote = DavResource(httpClient, url)
|
||||
SyncException.wrapWithRemoteResourceSuspending(url) {
|
||||
try {
|
||||
runInterruptible {
|
||||
@@ -386,32 +390,26 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
* @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: ResourceType, forceAsNew: Boolean = false) {
|
||||
protected open suspend fun uploadDirty(local: LocalType, 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.okHttpClient, uploadUrl)
|
||||
val remote = DavResource(httpClient, 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(
|
||||
bodyToUpload,
|
||||
upload.requestBody,
|
||||
ifNoneMatch = true, // fails if there's already a resource with that name
|
||||
callback = { response ->
|
||||
newETag = GetETag.fromResponse(response)?.eTag
|
||||
@@ -421,10 +419,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
)
|
||||
}
|
||||
|
||||
logger.fine("Upload successful; new ETag=$newETag / Schedule-Tag=$newScheduleTag")
|
||||
|
||||
// success (no exception thrown)
|
||||
onSuccessfulUpload(local, fileName, newETag, newScheduleTag)
|
||||
onSuccessfulUpload(local, fileName, newETag, newScheduleTag, upload.onSuccessContext)
|
||||
|
||||
} else {
|
||||
// update resource on server
|
||||
@@ -432,13 +428,12 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
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(
|
||||
bodyToUpload,
|
||||
upload.requestBody,
|
||||
ifETag = ifETag,
|
||||
ifScheduleTag = ifScheduleTag,
|
||||
callback = { response ->
|
||||
@@ -449,10 +444,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
)
|
||||
}
|
||||
|
||||
logger.fine("Upload successful; updated ETag=$updatedETag / Schedule-Tag=$updatedScheduleTag")
|
||||
|
||||
// success (no exception thrown)
|
||||
onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag)
|
||||
onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag, upload.onSuccessContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +454,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
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.NEED_PRIVILEGES))
|
||||
if (ex.errors.contains(Error(WebDAV.NeedPrivileges)))
|
||||
logger.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex)
|
||||
else
|
||||
throw e
|
||||
@@ -492,16 +485,6 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -509,7 +492,40 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
*
|
||||
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
|
||||
*/
|
||||
protected abstract fun generateUpload(resource: ResourceType): RequestBody
|
||||
@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)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@@ -599,7 +615,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
return@listRemote
|
||||
|
||||
// ignore collections
|
||||
if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true)
|
||||
if (response[ResourceType::class.java]?.types?.contains(WebDAV.Collection) == true)
|
||||
return@listRemote
|
||||
|
||||
val name = response.hrefName()
|
||||
@@ -657,7 +673,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
davCollection.reportChanges(
|
||||
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME
|
||||
WebDAV.GetETag
|
||||
) { response, relation ->
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF ->
|
||||
@@ -734,7 +750,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
private suspend fun querySyncState(): SyncState? {
|
||||
var state: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag, WebDAV.SyncToken) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
state = syncState(response)
|
||||
}
|
||||
@@ -745,7 +761,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
/**
|
||||
* 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 -> {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -16,25 +15,19 @@ import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.okhttp.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
|
||||
@@ -118,11 +111,10 @@ 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(
|
||||
@@ -131,8 +123,6 @@ class SyncNotificationManager @AssistedInject constructor(
|
||||
)
|
||||
} else {
|
||||
contentIntent = buildDebugInfoIntent(syncDataType, e, local, remote)
|
||||
if (local != null)
|
||||
viewItemAction = buildViewItemActionForLocalResource(local)
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
@@ -162,7 +152,6 @@ class SyncNotificationManager @AssistedInject constructor(
|
||||
)
|
||||
.setPriority(priority)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
viewItemAction?.let { builder.addAction(it) }
|
||||
|
||||
builder.build()
|
||||
}
|
||||
@@ -229,7 +218,7 @@ class SyncNotificationManager @AssistedInject constructor(
|
||||
private fun buildDebugInfoIntent(
|
||||
dataType: SyncDataType,
|
||||
e: Throwable,
|
||||
local: LocalResource<*>?,
|
||||
local: LocalResource?,
|
||||
remote: HttpUrl?
|
||||
): Intent {
|
||||
val builder = DebugInfoActivity.IntentBuilder(context)
|
||||
@@ -239,10 +228,13 @@ class SyncNotificationManager @AssistedInject constructor(
|
||||
|
||||
if (local != null)
|
||||
try {
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
if (remote != null)
|
||||
@@ -251,33 +243,4 @@ 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,12 +11,13 @@ 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.HttpClient
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
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
|
||||
@@ -47,7 +48,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
lateinit var httpClientBuilder: HttpClientBuilder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
@@ -65,7 +66,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
syncNotificationManagerFactory.create(account)
|
||||
}
|
||||
|
||||
val httpClient = lazy {
|
||||
val httpClient by lazy {
|
||||
httpClientBuilder.fromAccount(account).build()
|
||||
}
|
||||
|
||||
@@ -259,22 +260,30 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (httpClient.isInitialized())
|
||||
httpClient.value.close()
|
||||
logger.info("${dataStore.authority} sync of $account finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class TaskSyncer @AssistedInject constructor(
|
||||
|
||||
val syncManager = tasksSyncManagerFactory.tasksSyncManager(
|
||||
account,
|
||||
httpClient.value,
|
||||
httpClient,
|
||||
syncResult,
|
||||
localCollection,
|
||||
remoteCollection,
|
||||
|
||||
@@ -6,24 +6,24 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.text.format.Formatter
|
||||
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.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.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.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
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.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.synctools.exception.InvalidICalendarException
|
||||
@@ -32,8 +32,9 @@ 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.RequestBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
@@ -45,7 +46,7 @@ import java.util.logging.Level
|
||||
*/
|
||||
class TasksSyncManager @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted httpClient: OkHttpClient,
|
||||
@Assisted syncResult: SyncResult,
|
||||
@Assisted localCollection: LocalTaskList,
|
||||
@Assisted collection: Collection,
|
||||
@@ -66,7 +67,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
interface Factory {
|
||||
fun tasksSyncManager(
|
||||
account: Account,
|
||||
httpClient: HttpClient,
|
||||
httpClient: OkHttpClient,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTaskList,
|
||||
collection: Collection,
|
||||
@@ -76,7 +77,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collection.url)
|
||||
davCollection = DavCalendar(httpClient, collection.url)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -85,7 +86,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(0, CalDAV.MaxResourceSize, CalDAV.GetCTag, WebDAV.SyncToken) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Calendar accepts tasks up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
@@ -101,15 +102,26 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun generateUpload(resource: LocalTask): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
override fun generateUpload(resource: LocalTask): GeneratedResource {
|
||||
val task = requireNotNull(resource.task)
|
||||
logger.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
logger.log(Level.FINE, "Preparing upload of task ${resource.id}", task)
|
||||
|
||||
// get/create UID
|
||||
val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(task.uid)
|
||||
if (uidIsGenerated) {
|
||||
// modify in Task and persist to tasks provider
|
||||
task.uid = uid
|
||||
resource.updateUid(uid)
|
||||
}
|
||||
|
||||
// generate iCalendar and convert to request body
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os, Constants.iCalProdId)
|
||||
task.write(os, ProdId(Constants.iCalProdId))
|
||||
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
return GeneratedResource(
|
||||
suggestedFileName = DavUtils.fileNameFromUid(uid, "ics"),
|
||||
requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.work.WorkInfo
|
||||
@@ -25,6 +26,7 @@ import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Lazy
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -59,6 +61,7 @@ class SyncAdapterImpl @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncFrameworkIntegration: Lazy<SyncFrameworkIntegration>,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
/* context = */ context,
|
||||
@@ -117,11 +120,11 @@ class SyncAdapterImpl @Inject constructor(
|
||||
// Android 14+ does not handle pending sync state correctly.
|
||||
// As a defensive workaround, we can cancel specifically this still pending sync only
|
||||
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
|
||||
// if (Build.VERSION.SDK_INT >= 34) {
|
||||
// logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
|
||||
// "account=$accountOrAddressBookAccount authority=$authority upload=$upload")
|
||||
// syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras)
|
||||
// }
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
|
||||
"account=$accountOrAddressBookAccount authority=$authority extras=$extras")
|
||||
syncFrameworkIntegration.get().cancelSync(accountOrAddressBookAccount, authority, extras)
|
||||
}
|
||||
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncRequest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
@@ -101,11 +100,9 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the sync request in the Sync Framework for Android 14+.
|
||||
* This is a workaround for the bug that the sync framework does not handle pending syncs correctly
|
||||
* on Android 14+ (API level 34+).
|
||||
*
|
||||
* See: https://github.com/bitfireAT/davx5-ose/issues/1458
|
||||
* Cancels the sync request in the Sync Adapter Framework by sync request. This
|
||||
* is the defensive approach canceling only one specific sync request with matching
|
||||
* sync extras.
|
||||
*
|
||||
* @param account The account for which the sync request should be canceled.
|
||||
* @param authority The authority for which the sync request should be canceled.
|
||||
@@ -193,12 +190,6 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> {
|
||||
// Android 14+ does not handle pending sync state correctly.
|
||||
// For now we simply always return false
|
||||
// See also sync cancellation in [SyncAdapterImpl.onPerformSync]
|
||||
if (Build.VERSION.SDK_INT >= 34)
|
||||
return flowOf(false)
|
||||
|
||||
// Determine the pending state for each data type of the account as separate flows
|
||||
val pendingStateFlows: List<Flow<Boolean>> = dataTypes.mapNotNull { dataType ->
|
||||
// Map datatype to authority
|
||||
|
||||
@@ -67,6 +67,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.AppSettingsModel.PushDistributorInfo
|
||||
@@ -107,10 +108,11 @@ fun AppSettingsScreen(
|
||||
onProxyPortUpdated = model::updateProxyPort,
|
||||
|
||||
// Security
|
||||
onNavPermissionsScreen = onNavPermissionsScreen,
|
||||
showCertSettings = BuildConfig.allowCustomCerts,
|
||||
distrustSystemCerts = model.distrustSystemCertificates().collectAsStateWithLifecycle(null).value ?: false,
|
||||
onDistrustSystemCertsUpdated = model::updateDistrustSystemCertificates,
|
||||
onResetCertificates = model::resetCertificates,
|
||||
onNavPermissionsScreen = onNavPermissionsScreen,
|
||||
|
||||
// User interface
|
||||
onShowNotificationSettings = onShowNotificationSettings,
|
||||
@@ -149,10 +151,11 @@ fun AppSettingsScreen(
|
||||
onProxyPortUpdated: (Int) -> Unit,
|
||||
|
||||
// AppSettings security
|
||||
onNavPermissionsScreen: () -> Unit,
|
||||
showCertSettings: Boolean,
|
||||
distrustSystemCerts: Boolean,
|
||||
onDistrustSystemCertsUpdated: (Boolean) -> Unit,
|
||||
onResetCertificates: () -> Unit,
|
||||
onNavPermissionsScreen: () -> Unit,
|
||||
|
||||
// AppSettings UserInterface
|
||||
theme: Int,
|
||||
@@ -224,6 +227,8 @@ fun AppSettingsScreen(
|
||||
|
||||
val resetCertificatesSuccessMessage = stringResource(R.string.app_settings_reset_certificates_success)
|
||||
AppSettings_Security(
|
||||
onNavPermissionsScreen = onNavPermissionsScreen,
|
||||
showCertSettings = showCertSettings,
|
||||
distrustSystemCerts = distrustSystemCerts,
|
||||
onDistrustSystemCertsUpdated = onDistrustSystemCertsUpdated,
|
||||
onResetCertificates = {
|
||||
@@ -231,8 +236,7 @@ fun AppSettingsScreen(
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(resetCertificatesSuccessMessage)
|
||||
}
|
||||
},
|
||||
onNavPermissionsScreen = onNavPermissionsScreen
|
||||
}
|
||||
)
|
||||
|
||||
val resetHintsSuccessMessage = stringResource(R.string.app_settings_reset_hints_success)
|
||||
@@ -282,9 +286,10 @@ fun AppSettingsScreen_Preview() {
|
||||
onNavUp = {},
|
||||
onProxyTypeUpdated = {},
|
||||
onProxyPortUpdated = {},
|
||||
onNavPermissionsScreen = {},
|
||||
showCertSettings = true,
|
||||
onDistrustSystemCertsUpdated = {},
|
||||
onResetCertificates = {},
|
||||
onNavPermissionsScreen = {},
|
||||
onThemeSelected = {},
|
||||
onResetHints = {},
|
||||
tasksAppName = "No tasks app",
|
||||
@@ -420,15 +425,23 @@ fun AppSettings_Connection(
|
||||
|
||||
@Composable
|
||||
fun AppSettings_Security(
|
||||
onNavPermissionsScreen: () -> Unit,
|
||||
showCertSettings: Boolean,
|
||||
distrustSystemCerts: Boolean,
|
||||
onDistrustSystemCertsUpdated: (Boolean) -> Unit,
|
||||
onResetCertificates: () -> Unit,
|
||||
onNavPermissionsScreen: () -> Unit
|
||||
onResetCertificates: () -> Unit
|
||||
) {
|
||||
SettingsHeader(divider = true) {
|
||||
Text(stringResource(R.string.app_settings_security))
|
||||
}
|
||||
|
||||
Setting(
|
||||
name = stringResource(R.string.app_settings_security_app_permissions),
|
||||
summary = stringResource(R.string.app_settings_security_app_permissions_summary),
|
||||
onClick = onNavPermissionsScreen
|
||||
)
|
||||
|
||||
if (showCertSettings) {
|
||||
var showingDistrustWarning by remember { mutableStateOf(false) }
|
||||
if (showingDistrustWarning) {
|
||||
DistrustSystemCertificatesAlertDialog(
|
||||
@@ -456,12 +469,7 @@ fun AppSettings_Security(
|
||||
summary = stringResource(R.string.app_settings_reset_certificates_summary),
|
||||
onClick = onResetCertificates
|
||||
)
|
||||
|
||||
Setting(
|
||||
name = stringResource(R.string.app_settings_security_app_permissions),
|
||||
summary = stringResource(R.string.app_settings_security_app_permissions_summary),
|
||||
onClick = onNavPermissionsScreen
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ShareCompat
|
||||
@@ -16,11 +22,16 @@ import androidx.core.content.IntentCompat
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import com.google.common.base.Ascii
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Debug info activity. Provides verbose information for debugging and support. Should enable users
|
||||
@@ -33,46 +44,42 @@ import java.time.Instant
|
||||
* - enable App settings / Verbose logs, then open debug info activity (should provide debug info + logs; check logs, too)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class DebugInfoActivity : AppCompatActivity() {
|
||||
class DebugInfoActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
/** [android.accounts.Account] (as [android.os.Parcelable]) related to problem */
|
||||
private const val EXTRA_ACCOUNT = "account"
|
||||
|
||||
/** sync data type related to problem */
|
||||
private const val EXTRA_SYNC_DATA_TYPE = "syncDataType"
|
||||
|
||||
/** serialized [Throwable] that causes the problem */
|
||||
private const val EXTRA_CAUSE = "cause"
|
||||
|
||||
/** dump of local resource related to the problem (plain-text [String]) */
|
||||
private const val EXTRA_LOCAL_RESOURCE = "localResource"
|
||||
|
||||
/** logs related to the problem (plain-text [String]) */
|
||||
private const val EXTRA_LOGS = "logs"
|
||||
|
||||
/** URL of remote resource related to the problem (plain-text [String]) */
|
||||
private const val EXTRA_REMOTE_RESOURCE = "remoteResource"
|
||||
|
||||
/** A timestamp of the moment at which the error took place. */
|
||||
private const val EXTRA_TIMESTAMP = "timestamp"
|
||||
}
|
||||
@Inject
|
||||
lateinit var tasksAppManager: Lazy<TasksAppManager>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val extras = intent.extras
|
||||
|
||||
val extras = intent.extras
|
||||
val viewResourceIntent = IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
EXTRA_LOCAL_RESOURCE_URI,
|
||||
Uri::class.java
|
||||
)?.let { uri ->
|
||||
buildViewLocalResourceIntent(uri)
|
||||
}
|
||||
|
||||
val remoteResource = extras?.getString(EXTRA_REMOTE_RESOURCE)
|
||||
setContent {
|
||||
DebugInfoScreen(
|
||||
account = IntentCompat.getParcelableExtra(intent, EXTRA_ACCOUNT, Account::class.java),
|
||||
syncDataType = extras?.getString(EXTRA_SYNC_DATA_TYPE),
|
||||
cause = IntentCompat.getSerializableExtra(intent, EXTRA_CAUSE, Throwable::class.java),
|
||||
localResource = extras?.getString(EXTRA_LOCAL_RESOURCE),
|
||||
remoteResource = extras?.getString(EXTRA_REMOTE_RESOURCE),
|
||||
canViewResource = viewResourceIntent != null,
|
||||
localResource = extras?.getString(EXTRA_LOCAL_RESOURCE_SUMMARY),
|
||||
remoteResource = remoteResource,
|
||||
logs = extras?.getString(EXTRA_LOGS),
|
||||
timestamp = extras?.getLong(EXTRA_TIMESTAMP),
|
||||
onShareZipFile = ::shareZipFile,
|
||||
onViewFile = ::viewFile,
|
||||
onCopyRemoteUrl = {
|
||||
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clipData = ClipData.newPlainText("Remote resource", remoteResource)
|
||||
clipboard.setPrimaryClip(clipData)
|
||||
},
|
||||
onViewLocalResource = { viewResource(viewResourceIntent) },
|
||||
onNavUp = ::onSupportNavigateUp
|
||||
)
|
||||
}
|
||||
@@ -128,6 +135,45 @@ class DebugInfoActivity : AppCompatActivity() {
|
||||
startActivity(Intent.createChooser(intent, title))
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts activity to view the affected/problematic resource
|
||||
*/
|
||||
private fun viewResource(intent: Intent?) = try {
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(R.string.debug_info_can_not_view_resource),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds intent to view the problematic local resource at given Uri.
|
||||
*
|
||||
* Note that the TasksOrg app does not support viewing tasks via intent-filter.
|
||||
* @see [at.bitfire.davdroid.resource.LocalResource.getViewUri]
|
||||
*/
|
||||
private fun buildViewLocalResourceIntent(uri: Uri): Intent? =
|
||||
when (uri.authority) {
|
||||
// Support ACTION_VIEW
|
||||
in listOf(
|
||||
CalendarContract.AUTHORITY, // any calendar app
|
||||
JtxContract.JtxICalObject.VIEW_INTENT_HOST // jtx Board for journals, notes, tasks
|
||||
) -> Intent(Intent.ACTION_VIEW, uri)
|
||||
|
||||
// Need ACTION_EDIT (OpenTasks crashes on using ACTION_VIEW)
|
||||
TaskProvider.ProviderName.OpenTasks.authority // OpenTasks app
|
||||
-> Intent(Intent.ACTION_EDIT, uri)
|
||||
|
||||
// Need CONTENT_ITEM_TYPE to be set
|
||||
ContactsContract.AUTHORITY // any contacts app
|
||||
-> Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, ContactsContract.Contacts.CONTENT_ITEM_TYPE)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}?.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
/**
|
||||
* Builder for [DebugInfoActivity] intents
|
||||
@@ -167,12 +213,19 @@ class DebugInfoActivity : AppCompatActivity() {
|
||||
fun withLocalResource(dump: String?): IntentBuilder {
|
||||
if (dump != null)
|
||||
intent.putExtra(
|
||||
EXTRA_LOCAL_RESOURCE,
|
||||
EXTRA_LOCAL_RESOURCE_SUMMARY,
|
||||
Ascii.truncate(dump, MAX_ELEMENT_SIZE, "...")
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun withLocalResourceUri(uri: Uri?): IntentBuilder {
|
||||
if (uri == null)
|
||||
return this
|
||||
intent.putExtra(EXTRA_LOCAL_RESOURCE_URI, uri)
|
||||
return this
|
||||
}
|
||||
|
||||
fun withLogs(logs: String?): IntentBuilder {
|
||||
if (logs != null)
|
||||
intent.putExtra(
|
||||
@@ -197,4 +250,30 @@ class DebugInfoActivity : AppCompatActivity() {
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** [android.accounts.Account] (as [android.os.Parcelable]) related to problem */
|
||||
private const val EXTRA_ACCOUNT = "account"
|
||||
|
||||
/** sync data type related to problem */
|
||||
private const val EXTRA_SYNC_DATA_TYPE = "syncDataType"
|
||||
|
||||
/** serialized [Throwable] that causes the problem */
|
||||
private const val EXTRA_CAUSE = "cause"
|
||||
|
||||
/** Summary (dump of [at.bitfire.davdroid.resource.LocalResource] properties) of local resource related to the problem (plain-text [String]) */
|
||||
internal const val EXTRA_LOCAL_RESOURCE_SUMMARY = "localResourceSummary"
|
||||
|
||||
/** [Uri] of local resource related to the problem (as [android.os.Parcelable]) */
|
||||
internal const val EXTRA_LOCAL_RESOURCE_URI = "localResourceUri"
|
||||
|
||||
/** logs related to the problem (plain-text [String]) */
|
||||
private const val EXTRA_LOGS = "logs"
|
||||
|
||||
/** URL of remote resource related to the problem (plain-text [String]) */
|
||||
private const val EXTRA_REMOTE_RESOURCE = "remoteResource"
|
||||
|
||||
/** A timestamp of the moment at which the error took place. */
|
||||
private const val EXTRA_TIMESTAMP = "timestamp"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
|
||||
@@ -36,14 +36,15 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.composable.CardWithImage
|
||||
import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
@@ -57,11 +58,14 @@ fun DebugInfoScreen(
|
||||
syncDataType: String?,
|
||||
cause: Throwable?,
|
||||
localResource: String?,
|
||||
canViewResource: Boolean,
|
||||
remoteResource: String?,
|
||||
logs: String?,
|
||||
timestamp: Long?,
|
||||
onShareZipFile: (File) -> Unit,
|
||||
onViewFile: (File) -> Unit,
|
||||
onCopyRemoteUrl: () -> Unit,
|
||||
onViewLocalResource: () -> Unit,
|
||||
onNavUp: () -> Unit
|
||||
) {
|
||||
val model: DebugInfoModel = hiltViewModel(
|
||||
@@ -119,11 +123,14 @@ fun DebugInfoScreen(
|
||||
R.string.debug_info_unexpected_error
|
||||
),
|
||||
localResource = localResource,
|
||||
canViewResource = canViewResource,
|
||||
remoteResource = remoteResource,
|
||||
hasLogFile = logFile != null,
|
||||
onShareZip = { model.generateZip() },
|
||||
onViewLogsFile = { logFile?.let { onViewFile(it) } },
|
||||
onViewDebugFile = { debugInfo?.let { onViewFile(it) } },
|
||||
onCopyRemoteUrl = onCopyRemoteUrl,
|
||||
onViewLocalResource = onViewLocalResource,
|
||||
onNavUp = onNavUp
|
||||
)
|
||||
}
|
||||
@@ -140,11 +147,14 @@ fun DebugInfoScreen(
|
||||
modelCauseSubtitle: String?,
|
||||
modelCauseMessage: String?,
|
||||
localResource: String?,
|
||||
canViewResource: Boolean,
|
||||
remoteResource: String?,
|
||||
hasLogFile: Boolean,
|
||||
onShareZip: () -> Unit = {},
|
||||
onViewLogsFile: () -> Unit = {},
|
||||
onViewDebugFile: () -> Unit = {},
|
||||
onCopyRemoteUrl: () -> Unit = {},
|
||||
onViewLocalResource: () -> Unit = {},
|
||||
onNavUp: () -> Unit = {}
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -159,6 +169,7 @@ fun DebugInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
AppTheme {
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
@@ -240,25 +251,32 @@ fun DebugInfoScreen(
|
||||
icon = Icons.Rounded.Adb,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
remoteResource?.let {
|
||||
remoteResource?.let { remoteUrl ->
|
||||
Text(
|
||||
text = stringResource(R.string.debug_info_involved_remote),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = it,
|
||||
text = remoteUrl,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onCopyRemoteUrl,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.debug_info_copy_remote_url))
|
||||
}
|
||||
}
|
||||
localResource?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_info_involved_local),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = it,
|
||||
@@ -268,6 +286,15 @@ fun DebugInfoScreen(
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
if (canViewResource)
|
||||
OutlinedButton(
|
||||
onClick = { onViewLocalResource() },
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_view_local_resource)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLogFile) {
|
||||
@@ -327,6 +354,7 @@ fun DebugInfoScreen_Preview() {
|
||||
modelCauseSubtitle = "ModelCauseSubtitle",
|
||||
modelCauseMessage = "ModelCauseMessage",
|
||||
localResource = "local-resource-string",
|
||||
canViewResource = true,
|
||||
remoteResource = "remote-resource-string",
|
||||
hasLogFile = true
|
||||
)
|
||||
|
||||
@@ -5,25 +5,18 @@
|
||||
package at.bitfire.davdroid.ui.composable
|
||||
|
||||
import android.accounts.Account
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Error
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -22,10 +22,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -34,7 +31,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -49,12 +45,12 @@ fun EditTextInputDialog(
|
||||
onValueEntered: (String) -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
) {
|
||||
var textValue by remember {
|
||||
mutableStateOf(TextFieldValue(
|
||||
initialValue ?: "", selection = TextRange(initialValue?.length ?: 0)
|
||||
))
|
||||
}
|
||||
val state = rememberTextFieldState(
|
||||
initialText = initialValue ?: "",
|
||||
initialSelection = TextRange(initialValue?.length ?: 0)
|
||||
)
|
||||
|
||||
val confirmEnabled = state.text != initialValue
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
@@ -67,27 +63,24 @@ fun EditTextInputDialog(
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
if (passwordField)
|
||||
PasswordTextField(
|
||||
password = textValue.text,
|
||||
password = state,
|
||||
labelText = inputLabel,
|
||||
onPasswordChange = { textValue = TextFieldValue(it) },
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
)
|
||||
else
|
||||
TextField(
|
||||
label = { inputLabel?.let { Text(it) } },
|
||||
value = textValue,
|
||||
onValueChange = { textValue = it },
|
||||
singleLine = true,
|
||||
state = state,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onValueEntered(textValue.text)
|
||||
onKeyboardAction = {
|
||||
if (confirmEnabled) {
|
||||
onValueEntered(state.text.toString())
|
||||
onDismiss()
|
||||
}
|
||||
),
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -97,10 +90,10 @@ fun EditTextInputDialog(
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onValueEntered(textValue.text)
|
||||
onValueEntered(state.text.toString())
|
||||
onDismiss()
|
||||
},
|
||||
enabled = textValue.text != initialValue
|
||||
enabled = confirmEnabled
|
||||
) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.TextObfuscationMode
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedSecureTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -25,8 +29,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.HtmlCompat
|
||||
@@ -36,33 +38,28 @@ import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
|
||||
@Composable
|
||||
fun PasswordTextField(
|
||||
password: String,
|
||||
password: TextFieldState,
|
||||
labelText: String?,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
onKeyboardAction: KeyboardActionHandler? = null,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
isError: Boolean = false
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
OutlinedSecureTextField(
|
||||
state = password,
|
||||
label = labelText?.let { { Text(it) } },
|
||||
leadingIcon = leadingIcon,
|
||||
isError = isError,
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
modifier = modifier.focusGroup(),
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
onKeyboardAction = onKeyboardAction,
|
||||
textObfuscationMode = if (passwordVisible) TextObfuscationMode.Visible else TextObfuscationMode.RevealLastTyped,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
enabled = enabled,
|
||||
@@ -98,11 +95,10 @@ fun appPasswordHelpUrl(): Uri = ExternalUris.Manual.baseUrl.buildUpon()
|
||||
@Preview
|
||||
fun PasswordTextField_Sample() {
|
||||
PasswordTextField(
|
||||
password = "",
|
||||
password = rememberTextFieldState(""),
|
||||
labelText = "labelText",
|
||||
enabled = true,
|
||||
isError = false,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,11 +106,10 @@ fun PasswordTextField_Sample() {
|
||||
@Preview
|
||||
fun PasswordTextField_Sample_Filled() {
|
||||
PasswordTextField(
|
||||
password = "password",
|
||||
password = rememberTextFieldState("password"),
|
||||
labelText = "labelText",
|
||||
enabled = true,
|
||||
isError = false,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,11 +117,10 @@ fun PasswordTextField_Sample_Filled() {
|
||||
@Preview
|
||||
fun PasswordTextField_Sample_Error() {
|
||||
PasswordTextField(
|
||||
password = "password",
|
||||
password = rememberTextFieldState("password"),
|
||||
labelText = "labelText",
|
||||
enabled = true,
|
||||
isError = true,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,10 +128,9 @@ fun PasswordTextField_Sample_Error() {
|
||||
@Preview
|
||||
fun PasswordTextField_Sample_Disabled() {
|
||||
PasswordTextField(
|
||||
password = "password",
|
||||
password = rememberTextFieldState("password"),
|
||||
labelText = "labelText",
|
||||
enabled = false,
|
||||
isError = false,
|
||||
onPasswordChange = {},
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user