diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ac9be5c1e..cd459cce5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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" \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 468f34f14..3f9aea8a9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 diff --git a/.github/workflows/dependent-issues.yml b/.github/workflows/dependent-issues.yml deleted file mode 100644 index b6e805f25..000000000 --- a/.github/workflows/dependent-issues.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff74edcf6..353cad326 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 128a77954..a91bf3ae9 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -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 diff --git a/.tx/config b/.tx/config index 310b5f45d..059df6ad0 100644 --- a/.tx/config +++ b/.tx/config @@ -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-/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 420034491..1e8938756 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 0288d08ee..71fc87f34 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -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{ *; } diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt index 56166c88c..fc11cb4d2 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt @@ -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 { "")) 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 { "")) 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 { "")) 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 { "")) 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) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/ConscryptIntegrationTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/ConscryptIntegrationTest.kt new file mode 100644 index 000000000..aec0382e0 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/ConscryptIntegrationTest.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt similarity index 73% rename from app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt rename to app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt index 1c1209904..323d04666 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt @@ -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 - 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")) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt index 760332dbe..3ca969b5a 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt @@ -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,16 +31,15 @@ class OkhttpClientTest { @Test @SdkSuppress(maxSdkVersion = 34) fun testIcloudWithSettings() { - httpClientBuilder.build().use { client -> - client.okHttpClient - .newCall( - Request.Builder() - .get() - .url("https://icloud.com") - .build() - ) - .execute() - } + val client = httpClientBuilder.build() + client + .newCall( + Request.Builder() + .get() + .url("https://icloud.com") + .build() + ) + .execute() } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt index 9a06da14a..5f40f3ea0 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/repository/AccountRepositoryTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt new file mode 100644 index 000000000..2ec3b4e91 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt @@ -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) + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt index f0c786329..a28151168 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt deleted file mode 100644 index 053f03547..000000000 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt +++ /dev/null @@ -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)) - } - } - -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt index cfa0a52b1..ebef73e11 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalGroupTest.kt @@ -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 -> diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt index aa4fcf5e4..10fef8f54 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt @@ -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) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt index a77ca849f..76f2dee15 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -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()) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt index 5607b756b..2944290dd 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt @@ -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)) } diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt index 231f73473..eb44c163d 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt @@ -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) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt index 24ed9126b..6c14bc08a 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt new file mode 100644 index 000000000..456548fb7 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt @@ -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) + } + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt index da251a450..e69c3cc89 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt new file mode 100644 index 000000000..b9c9ba815 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt @@ -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() + + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt index 8ac7f0146..ec7a9f1ab 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/JtxSyncManagerTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt index 97522488a..dcb27e4cd 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt @@ -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 { +class LocalTestResource: LocalResource { override val id: Long? = null override var fileName: String? = null @@ -18,8 +19,6 @@ class LocalTestResource: LocalResource { var deleted = false var dirty = false - override fun prepareForUpload() = "generated-file.txt" - override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { dirty = false if (fileName.isPresent) @@ -32,8 +31,14 @@ class LocalTestResource: LocalResource { 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 + } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/ResourceDownloaderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/ResourceDownloaderTest.kt new file mode 100644 index 000000000..c7cc5aa58 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/ResourceDownloaderTest.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt index fefbd0cf8..6c1b6774c 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt index ee5440936..a08552d0d 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncerTest.kt @@ -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 { 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() } diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt index 43bcc454d..8dbad51fd 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt index c354f970e..0413a2917 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/account/TestAccount.kt @@ -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. */ diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt index 116eca336..d0f7593dc 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/DebugInfoActivityTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt index e3f320431..51815b1a0 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperationTest.kt @@ -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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e279299b..02ee4ca27 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -100,7 +100,7 @@ diff --git a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt index 9e302cdc6..3f378a317 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt @@ -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}" } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt index ef6dedbc7..85fddb1df 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt index 03e157445..8c312a095 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt index 3fb285b81..0681919d3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/log/LogManager.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt index 0859849f6..5b4c3205c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/ClientCertKeyManager.kt @@ -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?): Array? = null override fun chooseServerAlias(p0: String?, p1: Array?, p2: Socket?) = null override fun getClientAliases(p0: String?, p1: Array?) = arrayOf(alias) override fun chooseClientAlias(p0: Array?, p1: Array?, p2: Socket?) = alias - override fun getCertificateChain(forAlias: String?) = - certs.takeIf { forAlias == alias } + override fun getCertificateChain(forAlias: String): Array? { + 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 + if (result == null) + logger.log(Level.WARNING, "Couldn't obtain private key for alias $alias") + } + } catch (e: KeyChainException) { + // Android ` 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) - } - } - - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt new file mode 100644 index 000000000..e233683a9 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -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` 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 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().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 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 + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt index 9ed2836b5..7251ec795 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt @@ -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) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt index 8c36463bd..3d43bb526 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushMessageHandler.kt @@ -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 } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt index ad4e882d4..3f9cd7a9e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -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, private val collectionRepository: DavCollectionRepository, @ApplicationContext private val context: Context, - private val httpClientBuilder: Provider, + private val httpClientBuilder: Provider, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val logger: Logger, private val serviceRepository: DavServiceRepository @@ -180,25 +176,23 @@ 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 - // calculate next run time, but use the duplicate interval for safety (times are not exact) - val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS) - if (expires != null && expires >= nextRun.epochSecond) - logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}") - else { - // no existing subscription or expiring soon - logger.fine("Registering push subscription for ${collection.url}") - subscribe(httpClient, collection, endpoint) - } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e) - } + for (collection in subscribeTo) + try { + val expires = collection.pushSubscriptionExpires + // calculate next run time, but use the duplicate interval for safety (times are not exact) + val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS) + if (expires != null && expires >= nextRun.epochSecond) + logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}") + else { + // no existing subscription or expiring soon + logger.fine("Registering push subscription for ${collection.url}") + subscribe(httpClient, collection, endpoint) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e) } } @@ -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) - } + 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 } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 080b91c54..28218505b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -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) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt index ddb4a70a3..6dec4270c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -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, + private val httpClientBuilder: Provider, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val serviceRepository: DavServiceRepository ) { @@ -172,21 +165,20 @@ class DavCollectionRepository @Inject constructor( val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found") val account = Account(service.accountName, context.getString(R.string.account_type)) - httpClientBuilder.get().fromAccount(account).build().use { httpClient -> - runInterruptible(ioDispatcher) { - try { - DavResource(httpClient.okHttpClient, collection.url).delete { - // success, otherwise an exception would have been thrown → delete locally, too - delete(collection) - } - } catch (e: HttpException) { - if (e is NotFoundException || e is GoneException) { - // HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too - logger.info("Collection ${collection.url} not found on server, deleting locally") - delete(collection) - } else - throw e + val httpClient = httpClientBuilder.get().fromAccount(account).build() + runInterruptible(ioDispatcher) { + try { + DavResource(httpClient, collection.url).delete { + // success, otherwise an exception would have been thrown → delete locally, too + delete(collection) } + } catch (e: HttpException) { + if (e is NotFoundException || e is GoneException) { + // HTTP 404 Not Found or 410 Gone (collection is not there anymore) -> delete locally, too + logger.info("Collection ${collection.url} not found on server, deleting locally") + delete(collection) + } else + throw e } } } @@ -289,19 +281,17 @@ 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( - xmlBody = xmlBody, - method = method - ) { - // success, otherwise an exception would have been thrown - } - } + runInterruptible(ioDispatcher) { + DavResource(httpClient, url).mkCol( + xmlBody = xmlBody, + method = method + ) { + // success, otherwise an exception would have been thrown } + } } private fun generateMkColXml( @@ -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().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() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt index 773991032..d0f61ffa7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddress.kt @@ -6,4 +6,8 @@ package at.bitfire.davdroid.resource import at.bitfire.vcard4android.Contact -interface LocalAddress: LocalResource \ No newline at end of file +interface LocalAddress: LocalResource { + + fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt index 6fbb821e9..a4b1a8eba 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalAddressBookStore.kt @@ -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 = + override fun getAll(account: Account, client: ContentProviderClient): List = 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 -> diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt index 2f0318bab..f4d553a94 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -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 { val result = LinkedList() - 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 { val dirty = LinkedList() - - /* - * 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)) - } - } - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt index 21eb8edcc..fc56c886a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt @@ -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) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt index 5a4e95635..4dfaa5773 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt @@ -4,7 +4,12 @@ package at.bitfire.davdroid.resource -interface LocalCollection> { +/** + * 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 { /** a tag that uniquely identifies the collection (DAVx5-wide) */ val tag: String diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt index 0dbe3e3f7..6aa28a574 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt @@ -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 { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt index 8e8a7e4c4..4b8316c11 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalDataStore.kt @@ -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> { * * @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?) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt index 327747805..55bdf9dd3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt @@ -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 { + 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, 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) + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt index d8bf2191b..e0bf45515 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt @@ -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, 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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt index 856af2de7..ae2b0b297 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollectionStore.kt @@ -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 = - JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null) + override fun getAll(account: Account, client: ContentProviderClient): List = + 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 -> - val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name) - val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount) - provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) - } + override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) { + if (client == null) + return + val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name) + val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount) + client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) } override fun delete(localCollection: LocalJtxCollection) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt index 437a0a492..f492e4f0e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -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(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, 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) + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt index 74a787557..39147c10a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt @@ -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 { +interface LocalResource { companion object { /** @@ -44,18 +49,6 @@ interface LocalResource { /** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */ val flags: Int - /** - * Prepares the resource for uploading: - * - * 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider. - * 2. The new file name which can be used for the upload is derived from the UID and returned, but not - * saved to the content provider. The sync manager is responsible for saving the file name that - * was actually used. - * - * @return suggestion for new file name of the resource (like ".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 { 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 { */ 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? + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt index cf55f3b24..9c54f04ca 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -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 { - - 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, 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 { 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 { 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 { 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 { override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) = LocalTask(taskList, values) } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt index d87753cc7..440804280 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt @@ -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 { @@ -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())) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt index 80bd8e12c..0f9905586 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskListStore.kt @@ -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 -> - val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name) - val uri = Tasks.getContentUri(providerName.authority) - provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) - } + override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) { + if (client == null) + return + val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name) + val uri = Tasks.getContentUri(providerName.authority) + client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name)) } override fun delete(localCollection: LocalTaskList) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt index 8f8ba9f90..6e86e6501 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresher.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt index 494d9c115..3f1177605 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -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()!! @@ -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 { val mailboxes = LinkedList() 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 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") diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt index 7788cf6ce..f511ba962 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresher.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt index af56b687f..53c107131 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt @@ -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 ) /** diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index a2a7a65de..627ee6bbc 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -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,34 +153,31 @@ 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) + runInterruptible { + val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient) - // refresh home set list (from principal url) - service.principal?.let { principalUrl -> - logger.fine("Querying principal $principalUrl for home sets") - val serviceRefresher = serviceRefresherFactory.create(service, httpClient) - serviceRefresher.discoverHomesets(principalUrl) - } - - // refresh home sets and their member collections - homeSetRefresherFactory.create(service, httpClient) - .refreshHomesetsAndTheirCollections() - - // also refresh collections without a home set - refresher.refreshCollectionsWithoutHomeSet() - - // Lastly, refresh the principals (collection owners) - val principalsRefresher = principalsRefresherFactory.create(service, httpClient) - principalsRefresher.refreshPrincipals() - } + // refresh home set list (from principal url) + service.principal?.let { principalUrl -> + logger.fine("Querying principal $principalUrl for home sets") + val serviceRefresher = serviceRefresherFactory.create(service, httpClient) + serviceRefresher.discoverHomesets(principalUrl) } + // refresh home sets and their member collections + homeSetRefresherFactory.create(service, httpClient) + .refreshHomesetsAndTheirCollections() + + // also refresh collections without a home set + refresher.refreshCollectionsWithoutHomeSet() + + // Lastly, refresh the principals (collection owners) + val principalsRefresher = principalsRefresherFactory.create(service, httpClient) + principalsRefresher.refreshPrincipals() + } + } catch(e: InvalidAccountException) { logger.log(Level.SEVERE, "Invalid account", e) return Result.failure() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt index b6f4078ee..3236178e7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceDetectionUtils.kt @@ -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 = arrayOf( // generic WebDAV properties - CurrentUserPrivilegeSet.NAME, - DisplayName.NAME, - Owner.NAME, - ResourceType.NAME, - PushTransports.NAME, // WebDAV-Push - Topic.NAME - ) + when (serviceType) { // service-specific CalDAV/CardDAV properties + 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() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt index 3398fb9f8..e00ea520e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt @@ -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 = 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() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index 52ac7bbb7..5d3e44998 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -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" diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt index 208a47321..3df2d6db4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration.kt @@ -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].** * diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt index fb561c799..21bcd9248 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration10.kt @@ -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) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt index 684b1c42f..1f6eddadf 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration12.kt @@ -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 ) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21.kt new file mode 100644 index 000000000..a5a004a45 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21.kt @@ -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 + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt index e29aeb20a..cc7301e89 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration8.kt @@ -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) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt index f6deea966..024458e8b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/AddressBookSyncer.kt @@ -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 @@ -68,15 +68,16 @@ 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 addressBook local address book + * @param provideHttpClient returns HTTP client on demand + * @param provider content provider to access android contacts + * @param syncResult stores hard and soft sync errors + * @param collection the database collection associated with this address book */ private fun syncAddressBook( account: Account, addressBook: LocalAddressBook, - httpClient: Lazy, + 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, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt index 1867b1995..53c6262db 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -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,24 +187,38 @@ 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) + + // map Android event to iCalendar (also generates UID, if necessary) + val handler = AndroidEventHandler( + accountName = resource.recurringCalendar.calendar.account.name, + prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId) + ) + val mappedEvents = handler.mapToVEvents(localEvent) + + // persist UID if it was generated + if (mappedEvents.generatedUid) + resource.updateUid(mappedEvents.uid) + + // generate iCalendar and convert to request body + val iCalWriter = StringWriter() + ICalendarGenerator().write(mappedEvents.associatedEvents, iCalWriter) + val requestBody = iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + + return GeneratedResource( + suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"), + requestBody = requestBody, + onSuccessContext = GeneratedResource.OnSuccessContext( + sequence = updatedSequence + ) + ) } - override fun generateUpload(resource: LocalEvent): RequestBody = - SyncException.wrapWithLocalResource(resource) { - val event = resource.eventToUpload() - logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event) - - // write iCalendar to string and convert to request body - val iCalWriter = StringWriter() - EventWriter(Constants.iCalProdId).write(event, iCalWriter) - iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) - } - override suspend fun listAllRemote(callback: MultiResponseCallback) { // calculate time range limits val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays -> @@ -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,56 +284,50 @@ class CalendarSyncManager @AssistedInject constructor( // helpers - private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) { - val events: List - try { - events = EventReader().readEvents(reader) - } catch (e: InvalidICalendarException) { - logger.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) - notifyInvalidResource(e, fileName) + private fun processICalendar(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) { + val calendar = + try { + ICalendarParser().parse(reader) + } catch (e: InvalidICalendarException) { + logger.log(Level.WARNING, "Received invalid iCalendar, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + val uidsAndEvents = CalendarUidSplitter().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() - if (events.size == 1) { - val event = events.first() + // map AssociatedEvents (VEVENTs) to EventAndExceptions (Android events) + val androidEvent = AndroidEventBuilder( + calendar = localCollection.androidCalendar, + syncId = fileName, + eTag = eTag, + scheduleTag = scheduleTag, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ).build(event) - // 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 - } + // 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) + } - // update local event, if it exists - val local = localCollection.findByName(fileName) + // create/update local event in calendar provider + val local = localCollection.findByName(fileName) + if (local != null) { SyncException.wrapWithLocalResource(local) { - if (local != null) { - logger.log(Level.INFO, "Updating $fileName in local calendar", event) - local.update( - data = event, - fileName = fileName, - eTag = eTag, - scheduleTag = scheduleTag, - flags = LocalResource.FLAG_REMOTELY_PRESENT - ) - } else { - logger.log(Level.INFO, "Adding $fileName to local calendar", event) - localCollection.add( - event = event, - fileName = fileName, - eTag = eTag, - scheduleTag = scheduleTag, - flags = LocalResource.FLAG_REMOTELY_PRESENT - ) - } + logger.log(Level.INFO, "Updating $fileName in local calendar", event) + local.update(androidEvent) } - } else - logger.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName") + } else { + logger.log(Level.INFO, "Adding $fileName to local calendar", event) + localCollection.add(androidEvent) + } } override fun notifyInvalidResourceTitle(): String = diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt index cc34683b7..f730fac64 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncer.kt @@ -60,7 +60,7 @@ class CalendarSyncer @AssistedInject constructor( val syncManager = calendarSyncManagerFactory.calendarSyncManager( account, - httpClient.value, + httpClient, syncResult, localCollection, remoteCollection, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt index 68add4578..a6ad21026 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -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, accountSettingsFactory: AccountSettings.Factory, - private val httpClientBuilder: HttpClient.Builder, + private val resourceDownloaderFactory: ResourceDownloader.Factory, @SyncDispatcher syncDispatcher: CoroutineDispatcher ): SyncManager( 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,40 +272,50 @@ class ContactsSyncManager @AssistedInject constructor( return modified or superModified } - override fun generateUpload(resource: LocalAddress): RequestBody = - SyncException.wrapWithLocalResource(resource) { - val contact: Contact = when (resource) { - is LocalContact -> resource.getContact() - is LocalGroup -> resource.getContact() - else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup") - } - - logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact) - - val os = ByteArrayOutputStream() - val mimeType: MediaType - when { - hasJCard -> { - mimeType = DavAddressBook.MIME_JCARD - contact.writeJCard(os, Constants.vCardProdId) - } - hasVCard4 -> { - mimeType = DavAddressBook.MIME_VCARD4 - contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId) - } - else -> { - mimeType = DavAddressBook.MIME_VCARD3_UTF8 - contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId) - } - } - - return@wrapWithLocalResource os.toByteArray().toRequestBody(mimeType) + override fun generateUpload(resource: LocalAddress): GeneratedResource { + val contact: Contact = when (resource) { + is LocalContact -> resource.getContact() + is LocalGroup -> resource.getContact() + else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup") } + logger.log(Level.FINE, "Preparing upload of vCard #${resource.id}", contact) + + // get/create UID + val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(contact.uid) + if (uidIsGenerated) { + // modify in Contact and persist to contacts provider + contact.uid = uid + resource.updateUid(uid) + } + + // generate vCard and convert to request body + val os = ByteArrayOutputStream() + val mimeType: MediaType + when { + hasJCard -> { + mimeType = DavAddressBook.MIME_JCARD + contact.writeJCard(os, Constants.vCardProdId) + } + hasVCard4 -> { + mimeType = DavAddressBook.MIME_VCARD4 + contact.writeVCard(VCardVersion.V4_0, os, Constants.vCardProdId) + } + else -> { + mimeType = DavAddressBook.MIME_VCARD3_UTF8 + contact.writeVCard(VCardVersion.V3_0, os, Constants.vCardProdId) + } + } + + return GeneratedResource( + suggestedFileName = DavUtils.fileNameFromUid(uid, "vcf"), + requestBody = os.toByteArray().toRequestBody(mimeType) + ) + } 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) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilder.kt new file mode 100644 index 000000000..2453190d7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilder.kt @@ -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 + )) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt new file mode 100644 index 000000000..ba436c5b6 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt @@ -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 + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt index 2c76b8b55..2e502034c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -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,13 +97,17 @@ 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) - val os = ByteArrayOutputStream() - resource.write(os, Constants.iCalProdId) - os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) - } + override fun generateUpload(resource: LocalJtxICalObject): GeneratedResource { + logger.log(Level.FINE, "Preparing upload of icalobject #${resource.id}") + + val os = ByteArrayOutputStream() + resource.write(os, ProdId(Constants.iCalProdId)) + + return GeneratedResource( + suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"), + requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + ) + } override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt index 097b8d837..1d924732f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncer.kt @@ -71,7 +71,7 @@ class JtxSyncer @AssistedInject constructor( val syncManager = jtxSyncManagerFactory.jtxSyncManager( account, - httpClient.value, + httpClient, syncResult, localCollection, remoteCollection, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt new file mode 100644 index 000000000..6706fced5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt @@ -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:`. + * + * 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, + 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 + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt index e17e2f0a6..44f09f699 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncException.kt @@ -18,12 +18,12 @@ class SyncException(cause: Throwable) : Exception(cause) { // provide lambda wrappers for setting the local/remote resource - fun wrapWithLocalResource(localResource: LocalResource<*>?, body: () -> T): T = + fun wrapWithLocalResource(localResource: LocalResource?, body: () -> T): T = runBlocking { wrapWithLocalResourceSuspending(localResource, body) } - suspend fun wrapWithLocalResourceSuspending(localResource: LocalResource<*>?, body: suspend () -> T): T { + suspend fun 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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt index 9d8391afc..6a879bb19 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -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,21 +65,21 @@ 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 * - * @param account account to synchronize - * @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account] - * @param dataType data type to synchronize - * @param syncResult receiver for result of the synchronization (will be updated by [performSync]) - * @param localCollection local collection to synchronize (interface to content provider) - * @param collection collection info in the database - * @param resync whether re-synchronization is requested + * @param account account to synchronize + * @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account] + * @param dataType data type to synchronize + * @param syncResult receiver for result of the synchronization (will be updated by [performSync]) + * @param localCollection local collection to synchronize (interface to content provider) + * @param collection collection info in the database + * @param resync whether re-synchronization is requested */ -abstract class SyncManager, out CollectionType: LocalCollection, RemoteType: DavCollection>( +abstract class SyncManager, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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 -> { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt index 7efb92b88..8f161a0ff 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt @@ -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 - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt index 30389b24e..573bb87fe 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt @@ -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, 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, CollectionType: syncNotificationManagerFactory.create(account) } - val httpClient = lazy { + val httpClient by lazy { httpClientBuilder.fromAccount(account).build() } @@ -259,22 +260,30 @@ abstract class Syncer, 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) { - logger.log(Level.SEVERE, "Couldn't sync ${dataStore.authority}", e) - syncResult.numUnclassifiedErrors++ // Hard sync error + /* 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") } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt index bd8981af6..18dcc147f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TaskSyncer.kt @@ -72,7 +72,7 @@ class TaskSyncer @AssistedInject constructor( val syncManager = tasksSyncManagerFactory.tasksSyncManager( account, - httpClient.value, + httpClient, syncResult, localCollection, remoteCollection, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt index 9dec0fb87..dc14b2ed3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt @@ -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,17 +102,28 @@ class TasksSyncManager @AssistedInject constructor( override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT - override fun generateUpload(resource: LocalTask): RequestBody = - SyncException.wrapWithLocalResource(resource) { - val task = requireNotNull(resource.task) - logger.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task) + override fun generateUpload(resource: LocalTask): GeneratedResource { + val task = requireNotNull(resource.task) + logger.log(Level.FINE, "Preparing upload of task ${resource.id}", task) - val os = ByteArrayOutputStream() - task.write(os, Constants.iCalProdId) - - os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + // 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, ProdId(Constants.iCalProdId)) + + return GeneratedResource( + suggestedFileName = DavUtils.fileNameFromUid(uid, "ics"), + requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + ) + } + override suspend fun listAllRemote(callback: MultiResponseCallback) { SyncException.wrapWithRemoteResourceSuspending(collection.url) { logger.info("Querying tasks") diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt index 3249f8588..2e9d51a0a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncAdapterImpl.kt @@ -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, 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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt index 51971940a..18c8909d5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt @@ -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): Flow { - // 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> = dataTypes.mapNotNull { dataType -> // Map datatype to authority diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt index 373eb16d3..15d12d681 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AppSettingsScreen.kt @@ -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,48 +425,51 @@ 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)) } - var showingDistrustWarning by remember { mutableStateOf(false) } - if (showingDistrustWarning) { - DistrustSystemCertificatesAlertDialog( - onDistrustSystemCertsRequested = { onDistrustSystemCertsUpdated(true) }, - onDismissRequested = { showingDistrustWarning = false } - ) - } - - SwitchSetting( - checked = distrustSystemCerts, - name = stringResource(R.string.app_settings_distrust_system_certs), - summaryOn = stringResource(R.string.app_settings_distrust_system_certs_on), - summaryOff = stringResource(R.string.app_settings_distrust_system_certs_off) - ) { checked -> - if (checked) { - // Show warning before enabling. - showingDistrustWarning = true - } else { - onDistrustSystemCertsUpdated(false) - } - } - - Setting( - name = stringResource(R.string.app_settings_reset_certificates), - 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 ) + + if (showCertSettings) { + var showingDistrustWarning by remember { mutableStateOf(false) } + if (showingDistrustWarning) { + DistrustSystemCertificatesAlertDialog( + onDistrustSystemCertsRequested = { onDistrustSystemCertsUpdated(true) }, + onDismissRequested = { showingDistrustWarning = false } + ) + } + + SwitchSetting( + checked = distrustSystemCerts, + name = stringResource(R.string.app_settings_distrust_system_certs), + summaryOn = stringResource(R.string.app_settings_distrust_system_certs_on), + summaryOff = stringResource(R.string.app_settings_distrust_system_certs_off) + ) { checked -> + if (checked) { + // Show warning before enabling. + showingDistrustWarning = true + } else { + onDistrustSystemCertsUpdated(false) + } + } + + Setting( + name = stringResource(R.string.app_settings_reset_certificates), + summary = stringResource(R.string.app_settings_reset_certificates_summary), + onClick = onResetCertificates + ) + } } @Composable diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt index 991b49fb9..5e69a4f14 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -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 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" + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt index be8a45f08..2778ca00f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoGenerator.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt index d34223483..422b6ba24 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt @@ -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 ) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt index 2682273c7..cb0291e25 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt index 57df84218..6b2bc54bd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/InputDialogs.kt @@ -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)) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt index 527631a2a..a340ba552 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt @@ -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 = {}, ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt index 356b41646..20a0d3c5a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLogin.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Folder @@ -69,7 +71,6 @@ object AdvancedLogin : LoginType { username = uiState.username, onSetUsername = model::setUsername, password = uiState.password, - onSetPassword = model::setPassword, certAlias = uiState.certAlias, onSetCertAlias = model::setCertAlias, canContinue = uiState.canContinue, @@ -88,8 +89,7 @@ fun AdvancedLoginScreen( onSetUrl: (String) -> Unit = {}, username: String, onSetUsername: (String) -> Unit = {}, - password: String, - onSetPassword: (String) -> Unit = {}, + password: TextFieldState, certAlias: String, onSetCertAlias: (String) -> Unit = {}, canContinue: Boolean, @@ -159,7 +159,6 @@ fun AdvancedLoginScreen( PasswordTextField( password = password, - onPasswordChange = onSetPassword, labelText = stringResource(R.string.login_password_optional), leadingIcon = { Icon(Icons.Default.Password, null) @@ -194,7 +193,7 @@ fun AdvancedLoginScreen_Preview_Empty() { snackbarHostState = SnackbarHostState(), url = "", username = "", - password = "", + password = rememberTextFieldState(""), certAlias = "", canContinue = false ) @@ -207,7 +206,7 @@ fun AdvancedLoginScreen_Preview_AllFilled() { snackbarHostState = SnackbarHostState(), url = "dav.example.com", username = "someuser", - password = "password", + password = rememberTextFieldState("password"), certAlias = "someCert", canContinue = true ) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt index ebb63e8f5..4ffd2f673 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.ui.setup +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -30,7 +31,7 @@ class AdvancedLoginModel @AssistedInject constructor( data class UiState( val url: String = "", val username: String = "", - val password: String = "", + val password: TextFieldState = TextFieldState(), val certAlias: String = "" ) { @@ -47,7 +48,7 @@ class AdvancedLoginModel @AssistedInject constructor( baseUri = uri, credentials = Credentials( username = username.trimToNull(), - password = password.trimToNull()?.toSensitiveString(), + password = password.text.trimToNull()?.toSensitiveString(), certificateAlias = certAlias.trimToNull() ) ) @@ -61,7 +62,7 @@ class AdvancedLoginModel @AssistedInject constructor( uiState = uiState.copy( url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: "", username = initialLoginInfo.credentials?.username ?: "", - password = initialLoginInfo.credentials?.password?.asString() ?: "", + password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: ""), certAlias = initialLoginInfo.credentials?.certificateAlias ?: "" ) } @@ -74,10 +75,6 @@ class AdvancedLoginModel @AssistedInject constructor( uiState = uiState.copy(username = username) } - fun setPassword(password: String) { - uiState = uiState.copy(password = password) - } - fun setCertAlias(certAlias: String) { uiState = uiState.copy(certAlias = certAlias) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt index 201238957..1ec868707 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLogin.kt @@ -8,8 +8,9 @@ import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Password @@ -63,7 +64,6 @@ object EmailLogin : LoginType { email = uiState.email, onSetEmail = model::setEmail, password = uiState.password, - onSetPassword = model::setPassword, canContinue = uiState.canContinue, onLogin = { onLogin(uiState.asLoginInfo()) } ) @@ -76,8 +76,7 @@ object EmailLogin : LoginType { fun EmailLoginScreen( email: String, onSetEmail: (String) -> Unit = {}, - password: String, - onSetPassword: (String) -> Unit = {}, + password: TextFieldState, canContinue: Boolean, onLogin: () -> Unit = {} ) { @@ -129,7 +128,6 @@ fun EmailLoginScreen( PasswordTextField( password = password, - onPasswordChange = onSetPassword, labelText = stringResource(R.string.login_password), leadingIcon = { Icon(Icons.Default.Password, null) @@ -138,8 +136,9 @@ fun EmailLoginScreen( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), - keyboardActions = KeyboardActions { - if (canContinue) onLogin() + onKeyboardAction = { + if (canContinue) + onLogin() }, modifier = Modifier.fillMaxWidth() ) @@ -157,7 +156,7 @@ fun EmailLoginScreen( fun EmailLoginScreen_Preview() { EmailLoginScreen( email = "test@example.com", - password = "", + password = rememberTextFieldState(""), canContinue = false ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt index 6710bb00e..5c8ceae67 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EmailLoginModel.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.ui.setup +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -28,18 +29,19 @@ class EmailLoginModel @AssistedInject constructor( data class UiState( val email: String = "", - val password: String = "" + val password: TextFieldState = TextFieldState() ) { val uri = "mailto:$email".toURIorNull() - val canContinue = uri != null && password.isNotEmpty() + val canContinue // we have to use get() because password is not immutable + get() = uri != null && password.text.toString().isNotEmpty() fun asLoginInfo(): LoginInfo { return LoginInfo( baseUri = uri, credentials = Credentials( username = email, - password = password.toSensitiveString() + password = password.text.toSensitiveString() ) ) } @@ -51,7 +53,7 @@ class EmailLoginModel @AssistedInject constructor( init { uiState = uiState.copy( email = initialLoginInfo.credentials?.username ?: "", - password = initialLoginInfo.credentials?.password?.asString() ?: "" + password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: "") ) } @@ -59,8 +61,4 @@ class EmailLoginModel @AssistedInject constructor( uiState = uiState.copy(email = email) } - fun setPassword(password: String) { - uiState = uiState.copy(password = password) - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt index f1b16d63c..92b37820b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt @@ -195,9 +195,8 @@ class LoginScreenModel @AssistedInject constructor( detectResourcesJob = viewModelScope.launch { val result = withContext(Dispatchers.IO) { runInterruptible { - resourceFinderFactory.create(loginInfo.baseUri!!, loginInfo.credentials).use { finder -> - finder.findInitialConfiguration() - } + val finder = resourceFinderFactory.create(loginInfo.baseUri!!, loginInfo.credentials) + finder.findInitialConfiguration() } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt index bee507252..62c940aaa 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/NextcloudLoginModel.kt @@ -102,10 +102,6 @@ class NextcloudLoginModel @AssistedInject constructor( state[STATE_TOKEN] = value }*/ - override fun onCleared() { - loginFlow.close() - } - /** * Starts the Login Flow. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt index 77613c30e..9a5291b70 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLogin.kt @@ -8,8 +8,9 @@ import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Folder @@ -65,7 +66,6 @@ object UrlLogin : LoginType { username = uiState.username, onSetUsername = model::setUsername, password = uiState.password, - onSetPassword = model::setPassword, canContinue = uiState.canContinue, onLogin = { if (uiState.canContinue) @@ -82,8 +82,7 @@ fun UrlLoginScreen( onSetUrl: (String) -> Unit = {}, username: String, onSetUsername: (String) -> Unit = {}, - password: String, - onSetPassword: (String) -> Unit = {}, + password: TextFieldState, canContinue: Boolean, onLogin: () -> Unit = {} ) { @@ -151,7 +150,6 @@ fun UrlLoginScreen( PasswordTextField( password = password, - onPasswordChange = onSetPassword, labelText = stringResource(R.string.login_password), leadingIcon = { Icon(Icons.Default.Password, null) @@ -160,8 +158,9 @@ fun UrlLoginScreen( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), - keyboardActions = KeyboardActions { - if (canContinue) onLogin() + onKeyboardAction = { + if (canContinue) + onLogin() }, modifier = Modifier.fillMaxWidth() ) @@ -179,7 +178,7 @@ fun UrlLoginScreen_Preview() { UrlLoginScreen( url = "https://example.com", username = "user", - password = "", + password = rememberTextFieldState(""), canContinue = false ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt index c97f4bfa3..7f0de6db4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.ui.setup +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -30,7 +31,7 @@ class UrlLoginModel @AssistedInject constructor( data class UiState( val url: String = "", val username: String = "", - val password: String = "" + val password: TextFieldState = TextFieldState() ) { val urlWithPrefix = @@ -40,14 +41,15 @@ class UrlLoginModel @AssistedInject constructor( "https://$url" val uri = urlWithPrefix.trim().toURIorNull() - val canContinue = uri != null && username.isNotEmpty() && password.isNotEmpty() + val canContinue // we have to use get() because password is not immutable + get() = uri != null && username.isNotEmpty() && password.text.toString().isNotEmpty() fun asLoginInfo(): LoginInfo = LoginInfo( baseUri = uri, credentials = Credentials( username = username.trimToNull(), - password = password.trimToNull()?.toSensitiveString() + password = password.text.toString().trimToNull()?.toSensitiveString() ) ) @@ -60,7 +62,7 @@ class UrlLoginModel @AssistedInject constructor( uiState = UiState( url = initialLoginInfo.baseUri?.toString()?.removePrefix("https://") ?: "", username = initialLoginInfo.credentials?.username ?: "", - password = initialLoginInfo.credentials?.password?.asString() ?: "" + password = TextFieldState(initialLoginInfo.credentials?.password?.asString() ?: "") ) } @@ -72,8 +74,4 @@ class UrlLoginModel @AssistedInject constructor( uiState = uiState.copy(username = username) } - fun setPassword(password: String) { - uiState = uiState.copy(password = password) - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt index 791a82cfe..c94512272 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt @@ -5,6 +5,7 @@ package at.bitfire.davdroid.ui.webdav import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -36,7 +37,7 @@ class AddWebdavMountModel @Inject constructor( val displayName: String = "", val url: String = "", val username: String = "", - val password: String = "", + val password: TextFieldState = TextFieldState(), val certificateAlias: String? = null ) { val urlWithPrefix = @@ -67,10 +68,6 @@ class AddWebdavMountModel @Inject constructor( uiState = uiState.copy(username = username) } - fun setPassword(password: String) { - uiState = uiState.copy(password = password) - } - fun setCertificateAlias(certAlias: String) { uiState = uiState.copy(certificateAlias = certAlias) } @@ -85,7 +82,7 @@ class AddWebdavMountModel @Inject constructor( val displayName = uiState.displayName val credentials = Credentials( username = uiState.username.trimToNull(), - password = uiState.password.trimToNull()?.toSensitiveString(), + password = uiState.password.text.trimToNull()?.toSensitiveString(), certificateAlias = uiState.certificateAlias ) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt index 6426c046a..1e136af7c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt @@ -7,8 +7,9 @@ package at.bitfire.davdroid.ui.webdav import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Help @@ -70,7 +71,6 @@ fun AddWebdavMountScreen( username = uiState.username, onSetUsername = model::setUsername, password = uiState.password, - onSetPassword = model::setPassword, certificateAlias = uiState.certificateAlias, onSetCertificateAlias = model::setCertificateAlias, canContinue = uiState.canContinue, @@ -92,8 +92,7 @@ fun AddWebDavMountScreen( onSetUrl: (String) -> Unit = {}, username: String, onSetUsername: (String) -> Unit = {}, - password: String, - onSetPassword: (String) -> Unit = {}, + password: TextFieldState, certificateAlias: String?, onSetCertificateAlias: (String) -> Unit = {}, canContinue: Boolean, @@ -163,7 +162,7 @@ fun AddWebDavMountScreen( value = url, onValueChange = onSetUrl, singleLine = true, - readOnly = isLoading, + enabled = !isLoading, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Next, keyboardType = KeyboardType.Uri @@ -185,7 +184,7 @@ fun AddWebDavMountScreen( leadingIcon = { Icon(Icons.Default.Sell, null) }, - readOnly = isLoading, + enabled = !isLoading, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), modifier = Modifier .fillMaxWidth() @@ -207,7 +206,7 @@ fun AddWebDavMountScreen( leadingIcon = { Icon(Icons.Default.AccountCircle, null) }, - readOnly = isLoading, + enabled = !isLoading, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Next, keyboardType = KeyboardType.Email @@ -218,18 +217,19 @@ fun AddWebDavMountScreen( ) PasswordTextField( password = password, - onPasswordChange = onSetPassword, labelText = stringResource(R.string.login_password_optional), - readOnly = isLoading, + enabled = !isLoading, leadingIcon = { Icon(Icons.Default.Password, null) }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done ), - keyboardActions = KeyboardActions( - onDone = { onAddMount() } - ), + onKeyboardAction = { + // can only be called when not loading + if (canContinue) + onAddMount() + }, modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp) @@ -258,7 +258,7 @@ fun AddWebDavMountScreen_Preview() { displayName = "Test", url = "https://example.com", username = "user", - password = "password", + password = rememberTextFieldState("password"), certificateAlias = null, canContinue = true ) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt index 818f399ba..e2f2cb8b0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt @@ -4,12 +4,14 @@ package at.bitfire.davdroid.util +import at.bitfire.davdroid.util.DavUtils.generateUidIfNecessary import okhttp3.HttpUrl import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import java.net.URI import java.net.URISyntaxException import java.util.Locale +import java.util.UUID /** * Some WebDAV and HTTP network utility methods. @@ -43,6 +45,68 @@ object DavUtils { return String.format(Locale.ROOT, "#%06X%02X", color, alpha) } + /** + * Generates a usable WebDAV resource name (file name) from an UID. + * + * If the UID contains only characters that are usually not problematic in file names, + * the returned value is `.`. If there are problematic characters, + * the file name will be generated from a random UUID plus suffix instead. + * + * @param uid UID of the iCalendar or vCard + * @param suffix suffix to use (without dot, for instance `ics` for iCalendar files) + * @param generateUuid generator that generates a random UUID + * + * @return file name that can be used to upload the resource + */ + fun fileNameFromUid( + uid: String, + suffix: String, + generateUuid: () -> String = { UUID.randomUUID().toString() } + ): String { + val uidIsGoodBaseName: Boolean = 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) + } + val baseName = if (uidIsGoodBaseName) + uid + else + generateUuid() + return "$baseName.$suffix" + } + + /** + * Result of [generateUidIfNecessary]. + * + * @param uid resulting UID (either from existing or generated) + * @param generated *true*: [uid] was generated by [generateUidIfNecessary]; *false*: [uid] was taken from existing UID + */ + data class UidGenerationResult( + val uid: String, + val generated: Boolean + ) + /** + * Generates a UID for an iCalendar/vCard if there is no existing UID. + * + * @param existingUid existing UID (may be null) + * @param generateUuid generator that generates a random UUID + * + * @return decomposable result that contains either the existing or the generated UID and whether it was generated + */ + fun generateUidIfNecessary( + existingUid: String?, + generateUuid: () -> String = { UUID.randomUUID().toString() } + ): UidGenerationResult = + if (existingUid == null) { + // generate new UID + UidGenerationResult(generateUuid(), generated = true) + } else { + // use existing UID + UidGenerationResult(existingUid, generated = false) + } + // extension methods diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt index c1ce0b3ab..6912fe64d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/SensitiveString.kt @@ -61,8 +61,8 @@ class SensitiveString private constructor( fun CharArray.toSensitiveString() = SensitiveString(this.concatToString()) - fun String.toSensitiveString() = - SensitiveString(this) + fun CharSequence.toSensitiveString() = + SensitiveString(this.toString()) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt index a4e76ddf0..90626e2b1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/StringUtils.kt @@ -4,10 +4,9 @@ package at.bitfire.davdroid.util -import com.google.common.base.Joiner import com.google.common.base.Strings -fun String?.trimToNull() = Strings.emptyToNull(this?.trim()) +fun CharSequence?.trimToNull() = Strings.emptyToNull(this?.trim()?.toString()) fun String.withTrailingSlash() = if (this.endsWith('/')) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt index 9f6e8d0cd..5ea906b24 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt @@ -4,16 +4,17 @@ package at.bitfire.davdroid.webdav -import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.network.HttpClientBuilder import at.bitfire.davdroid.network.MemoryCookieStore import okhttp3.CookieJar +import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import javax.inject.Inject import javax.inject.Provider class DavHttpClientBuilder @Inject constructor( private val credentialsStore: CredentialsStore, - private val httpClientBuilder: Provider, + private val httpClientBuilder: Provider, ) { /** @@ -22,7 +23,7 @@ class DavHttpClientBuilder @Inject constructor( * @param mountId ID of the mount to access * @param logBody whether to log the body of HTTP requests (disable for potentially large files) */ - fun build(mountId: Long, logBody: Boolean = true): HttpClient { + fun build(mountId: Long, logBody: Boolean = true): OkHttpClient { val cookieStore = cookieStores.getOrPut(mountId) { MemoryCookieStore() } @@ -31,7 +32,10 @@ class DavHttpClientBuilder @Inject constructor( .setCookieStore(cookieStore) credentialsStore.getCredentials(mountId)?.let { credentials -> - builder.authenticate(host = null, getCredentials = { credentials }) + builder.authenticate( + domain = null, + getCredentials = { credentials } + ) } return builder.build() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt index 19cb46118..57c806f0f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DocumentProviderUtils.kt @@ -13,7 +13,7 @@ import android.provider.DocumentsContract.buildChildDocumentsUri import android.provider.DocumentsContract.buildRootsUri import android.webkit.MimeTypeMap import androidx.core.app.TaskStackBuilder -import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.okhttp.exception.HttpException import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity import java.io.FileNotFoundException diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt index db221dca1..8d7cb9f6d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt @@ -5,11 +5,11 @@ package at.bitfire.davdroid.webdav import androidx.annotation.WorkerThread -import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.dav4jvm.property.webdav.GetETag -import at.bitfire.davdroid.network.HttpClient import okhttp3.HttpUrl +import okhttp3.OkHttpClient import java.time.Instant /** @@ -27,13 +27,13 @@ data class HeadResponse( companion object { @WorkerThread - fun fromUrl(client: HttpClient, url: HttpUrl): HeadResponse { + fun fromUrl(client: OkHttpClient, url: HttpUrl): HeadResponse { var size: Long? = null var eTag: String? = null var lastModified: Instant? = null var supportsPartial: Boolean? = null - DavResource(client.okHttpClient, url).head { response -> + DavResource(client, url).head { response -> response.header("ETag", null)?.let { val getETag = GetETag(it) if (!getETag.weak) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt index a4c391b22..c43ba105e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt @@ -14,11 +14,10 @@ import android.system.ErrnoException import android.system.OsConstants import androidx.annotation.RequiresApi import androidx.core.content.getSystemService -import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.HttpUtils -import at.bitfire.dav4jvm.exception.DavException -import at.bitfire.dav4jvm.exception.HttpException -import at.bitfire.davdroid.network.HttpClient +import at.bitfire.dav4jvm.okhttp.DavResource +import at.bitfire.dav4jvm.okhttp.exception.DavException +import at.bitfire.dav4jvm.okhttp.exception.HttpException import at.bitfire.davdroid.util.DavUtils import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheLoader @@ -37,13 +36,13 @@ import kotlinx.coroutines.runInterruptible import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.MediaType +import okhttp3.OkHttpClient import java.io.InterruptedIOException import java.util.logging.Logger -import javax.annotation.WillClose @RequiresApi(26) class RandomAccessCallback @AssistedInject constructor( - @Assisted @WillClose private val httpClient: HttpClient, + @Assisted private val httpClient: OkHttpClient, @Assisted private val url: HttpUrl, @Assisted private val mimeType: MediaType?, @Assisted headResponse: HeadResponse, @@ -63,7 +62,7 @@ class RandomAccessCallback @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallback + fun create(httpClient: OkHttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallback } data class PageIdentifier( @@ -71,7 +70,7 @@ class RandomAccessCallback @AssistedInject constructor( val size: Int ) - private val dav = DavResource(httpClient.okHttpClient, url) + private val dav = DavResource(httpClient, url) private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size") private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified") @@ -127,7 +126,6 @@ class RandomAccessCallback @AssistedInject constructor( // free resources ioThread.quitSafely() - httpClient.close() } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt index 2d2ab9e3d..54c0062e3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallbackWrapper.kt @@ -8,13 +8,13 @@ import android.os.ProxyFileDescriptorCallback import android.system.ErrnoException import android.system.OsConstants import androidx.annotation.RequiresApi -import at.bitfire.davdroid.network.HttpClient import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import okhttp3.HttpUrl import okhttp3.MediaType +import okhttp3.OkHttpClient /** * Use this wrapper to ensure that all memory is released as soon as [onRelease] is called. @@ -32,7 +32,7 @@ import okhttp3.MediaType */ @RequiresApi(26) class RandomAccessCallbackWrapper @AssistedInject constructor( - @Assisted httpClient: HttpClient, + @Assisted httpClient: OkHttpClient, @Assisted url: HttpUrl, @Assisted mimeType: MediaType?, @Assisted headResponse: HeadResponse, @@ -42,7 +42,7 @@ class RandomAccessCallbackWrapper @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallbackWrapper + fun create(httpClient: OkHttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallbackWrapper } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt index 50cb1f4e5..a4545b289 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -5,10 +5,9 @@ package at.bitfire.davdroid.webdav import android.os.ParcelFileDescriptor -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.davdroid.di.IoDispatcher -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.util.DavUtils import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -19,18 +18,18 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl import okhttp3.MediaType +import okhttp3.OkHttpClient import okhttp3.RequestBody import okio.BufferedSink import java.io.IOException import java.util.logging.Level import java.util.logging.Logger -import javax.annotation.WillClose /** - * @param client HTTP client ([StreamingFileDescriptor] is responsible to close it) + * @param client HTTP client to use */ class StreamingFileDescriptor @AssistedInject constructor( - @Assisted @WillClose private val client: HttpClient, + @Assisted private val client: OkHttpClient, @Assisted private val url: HttpUrl, @Assisted private val mimeType: MediaType?, @Assisted private val externalScope: CoroutineScope, @@ -41,10 +40,10 @@ class StreamingFileDescriptor @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(client: HttpClient, url: HttpUrl, mimeType: MediaType?, externalScope: CoroutineScope, finishedCallback: OnSuccessCallback): StreamingFileDescriptor + fun create(client: OkHttpClient, url: HttpUrl, mimeType: MediaType?, externalScope: CoroutineScope, finishedCallback: OnSuccessCallback): StreamingFileDescriptor } - val dav = DavResource(client.okHttpClient, url) + val dav = DavResource(client, url) var transferred: Long = 0 fun download() = doStreaming(false) @@ -75,7 +74,6 @@ class StreamingFileDescriptor @AssistedInject constructor( writeFd.close() } catch (_: IOException) {} - client.close() finishedCallback.onFinished(transferred, success) } } @@ -99,7 +97,7 @@ class StreamingFileDescriptor @AssistedInject constructor( body.byteStream().use { source -> transferred += source.copyTo(destination) } - logger.finer("Downloaded $transferred byte(s) from $url") + logger.fine("Downloaded $transferred byte(s) from $url") } } else @@ -120,11 +118,11 @@ class StreamingFileDescriptor @AssistedInject constructor( override fun writeTo(sink: BufferedSink) { ParcelFileDescriptor.AutoCloseInputStream(readFd).use { input -> transferred += input.copyTo(sink.outputStream()) - logger.finer("Uploaded $transferred byte(s) to $url") + logger.fine("Uploaded $transferred byte(s) to $url") } } } - DavResource(client.okHttpClient, url).put(body) { + DavResource(client, url).put(body) { // upload successful } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt index def00da37..638bb23b5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -7,12 +7,12 @@ package at.bitfire.davdroid.webdav import android.content.Context import android.provider.DocumentsContract import androidx.annotation.VisibleForTesting -import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.WebDavMount import at.bitfire.davdroid.di.IoDispatcher -import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.network.HttpClientBuilder import at.bitfire.davdroid.settings.Credentials import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -27,7 +27,7 @@ class WebDavMountRepository @Inject constructor( @ApplicationContext private val context: Context, private val db: AppDatabase, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val httpClientBuilder: Provider + private val httpClientBuilder: Provider ) { private val mountDao = db.webDavMountDao() @@ -127,21 +127,19 @@ class WebDavMountRepository @Inject constructor( val validVersions = arrayOf("1", "2", "3") val builder = httpClientBuilder.get() - if (credentials != null) builder.authenticate( - host = null, + domain = null, getCredentials = { credentials } ) + val httpClient = builder.build() var webdavUrl: HttpUrl? = null - builder.build().use { httpClient -> - val dav = DavResource(httpClient.okHttpClient, url) - runInterruptible { - dav.options(followRedirects = true) { davCapabilities, response -> - if (davCapabilities.any { it in validVersions }) - webdavUrl = dav.location - } + val dav = DavResource(httpClient, url) + runInterruptible { + dav.options(followRedirects = true) { davCapabilities, response -> + if (davCapabilities.any { it in validVersions }) + webdavUrl = dav.location } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt index a031edd43..0f183006c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CopyDocumentOperation.kt @@ -5,8 +5,8 @@ package at.bitfire.davdroid.webdav.operation import android.content.Context -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.davdroid.db.AppDatabase import at.bitfire.davdroid.db.WebDavDocument import at.bitfire.davdroid.di.IoDispatcher @@ -40,38 +40,37 @@ class CopyDocumentOperation @Inject constructor( if (srcDoc.mountId != dstFolder.mountId) throw UnsupportedOperationException("Can't COPY between WebDAV servers") - httpClientBuilder.build(srcDoc.mountId).use { client -> - val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db)) - val dstUrl = dstFolder.toHttpUrl(db).newBuilder() - .addPathSegment(name) - .build() + val client = httpClientBuilder.build(srcDoc.mountId) + val dav = DavResource(client, srcDoc.toHttpUrl(db)) + val dstUrl = dstFolder.toHttpUrl(db).newBuilder() + .addPathSegment(name) + .build() - try { - runInterruptible(ioDispatcher) { - dav.copy(dstUrl, false) { - // successfully copied - } + try { + runInterruptible(ioDispatcher) { + dav.copy(dstUrl, false) { + // successfully copied } - } catch (e: HttpException) { - e.throwForDocumentProvider(context) } - - val dstDocId = documentDao.insertOrReplace( - WebDavDocument( - mountId = dstFolder.mountId, - parentId = dstFolder.id, - name = name, - isDirectory = srcDoc.isDirectory, - displayName = srcDoc.displayName, - mimeType = srcDoc.mimeType, - size = srcDoc.size - ) - ).toString() - - DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId) - - /* return */ dstDocId + } catch (e: HttpException) { + e.throwForDocumentProvider(context) } + + val dstDocId = documentDao.insertOrReplace( + WebDavDocument( + mountId = dstFolder.mountId, + parentId = dstFolder.id, + name = name, + isDirectory = srcDoc.isDirectory, + displayName = srcDoc.displayName, + mimeType = srcDoc.mimeType, + size = srcDoc.size + ) + ).toString() + + DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId) + + /* return */ dstDocId } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt index bd5e3b33b..0f7623892 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/CreateDocumentOperation.kt @@ -6,8 +6,8 @@ package at.bitfire.davdroid.webdav.operation import android.content.Context import android.provider.DocumentsContract.Document -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.davdroid.db.AppDatabase import at.bitfire.davdroid.db.WebDavDocument import at.bitfire.davdroid.di.IoDispatcher @@ -41,45 +41,44 @@ class CreateDocumentOperation @Inject constructor( val createDirectory = mimeType == Document.MIME_TYPE_DIR var docId: Long? - httpClientBuilder.build(parent.mountId).use { client -> - for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) { - val newName = displayNameToMemberName(displayName, attempt) - val parentUrl = parent.toHttpUrl(db) - val newLocation = parentUrl.newBuilder() - .addPathSegment(newName) - .build() - val doc = DavResource(client.okHttpClient, newLocation) - try { - runInterruptible(ioDispatcher) { - if (createDirectory) - doc.mkCol(null) { - // directory successfully created - } - else - doc.put(RequestBody.EMPTY, ifNoneMatch = true) { - // document successfully created - } - } - - docId = documentDao.insertOrReplace( - WebDavDocument( - mountId = parent.mountId, - parentId = parent.id, - name = newName, - isDirectory = createDirectory, - mimeType = mimeType.toMediaTypeOrNull(), - eTag = null, - lastModified = null, - size = if (createDirectory) null else 0 - ) - ) - - DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId) - - return@runBlocking docId.toString() - } catch (e: HttpException) { - e.throwForDocumentProvider(context, ignorePreconditionFailed = true) + val client = httpClientBuilder.build(parent.mountId) + for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) { + val newName = displayNameToMemberName(displayName, attempt) + val parentUrl = parent.toHttpUrl(db) + val newLocation = parentUrl.newBuilder() + .addPathSegment(newName) + .build() + val doc = DavResource(client, newLocation) + try { + runInterruptible(ioDispatcher) { + if (createDirectory) + doc.mkCol(null) { + // directory successfully created + } + else + doc.put(RequestBody.EMPTY, ifNoneMatch = true) { + // document successfully created + } } + + docId = documentDao.insertOrReplace( + WebDavDocument( + mountId = parent.mountId, + parentId = parent.id, + name = newName, + isDirectory = createDirectory, + mimeType = mimeType.toMediaTypeOrNull(), + eTag = null, + lastModified = null, + size = if (createDirectory) null else 0 + ) + ) + + DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId) + + return@runBlocking docId.toString() + } catch (e: HttpException) { + e.throwForDocumentProvider(context, ignorePreconditionFailed = true) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt index 9736e90c1..c18a58be9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/DeleteDocumentOperation.kt @@ -5,8 +5,8 @@ package at.bitfire.davdroid.webdav.operation import android.content.Context -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.davdroid.db.AppDatabase import at.bitfire.davdroid.di.IoDispatcher import at.bitfire.davdroid.webdav.DavHttpClientBuilder @@ -34,21 +34,20 @@ class DeleteDocumentOperation @Inject constructor( logger.fine("WebDAV removeDocument $documentId") val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() - httpClientBuilder.build(doc.mountId).use { client -> - val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) - try { - runInterruptible(ioDispatcher) { - dav.delete { - // successfully deleted - } + val client = httpClientBuilder.build(doc.mountId) + val dav = DavResource(client, doc.toHttpUrl(db)) + try { + runInterruptible(ioDispatcher) { + dav.delete { + // successfully deleted } - logger.fine("Successfully removed") - documentDao.delete(doc) - - DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) - } catch (e: HttpException) { - e.throwForDocumentProvider(context) } + logger.fine("Successfully removed") + documentDao.delete(doc) + + DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) + } catch (e: HttpException) { + e.throwForDocumentProvider(context) } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt index c9faa3fa4..31b067a01 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/MoveDocumentOperation.kt @@ -5,8 +5,8 @@ package at.bitfire.davdroid.webdav.operation import android.content.Context -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.davdroid.db.AppDatabase import at.bitfire.davdroid.di.IoDispatcher import at.bitfire.davdroid.webdav.DavHttpClientBuilder @@ -42,22 +42,21 @@ class MoveDocumentOperation @Inject constructor( .addPathSegment(doc.name) .build() - httpClientBuilder.build(doc.mountId).use { client -> - val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) - try { - runInterruptible(ioDispatcher) { - dav.move(newLocation, false) { - // successfully moved - } + val client = httpClientBuilder.build(doc.mountId) + val dav = DavResource(client, doc.toHttpUrl(db)) + try { + runInterruptible(ioDispatcher) { + dav.move(newLocation, false) { + // successfully moved } - - documentDao.update(doc.copy(parentId = dstParent.id)) - - DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId) - DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId) - } catch (e: HttpException) { - e.throwForDocumentProvider(context) } + + documentDao.update(doc.copy(parentId = dstParent.id)) + + DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId) + DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId) + } catch (e: HttpException) { + e.throwForDocumentProvider(context) } doc.id.toString() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt index ad0f014b3..05bd809f0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentOperation.kt @@ -10,7 +10,6 @@ import android.os.CancellationSignal import android.os.ParcelFileDescriptor import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.di.IoDispatcher -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.webdav.DavHttpClientBuilder import at.bitfire.davdroid.webdav.DocumentProviderUtils import at.bitfire.davdroid.webdav.HeadResponse @@ -25,6 +24,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl +import okhttp3.OkHttpClient import java.io.FileNotFoundException import java.util.logging.Logger import javax.inject.Inject @@ -100,7 +100,7 @@ class OpenDocumentOperation @Inject constructor( } } - private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) { + private suspend fun headRequest(client: OkHttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) { HeadResponse.fromUrl(client, url) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt index d267ed47a..a57a3a59a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt @@ -14,7 +14,7 @@ import android.net.ConnectivityManager import android.os.CancellationSignal import android.os.ParcelFileDescriptor import androidx.core.content.getSystemService -import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.di.IoDispatcher import at.bitfire.davdroid.webdav.DavHttpClientBuilder @@ -76,24 +76,23 @@ class OpenDocumentThumbnailOperation @Inject constructor( // create thumbnail val job = accessScope.async { withTimeout(THUMBNAIL_TIMEOUT_MS) { - httpClientBuilder.build(doc.mountId, logBody = false).use { client -> - val url = doc.toHttpUrl(db) - val dav = DavResource(client.okHttpClient, url) - var result: ByteArray? = null - runInterruptible(ioDispatcher) { - dav.get("image/*", null) { response -> - response.body.byteStream().use { data -> - BitmapFactory.decodeStream(data)?.let { bitmap -> - val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y) - val baos = ByteArrayOutputStream() - thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos) - result = baos.toByteArray() - } + val client = httpClientBuilder.build(doc.mountId, logBody = false) + val url = doc.toHttpUrl(db) + val dav = DavResource(client, url) + var result: ByteArray? = null + runInterruptible(ioDispatcher) { + dav.get("image/*", null) { response -> + response.body.byteStream().use { data -> + BitmapFactory.decodeStream(data)?.let { bitmap -> + val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y) + val baos = ByteArrayOutputStream() + thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos) + result = baos.toByteArray() } } } - result } + result } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt index 74157e648..393931e31 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/QueryChildDocumentsOperation.kt @@ -7,8 +7,8 @@ package at.bitfire.davdroid.webdav.operation import android.content.Context import android.provider.DocumentsContract.Document import android.provider.DocumentsContract.buildChildDocumentsUri -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.okhttp.DavCollection +import at.bitfire.dav4jvm.okhttp.Response import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet import at.bitfire.dav4jvm.property.webdav.DisplayName import at.bitfire.dav4jvm.property.webdav.GetContentLength @@ -18,6 +18,7 @@ import at.bitfire.dav4jvm.property.webdav.GetLastModified import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes import at.bitfire.dav4jvm.property.webdav.ResourceType +import at.bitfire.dav4jvm.property.webdav.WebDAV import at.bitfire.davdroid.R import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.WebDavDocument @@ -33,6 +34,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible +import okhttp3.MediaType.Companion.toMediaTypeOrNull import java.io.FileNotFoundException import java.util.concurrent.ConcurrentHashMap import java.util.logging.Level @@ -128,58 +130,57 @@ class QueryChildDocumentsOperation @Inject constructor( val newChildrenList = hashMapOf() val parentUrl = parent.toHttpUrl(db) - httpClientBuilder.build(parent.mountId).use { client -> - val folder = DavCollection(client.okHttpClient, parentUrl) + val client = httpClientBuilder.build(parent.mountId) + val folder = DavCollection(client, parentUrl) - try { - runInterruptible(ioDispatcher) { - folder.propfind(1, *DAV_FILE_FIELDS) { response, relation -> - logger.fine("$relation $response") + try { + runInterruptible(ioDispatcher) { + folder.propfind(1, *DAV_FILE_FIELDS) { response, relation -> + logger.fine("$relation $response") - val resource: WebDavDocument = - when (relation) { - Response.HrefRelation.SELF -> // it's about the parent - parent + val resource: WebDavDocument = + when (relation) { + Response.HrefRelation.SELF -> // it's about the parent + parent - Response.HrefRelation.MEMBER -> // it's about a member - WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName()) + Response.HrefRelation.MEMBER -> // it's about a member + WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName()) - else -> { - // we didn't request this; log a warning and ignore it - logger.warning("Ignoring unexpected $response $relation in $parentUrl") - return@propfind - } + else -> { + // we didn't request this; log a warning and ignore it + logger.warning("Ignoring unexpected $response $relation in $parentUrl") + return@propfind } - - val updatedResource = resource.copy( - isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) - ?: resource.isDirectory, - displayName = response[DisplayName::class.java]?.displayName, - mimeType = response[GetContentType::class.java]?.type, - eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.eTag, - lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(), - size = response[GetContentLength::class.java]?.contentLength, - mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind, - mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind, - mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent, - quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes, - quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes, - ) - - if (resource == parent) - documentDao.update(updatedResource) - else { - documentDao.insertOrUpdate(updatedResource) - newChildrenList[resource.name] = updatedResource } - // remove resource from known child nodes, because not found on server - oldChildren.remove(resource.name) + val updatedResource = resource.copy( + isDirectory = response[ResourceType::class.java]?.types?.contains(WebDAV.Collection) + ?: resource.isDirectory, + displayName = response[DisplayName::class.java]?.displayName, + mimeType = response[GetContentType::class.java]?.type?.toMediaTypeOrNull(), + eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.eTag, + lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(), + size = response[GetContentLength::class.java]?.contentLength, + mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind, + mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind, + mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent, + quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes, + quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes, + ) + + if (resource == parent) + documentDao.update(updatedResource) + else { + documentDao.insertOrUpdate(updatedResource) + newChildrenList[resource.name] = updatedResource } + + // remove resource from known child nodes, because not found on server + oldChildren.remove(resource.name) } - } catch (e: Exception) { - logger.log(Level.WARNING, "Couldn't query children", e) } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't query children", e) } // Delete child nodes which were not rediscovered (deleted serverside) @@ -191,15 +192,15 @@ class QueryChildDocumentsOperation @Inject constructor( companion object { val DAV_FILE_FIELDS = arrayOf( - ResourceType.NAME, - CurrentUserPrivilegeSet.NAME, - DisplayName.NAME, - GetETag.NAME, - GetContentType.NAME, - GetContentLength.NAME, - GetLastModified.NAME, - QuotaAvailableBytes.NAME, - QuotaUsedBytes.NAME, + WebDAV.ResourceType, + WebDAV.CurrentUserPrivilegeSet, + WebDAV.DisplayName, + WebDAV.GetETag, + WebDAV.GetContentType, + WebDAV.GetContentLength, + WebDAV.GetLastModified, + WebDAV.QuotaAvailableBytes, + WebDAV.QuotaUsedBytes, ) /** List of currently active [queryChildDocuments] runners. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt index 26dd38549..028a7bdfd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/RenameDocumentOperation.kt @@ -5,8 +5,8 @@ package at.bitfire.davdroid.webdav.operation import android.content.Context -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.davdroid.db.AppDatabase import at.bitfire.davdroid.di.IoDispatcher import at.bitfire.davdroid.webdav.DavHttpClientBuilder @@ -35,29 +35,28 @@ class RenameDocumentOperation @Inject constructor( logger.fine("WebDAV renameDocument $documentId $displayName") val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() - httpClientBuilder.build(doc.mountId).use { client -> - for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) { - val newName = displayNameToMemberName(displayName, attempt) - val oldUrl = doc.toHttpUrl(db) - val newLocation = oldUrl.newBuilder() - .removePathSegment(oldUrl.pathSegments.lastIndex) - .addPathSegment(newName) - .build() - try { - val dav = DavResource(client.okHttpClient, oldUrl) - runInterruptible(ioDispatcher) { - dav.move(newLocation, false) { - // successfully renamed - } + val client = httpClientBuilder.build(doc.mountId) + for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) { + val newName = displayNameToMemberName(displayName, attempt) + val oldUrl = doc.toHttpUrl(db) + val newLocation = oldUrl.newBuilder() + .removePathSegment(oldUrl.pathSegments.lastIndex) + .addPathSegment(newName) + .build() + try { + val dav = DavResource(client, oldUrl) + runInterruptible(ioDispatcher) { + dav.move(newLocation, false) { + // successfully renamed } - documentDao.update(doc.copy(name = newName)) - - DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) - - return@runBlocking doc.id.toString() - } catch (e: HttpException) { - e.throwForDocumentProvider(context, true) } + documentDao.update(doc.copy(name = newName)) + + DocumentProviderUtils.notifyFolderChanged(context, doc.parentId) + + return@runBlocking doc.id.toString() + } catch (e: HttpException) { + e.throwForDocumentProvider(context, true) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b753696b3..e98d899d3 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -186,6 +186,7 @@ الوصف معلومات تصحيح العلل + نسخ عنوان URL حدث خطأ. حدث خطأ HTTP. @@ -202,7 +203,6 @@ خطأ شبكة أو الإدخال/الإخراج - %s خطأ خادم HTTP - %s خطأ تخزين محلي - %s - عرض العنصر استلام جهة اتصال غير صالحة من الخادم استلام حدث غير صالح من الخادم استلام مهمة غير صالحة من الخادم diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index eee29874e..f93a153a4 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -408,10 +408,11 @@ Грешка на сървъра Грешка на WebDAV Грешка на входа/изхода - Заявката е отказана. За подробности проверете свързаните със завката ресурси и информацията за отстраняване на дефекти. - Заявеният ресурс не съществува (вече). За подробности проверете свързаните със завката ресурси и информацията за отстраняване на дефекти. - Възникнал е проблем от страна на сървъра. Свържете се с поддръжката му. - Възникнала е неочаквана грешка. За подробности проверете информацията за отстраняване на дефекти. + Заявката е отказана от сървъра. + Заявеният ресурс (вече) не съществува. + Сървърът не позволява този вид заявено действие. + Грешка на сървъра. Свържете се с поддръжката на сървъра. + Неочаквана грешка. Прегледайте дневника за отстраняване на грешки за повече подробности. Подробности Информацията за отстраняване на дефекта е събрана Ресурси, имащи отношение @@ -421,8 +422,11 @@ Дневници Налични са подробни дневници Преглед + Копиране на адреса + Проверка на ресурса Съобщение за защита на личните данни Дневниците и информацията за отстраняване на грешки могат да съдържат лична информация. Имайте го предвид, когато ги споделяте публично. + Ресурсът не може да бъде прегледан Възникна грешка. Възникна грешка на HTTP. @@ -463,7 +467,6 @@ Грешка в сървъра на HTTP – %s Грешка в местното хранилище – %s Грешка (достигнат максимален брой опити) - Преглед Получен е недействителен контакт от сървъра Получено е недействително събитие от сървъра Получен е недействителен файл от сървъра diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index e56c0a383..63d4bf5e9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -408,10 +408,9 @@ Error del servidor Error del WebDAV Error d\'E/S - S\'ha denegat la sol·licitud. Verifiqueu els recursos implicats i la informació de depuració dels detalls. - El recurs sol·licitat no existeix (mai més). Verifiqueu els recursos implicats i la informació de depuració dels detalls. - Hi ha hagut un problema a la banda del servidor. Poseu-vos en contacte amb el vostre suport del servidor. - S\'ha produït un error inesperat. Visualitzeu la informació de depuració dels detalls. + El servidor no permet el tipus d\'operació sol·licitat. + S\'ha produït un problema a la banda del servidor. Poseu-vos en contacte amb l\'assistència del servidor. + S\'ha produït un error inesperat. Vegeu els detalls a la informació de depuració. Vista dels detalls S\'ha recopilat la informació de depuració Recursos implicats @@ -421,6 +420,7 @@ Registres Hi ha registres detallats disponibles Visualitza els registres + Copia l\'URL Avís de privadesa Els registres i la informació de depuració poden contenir informació privada. Tingueu en compte això quan ho compartiu públicament. @@ -463,7 +463,6 @@ Error del servidor HTTP: %s Error d\'emmagatzematge local: %s Error de programari (s\'ha arribat al màxim de reintents) - Veure element S\'ha rebut un contacte no vàlid del servidor S\'ha rebut un esdeveniment no vàlid del servidor S\'ha rebut una tasca no vàlida del servidor diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7face122b..fff0324e7 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -357,10 +357,6 @@ Chyba serveru Chyba WebDAV Chyba vstupu/výstupu - Požadavek byl odepřen. Podrobnosti naleznete v souvisejících prostředcích a ladících informacích. - Požadovaný prostředek (už) neexistuje. Podrobnosti naleznete v souvisejících prostředcích a ladících informacích. - Došlo k problému na straně serveru. Obraťte se na podporu serveru, který využíváte. - Došlo k neočekávané chybě. Podrobnosti naleznete v ladících informacích. Zobrazit podrobnosti Ladící informace byly shromážděny Prostředky, kterých se týká @@ -370,6 +366,7 @@ Záznamy událostí Jsou k dispozici podrobnější záznamy událostí Zobrazit záznamy událostí + Zkopírovat URL adresu Došlo k chybě. Došlo k HTTP chybě. @@ -406,7 +403,6 @@ Chyba HTTP serveru – %s Chyba místního úložiště – %s Lehká chyba (maximální počet pokusů dosažen) - Zobrazit položku Ze serveru obdržen neplaný kontakt Ze serveru obdržena neplatná událost Ze serveru obdržen neplatný úkol diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 02d000620..8f94e287a 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -3,17 +3,18 @@ Konto findes ikke (længere) DAVx⁵ adressebog + Foretag ikke ændringer i din konto her! Brug i stedet app\'en til direkte at håndtere konti. Slet Fjern Annullér - Aktivere + Aktivér Feltet er påkrævet Hjælp - Naviger opad + Navigér opad Indstillinger Del - Synkroniseringen er startet/i kø - Databasen er ødelagt + Synkroniseringen er startet/sat i kø + Databasen er korrupt Alle konti er fjernet lokalt. Fejlfinding Andre vigtige beskeder @@ -22,7 +23,7 @@ Synkroniseringsfejl Vigtige fejl, såsom uventede serversvar, der stopper synkroniseringen Synkroniseringsadvarsler - Ikke-kritiske synkroniseringproblemer som visse ugyldige filer + Ikke-kritiske synkroniseringproblemer såsom ugyldige filer Netværks- og I/O-fejl Timeouts, forbindelsesproblemer m.m. (ofte midlertidig) @@ -358,10 +359,6 @@ Lav lagerplads. Android vil ikke synkronisere lokale ændringer med det samme, m Server fejl WebDAV fejl Ind/ud fejl - Anmodning blev afvist. Tjek involverede ressourcer og fejlsøgnings oplysninger for detaljer. - Den ønskede ressource findes ikke. Kontrollér involverede ressourcer og fejlsøgnings oplysninger for detaljer. - Der opstod et problem på server siden. Kontakt server brugerhjælp. - En uventet fejl er opstået. Se fejlsøgnings info for detaljer. Vis detaljer Fejlsøgnings information er indsamlet Involveret ressourcer @@ -371,6 +368,7 @@ Lav lagerplads. Android vil ikke synkronisere lokale ændringer med det samme, m Log Uddybende log er tilgængelig Vis logfiler + Kopiere URL Der er opstået en fejl. Der er opstået en HTTP-fejl. @@ -407,7 +405,6 @@ Lav lagerplads. Android vil ikke synkronisere lokale ændringer med det samme, m HTTP-serverfejl - %s Lokal lagringsfejl - %s Blød fejl (maks forsøg nået) - Vis element Modtaget ugyldig kontakt fra server Modtaget ugyldig begivenhed fra server Modtaget ugyldig opgave fra server diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7b9c2c778..051e2a49f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -391,7 +391,7 @@ Lesen/Schreiben Titel Beschreibung - Besitzer:in + Besitzende Entität Push-Unterstützung Server bietet Push-Unterstützung Um %1$s angemeldet, läuft ab %2$s @@ -408,10 +408,11 @@ Serverfehler WebDAV-Fehler E/A-Fehler - Die Anfrage wurde abgelehnt. Details unter Beteiligte Ressourcen und in den Debug-Informationen. - Die Ressource existiert nicht (mehr). Details unter Beteiligte Ressourcen und in den Debug-Informationen. - Ein serverseitiges Problem ist aufgetreten. Bitte kontaktieren Sie den Server-Support. - Ein unerwarteter Fehler ist aufgetreten. Details in den Debug-Informationen. + Die Anfrage wurde vom Server abgelehnt. + Die angeforderte Ressource existiert nicht (mehr). + Der Server erlaubt die angeforderte Art der Operation nicht. + Es trat ein serverseitiges Problem auf. Wenden Sie sich bitte an den Server-Support. + Es trat ein unerwarteter Fehler auf. Einzelheiten dazu finden Sie in der Debug-Info. Details anzeigen Debug-Informationen wurden gesammelt Beteiligte Ressourcen @@ -421,8 +422,11 @@ Protokoll Ausführliches Protokoll verfügbar Logs anzeigen + URL kopieren + Ressource überprüfen Datenschutzhinweis Protokolle und Debug-Informationen können private Daten enthalten. Seien Sie sich dessen bewusst, wenn Sie diese öffentlich weitergeben. + Ressource kann nicht angezeigt werden Ein Fehler ist aufgetreten. Ein HTTP-Fehler ist aufgetreten. @@ -463,7 +467,6 @@ HTTP-Serverfehler – %s Lokaler Speicherfehler – %s Weicher Fehler (maximale Anzahl an Wiederholungen erreicht) - Untersuchen Ungültigen Kontakt vom Server erhalten Ungültigen Termin vom Server erhalten Ungültige Aufgabe vom Server erhalten diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 222f32a2e..c90ce7ca7 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -357,10 +357,6 @@ Σφάλμα διακομιστή Σφάλμα WebDAV Σφάλμα I/O - Το αίτημα απορρίφθηκε. Ελέγξτε τους σχετικούς πόρους και τις πληροφορίες εντοπισμού σφαλμάτων για λεπτομέρειες. - Ο ζητούμενος πόρος δεν υπάρχει (πλέον). Ελέγξτε τους σχετικούς πόρους και τις πληροφορίες εντοπισμού σφαλμάτων για λεπτομέρειες. - Παρουσιάστηκε ένα πρόβλημα από την πλευρά του διακομιστή. Παρακαλούμε επικοινωνήστε με την υποστήριξη του διακομιστή σας. - Προέκυψε ένα απροσδόκητο σφάλμα. Δείτε τις πληροφορίες εντοπισμού σφαλμάτων για λεπτομέρειες. Προβολή λεπτομερειών Έχουν συλλεχθεί πληροφορίες εντοπισμού σφαλμάτων Εμπλεκόμενοι πόροι @@ -370,6 +366,7 @@ Ιστορικό Διατίθενται αναλυτικά αρχεία καταγραφής Προβολή ιστορικού + Αντιγραφή URL Παρουσιάστηκε σφάλμα. Παρουσιάστηκε σφάλμα HTTP. @@ -406,7 +403,6 @@ Σφάλμα διακομιστής HTTP – %s Σφάλμα τοπικού αποθηκευτικού χώρου – %s Σφάλμα (φτάσατε στον μέγιστο αριθμό επαναλήψεων) - Προβολή αντικειμένου Ελήφθη μη έγκυρη επαφή από το διακομιστή Ελήφθη μη έγκυρο συμβάν από το διακομιστή Έλαβε μη έγκυρη εργασία από το διακομιστή diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 460f950db..3ce147b32 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -303,10 +303,6 @@ Server Error WebDAV Error I/O Error - The request has been denied. Check involved resources and debug info for details. - The requested resource doesn\'t exist (anymore). Check involved resources and debug info for details. - A server-side problem occured. Please contact your server support. - An unexpected error has occured. View debug info for details. View details Debug info have been collected Involved resources @@ -316,6 +312,7 @@ Logs Verbose logs are available View logs + Copy URL An error has occurred. An HTTP error has occurred. @@ -352,7 +349,6 @@ HTTP server error – %s Local storage error – %s Soft error (max retries reached) - View item Received invalid contact from server Received invalid event from server Received invalid task from server diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1e221a61c..051b2d7d4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -361,10 +361,6 @@ Error del servidor Error de WebDAV Error de E/S - La solicitud ha sido denegada. Comprueba los recursos implicados y la información de depuración para obtener más detalles. - El recurso solicitado (ya) no existe. Comprueba los recursos implicados y la información de depuración para obtener más detalles. - Se ha producido un problema en el servidor. Por favor, ponte en contacto con el soporte de su servidor. - Se ha producido un error inesperado. Ve la información de depuración para más detalles. Ver detalles Se ha recogido la información de depuración Recursos implicados @@ -374,6 +370,7 @@ Registros Los registros verbosos están disponibles Ver registros + Copiar URL Ocurrió un error. Ha ocurrido un error HTTP. @@ -410,7 +407,6 @@ Error de servidor – %s Error de almacenamiento local – %s Error no crítico (se ha llegado al número máximo de intentos) - Ver item Contacto inválido recibido del servidor Evento inválido recibido del servidor Tarea inválida recibidas del servidor diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index ffa9c4f15..44889309b 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -408,8 +408,9 @@ Serveri viga WebDAVi viga Sisend-/väljundviga - Osapool keeldus päringule vastamast. Kontrolli asjaomaseid teenuseid ja tarvikuid ning üksikasjalikku teavet võid leida silumislogidest. - Soovitud teenust või tarvikud pole (enam) olemas. Kontrolli asjaomaseid teenuseid ja tarvikuid ning üksikasjalikku teavet võid leida silumislogidest. + Server keeldus päringule vastamast. + Päritud andmeressurssi ei leidu (enam). + Server ei võimalda antud päringu tüüpi kasutada või soovitud tegevust teha. Tekkis serveripoolne viga. Palun võta ühendust serveri haldajaga. Tekkis ootamatu viga. Lisainfot leiad silumisteabest. Vaata üksikasju @@ -421,8 +422,11 @@ Logid Saadaval on üksikasjalikud logid Vaata logisid + Kopeeri võrguaadress + Uuri ressurssi Privaatsusteade Logid ja veaotsingu teave võivad sisaldada privaatset teavet. Nende andmete avalikul jagamisel palun arvesta sellega. + Ressurssi pole võimalik näha Tekkis viga. Tekkis http-viga. @@ -463,7 +467,6 @@ HTTP serveri viga – %s Kohaliku salvestusruumi viga – %s Pehme viga (korduspäringute arvu ülempiir on käes) - Vaata objekti Saime serverist vigase kontaktikirje Saime serverist vigase sündmusekirje Saime serverist vigase ülesandekirje diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 99ea08bbd..542f519ce 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -409,10 +409,6 @@ Zerbitzari errorea WebDAV errorea S/I errorea - Eskaera ukatu egin da. Egiaztatu parte hartzen dituzten baliabideak eta arazketa informazioa xehetasun gehiagorako. - Eskatutako baliabidea ez dago (jada). Egiaztatu parte hartzen dituzten baliabideak eta arazketa informazioa xehetasun gehiagorako. - Zerbitzariak arazo bat izan du. Mesedez jarri harremanetan zure zerbitzariaren laguntzarekin. - Ustekabeko errore bat gertatu da. Ikusi arazketa informazioa xehetasunentzako. Ikusi xehetasunak Arazketa informazioa lortu da Parte hartzen duten baliabideak @@ -422,6 +418,7 @@ Egunkariak Erregistro xehetuak eskuragarri daude Ikusi egunkariak + Kopiatu URL Pribatutasun oharra Erregistroek eta arazketa-informazioak informazio pribatua izan dezakete. Kontuan izan hau publikoki partekatzerakoan. @@ -464,7 +461,6 @@ HTTP zerbitzari-errorea – %s Biltegiratze lokal errorea – %s Errore leuna (saiakera maximora heldu da) - Ikusi elementua Kontaktu baliogabea jaso da zerbitzaritik Gertaera baliogabea jaso da zerbitzaritik Zeregin baliogabea jaso da zerbitzaritik diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2ebd1672c..142422dae 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -284,10 +284,6 @@ خطای سرور خطای WebDAV خطای ورودی خروجی - درخواست رد شده است منابع درگیر و اطلاعات اشکال زدایی را برای جزئیات بررسی کنید. - درخواست رد شده است منابع درگیر و اطلاعات اشکال زدایی را برای جزئیات بررسی کنید. - مشکلی در سمت سرور رخ داد. لطفا با پشتیبانی سرور خود تماس بگیرید. - خطایی غیرمنتظره رخ داده است. برای جزئیات ، اطلاعات اشکال زدایی را مشاهده کنید. نمایش جزئیات اطلاعات اشکال زدایی جمع آوری شده است منابع درگیر @@ -297,6 +293,7 @@ رویدادها رویدادهای مربوط به گفتار موجود است دیدن رویدادها + URL را کپی کنید خطایی رخ داده است. خطای HTTP رخ داده است. @@ -322,7 +319,6 @@ شبکه یا خطای ورودی / خروجی – %s خطای سرور HTTP – %s خطای ذخیره سازی محلی – %s - مشاهده مورد مخاطب نامعتبر از سرور دریافت شد رویداد نامعتبر از سرور دریافت شد کار نامعتبر از سرور دریافت شد diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5a7e14810..024f0fac8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -378,10 +378,6 @@ Erreur Serveur Erreur WebDAV Erreur d\'entrée/sortie - La demande a été rejetée. Vérifiez les ressources impliquées et les informations de débogage pour plus de détails. - La ressource demandée n\'existe plus. Vérifiez les ressources concernées et les informations de débogage pour plus de détails. - Un problème s\'est produit côté serveur. Veuillez contacter le support de votre serveur. - Une erreur inattendue s\'est produite. Voir les informations de débogage pour plus de détails. Voir les détails Les informations de débogage ont été collectées Ressources impliquées @@ -391,6 +387,7 @@ Journaux Des journaux verbeux sont disponibles Voir les journaux + Copier l\'URL Une erreur est survenue. Une erreur HTTP est survenue. @@ -427,7 +424,6 @@ Erreur de serveur HTTP - %s Erreur de stockage local - %s Erreur logicielle (nombre maximum de tentatives atteint) - Voir l\'élément Reçu un contact invalide du serveur Reçu un événement invalide du serveur Reçu une tâche invalide du serveur diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 7e50f1457..83b1013f7 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -391,10 +391,6 @@ Fallo no servidor Fallo WebDAV Fallo I/O - A solicitude foi denegada. Comproba os recursos implicados e o rexistro de depuración para máis info. - O recurso solicitado xa non existe. Comproba os recursos implicados e a información de depuración. - Hai un fallo no lado do servidor. Contacta co soporte do servidor. - Aconteceu un fallo non agardado. Mira a info de depuración para detalles. Ver detalles Recolleuse a info de depuración Recursos implicados @@ -404,6 +400,7 @@ Rexistros Están dispoñibles rexistros explicativos Ver rexistros + Copiar URL Algo fallou. Houbo un fallo HTTP. @@ -440,7 +437,6 @@ Fallo servidor HTTP – %s Fallo almacenamento local – %s Erro (acadouse o máx. de reintentos) - Ver elemento Recibido contacto non válido desde o servidor Recibido evento non válido desde o servidor Recibida tarefa non válida desde o servidor diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 0e3bda653..638b7458a 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -243,10 +243,6 @@ Greška na poslužitelju WebDAV greška I/O greška - Zahtjev je odbijen. Pojedinosti potražite u uključenim resursima i debug informacijama. - Traženi resurs ne postoji (više). Pojedinosti potražite u uključenim resursima i debug informacijama. - Greška sa poslužiteljske strane. Molimo obratite se podršci za poslužitelja. - Dogodila se neočekivana pogreška. Pojedinosti potražite u debug informacijama. Pogledaj pojedinosti Debug info je prikupljen Uključeni resursi @@ -256,6 +252,7 @@ Logovi Opsežniji logovi su dostupni Pregledaj logove + Kopiraj URL Dogodila se greška. Dogodila se HTTP greška. @@ -274,7 +271,6 @@ Mrežna ili I/O greška – %s HTTP poslužiteljska greška – %s Greška lokalne pohrane – %s - Pregledaj stavku Primljen je nevažeći kontakt sa poslužitelja Primljen je nevažeći dogđaj sa poslužitelja Primljen je nevažeći zadatak sa poslužitelja diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 04ad66414..bb2ef91c4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -390,10 +390,6 @@ Kiszolgálóhiba WebDAV hiba Ki-/bemeneti hiba - A kérés megtagadva. Ellenőrizze az érintett erőforrásokat és a hibakeresési információkat a további részletekért. - A kért erőforrás (már) nem létezik. A további részletekért ellenőrizze az érintett erőforrásokat és a hibakeresési információkat. - Kiszolgálóoldali hiba történt. Vegye fel a kapcsolatot a kiszolgáló üzemeltetőjével. - Váratlan hiba történt. Hibakereséshez használja a hibakeresési információkat. Részletek megtekintése A hibakeresési információ összegyűjtése befejeződött Érintett erőforrások @@ -403,6 +399,7 @@ Naplók Rendelkezésre állnak részletes naplóbejegyzések Naplóbejegyzések megtekintése + URL másolása Hiba történt. HTTP hiba történt. @@ -440,7 +437,6 @@ HTTP kiszolgálóhiba – %s Helyi tárhelyhiba –%s Nem végzetes hiba (az újrapróbálkozások száma elérte a maximumot) - Elem megtekintése A kiszolgáló érvénytelen névjegyet küldött A kiszolgáló érvénytelen eseményt küldött A kiszolgáló érvénytelen feladatot küldött diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4f504623c..83f488c28 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -350,10 +350,6 @@ Lasciare vuoto per non creare un promemoria predefinito. Errore del Server Errore WebDAV Errore I/O - La richiesta è stata negata. Controlla le fonti coinvolte e le informazioni debug per dettagli. - La fonte richiesta non esiste (più). Controlla le fonti coinvolte e le informazioni debug per dettagli. - Si è verificato un problema del server. Per favore contatta il tuo server di supporto. - Si è verificato un errore inaspettato. Vedi le informazioni di debug per maggiori dettagli. Vedi dettagli Sono state raccolte informazioni di debug Fonti coinvolte @@ -363,6 +359,7 @@ Lasciare vuoto per non creare un promemoria predefinito. Registri Sono disponibili registri verbali Vedi i registri + Copia URL Si è verificato un errore. Si è verificato un errore HTTP. @@ -398,7 +395,6 @@ Lasciare vuoto per non creare un promemoria predefinito. Errore di rete o di I/O – %s Errore server HTTP – %s Errore di archiviazione locale – %s - Visualizza oggetto Contatto non valido ricevuto dal server Evento non valido ricevuto dal server Attività non valida ricevuta dal server diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index c19839f1c..48fcdbe9f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -405,9 +405,10 @@ サーバーエラー WebDAV エラー I/O エラー - リクエストが拒否されました。詳細は関連するリソースとデバッグ情報を確認してください。 - 要求されたリソースは (もう) 存在しません。詳細は関連するリソースとデバッグ情報を確認してください。 - サーバー側に問題が発生しました。お使いのサーバーのサポートに連絡してください。 + リクエストはサーバーにより拒否されました。 + リクエストされたリソースが存在しません。 + サーバーがリクエストされた形式の操作を許可していません。 + サーバー側で問題が発生しました。あなたのサーバーのサポートに連絡してください。 予期せぬエラーが発生しました。詳細はデバッグ情報を確認してください。 詳細を表示 収集されたデバッグ情報 @@ -418,6 +419,7 @@ ログ 詳細なログが利用できます ログを表示 + URL をコピー プライバシー通知 ログやデバッグ情報はプライベートな情報を含むことがあります。共有する場合には、注意して取り扱ってください。 @@ -460,7 +462,6 @@ HTTP サーバーエラー – %s ローカルストレージエラー – %s ソフトエラー (再試行回数の上限に到達) - アイテムを表示 サーバーから無効な連絡先を受信しました サーバーから無効な予定を受信しました サーバーから無効な ToDo リストを受信しました diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index a8b8b00b0..39bf4d778 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -357,10 +357,6 @@ სერვერის შეცდომა WebDAV შეცდომა წაკითხვა/ჩაწერის შეცდომა - ეს მოთხოვნა იქნა უარყოფილი. შეამოწმეთ შესაბამისი რესურსები და დებაგის ინფო დეტალებისთვის. - მოთხოვნილი რესურსი (აღარ) არსებობს. შეამოწმეთ შესაბამისი რესურსები და დებაგის ინფო დეტალებისთვის. - მოხდა პრობლემა სერვერის მხარეს. გთხოვთ, დაუკავშირდეთ თქვენს სერვერის მხარდაჭერას. - მოხდა მოულოდნელი შეცდომა. იხილეთ დებაგის ინფო დეტალებისთვის. დეტალების ნახვა დებაგის ინფო შეგროვდა შესაბამისი რესურსები @@ -406,7 +402,6 @@ HTTP სერვერის შეცდომა - %s ადგილობრივი მეხსიერების შეცდომა - %s რბილის შეცდომა (მიღწეულია თავიდან ცდის მაწსიმუმი) - ჩანაწერის ნახვა მიღებულია არასწორი კონტაქტი სერვერიდან მიღებულია არასწორი ღონისძიება სერვერიდან მიღებული არასწორი დავალება სერვერიდან diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ae6128f84..5817875c6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -397,10 +397,6 @@ 서버 에러 WebDAV 에러 I/O 에러 - 요청이 거부되었습니다. 자세한 내용은 관련 리소스 및 디버그 정보를 확인하십시오. - 요청한 리소스가 (더 이상) 존재하지 않습니다. 자세한 내용은 관련 리소스 및 Debug info를 확인하십시오. - 서버 측 문제가 발생했습니다. 서버 지원 담당자에게 문의해 주세요. - 예기치 않은 오류가 발생했습니다. 자세한 내용은 디버그 정보를 참조하십시오. 상세 설명보기 디버그 정보가 수집되었습니다. 관련 리소스 @@ -410,6 +406,7 @@ Logs 상세 logs를 사용할 수 있습니다. logs 보기 + URL 복사 개인정보 보호 고지 로그 및 디버그 정보에는 개인 정보가 포함될 수 있습니다. 이를 공개적으로 공유할 때 유의하시기 바랍니다. @@ -449,7 +446,6 @@ HTTP 서버 오류 – %s Local storage 오류 – %s 일시적 오류 (최대 재시도 횟수 도달) - 항목 보기 서버로부터 잘못된 연락처를 받았습니다. 서버에서 잘못된 이벤트를 수신했습니다. 서버에서 잘못된 작업을 수신했습니다. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 177eca5b6..fe6865ee8 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -149,6 +149,7 @@ Beskrivelse Feilrettingsinfo + Kopier nettadresse En feil har inntruffet En HTTP-feil har inntruffet. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 9c1fa301c..ba19da67f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -408,10 +408,11 @@ Serverfout WebDAV fout I/O-fout - Het verzoek is afgewezen. Controleer de betrokken bronnen en debug-info voor details. - De gevraagde bron bestaat niet (meer). Controleer de betrokken bronnen en debug-info voor details. - Er is bij de server een probleem opgetreden. Neem contact op met de server-ondersteuning. - Er is een onverwachte fout opgetreden. Bekijk debug-info voor details. + Het verzoek is door de server afgewezen. + De gevraagde bron bestaat niet (meer). + De server staat het gevraagde type bewerking niet toe. + Er deed zich een probleem aan de serverzijde voor. Neem contact op met uw serverondersteuning. + Er is een onverwachte fout opgetreden. Bekijk foutopsporingsinformatie voor details. Details bekijken Debug-info is verzameld Betrokken bronnen @@ -421,8 +422,11 @@ Logboeken Uitgebreide logboeken zijn beschikbaar Details bekijken + URL kopiëren + Bron inspecteren Privacyverklaring Logboeken en foutopsporingsgegevens kunnen privé-informatie bevatten. Houd hier rekening mee als u ze openbaar deelt. + Kan bron niet bekijken Er is een fout opgetreden. Een HTTP-fout is opgetreden. @@ -463,7 +467,6 @@ HTTP-server fout - %s Lokale opslag fout - %s Soft error (max. aantal pogingen bereikt) - Item bekijken Ongeldig contact ontvangen van server Ongeldige gebeurtenis ontvangen van server Ongeldige taak ontvangen van server diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index bc8e95e52..d9cf24687 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -3,13 +3,17 @@ Konto (już) nie istnieje Książka adresowa DAVx⁵ + Nie zmieniaj konta tutaj! Zamiast tego zarządzaj kontami bezpośrednio za pomocą aplikacji. + Usuń Usuń Anuluj Włącz To pole jest wymagane Pomoc Nawiguj w górę + Menu opcji Udostępnij + Synchronizacja rozpoczęta/zakolejkowana Uszkodzona baza danych Wszystkie konta zostały usunięte lokalnie. Debugowanie @@ -29,6 +33,7 @@ Dla synchronizacji w regularnych przedziałach, %s musi mieć pozwolenie na pracę w tle. W przeciwnym razie, Android może wstrzymać synchronizację w dowolnym momencie. Nie potrzebuję regularnych przedziałów synchronizacji.* %s kompatybilność + Sterowniki sprzętowe, specyficzne dla wybranych dostawców, mogą blokowac synchronziację. Jeśli dotyczy to również Ciebie, możesz rozwiązać ten problem tylko ręcznie. Wprowadziłem potrzebne ustawienia. Nie przypominaj mi ponownie.* * Pozostaw nie zaznaczone, aby otrzymać przypomnienie później. Można zresetować w ustawieniach aplikacji wybierając / %s. Więcej informacji @@ -39,11 +44,19 @@ OpenTasks Wydaje się, że nie jest już rozwijany – nie jest zalecany. Tasks.org + nie są wspierane.]]> Sklep aplikacji nie jest dostępny Nie potrzebuję obsługi zadań.* Oprogramowanie open-source Cieszymy się, że używasz %s, czyli oprogramowania typu open-source. Rozwój, utrzymanie i wsparcie to ciężka praca. Prosimy o rozważenie wniesienia swojego wkładu (jest wiele sposobów) lub darowizny. Byłoby to bardzo cenne! Jak wspomóc/wesprzeć + Nie przypominaj mi przez + + %dmiesiąc + %dmiesiące + %dmiesięcy + %d miesiące/miesięcy + Dalej Uprawnienia @@ -79,6 +92,10 @@ Uprawnienie lokalizacji odebrane Uprawnienie lokalizacji w tle Zezwól przez cały czas + Uprawnienia lokalizacji ustawione na: %s + Uprawnienia lokalizacji nie ustawione na: %s + %s używa danych o lokalizacji (tylko SSID WiFi ) wyłącznie do ograniczania synchronizacji tylko do wybranych SSID-ów WiFi. Będzie się to działo również gdy synchronizacja przebiega w tle. + Wszystkie dane o lokalizacji (tylko SSID WiFi) są używane tylko lokalnie i nie są nigdzie wysyłane. Lokalizacja zawsze włączona Usługa lokalizacji jest włączona Usługa lokalizacji jest wyłączona @@ -105,16 +122,29 @@ Strona WWW Podręcznik Często zadawane pytania + Dla organizacji Społeczność + Wesprzyj ten projekt + Jak wnieść wkład Polityka prywatności + Witaj w DAVx⁵! + Połącz się ze swoim serwerem i utrzymuj swój kalendarz i kontakty zsynchronizowane. Synchronizuj wszystkie konta Powiadomienia wyłączone. Nie będziesz otrzymywać powiadomień o błędach synchronizacji. + Automatyczna synchronizacja nie jest aktywna (brak zweryfikowanego połączenia z Internetem). Zarządzaj połączeniami Włączono oszczędzanie danych. Synchronizacja w tle jest ograniczona. Zarządzaj oszczędzaniem danych + Oszczędzanie baterii jest włączone. Synchronizacja może być ograniczona. + Zarządzaj oszczędzaniem baterii Mało miejsca do przechowywania. Android nie zsynchronizuje lokalnych zmian od razu, ale podczas następnej regularnej synchronizacji. Zarządzaj pamięcią + Brak dostawcy kalendarza + Czy zablokowałeś aplikację systemową \"Przechowywanie kalendarza\" ? + Brak dostawcy kontaktów + Czy zablokowałeś aplikację systemową \"Przechowywanie kontaktów\" ? + Zarządzaj aplikacjami Wykrycie serwisu nie powiodło się Nie można odświeżyć listy kolekcji @@ -125,9 +155,13 @@ Ustawienia Debugowanie Pokaż informacje do debugowania + Wyświetl/udostępnij szczegóły konfiguracji i logi Rozszerzone logowanie + Zbieranie logów jest włączone. Możesz zobaczyć logi jako część informacji debugowania. Logowanie jest wyłączone Optymalizacja baterii + Aplikacja dodana do wyjątków (zalecane) + Zastosowano ograniczenia baterii (nie zalecane) Łączność Typ proxy @@ -144,6 +178,7 @@ Nie ufaj certyfikatom systemowym Certyfikaty systemowe i użytkownika nie są zaufane Certyfikaty systemowe i użytkownika są zaufane (zalecane) + Jeśli to ustawienie jest aktywne to certyfikaty systemowe nie są uznawane za zaufane. Oznacza to, że będziesz musiał(a) ręcznie zakceptować każdy certyfikat (również gdy serwer odświeży swój certyfikat) lub konfiguracja konta i synchronizacja nie będą działały. Zresetuj (nie)zaufane certyfikaty Zresetuj wszystkie niestandardowe certyfikaty Wszystkie niestandardowe certyfikaty zostały wyczyszczone @@ -162,13 +197,26 @@ Integracja Aplikacja zadań Nie znaleziono kompatybilnej aplikacji zadań + UnifiedPush (eksperymentalny) + Żaden (wyłącz Push) + Wybierz kolportera + Nie zainstalowano kolportera wiadomości Push + Brak konfiguracji punktu końcowego + Gotowy aby otrzymywać wiadomości Push poprzez %s + FCM (Google Play) + Wiadomości Push są zawsze szyfrowane. + Konto zostało usunięte CardDAV CalDAV Webcal + Dodatkowe uprawnienia są wymagane aby zsynchronizować te kolekcje. + Zarządzaj uprawnieniami Synchronizuj teraz Ustawienia konta Zmień nazwę konta + Niezapisane dane lokalne mogą zostać usunięte. Po zmianie nazwy wymagana jest powtórna synchronizacja. + Nowa nazwa konta Zmień nazwę Nazwa konta jest już zajęta Nie udało się zmienić nazwy konta @@ -178,29 +226,52 @@ synchronizuj tę kolekcję tylko do odczytu kalendarz + kontakty dziennik + zadania Pokaż tylko osobiste + Odśwież listę + Subskrypcje Webcal mogą być synchronizowane z zewnętrznymi aplikacjami. Nie znaleziono aplikacji obsługującej Webcal Zainstaluj ICSx⁵ Dodaj konto + polityką prywatności.]]> + Logowanie ogólne + Logowanie zależne od dostawcy + Kontynuj Zaloguj Logowanie za pomocą adresu e‑mail Adres e‑mail Wymagany poprawny adres e‑mail + Usługi są wykrywane używając rekordów DNS oraz znanych adresów URL.]]> Hasło + Ukryj hasło + Pokaż hasło + Hasło (opcjonalnie) Logowanie za pomocą adresu URL i nazwy użytkownika Nazwa użytkownika + Nazwa użytkownika (opcjonalnie) Podstawowy adres URL + usługi są również wykrywane używając rekordów DNS oraz znanych adresów URL.]]> Wybierz certyfikat Dodaj konto Nazwa konta + Użycie znaku apostrofu (\') wydaje się powodować problemy na niektórych urządzeniach. Użyj swojego adresu e‑mail jako nazwy konta, ponieważ Android będzie używał nazwy konta jako pola ORGANIZATOR dla wydarzeń, które stworzysz. Nie możesz posiadać dwóch kont o takiej samej nazwie. Metoda grupowania kontaktów: Wymagana nazwa konta Nazwa konta jest już zajęta + Konto nie mogło być dodane + Zakończ + Logowanie zaawansowane + Brak certyfikatu klienta (opcjonalnie) + Certyfikat klienta: %s Nie znaleziono certyfikatu Zainstaluj certyfikat + Fastmail + Konto Fastmail + Zaloguj się poprzez Fastmail Kontakty Google / Kalendarz Konto Google Zaloguj się za pomocą Google @@ -218,6 +289,10 @@ Wykrywanie konfiguracji Proszę czekać, odpytywanie serwera… Nie można znaleźć usługi CalDAV lub CardDAV. + Podstawowy adres URL prawdopodobnie nie jest dostępnym adresem URL CalDAV/CardDAV, a wykrycie usługi nie powiodło się. + naszą listę przetestowanych usług i ich podstawowych adresów URL.]]> + Sprawdź również dokładnie uwierzytelnianie (zazwyczaj nazwę użytkownika i hasło). + Dalsze informacje techniczne są dostępne w logach. Otwórz logi Synchronizacja @@ -249,7 +324,15 @@ Do przeprowadzenia synchronizacji wystarczy VPN bez sprawdzonego połączenia internetowego Uwierzytelnianie Nazwa użytkownika + Hasło lub hasło aplikacji + hasła aplikacji.]]> + Nowe hasło Zaktualizuj hasło zgodnie z serwerem + Autoryzuj ponownie (OAuth) + Użyj jeśli dostęp został cofnięty + Poprawna autoryzacja + Certyfikat klienta + Brak dostępnego lub wybranego certyfikatu Zainstaluj certyfikat CalDAV Limit czasowy przeszłych wydarzeń @@ -284,20 +367,42 @@ Stwórz książkę adresową + Tworzenie książki adresowej poprzez CardDAV może nie być wspierane przez ten server. Utwórz kalendarz + Domyslna strefa czasowa (opcjonalnie) + Możliwe wpisy kalendarza Wydarzenia Zadania Notatki/dziennik + Tworzenie kalendarza poprzez CalDAV może nie być wspierane przez ten server. Kolor Tytuł Miejsce zapisu + Opis (opjonalnie) Stwórz + kontakty + wydarzenia + zadania Usuń kolekcję + Ta kolekcja (%s) i wszystkie jej dane zostaną bezpowrotnie usunięte, zarówno lokalnie jak i na serwerze. Synchronizacja + Synchronizacja włączona + Synchronizacja wyłączona + Tylko do odczytu + Tylko do odczytu (przez serwer) + Tylko do odczytu (przez politykę) + Tylko do odczytu (tylko lokalnie) + Odczyt/zapis Tytuł Opis + Właściciel + Wsparcie protokołu Push + Serwer zgłasza wsparcie protokołu Push + Zasubskrybowano %1$s, wygasa %2$s + Ostatnia synchronizacja (%s) + Adres (URL) Informacje debugowania Archiwum ZIP @@ -309,10 +414,11 @@ Błąd serwera Błąd WebDAV Błąd we/wy - Żądanie zostało odrzucone. Sprawdź zaangażowane zasoby oraz szczegóły informacji debugowania. - Żądany zasób (już) nie istnieje. Sprawdź zaangażowane zasoby oraz szczegóły informacji debugowania. - Wystapił problem po stronie serwera. Skontaktuj się proszę z zespołem wsparcia Twojego serwera. - Wystąpił niespodziewany błąd. Obejrzyj szczegóły w informacji debugowania. + Żądanie zostało odrzucone przez serwer. + Żądany zasób (już) nie istnieje. + Serwer nie zezwala na żądany typ operacji. + Wystąpił problem po stronie serwera. Skontaktuj się proszę ze wsparciem serwera. + Wystapił nieoczekiwany błąd. Przejrzyj informacje debugowania dla dodatkowych szczegółów. Obejrzyj szczegóły Informacje debuggowania zostały zebrane Zaangażowane zasoby @@ -322,6 +428,11 @@ Logi Szczegółowe logi są dostępne Otwórz logi + Kopiuj adres URL + Sprawdź zasób + Notatka o prywatności + Logi i informacje debugowania mogą zawierać prywatne dane. Proszę być tego świadomymi w przypadku publicznego udostępniania. + Nie można wyświetlić zasobu Wystąpił błąd. Wystąpił błąd HTTP. @@ -334,12 +445,16 @@ Odlinkuj Dodaj punkt linkowania WebDAV Uzyskaj bezpośredni dostęp do plików w chmurze, dodając montowanie WebDAV! + jak działają punkty montowania WebDAV .]]> Nazwa wyświetlana WebDAV URL Błędny URL + Punkt montowania i wyświetlana nazwa Uwierzytelnianie Nazwa użytkownika Hasło + Nazwa użytkownika (opcjonalnie) + Hasło (opcjonalnie) Dodaj link Brak usługi WebDAV pod tym URL Usuń punkt montowania @@ -358,12 +473,17 @@ Błąd serwera HTTP — %s Błąd lokalnego storage’u — %s Błąd programowy (osiągnięto maksymalną liczbę ponownych prób) - Zobacz element Otrzymano błędny kontakt z serwera Otrzymano błędne wydarzenie z serwera Otrzymano błędne zadanie z serwera Zignorowano jeden lub więcej nieważnych zasobów + Oczekująca synchronizacja + Zdalne dane uległy zmianie + Synchronizuj wszystko Synchronizuj wszystkie konta + Przycisk Etykietowanej Synchronizacji + Przycisk Synchronizacji Ikon + Naciśnij aby zsynchronizować ręcznie. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 000000000..35518df53 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,484 @@ + + + + A conta não existe (mais) + Lista de contatos do DAVx⁵ + Não mude a conta por aqui! Em vez disso, use o app diretamente para gerenciar as contas. + Apagar + Remover + Cancelar + Ativar + Este campo é necessário + Ajuda + Navegar para cima + Menu de opções + Compartilhar + Sincronização foi iniciada/enfileirada + O banco de dados está corrompido + Todas as contas foram removidas localmente. + Depuração + Outras mensagens importantes + Mensagens de estado de baixa prioridade + Sincronização + Erros de sincronização + Erros importantes que interrompem a sincronização, como respostas inesperadas do servidor + Alertas de sincronização + Problemas não fatais de sincronização, como certos arquivos inválidos + Erros de E/S e de rede + Tempos limite atingidos, problemas de conexão, etc. (geralmente temporários) + + Seus dados. Sua escolha. + Assuma o controle. + Intervalos periódicos de sincronização + Para sincronizar em intervalos periódicos, o %s deve ter permissão para executar-se em segundo plano. Caso contrário, o Android pode pausar a sincronização a qualquer momento. + Eu não preciso de sincronização periódica.* + Compatibilidade com o %s + Firmware de fabricantes específicas podem bloquear a sincronização. Se for atingido, você pode resolver isso manualmente. + Fiz as configurações necessárias. Não me lembre novamente.* + * Deixe desmarcado para ser lembrado depois. Pode ser reconfigurado nas configurações do app / %s + Mais informações + jtx Board + + Suporte a tarefas + Se seu servidor ter suporte a tarefas, elas podem ser sincronizadas com um app de tarefas compatível: + OpenTasks + Parece não ser mais desenvolvido – não é recomendado. + Tasks.org + alguns recursos.]]> + Nenhuma loja de apps disponível + Não preciso de suporte a tarefas.* + Software de código aberto + Estamos felizes que você usa o %s, que é software de código aberto. O desenvolvimento, a manutenção, e o suporte são um trabalho díficil. Considere contribuir (há varias formas) ou uma doação. Seria muito apreciado! + Como contribuir/doar + Não me lembre por + + %dmês + %d de meses + %d meses + + Avançar + + Permissões + O %s requer permissões para funcionar corretamente. + Todas as abaixo + Use isso para ativar todos os recursos (recomendado) + Todas as permissões foram concedidas + Permissões de contatos + Sem sincronização dos contatos (não é recomendado) + A sincronização dos contatos é possível + Permissões de calendário + Sem sincronização do calendário (não é recomendado) + A sincronização do calendário é possível + Permissão de notificação + Notificações desativadas (não é recomendado) + Notificações ativadas + Permissões do jtx Board + Permissões do OpenTasks + Permissões do Tasks + Sem sincronização de tarefas + A sincronização de tarefas é possível + Manter permissões + As permissões podem ser reconfiguradas automaticamente (não é recomendado) + As permissões não serão reconfiguradas automaticamente + Clique em Permissões > desmarque \"Gerenciar o app fora do uso\" + Se uma opção não funciona, use as configurações do app / Permissões. + Configurações do app + + Permissões de SSID do Wi-Fi + Para poder acessar o nome da rede Wi-Fi atual (o SSID), essas condições devem ser cumpridas: + Permissão de localização precisa + A permissão de localização foi concedida + A permissão de localização foi negada + Permissão de localização em segundo plano + Permitir o tempo todo + A permissão de localização está configurada para: %s + A permissão de localização não está configurada para: %s + O %s usa dados de localização (somente o SSID do Wi-Fi) para restringir a sincronização para somente um SSID de Wi-Fi. Isso pode acontecer até mesmo quando a sincronização está sendo executada em segundo plano. + Todos os dados de localização (que são somente o SSID do Wi-Fi) são usados apenas localmente e não são enviados para quaisquer lugares. + Localização sempre ativada + O serviço de localização está ativado + O serviço de localização está desativado + + Traduções + Bibliotecas + Versão %1$s (%2$d) + © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e contribuidores + Este programa é distribuído SEM QUALQUER GARANTIA. É software livre e pode ser redistribuído sob algumas condições. + + Não foi possível criar o arquivo de registros + Agora registrando todas as atividades do %s + Visualizar/compartilhar + Desativar + + Adaptador de sincronização do CalDAV/CardDAV + Sobre / Licença + Retorno da beta + Instale um navegador da web + Configurações + Novidades e atualizações + Ferramentas + Links externos + Site + Manual + Perguntas frequentes + Para organizações + Comunidade + Apoie o projeto + Como contribuir + Política de privacidade + Boas-vindas ao DAVx⁵! + Conecte-se ao seu servidor e mantenha seus calendários e contatos sincronizados. + Sincronizar todas as contas + + As notificações estão desativadas. Você não será notificado sobre erros de sincronização. + A sincronização automática não está ativa (sem conexão verificada à internet). + Gerenciar conexões + A economia de dados está ativada. A sincronização em segundo plano está restrita. + Gerenciar economia de dados + A economia de bateria está ativada. A sincronização pode ser restrita. + Gerenciar economia de bateria + Há pouco espaço de armazenamento. O Android não sincronizará alterações locais imediatamente, mas sim na próxima sincronização periódica. + Gerenciar armazenamento + O provedor de calendários está ausente + Você desativou o app do sistema chamado \"Armazenamento de calendários\"? + O provedor de contatos está ausente + Você desativou o app do sistema chamado \"Armazenamento de contatos\"? + Gerenciar apps + + A detecção de serviço falhou + Não foi possível recarregar a lista de coleções + + Executando em primeiro plano + Em alguns dispositivos, isto é necessário para a sincronização automática. + + Configurações + Depuração + Mostrar informações de depuração + Visualizar/compartilhar registros e detalhes da configuração + Registro verboso + A coleta de registro está ativa. Você pode visualizar os registros nas informações de depuração. + A coleta de registros está desativada + Otimização de bateria + O app está isento (recomendado) + O app não está isento (não recomendado) + Conexão + Tipo da proxy + + Padrão do sistema + Sem proxy + HTTP + SOCKS (pro Orbot) + + Nome do servidor da proxy + Porta da proxy + Segurança + Permissões do app + Revise as permissões necessárias para a sincronização + Desconfiar dos certificados do sistema + ACs do sistema e adicionadas pelo usuário não serão confiadas + ACs do sistema e adicionadas pelo usuário serão confiadas (recomendado) + Se essa configuração está ativa, os certificados do sistema não são tratados como confiáveis. Isso significa que você terá que manualmente aceitar cada certificado (e também quando o servidor renova o seu certificado) ou a configuração da conta e a sincronização não funcionarão. + Reconfigurar certificados + Reconfigura a confiança de todos os certificados personalizados + Todos os certificados personalizados foram limpos + Interface do usuário + Configurações de notificações + Gerencie canais de notificação e suas configurações + Escolher tema + + Padrão do sistema + Claro + Escuro + + Reconfigurar dicas + Reativa as dicas que foram ignoradas anteriormente + Todas as dicas serão mostradas novamente + Integração + App de tarefas + Nenhum app compatível de tarefas encontrado + UnifiedPush (experimental) + Nenhum (desativar push) + Escolha um distribuidor + Nenhum distribuidor de push instalado + Nenhum servidor configurado + Pronto para receber mensagens push pelo %s + FCM (Google Play) + As mensagens push são sempre criptografadas. + + A conta foi removida + CardDAV + CalDAV + Webcal + São necessárias permissões adicionais para sincronizar essas coleções. + Gerenciar permissões + Sincronizar agora + Configurações da conta + Renomear conta + Dados locais que não foram salvos podem ser ignorados. Uma nova sincronização é necessária após uma renomeação. + Nome novo da conta + Renomear + O nome da conta já foi utilizado + Não foi possível renomear a conta + Apagar conta + Realmente apagar a conta? + Todas as cópias locais das listas de contatos, calendários e listas de tarefas serão apagadas. + sincronizar esta coleção + somente leitura + calendário + contatos + diário + tarefas + Mostrar somente pessoais + Recarregar lista + Inscrições de Webcal podem ser sincronizadas com apps externos. + Nenhum app compatível com Webcal encontrado + Instalar ICSx⁵ + + Adicionar conta + política de privacidade.]]> + Autenticação genérica + Autenticação específica ao provedor + Continuar + Entrar + Entrar com endereço de e-mail + Endereço de e-mail + Um endereço de e-mail válido é necessário + Os serviços são descobertos usando registros de DNS e URLs well-known.]]> + Senha + Ocultar senha + Mostrar senha + Senha (opcional) + Entrar com URL e nome de usuário + Nome do usuário + Nome do usuário (opcional) + URL base + serviços também são descobertosusando registros de DNS e URLs well-known.]]> + Selecionar certificado + Adicionar conta + Nome da conta + O uso de apóstrofos (\') pode causar problemas em alguns dispositivos. + Use o seu endereço de e-mail como o nome da conta pois o Android usará o nome como o campo ORGANIZER pata os eventos que cria. Você não pode ter duas contas com o mesmo nome. + Método de agrupamento de contatos: + O nome da conta é necessário + O nome da conta já foi utilizado + A conta não pôde ser adicionada + Concluir + Autenticação avançada + Sem certificado de cliente (opcional) + Certificado de cliente: %s + Nenhum certificado encontrado + Instalar certificado + Fastmail + Conta do Fastmail + Entrar com Fastmail + Google Contatos / Agenda + Conta do Google + Entrar com Google + ID do cliente (opcional) + política de privacidade para detalhes.]]> + Política de Dados de Usuário dos Google API Services, incluindo os requisitos de Uso Limitado.]]> + Não foi possível obter o código de autorização + Nextcloud + Entrar com Nextcloud + Isso iniciará o processo de autenticação do Nextcloud num navegador da web. + Endereço do servidor do Nextcloud + Entrar + Não foi possível obter a URL de autenticação + Não foi possível obter os dados de autenticação + Detecção de configuração + Aguarde, consultando o servidor… + Não foi possível encontrar o serviço de CalDAV ou CardDAV. + O URL base não parece ser um URL acessível de CalDAV/CardDAV e a detecção de serviço não foi bem-sucedida. + nossa lista de serviços testados e seus URLs base.]]> + Certifique-se da autenticação (normalmente nome de usuário e senha). + Mais informações técnicas estão disponíveis nos registros. + Visualizar registros + + Sincronização + Intervalo de sincronização dos contatos + Apenas manualmente + A cada %d minutos e imediatamente em alterações locais + Intervalo de sincronização dos calendários + Intervalo de sincronização das tarefas + + Apenas manualmente + A cada 15 minutos + A cada 30 minutos + A cada hora + A cada 2 horas + A cada 4 horas + Todo dia + + Sincronizar apenas por Wi-Fi + A sincronização está restrita a apenas conexões de Wi-Fi + O tipo de conexão não está sendo considerado + Restrição de SSID do Wi-Fi + Sincronizará apenas em %s + Todas as conexões Wi-Fi serão utilizadas + Nomes das redes Wi-Fi permitidas (SSIDs) separados por vírgulas (deixe em branco para todas) + A restrição de SSID de Wi-Fi requer configuração adicional + Gerenciar + Exigir conexão base verificada para VPNs + Uma VPN sem conexão base verificada não é suficiente para executar a sincronização (recomendado) + Uma VPN sem conexão base verificada é suficiente para executar a sincronização + Autenticação + Nome do usuário + Senha ou senha de app + senha de app.]]> + Senha nova + Atualize a senha de acordo com o seu servidor. + Autorizar novamente (OAuth) + Use caso o acesso for revogado + A autorização foi bem-sucedida + Certificado de cliente + Nenhum certificado disponível ou selecionado + Instalar certificado + CalDAV + Limite de tempo para eventos passados + Todos os eventos serão sincronizados + + Eventos que ocorreram a mais de um dia atrás serão ignorados + Eventos que ocorreram a mais de %d de dias atrás serão ignorados + Eventos que ocorreram a mais de %d dias atrás serão ignorados + + Os eventos que ocorreram antes desse número de dias serão ignorados (pode ser 0). Deixe em branco para sincronizar todos os eventos. + Lembrete padrão + + Lembrete padrão um minuto antes do evento + Lembrete padrão %d de minutos antes do evento + Lembrete padrão %d minutos antes do evento + + Nenhum lembrete padrão será criado + Se lembretes padrão devem ser criados para eventos sem um: o número de minutos desejado antes do evento. Deixe em branco para desativar os lembretes padrão. + Gerenciar cores do calendários + As cores dos calendários serão reconfiguradas a cada sincronização + As cores dos calendários podem ser configuradas por outros apps + Suporte a cores de eventos + As cores de eventos serão sincronizadas + As cores dos eventos não serão sincronizadas + CardDAV + Método de agrupamento dos contatos + + Os grupos são vCards separados + Os grupos são categorias por contato + + + Criar lista de contatos + A criação de listas de contatos pelo CardDAV pode não ser suportada pelo servidor. + Criar calendário + Fuso horário padrão (opcional) + + Possíveis itens do calendário + Eventos + Tarefas + Anotações / diário + A criação de calendários pelo CalDAV pode não ser suportada pelo servidor. + Cor + Título + Localização de armazenamento + Descrição (opcional) + Criar + + contatos + eventos + tarefas + Apagar coleção + Esta coleção (%s) e todos os seus dados serão removidos para sempre, tanto localmente como no servidor. + Sincronização + A sincronização está ativada + A sincronização está desativada + Somente leitura + Somente leitura (pelo servidor) + Somente leitura (pela política) + Somente leitura (apenas localmente) + Ler/gravar + Título + Descrição + Proprietário + Suporte a push + O servidor anuncia suporte a push + Inscrito em %1$s, vence às %2$s + Última sincronização (%s) + Endereço (URL) + + Informações de depuração + Arquivo ZIP + Contém informações de depuração e registros + Compartilhe o arquivo para transferi-lo para um computador, ou envie-o por e-mail, anexando ele a um ticket de suporte. + Compartilhar arquivo + Informações de depuração anexadas à mensagem (requer suporte a anexos no app destinatário) + Erro de HTTP + Erro do servidor + Erro de WebDAV + Erro de E/S + A solicitação foi negada pelo servidor. + O recurso solicitado não existe (mais). + O servidor não permite o tipo de operação solicitada. + Ocorreu um problema no lado do servidor. Contate o suporte do seu servidor. + Ocorreu um erro inesperado. Visualize as informações de depuração para detalhes. + Visualizar detalhes + As informações de depuração foram coletadas + Recursos envolvidos + Relacionados ao problema + Recurso remoto: + Recurso local: + Registros + Registros verbosos estão disponíveis + Visualizar registros + Copiar URL + Inspecionar recurso + Comunicado de privacidade + Os registros e as informações de depuração podem conter informações privadas. Tenha isso em mente ao compartilhá-os publicamente. + Não é possível visualizar o recurso + + Ocorreu um erro. + Ocorreu um erro de HTTP. + Ocorreu um erro de E/S. + Mostrar detalhes + + Montagens WebDAV + Cota utilizada: %1$s / disponível: %2$s + Compartilhar conteúdo + Desmontar + Adicionar montagem WebDAV + Acesse diretamente seus arquivos da nuvem adicionando uma montagem WebDAV! + como as montagens WebDAV funcionam.]]> + Nome de exibição + URL do WebDAV + URL inválido + Ponto de montagem e nome de exibição + Autenticação + Nome do usuário + Senha + Nome do usuário (opcional) + Senha (opcional) + Adicionar montagem + Nenhum serviço de WebDAV neste URL + Remover ponto de montagem + Os detalhes da conexão serão perdidos, mas nenhum arquivo será apagado. + Acessando arquivo do WebDAV + Baixando arquivo do WebDAV + Enviando arquivo do WebDAV + Montagem WebDAV + + Permissões do DAVx⁵ + São necessárias permissões adicionais + %s é muito antigo + Versão mínima necessária: %1$s + Falha na autenticação (certifique-se das credenciais) + Erro de rede ou E/S – %s + Erro do servidor de HTTP – %s + Erro do armazenamento local – %s + Erro suave (número máximo de tentativas atingido) + Contato inválido foi recebido do servidor + Evento inválido foi recebido do servidor + Tarefa inválida foi recebida do servidor + Ignorando um ou mais recursos inválidos + Sincronização pendente + Os dados remotos mudaram + + Sincronizar tudo + Sincronizar todas as contas + Toque para executar a sincronização manualmente. + + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0a5631057..82ad1565f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2,9 +2,9 @@ A conta não existe (mais) - Livro de endereços do DAVx⁵ - Não mude a conta por aqui! Em vez disso, use o app diretamente para gerenciar as contas. - Excluir + Lista de contactos da DAVx⁵ + Não mude a conta por aqui! Em vez disso, use a aplicação diretamente para gerir as contas. + Eliminar Remover Cancelar Ativar @@ -23,31 +23,31 @@ Erros de sincronização Erros importantes que interrompem a sincronização, como respostas inesperadas do servidor Avisos de sincronização - Problemas de sincronização não graves, como determinados arquivos inválidos + Problemas de sincronização não fatais, como determinados ficheiros inválidos Erros de rede e E/S - Tempos de espera, problemas de conexão, etc. (geralmente temporários) + Tempos expirados, problemas de conexão, etc. (geralmente temporários) Seus dados. Sua escolha. Assuma o controle. - Intervalos de sincronização regulares - Para sincronização em intervalos regulares, o %s deve ter permissão para executar em segundo plano. Caso contrário, o Android poderá pausar a sincronização a qualquer momento. - Eu não preciso de intervalos de sincronização regulares.* - Compatibilidade %s - Fiz as configurações necessárias. Não me lembre novamente.* - * Deixe desmarcado para ser lembrado mais tarde. Pode ser redefinido nas configurações do aplicativo / %s. + Intervalos periódicos de sincronização + Para sincronização em intervalos periódicos, o %s deve ter permissão para executar em segundo plano. Caso contrário, o Android poderá pausar a sincronização a qualquer momento. + Eu não preciso de sincronização periódica.* + Compatibilidade com %s + Fiz as definições necessárias. Não me lembre novamente.* + * Deixe desmarcado para ser lembrado mais tarde. Pode ser redefinido nas definições da aplicação / %s. Mais informações jtx Board - + Suporte a tarefas - Se tarefas são suportadas por seu servidor, elas podem ser sincronizadas com um app suportado de tarefas: + Se tarefas são suportadas por seu servidor, elas podem ser sincronizadas com uma aplicação suportada de tarefas: OpenTasks Parece não ser mais desenvolvido -- não recomendado. Tasks.org não são suportados.]]> - Nenhuma loja de aplicativos disponível + Nenhuma loja de aplicações disponível Não preciso de suporte a tarefas.* - Software Livre - Estamos felizes por usar o %s, que é um software de código aberto. Desenvolvimento, manutenção e suporte são um trabalho árduo. Considere contribuir (existem várias maneiras) ou fazer uma doação. Seria muito apreciado! + Software livre + Estamos felizes que usa a %s, que é um software de código aberto. Desenvolvimento, manutenção e suporte são um trabalho árduo. Considere contribuir (existem várias maneiras) ou fazer uma doação. Seria muito apreciado! Como contribuir/doar Não me lembrar por @@ -57,42 +57,42 @@ Próximo - Permissões - %s requer permissões para trabalhar corretamente. + Autorizações + %s requer autorizações para funcionar corretamente. Todos os abaixo Use isto para ativar todas as funcionalidades (recomendado) - Todas as permissões concedidas - Permissões de contatos - Nenhum contato sincronizado (não recomendado) - Possível sincronização de contatos - Permissões de calendário + Todas as autorizações + Autorizações de contactos + Nenhum contacto sincronizado (não recomendado) + Possível sincronização de contactos + Autorizações de calendário Nenhum calendário sincronizado (não recomendado) Possível sincronização de calendário - Permissão de notificação + Autorizações de notificação Notificações desativadas (não recomendado) Notificações ativadas - Permissões do jtx Board - Permissões do OpenTasks - Permissões das tarefas + Autorizações da jtx Board + Autorizações da OpenTasks + Autorizações da Tasks Sem sincronização de tarefas Sincronização de tarefas possível - Manter as permissões - As permissões podem ser redefinidas automaticamente (não recomendado) - As permissões não serão redefinidas automaticamente - Toque em Permissões > desmarque \"Pausar atividade no app quando não usado\" - Se uma opção não funcionar, use as configurações / permissões do aplicativo. - Configurações do aplicativo + Manter as autorizações + As autorizações podem ser redefinidas automaticamente (não recomendado) + As autorizações não serão redefinidas automaticamente + Toque em Autorizações > desmarque \"Gerir app se não for usada\" + Se uma opção não funcionar, use as definições / autorizações da aplicação. + Definições da aplicação - Permissões WiFi SSID + Autorizações de SSID do Wi-Fi Para que seja possível acessar o nome do WiFi atual (SSID), essas condições tem que ser compridas: - Permissão de localização precisa - Permissão de localização concedida - Permissão de localização negada - Permissão de acesso à localização em segundo plano - Permitir o tempo todo - Permissão de localização está definida como:%s - Permissão de localização não está definida como:%s - O %susa dados de localização (somente os SSIDs de Wi-Fi) para restringir a sincronização em uma rede Wi-Fi específica. Isso pode acontecer mesmo quando a sincronização é executada em segundo plano. + Autorização de localização precisa + Autorização de localização concedida + Autorização de localização negada + Autorização de acesso à localização em segundo plano + Permitir sempre + Autorização de localização está definida como: %s + Autorização de localização não está definida como: %s + A %s usa dados de localização (somente os SSIDs de Wi-Fi) para restringir a sincronização em uma rede Wi-Fi específica. Isso pode acontecer mesmo quando a sincronização é executada em segundo plano. Todos os dados de localização (que são somente SSIDs de Wi-Fi) são usados somente localmente e não são enviados para qualquer servidor. Localização sempre ativa Serviço de localização está ativado @@ -104,87 +104,87 @@ © Ricki Hirner, Bernhard Stockmann (bitfire web engineering GmbH) e contribuidores Este programa é distribuído SEM NENHUMA GARANTIA. Ele é software livre e pode ser redistribuído sob algumas condições. - Não foi possível criar o arquivo de log + Não foi possível criar o ficheiro de registos Registrando todas as atividades de %s Ver/partilhar Desativar Sincronização de CalDAV/CardDAV - Sobre / Licença - Comentários sobre a versão beta + Acerca / Licença + Comentários acerca da versão beta Instale um navegador Web - Configurações - Novidades e atualizações + Definições + Novidades e actualizações Ferramentas Links externos - Site na Web + Sítio da web Manual Perguntas fequentes Comunidade Apoie o projeto Como contribuir Política de privacidade - Bem-vindo ao DAVx⁵! - Conecte-se ao seu servidor e mantenha seus calendários e contatos sincronizados. + Boas-vindas à DAVx⁵! + Conecte-se ao seu servidor e mantenha seus calendários e contactos sincronizados. Sincronizar todas as contas - As notificações estão desativadas. Você não será notificado sobre erros de sincronização. + As notificações estão desativadas. Você não será notificado acerca erros de sincronização. Sincronização automática inativa (sem conexão à internet verificada). - Gerenciar conexões - A economia de dados está ativada. A sincronização em segundo plano está restrita. - Gerenciar a economia de dados - A economia de bateria está ativada. A sincronização pode estar restrita. - Gerenciar a economia de bateria - Pouco espaço de armazenamento. O Android não sincronizará as mudanças locais imediatamente, mas sim na próxima sincronização regular. - Gerenciar armazenamento + Gerir conexões + A poupança de dados está ativada. A sincronização em segundo plano está restrita. + Gerir a poupança de dados + A poupança de bateria está ativada. A sincronização pode estar restrita. + Gerir a poupança de bateria + Pouco espaço de armazenamento. O Android não sincronizará as mudanças locais imediatamente, mas sim na próxima sincronização periódica. + Gerir armazenamento Provedor de calendários ausente - Você desativou o app do sistema chamado \"Armazenamento de calendários\"? - Provedor de contatos ausente - Você desativou o app do sistema chamado \"Armazenamento de contatos\"? - Gerenciar apps + Você desativou a app do sistema chamado \"Armazenamento de calendários\"? + Provedor de contactos ausente + Você desativou a app do sistema chamado \"Armazenamento de contactos\"? + Gerir apps Falha na detecção do serviço Não foi possível atualizar a lista da coleção Executando em primeiro plano - Em alguns dispositivos, isto é necessário para a sincronização automática. + Em alguns aparelhos, isto é necessário para a sincronização automática. - Configurações + Definições Depuração Mostrar informações de depuração - Ver/compartilhar configurações de configuração e registros + Ver/partilhar configurações de configuração e registos Registro de atividades detalhado O registro está ativo. Você pode ver os registros como parte das informações de depuração. Registro de atividades desativado Otimização da bateria - O app está isento (recomendado) + A app está isenta (recomendado) As restrições de bateria se aplicam (não recomendado) Conexão Tipo de proxy - Padrão do sistema + Predefinição do sistema Sem proxy HTTP SOCKS (para Orbot) - Nome do host da proxy + Nome do host do proxy Porta do proxy Segurança - Permissões do aplicativo - Revise as permissões necessárias para sincronização + Autorizações da aplicação + Revise as autorizações necessárias para sincronização Desconfiar dos certificados de sistema ACs adicionadas pelo usuário e pelo sistema não serão confiáveis ACs adicionadas pelo usuário e pelo sistema serão confiáveis (recomendado) Se essa configuração está ativa, os certificados do sistema não são tratados como confiáveis. Isso significa que você terá que manualmente aceitar cada certificado (e quando o servidor renova o seu certificado) ou a configuração da conta e a sincronização não funcionarão. - Redefinir certificados não-confiáveis + Redefinir certificados (não) confiados Restaura a confiança de todos os certificados personalizados Todos os certificados personalizados foram restaurados Interface de usuário - Configurações das notificações - Gerenciar os canais de notificação e suas configurações + Definições das notificações + Gerir os canais de notificação e suas definições Escolha um tema - Padrão do sistema + Predefinição do sistema Claro Escuro @@ -193,44 +193,44 @@ Todas as sugestões serão exibidas novamente Integração App de tarefas - Nenhum app de tarefas compatível encontrado + Nenhuma app de tarefas compatível encontrada UnifiedPush (experimental) Nenhum (desativar push) Escolha um distribuidor Nenhum distribuidor push instalado Nenhum servidor configurado - Pronto para receber mensagems push pelo %s + Pronto para receber mensagens push pelo %s CardDAV CalDAV - WebCal - Permissões adicionais são necessárias para sincronizar essas coleções. - Gerenciar permissões + Webcal + Autorizações adicionais são necessárias para sincronizar essas coleções. + Gerir autorizações Sincronizar agora - Configurações da conta + Definições da conta Renomear conta Dados locais que não foram salvos podem ser ignorados. Uma nova sincronização é necessária após uma renomeação. Nome novo da conta Renomear O nome da conta já foi utilizado Não foi possível renomear a conta - Excluir conta - Deseja excluir a conta? - Todas as cópias locais dos livros de endereços, calendários e listas de tarefas serão excluídas. + Eliminar conta + Realmente eliminar a conta? + Todas as cópias locais das listas de contactos, calendários e listas de tarefas serão eliminadas. sincronizar esta coleção Somente leitura calendário - contatos - jornal + contactos + diário tarefas Mostrar somente pessoal Recarregar lista - Inscrições WebCAL podem ser sincronizadas com apps externos. - Não foi encontrado um aplicativo capaz de lidar com Webcal + Inscrições WebCAL podem ser sincronizadas com apps externas. + Não foi encontrado uma aplicação capaz de lidar com Webcal Instalar ICSx⁵ Adicionar conta - política de privacidade.]]> + política de privacidade.]]> Login genérico Login de provedor específico Continuar @@ -239,9 +239,9 @@ Endereço de e-mail É necessário um e-mail válido Serviços são descobertos através do DNS e URLs conhecidas.]]> - Senha - Ocultar senha - Mostrar senha + Palavra-passe + Ocultar palavra-passe + Mostrar palavra-passe Autenticação com usuário e URL Usuário URL base @@ -249,9 +249,9 @@ Selecionar certificado Adicionar conta Nome da conta - O uso de apóstrofos (\') pode causar problemas em certos dispositivos. + O uso de apóstrofos (\') pode causar problemas em certos aparelhos. Use seu endereço de e-mail como nome da conta porque o Android irá usar esse nome como campo AGENDA nos eventos que você criar. Não é possível ter duas contas com o mesmo nome. - Método do grupo Contato: + Método de agrupamento de contactos: É necessário um nome de conta O nome da conta já foi utilizado A conta não pôde ser adicionada @@ -260,12 +260,12 @@ Certificado do cliente: %s Nenhum certificado encontrado Instalar certificado - Google Contatos / Agenda + Google Contactos / Agenda Conta Google Fazer login com o Google ID do cliente (opcional) - Política de Privacidade para detalhes.]]> - Google API Services User Data Policy, incluindo com os requisitos de Uso Limitado.]]> + Política de Privacidade para detalhes.]]> + Google API Services User Data Policy, incluindo com os requisitos de Uso Limitado.]]> Não foi possível obter o código de autorização Nextcloud Fazer login com Nextcloud @@ -275,16 +275,16 @@ Não foi possível obter a URL de login Não foi possível obter os dados de login Detecção de configuração - Aguarde, procurando servidor… + Aguarde, a procurar o servidor… Não foi possível encontrar o serviço CalDAV ou CardDAV. A URL base não parece ser uma URL de CalDAV/CardDAV accesível e a detecção de serviço não foi sucedida. nossa lista de serviços testados e suas URLs base.]]> - Tenha certeza dos dados de autenticação (normalmente nome de usuário e senha) - Mais informações técnicas estão disponíveis nos logs. - Visualizar logs + Tenha certeza dos dados de autenticação (normalmente nome de usuário e palavra-passe) + Mais informações técnicas estão disponíveis nos registos. + Visualizar registos Sincronização - Intervalo sinc. de contatos + Intervalo sinc. de contactos Apenas manualmente A cada %d minutos + imediatamente nas alterações locais Intervalo sinc. de calendários @@ -305,15 +305,15 @@ Sincronizar apenas com %s Todas as conexões WiFi serão usadas Nomes separados por vírgula (SSIDs) das redes WiFi (deixe em branco para todas) - A restrição de SSID de WiFi requer configuração adicional - Gerenciar + A restrição de SSID de WiFi requer definições adicionais + Gerir VPN requer conexão à internet VPN sem uma conexão à internet validada não é suficiente para executar uma sincronização (recomendado) VPN sem uma conexão à internet validada é suficiente para executar uma sincronização Autenticação Nome do usuário - Senha nova - Atualize a senha de acordo com seu servidor + Palavra-passe nova + Atualize a palavra-passe de acordo com seu servidor. Certificado do cliente Nenhum certificado disponível ou selecionado Instalar certificado @@ -326,29 +326,29 @@ Eventos que ocorreram a mais de %d dias serão ignorados Os eventos que ocorreram antes desse número de dias serão ignorados (pode ser 0). Deixe em branco para sincronizar todos os eventos. - Lembrete padrão + Lembrete predefinido Lembrete padrão um minuto antes do evento Lembrete padrão %d minutos antes do evento - Lembrete padrão %d minutos antes do evento + Lembrete predefinido %d minutos antes do evento - Nenhum lembrete padrão está criado - Se lembretes padrão devem ser criados para eventos sem lembrete: o número desejado de minutos antes do evento. Deixe em branco para desativar os lembretes padrão. - Gerenciar cores dos calendários + Nenhum lembrete predefinido está criado + Se lembretes predefinidos devem ser criados para eventos sem lembrete: o número desejado de minutos antes do evento. Deixe em branco para desativar os lembretes padrão. + Gerir cores dos calendários Cores dos calendários são redefinidas quando uma sincronização é feita - Cores dos calendários podem ser definidas por outros apps + Cores dos calendários podem ser definidas por outras apps Suporte para cor de evento Cores de eventos são sincronizadas Cores de eventos não são sincronizadas CardDAV - Método do grupo Contato + Método de agrupamento dos contactos Grupos são vCards separados - Grupos são categorias por contato + Grupos são categorias por contacto - Criar livro de endereços - A criação de livro de endereços por CardDAV pode não ser suportada pelo servidor. + Criar lista de contactos + A criação de lista de contactos por CardDAV pode não ser suportada pelo servidor. Criar calendário Possíveis itens de calendário @@ -362,9 +362,9 @@ Descrição (opcional) Criar - contatos + contactos tarefas - Excluir coleção + Eliminar coleção Esta coleção (%s) e todos os seus dados serão removidos permanentemente, tanto localmente como no servidor. Sincronização Sincronização ativada @@ -385,56 +385,53 @@ Informações de depuração Arquivo ZIP - Contém informações de debug e logs - Compartilhe o arquivo para transferir ele para um computador, para enviar por e-mail ou para anexar ele à um ticket de suporte. + Contém informações de depuração e registos + Partilhe o arquivo para transferir ele para um computador, para enviar por e-mail ou para anexar ele à um ticket de suporte. Partilhar arquivo - Informações de depuração anexadas a esta mensagem (requer suporte a anexo pelo aplicativo receptor). + Informações de depuração anexadas a esta mensagem (requer suporte a anexo pela aplicação receptora). Erro HTTP Erro do servidor Erro do WebDAV Erro de E/S - O pedido foi negado. Verifique os recursos envolvidos e as informações de depuração para obter detalhes. - O recurso solicitado não existe (não mais). Verifique os recursos envolvidos e as informações de depuração para obter detalhes. - Ocorreu um problema. Por favor, entre em contato com o suporte do seu servidor.. - Ocorreu um erro inesperado. Consulte as informações de depuração para mais detalhes. Veja detalhes - Informações sobre depuração foram coletadas + Informações acerca depuração foram coletadas Recursos envolvidos Relacionado ao problema Recurso remoto: Recurso local: - Registros - Registros descritivos disponíveis - Visualizar logs + Registos + Registos verbosos estão disponíveis + Visualizar registos + Copiar URL Ocorreu um erro. Ocorreu um erro de HTTP. Ocorreu um erro de leitura/gravação. Mostrar detalhes - Pastas WebDAV - Quota utilizada: %1$s / disponível: %2$s + Montagens WebDAV + Cota utilizada: %1$s / disponível: %2$s Partilhar conteúdo Desmontar - Adicionar uma pasta WebDAV - Acesse diretamente seus arquivos da nuvem adicionando uma pasta WebDAV! + Adicionar montagem WebDAV + Acesse diretamente seus ficheiros da nuvem adicionando uma montagem WebDAV! Nome de exibição URL do WebDAV URL inválida Autenticação Nome do usuário - Palavra passe - Adicionar pasta + Palavra-passe + Adicionar montagem Nenhum serviço WebDAV nesta URL - Remover pasta - Detalhes de conexão serão perdidos, mas nenhum arquivo será excluído. - Acessando arquivo do WebDAV - Baixando arquivo do WebDAV - Enviando arquivo do WebDAV - Pasta WebDAV + Remover ponto de montagem + Detalhes de conexão serão perdidos, mas nenhum ficheiro será eliminado. + A acessar ficheiro do WebDAV + A descarregar ficheiro do WebDAV + A enviar ficheiro do WebDAV + Montagem WebDAV - Permissões do DAVx⁵ - É necessário permissões adicionais + Autorizações da DAVx⁵ + É necessário autorizações adicionais %s muito antigo Versão mínima exigida: %1$s Falha de autenticação (verifique as credenciais) @@ -442,11 +439,10 @@ Erro no servidor HTTP – %s Erro de armazenamento local – %s Erro simples (número de tentativas máximo atingido) - Ver item - Contato inválido recebido do servidor + Contacto inválido recebido do servidor Evento inválido recebido do servidor Tarefa inválida recebida do servidor - Ignorando um ou mais recursos inválidos + A ignorar um ou mais recursos inválidos Sincronização em espera Dados remotos podem ter mudado diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 05c06a124..7c54e72ed 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -411,8 +411,9 @@ Eroare de server Eroare WebDAV Eroare I/O - Solicitarea a fost respinsă. Verifică resursele implicate și informațiile de depanare pentru detalii. - Resursa solicitată nu mai există (mai mult). Verifică resursele implicate și informațiile de depanare pentru detalii. + Solicitarea a fost respinsă de server. + Resursa solicitată nu (mai) există. + Serverul nu permite tipul de operare solicitat. A apărut o problemă la nivelul serverului. Contactează asistența serverului. A apărut o eroare neașteptată. Vezi informațiile de depanare pentru detalii. Vezi detaliile @@ -424,8 +425,11 @@ Jurnale Jurnalele detaliate sunt disponibile Vezi jurnalele + Copiază adresa URL + Inspectează resursa Notificare de confidențialitate Jurnalele și informațiile de depanare pot conține informații private. Fii conștient de acest lucru atunci când îl publici. + Nu se poate vizualiza resursa A avut loc o eroare. A apărut o eroare HTTP. @@ -466,7 +470,6 @@ Eroare de server HTTP – %s Eroare de stocare locală – %s Eroare soft (încercări maxime atinse) - Vezi elementul S-a primit contact nevalid de la server S-a primit eveniment nevalid de la server S-a primit sarcină nevalidă de la server diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4c47bb5d5..7270d435e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,7 +1,7 @@ - Аккаунт не существует (больше) + Аккаунт (больше) не существует Адресная книга DAVx⁵ Не меняйте аккаунт здесь! Вместо этого используйте приложение для управления учетными записями. Удалить @@ -165,7 +165,7 @@ Соединение Тип прокси - Определен системой + Системный Без прокси HTTP SOCKS (для Orbot) @@ -187,7 +187,7 @@ Управление каналами уведомлений и их настройками Выбор темы - Определена системой + Системная Светлая Темная @@ -258,7 +258,7 @@ Добавить аккаунт Название аккаунта Использование апострофов (\'), как оказалось, вызывает проблемы на некоторых устройствах. - Укажите ваш адрес email в качестве названия аккаунта, поскольку Android будет его использовать в поле ORGANIZER для создаваемых событий. У вас не может быть двух аккаунтов с тем же именем. + Используйте свой адрес email в качестве названия аккаунта, поскольку Android будет использовать его в качестве поля ОРГАНИЗАТОР для создаваемых вами событий. Не допускается наличие двух аккаунтов с одинаковыми названиями. Метод группировки контактов: Название аккаунта обязательно Название аккаунта уже используется @@ -324,7 +324,7 @@ VPN без основного интернета достаточно для выполнения синхронизации Аутентификация Имя пользователя - Пароль или пароль приложения + Пароль аккаунта или пароль приложения пароль приложения.]]> Новый пароль Обновить пароль @@ -414,8 +414,9 @@ Ошибка сервера Ошибка WebDAV Ошибка ввода/вывода - Запрос был отклонен. Для получения подробной информации проверьте задействованные ресурсы и отладочную информацию. - Запрошенного ресурса не существует (больше не существует). Проверьте задействованные ресурсы и отладочную информацию для получения подробной информации. + Запрос был отклонен сервером. + Запрошенный ресурс (больше) не существует. + Сервер не разрешает запрошенный тип операции. Возникла проблема на стороне сервера. Пожалуйста, свяжитесь со службой поддержки вашего сервера. Произошла неожиданная ошибка. Просмотрите отладочную информацию, чтобы узнать подробности. Просмотр @@ -427,8 +428,11 @@ Логи Доступны подробные логи Просмотр логов + Скопировать URL + Проверить ресурс Предупреждение о конфиденциальности Журналы и отладочная информация могут содержать конфиденциальную информацию. Пожалуйста, помните об этом, когда делитесь ими. + Невозможно просмотреть ресурс Произошла ошибка. Произошла ошибка HTTP @@ -469,7 +473,6 @@ Ошибка HTTP-сервера – %s Ошибка локального хранилища – %s Ошибка (достигнуто максимальное количество повторных попыток) - Просмотр элемента Получен неверный контакт с сервера Получено недействительное событие от сервера Получена недействительная задача от сервера diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 526858232..bb69bf071 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -168,6 +168,7 @@ Popis Ladiace informácie + Kopírovať URL Vyskytla sa chyba. Vyskytla sa HTTP chyba. @@ -184,7 +185,6 @@ Sieťová alebo V/V chyba – %s Chyba HTTP servera – %s Chyba miestneho úložiska – %s - Zobraziť položku Kontakt prijatý zo servera je neplatný Udalosť prijatá zo servera nie je platná Úloha prijatá zo servera nie je platná diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 142913a9c..6067be70c 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -185,6 +185,7 @@ Opis Informacije razhroščevalnika + Kopiraj URL Zgodila se je napaka Zgodila se je HTTP napaka. @@ -201,7 +202,6 @@ Omrežna ali I/O napaka -- %s HTTP strežniška napaka -- %s Napaka lokalne shrambe -- %s - Prikaži predmet S strežnika so bili prejeti neveljavni kontakti S strežnika so bili prejeti neveljavni dogodki S strežnika so bili prejeti neveljavni dogodki diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index eaa1e18a1..9a8d510d2 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -258,6 +258,7 @@ Прикажи детаље Записи Прикажи записе + Копирај УРЛ Десила се грешка. Десила се ХТТП грешка. @@ -275,7 +276,6 @@ Мрежна или У/И грешка – %s Грешка ХТТП сервера – %s Грешка локалног складишта – %s - Прикажи ставку Синхронизуј све налоге diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 6749b6a9f..bda4f118d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -33,6 +33,7 @@ För att programmet skall kunna köra regelbunden synkronisering %s måste det tillåtas att köra i bakgrunden. Annars kan Android pausa synkroniseringen när som helst. Jag behöver inte regelbunden synkronisering.* %s kompatibilitet + Leverantörsspecifik firmware kan blockera synkronisering. Om du är drabbad kan du bara lösa detta manuellt. Jag har gjort de nödvändiga inställningarna. Påminn mig inte igen.* * Lämna omarkerat för att bli påmind senare.. Kan återställas i appens inställningar / %s. Mer information @@ -119,6 +120,7 @@ Websida Manual FAQ + För organisationer Gemenskap Stöd projektet Hur man kan bidra @@ -199,8 +201,10 @@ Ingen push leverantör installerad Ingen slutpunkt konfigurerad Redo att ta emot push meddelanden över %s + FCM (Google Play) Push-meddelanden är alltid krypterade. + Konto har blivit borttaget CardDAV CalDAV Webcal @@ -243,8 +247,10 @@ Lösenord Dölj lösenord Visa lösenord + Lösenord (valfritt) Logga in med URL och användarnamn Användarnamn + Användarnamn (valfritt) Bas-URL tjänster upptäcks även genom DNS uppslag och välkända URL:er.]]> Välj certifikat @@ -258,6 +264,7 @@ Konto kunde inte läggas till Klart Avancerad inloggning + Inget klientcertifikat (valfritt) Klientcertifikat: %s Inget certifikat funnet Installera certifikat @@ -357,6 +364,7 @@ Skapa adressbok Att skapa adressbok över CardDAV kanske inte stöds av servern. Skapa kalender + Standardtidszon (valfritt) Möjliga kalenderposter Händelser @@ -366,6 +374,7 @@ Färg Titel Lagringsplats + Beskrivning (valfritt) Skapa kontakter @@ -400,10 +409,11 @@ Server-fel WebDAV-fel I/O-fel - Begäran har avslagits. Kontrollera involverade resurser och felsökningsinformation för detaljer. - Den begärda resursen finns inte (längre). Kontrollera involverade resurser och felsökningsinformation för detaljer. - Ett problem på serversidan uppstod. Kontakta din serversupport. - Ett oväntat fel har uppstått. Se felsökningsinformation för detaljer. + Förfrågan har blivit nekad av servern. + Den begärda resursen finns inte (längre) + Servern tillåter inte den begärda typen av åtgärd. + Ett problem har uppstått på serversidan. Kontakta din serversupport. + Ett oväntat problem har uppstått. Kolla felsökningsloggen för mer detaljer. Visa detaljer Felsökningsinformation har samlats in Inblandade resurser @@ -413,8 +423,11 @@ Loggar Utförliga loggar finns tillgängliga Visa loggar + Kopiera URL + Inspektera resurs. Integritetspolicy Loggar och felsökningsinformation kan innehålla privat information. Var medveten om detta när du delar offentligt. + Det går inte att visa resursen Ett fel har uppstått. Ett HTTP-fel har uppstått. @@ -431,9 +444,12 @@ Visningsnamn WebDAV URL Felaktig URL + Monteringspunkt och visningsnamn Autentisering Användarnamn Lösenord + Användarnamn (valfritt) + Lösenord (valfritt) Lägg till fäste Ingen WebDAV-tjänst på denna URL Ta bort monteringspunkt @@ -452,7 +468,6 @@ HTTP server fel - %s Lokalt lagringsfel - %s Mjukt fel (max antal återanslutningar nådda) - Visa objekt Fick ogiltig kontakt från servern Fick ogiltig händelse från servern Fick ogiltigt ärende från servern diff --git a/app/src/main/res/values-szl/strings.xml b/app/src/main/res/values-szl/strings.xml index 406588d9b..c6e40e57e 100644 --- a/app/src/main/res/values-szl/strings.xml +++ b/app/src/main/res/values-szl/strings.xml @@ -181,6 +181,7 @@ Ôpis Informacyje debugowe + Skopiuj URL Trefiōł sie błōnd. Trefiōł sie błōnd HTTP. @@ -197,7 +198,6 @@ Feler necu abo I/O – %s Feler serwera HTTP – %s Feler lokalnego przechowowanio – %s - Pokoż elymynt Dostany kōntakt ze serwera je niynoleżny Dostane zdarzynie ze serwera je niynoleżne Dostane zadanie ze serwera je niynoleżne diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 27190dc6c..44083f83f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -235,6 +235,7 @@ Помилка WebDAV Помилка I/O Переглянути деталі + Скопіювати URL Трапилась помилка. Трапилась помилка HTTP. @@ -252,7 +253,6 @@ Помилка мережі та вводу/виводу — %s Помилка сервера HTTP — %s Помилка локального сховища — %s - Перегляд елементу Отримано помилковий контакт від сервера Отримано помилкову подію від сервера Отримано помилкове завдання від сервера diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 4d80be36f..e67076937 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -273,10 +273,6 @@ Lỗi máy chủ Lỗi WebDAV Lỗi I/O - Yêu cầu đã bị từ chối. Hãy xem các tài nguyên và thông tin gỡ lỗi có liên quan để biết thêm chi tiết. - Tài nguyên được yêu cầu không tồn tại (nữa). Hãy kiểm tra các tài nguyên và thông tin gỡ lỗi có liên quan để biết thêm chi tiết. - Đã xảy ra vấn đề của máy chủ. Vui lòng liên hệ bộ phận hỗ trợ của máy chủ. - Đã xảy ra lỗi không mong đợi. Hãy xem thông tin gỡ lỗi để biết thêm chi tiết. Xem chi tiết Đã thu thập thông tin gỡ lỗi Tài nguyên có liên quan @@ -286,6 +282,7 @@ Nhật ký Có nhật ký chi tiết Xem nhật ký + Sao chép URL Đã xảy ra lỗi. Đã xảy ra lỗi HTTP. @@ -321,7 +318,6 @@ Lỗi mạng hoặc I/O – %s Lỗi máy chủ HTTP – %s Lỗi kho lưu trữ cục bộ – %s - Xem mục Đã nhận liên hệ không hợp lệ từ máy chủ Đã nhận sự kiện không hợp lệ từ máy chủ Đã nhận công việc không hợp lệ từ máy chủ diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 756bbc409..b591b3fa8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -37,10 +37,13 @@ 所需設定已完成,不用再提醒我* * 取消勾選則稍後會再次提醒,可於設定中重置 / %s 更多資訊 + jtx Board 待辦事項支援 如果你的服務器支持任務,它們可以與支援任務的app同步: + OpenTasks 似乎已不再繼續開發 - 不建議使用。 + Tasks.org 不被支援。]]> 沒有應用商店可用 我不需要任務支援。* @@ -75,7 +78,24 @@ 保持權限 權限可能會被自動重設(不推薦) 權限不會被自動重設 + 點選權限 > 取消勾選「若應用程式未使用則移除權限」 + 如果開關無法使用,請前往應用程式設定 / 權限。 + 應用程式設定 + WiFi SSID 權限 + 要能夠存取目前的 WiFi 名稱(SSID),必須符合以下條件: + 精確位置權限 + 已授予位置權限 + 已拒絕位置權限 + 背景位置權限 + 永遠允許 + 位置權限已設定為:%s + 位置權限未設定為:%s + %s使用位置資料(僅限 WiFi SSID)僅用來限制同步至特定的 WiFi SSID。即使同步在背景執行時,也會套用此限制。 + 所有位置資料(僅限 WiFi SSID)皆僅在本機使用,不會傳送至任何地方。 + 位置永遠啟用 + 位置服務已啟用 + 位置服務已停用 翻譯 函式庫 @@ -84,6 +104,9 @@ 我們「完全不保證」本程式無瑕疵。這是個自由軟體,歡迎您在符合公用授權條款的情況下任意散布它。 無法創建事項記錄文檔 + 現在正在記錄所有 %s 活動 + 檢視/分享 + 停用 CalDAV/CardDAV 同步器 關於我們 / 授權條款 @@ -96,40 +119,101 @@ 我們的網站 使用説明書 常見問答 + 適用於組織 + 社群 + 支持此項目 + 如何貢獻 隱私權政策 + 歡迎使用 DAVx⁵! + 連線到您的伺服器,並保持行事曆與聯絡人同步。 + 同步所有帳戶 + 通知已停用。您將不會收到同步錯誤的通知。 + 自動同步未啟用(沒有已驗證的網際網路連線)。 + 管理連線 + 已啟用數據節省模式。背景同步受到限制。 + 管理數據節省模式 + 已啟用省電模式。同步可能會受到限制。 + 管理省電模式 + 儲存空間不足。Android 不會立即同步本機變更,而會在下次的定期同步時進行。 + 管理儲存空間 + 缺少行事曆提供者 + 您是否已停用「行事曆儲存空間」系統應用程式? + 缺少聯絡人提供者 + 您是否已停用「聯絡人儲存空間」系統應用程式? + 管理應用程式 未發現遠端服務 無法更新清單 + 在前景執行 + 在某些裝置上,這對自動同步是必要的。 設定 除錯 顯示除錯訊息 + 檢視/分享組態詳細資料與日誌 詳細除錯記錄 + 記錄功能已啟用。您可以在除錯資訊中檢視日誌。 日誌記錄已停用 電池最佳化 + 排除本應用程式(建議) + 套用電池限制(不建議) 網路連線 + 代理類型 + + 系統預設 + 無代理 + HTTP + SOCKS(用於 Orbot) + + 代理主機名稱 + 代理連接埠 安全性 + 應用程式權限 + 檢視同步所需的權限 不信任系統憑證 系統憑證和使用者自訂憑證將不被信任 系統憑證和使用者自訂憑證將被信任 (推薦設定) + 若啟用此設定,系統憑證將不被視為可信任。這表示您必須手動接受每一張憑證(包含伺服器更新憑證時),否則帳戶設定與同步將無法運作。 重新開啟之前關閉的提示 重設對所有自訂憑證的信任 所有自訂憑證已清除 使用介面 通知設定 管理通知頻道和設定 + 選擇主題 + + 系統預設 + 淺色 + 深色 + 重新開啟提示 重新啟用之前取消的提示 所有提示將再次顯示 + 整合 + 待辦事項 應用程式 + 找不到相容的待辦事項應用程式 + UnifiedPush(實驗性) + 無(停用推播) + 選擇分發服務 + 未安裝推播分發服務 + 未設定端點 + 已準備好透過 %s 接收推播訊息 + FCM (Google Play) + 推播訊息一律加密。 + 帳戶已被移除 CardDAV聯絡人檔案 CalDav行事曆檔案 Webcal網際網絡行事曆 + 需要額外的權限才能同步這些收藏。 + 管理權限 立即同步 帳號設定 重新命名帳號 + 未儲存的本機資料可能會被捨棄。重新命名後需要再次同步。 + 新帳戶名稱 重新命名 這個賬號名稱已經被取過了 無法重新命名帳號 @@ -139,29 +223,74 @@ 同步這個行事曆或工作清單 唯讀 行事曆 + 聯絡人 + 日誌 + 待辦事項 只顯示個人 + 重新整理清單 + Webcal 訂閱可與外部應用程式同步。 未找到支援Webcal的APP 安裝ICSx⁵ 新增帳號 + 隱私權政策。]]> + 一般登入 + 特定提供者登入 + 繼續 登入 用 Email 地址登入 Email 地址 請輸入有效的 Email 地址 + 服務會透過 DNS 紀錄與 well-known URL 自動探索。]]> 密碼 + 隱藏密碼 + 顯示密碼 + 密碼(可選) 用網址和帳號登入 使用者帳號 + 使用者名稱(可選) 根 URL + 服務也會透過 DNS 紀錄與 well-known URL 自動探索。]]> 點選憑證 新增帳號 帳號名稱 + 在某些裝置上使用單引號 (\') 似乎會造成問題。 使用 Email 地址當作裝置上的帳號顯示名稱,因為當您在行事曆創建活動時,Android 會把帳號顯示名稱放到「活動發起人」欄位。兩個帳號不能有相同的名稱。 聯絡人群組的儲存格式 需要帳號名稱 這個賬號名稱已經被取過了 + 無法新增帳戶 + 完成 + 進階登入 + 無用戶端憑證(可選) + 用戶端憑證:%s + 找不到憑證 + 安裝憑證 + Fastmail + Fastmail 帳戶 + 使用 Fastmail 登入 + Google 聯絡人 / 行事曆 + Google 帳戶 + 使用 Google 登入 + 用戶端 ID(可選) + 隱私權政策。]]> + Google API 服務使用者資料政策,包括有限使用的相關要求。]]> + 無法取得授權碼 + Nextcloud + 使用 Nextcloud 登入 + 這將在網頁瀏覽器中啟動 Nextcloud 登入流程。 + Nextcloud 伺服器位址 + 登入 + 無法取得登入 URL + 無法取得登入資料 設定錯誤 請稍待,正在詢問伺服器… 找不到 CalDAV 或 CardDAV 服務。 + 基礎 URL 似乎不是可存取的 CalDAV/CardDAV URL,且服務偵測未成功。 + 我們的已測試服務清單及其基礎 URL。]]> + 請同時再次確認驗證資訊(通常是使用者名稱與密碼)。 + 更多技術資訊可在日誌中取得。 + 檢視日誌 同步設定 聯絡人同步間隔 @@ -185,10 +314,23 @@ 只在%s連線時同步 所有 WiFi 連線都可以使用 使用逗號分割的名稱 (SSIDs) 表示的 WiFi 連線(留空則代表全部) + WiFi SSID 限制需要進一步設定 管理 + VPN 需要基礎網際網路連線 + 沒有基礎已驗證網際網路連線的 VPN 不足以執行同步(建議) + 沒有基礎已驗證網際網路連線的 VPN 仍可執行同步 認證 使用者帳號 + 密碼或應用程式專用密碼 + 應用程式專用密碼。]]> + 新密碼 您在伺服器上使用中的密碼 + 再次授權(OAuth) + 當存取權遭撤銷時使用 + 授權成功 + 用戶端憑證 + 沒有可用或已選取的憑證 + 安裝憑證 CalDAV 過去活動的時間限制 將會同步所有活動 @@ -203,7 +345,11 @@ 未設定預設提醒 當沒有提醒的活動需要加入預設提醒時,活動開始前多少分鐘出發提醒。留空則停用預設提醒。 管理行事曆的顏色 + 行事曆顏色會在每次同步時重設 + 行事曆顏色可由其他應用程式設定 設定活動的顔色 + 活動顏色已同步 + 活動顏色未同步 CardDAV 聯絡人群組的儲存格式 @@ -212,27 +358,68 @@ 建立通訊錄 + 伺服器可能不支援透過 CardDAV 建立通訊錄。 建立行事曆 + 預設時區(可選) + 可使用的行事曆項目 活動 事務 筆記/日誌 + 伺服器可能不支援透過 CalDAV 建立行事曆。 顔色 標題 存儲位置 + 描述(可選) 建立 + 聯絡人 + 活動 + 待辦事項 刪除行事曆或工作清單 + 此收藏(%s)及其所有資料將被永久移除,包括本機與伺服器上的內容。 同步 + 已啟用同步 + 已停用同步 + 唯讀 + 唯讀(由伺服器設定) + 唯讀(由設定決定) + 唯讀(僅限本機) + 讀取/寫入 標題 描述 + 擁有者 + Push support + 伺服器宣告支援推播 + 於 %1$s 訂閱,於 %2$s 到期 + 上次同步(%s) + 位址(URL) 除錯訊息 ZIP 壓縮檔 + 包含除錯資訊與日誌 + 分享此封存檔以傳輸至電腦、透過電子郵件傳送,或附加至支援服務單。 + 分享封存檔 + 已將除錯資訊附加至此訊息(需要接收應用程式支援附件)。 HTTP 錯誤 伺服器錯誤 WebDAV 錯誤 讀寫錯誤 + 伺服器不允許執行請求的操作類型。 + 發生伺服器端問題。請聯絡您的伺服器支援人員。 + 發生非預期的錯誤。請檢視除錯資訊以取得詳細內容。 + 顯示詳細訊息 + 已收集除錯資訊 + 相關資源 + 與問題相關 + 遠端資源: + 本機資源: + 日誌 + 可用詳細日誌 + 更多技術資訊可在日誌中取得。 + 拷貝URL + 隱私權通知 + 日誌和除錯資訊可能包含私人資訊,請在公開分享時注意。 發生錯誤 HTTP 發生錯誤 @@ -240,18 +427,25 @@ 顯示細節 WebDAV 掛載 + 已使用配額:%1$s / 可用配額:%2$s + 分享内容 取消掛載 新增 WebDAV 掛載 只要新增對應的 WebDAV 掛載就可以直接存取你的雲端檔案! WebDAV 如何運作請見文件。]]> 顯示名稱 WebDAV 網址 + 無效 URL + 掛載點和顯示名稱 認證 使用者帳號 密碼 + 使用者名稱(可選) + 密碼(可選) 新增掛載 此網址沒有 WebDAV 服務 移除掛點點 + 連線詳細資訊將會遺失,但不會刪除任何檔案。 正在存取 WebDAV 檔案 正在下載 WebDAV 檔案 正在上傳檔案至 WebDAV @@ -259,15 +453,24 @@ DAVx⁵ 權限 需要額外的權限 + %s過舊 + 最低需求版本:%1$s 鑒權失敗(你需要檢查登錄憑證) 網際網絡或者輸入輸出錯誤——%s HTTP伺服器錯誤——%s 資料庫錯誤——%s - 查閲項目 + 非嚴重錯誤(已達到最大重試次數) 收到了無效的聯絡人 - 收到了無效的事件 + 收到了無效的活動 收到了無效的任務 略過了一個或多個無效的資料 + 同步處理中 + 遠端資料已變更 + 全部同步 + 同步所有帳戶 + 標示的同步按鈕 + 同步按鈕圖示 + 點擊以手動執行同步。 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c37a3aef3..fd48c9c3e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -405,10 +405,11 @@ 服务器错误 WebDAV错误 I/O错误 - 该请求已被拒绝。 请检查涉及的资源和调试信息,以了解详情。 - 所请求的资源不再存在。请检查涉及的资源和调试信息,以了解详情。 + 服务器拒绝了该请求 + 所请求的资源(不再存在)不存在。 + 服务器不允许请求的操作类型。 发生服务器端问题。 请联系您的服务器支持 - 发生意外错误。 查看调试信息以获取详细信息。 + 发生意外错误。详情见调试信息。 查看细节 已收集调试信息 所涉资源 @@ -418,8 +419,11 @@ 日志 详细日志可用 查看日志 + 复制 URL + 查看资源 隐私声明 日志和调试信息可能包含私密信息。公开分享时请意识到这一点 + 无法查看资源 出现错误 出现 HTTP 错误 @@ -460,7 +464,6 @@ HTTP 服务器错误 – %s 本地存储错误 – %s 软错误(达到最大重试次数) - 显示项目 从服务器收到无效的通讯录 从服务器收到无效的日历事件 从服务器收到无效的任务项 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5b340ae8..6ea89a193 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -476,8 +476,11 @@ Logs Verbose logs are available View logs + Copy URL + Inspect resource Privacy notice Logs and debug info may contain private information. Please be aware of this when sharing publicly. + Unable to view resource An error has occurred. @@ -522,7 +525,6 @@ HTTP server error – %s Local storage error – %s Soft error (max retries reached) - View item Received invalid contact from server Received invalid event from server Received invalid task from server diff --git a/app/src/test/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilderTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilderTest.kt new file mode 100644 index 000000000..8292d895d --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilderTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.content.ContentValues +import android.content.Entity +import android.provider.CalendarContract.Events +import android.provider.CalendarContract.Reminders +import androidx.core.content.contentValuesOf +import at.bitfire.synctools.storage.calendar.EventAndExceptions +import at.bitfire.synctools.test.assertEntitiesEqual +import at.bitfire.synctools.test.assertEventAndExceptionsEqual +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.ConscryptMode + +@RunWith(RobolectricTestRunner::class) +@ConscryptMode(ConscryptMode.Mode.OFF) // required because main project uses Conscrypt, but unit tests do not +class DefaultReminderBuilderTest { + + val builder = DefaultReminderBuilder(minBefore = 15) + + @Test + fun `add() adds to main event and exceptions`() { + val event = EventAndExceptions( + main = Entity(ContentValues()), + exceptions = listOf( + Entity(ContentValues()) + ) + ) + builder.add(to = event) + assertEventAndExceptionsEqual( + EventAndExceptions( + main = Entity(ContentValues()).apply { + addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.MINUTES to 15, + Reminders.METHOD to Reminders.METHOD_ALERT + )) + }, + exceptions = listOf( + Entity(ContentValues()).apply { + addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.MINUTES to 15, + Reminders.METHOD to Reminders.METHOD_ALERT + )) + } + ) + ), + event + ) + } + + @Test + fun `addToEvent() adds to non-all-day event without other reminder`() { + val entity = Entity(ContentValues()) + builder.addToEvent(entity) + assertEntitiesEqual(Entity(ContentValues()).apply { + addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.MINUTES to 15, + Reminders.METHOD to Reminders.METHOD_ALERT + )) + }, entity) + } + + @Test + fun `addToEvent() doesn't add to all-day event`() { + val entity = Entity(contentValuesOf( + Events.ALL_DAY to 1 + )) + builder.addToEvent(entity) + assertFalse(entity.subValues.any { it.uri == Reminders.CONTENT_URI }) + } + + @Test + fun `addToEvent() doesn't add to event with another reminder`() { + val entity = Entity(ContentValues()).apply { + addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.MINUTES to 30, + Reminders.METHOD to Reminders.METHOD_ALERT + )) + } + builder.addToEvent(entity) + assertEntitiesEqual(Entity(ContentValues()).apply { + addSubValue(Reminders.CONTENT_URI, contentValuesOf( + Reminders.MINUTES to 30, + Reminders.METHOD to Reminders.METHOD_ALERT + )) + }, entity) + } + +} \ No newline at end of file diff --git a/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt index 8f7bff053..8e8519770 100644 --- a/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt +++ b/app/src/test/kotlin/at/bitfire/davdroid/sync/SyncExceptionTest.kt @@ -16,8 +16,8 @@ class SyncExceptionTest { @Test fun testWrapWithLocalResource_LocalResource_Exception() { - val outer = mockk>() - val inner = mockk>() + val outer = mockk() + val inner = mockk() val e = Exception() val result = assertSyncException { @@ -34,8 +34,8 @@ class SyncExceptionTest { @Test fun testWrapWithLocalResource_LocalResource_SyncException() { - val outer = mockk>() - val inner = mockk>() + val outer = mockk() + val inner = mockk() val e = SyncException(Exception()) val result = assertSyncException { @@ -52,7 +52,7 @@ class SyncExceptionTest { @Test fun testWrapWithLocalResource_RemoteResource_Exception() { - val local = mockk>() + val local = mockk() val remote = mockk() val e = Exception() @@ -71,7 +71,7 @@ class SyncExceptionTest { @Test fun testWrapWithLocalResource_RemoteResource_SyncException() { - val local = mockk>() + val local = mockk() val remote = mockk() val e = SyncException(Exception()) @@ -92,7 +92,7 @@ class SyncExceptionTest { @Test fun testWrapWithRemoteResource_LocalResource_Exception() { val remote = mockk() - val local = mockk>() + val local = mockk() val e = Exception() val result = assertSyncException { @@ -111,7 +111,7 @@ class SyncExceptionTest { @Test fun testWrapWithRemoteResource_LocalResource_SyncException() { val remote = mockk() - val local = mockk>() + val local = mockk() val e = SyncException(Exception()) val result = assertSyncException { diff --git a/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/util/DavUtilsTest.kt similarity index 62% rename from app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt rename to app/src/test/kotlin/at/bitfire/davdroid/util/DavUtilsTest.kt index 0b96e8459..799c69e87 100644 --- a/app/src/test/kotlin/at/bitfire/davdroid/DavUtilsTest.kt +++ b/app/src/test/kotlin/at/bitfire/davdroid/util/DavUtilsTest.kt @@ -2,14 +2,12 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ -package at.bitfire.davdroid +package at.bitfire.davdroid.util -import at.bitfire.davdroid.util.DavUtils import at.bitfire.davdroid.util.DavUtils.lastSegment import at.bitfire.davdroid.util.DavUtils.parent import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType -import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Test @@ -28,14 +26,39 @@ class DavUtilsTest { assertEquals("#000000FF", DavUtils.ARGBtoCalDAVColor(0xFF000000.toInt())) } + @Test + fun `fileNameFromUid (good uid)`() { + assertEquals("good-uid.txt", DavUtils.fileNameFromUid("good-uid", "txt")) + } + + @Test + fun `fileNameFromUid (bad uid)`() { + assertEquals("new-uuid.txt", DavUtils.fileNameFromUid("bad\\uid", "txt", generateUuid = { "new-uuid" })) + } + + @Test + fun `generateUidIfNecessary (existing uid)`() { + assertEquals( + DavUtils.UidGenerationResult("existing", generated = false), + DavUtils.generateUidIfNecessary("existing") + ) + } + + @Test + fun `generateUidIfNecessary (no existing uid)`() { + assertEquals( + DavUtils.UidGenerationResult("new-uuid", generated = true), + DavUtils.generateUidIfNecessary(null, generateUuid = { "new-uuid" }) + ) + } @Test fun testHttpUrl_LastSegment() { val exampleURL = "http://example.com/" - Assert.assertEquals("/", exampleURL.toHttpUrl().lastSegment) - Assert.assertEquals("dir", (exampleURL + "dir").toHttpUrl().lastSegment) - Assert.assertEquals("dir", (exampleURL + "dir/").toHttpUrl().lastSegment) - Assert.assertEquals("file.html", (exampleURL + "dir/file.html").toHttpUrl().lastSegment) + assertEquals("/", exampleURL.toHttpUrl().lastSegment) + assertEquals("dir", (exampleURL + "dir").toHttpUrl().lastSegment) + assertEquals("dir", (exampleURL + "dir/").toHttpUrl().lastSegment) + assertEquals("file.html", (exampleURL + "dir/file.html").toHttpUrl().lastSegment) } @Test @@ -53,4 +76,4 @@ class DavUtilsTest { assertEquals("http://example.com/".toHttpUrl(), "http://example.com".toHttpUrl().parent()) } -} +} \ No newline at end of file diff --git a/fastlane/metadata/android/pt-rBR/full_description.txt b/fastlane/metadata/android/pt-rBR/full_description.txt new file mode 100644 index 000000000..c9244cfb4 --- /dev/null +++ b/fastlane/metadata/android/pt-rBR/full_description.txt @@ -0,0 +1,5 @@ +O DAVx⁵ é um aplicativo de gerenciamento e sincronização de CalDAV/CardDAV para Android que se integra nativamente com aplicativos de calendário e de contatos do Android. + +Use-o com o seu próprio servidor ou com um hospedeiro confiado para manter seus contatos, eventos, e tarefas sob seu controle. + +Para mais informações, e uma lista de servidores/serviços testados, dê uma olhada no site. diff --git a/fastlane/metadata/android/pt-rBR/short_description.txt b/fastlane/metadata/android/pt-rBR/short_description.txt new file mode 100644 index 000000000..8dc6c0e24 --- /dev/null +++ b/fastlane/metadata/android/pt-rBR/short_description.txt @@ -0,0 +1 @@ +Sincronização e cliente de CalDAV/CardDAV diff --git a/fastlane/metadata/android/pt/full_description.txt b/fastlane/metadata/android/pt/full_description.txt index fcc541f91..fc4f0db94 100644 --- a/fastlane/metadata/android/pt/full_description.txt +++ b/fastlane/metadata/android/pt/full_description.txt @@ -1,5 +1,5 @@ -DAVx⁵ é um aplicativo de gerenciamento e sincronização CalDAV/CardDAV para Android que se integra nativamente com aplicativos de calendário/contatos do Android. +A DAVx⁵ é uma aplicação de gerenciamento e sincronização CalDAV/CardDAV para Android que se integra nativamente com aplicativos de calendário/contactos do Android. -Use-o com seu próprio servidor ou com um host confiável para manter seus contatos, eventos e tarefas sob seu controle. +Use-a com seu próprio servidor ou com um hóspede confiável para manter seus contactos, eventos e tarefas sob seu controle. -Para mais informações e uma lista de servidores/serviços testados, dê uma olhada no site. +Para mais informações e uma lista de servidores/serviços testados, visite o sítio. diff --git a/fastlane/metadata/android/pt/short_description.txt b/fastlane/metadata/android/pt/short_description.txt index 719091308..8dc6c0e24 100644 --- a/fastlane/metadata/android/pt/short_description.txt +++ b/fastlane/metadata/android/pt/short_description.txt @@ -1 +1 @@ -Sincronização e Cliente CalDAV/CardDAV +Sincronização e cliente de CalDAV/CardDAV diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af4d6ba64..5f162448a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ # Comments apply to next line [versions] -android-agp = "8.13.0" +android-agp = "8.13.1" android-desugaring = "2.1.5" -androidx-activityCompose = "1.11.0" +androidx-activityCompose = "1.12.0" androidx-appcompat = "1.7.1" androidx-arch = "2.2.0" androidx-browser = "1.9.0" androidx-core = "1.17.0" androidx-hilt = "1.3.0" -androidx-lifecycle = "2.9.4" +androidx-lifecycle = "2.10.0" androidx-paging = "3.3.6" androidx-preference = "1.2.1" androidx-security = "1.1.0" @@ -17,27 +17,29 @@ androidx-test-core = "1.7.0" androidx-test-runner = "1.7.0" androidx-test-rules = "1.7.0" androidx-test-junit = "1.3.0" -androidx-work = "2.10.5" -bitfire-cert4android = "41009d48ed" -bitfire-dav4jvm = "f11523619b" -bitfire-synctools = "1a7f70b1a0" +androidx-work = "2.11.0" +bitfire-cert4android = "42d883e958" +bitfire-dav4jvm = "acd9bca096" +bitfire-synctools = "ad0c68d820" compose-accompanist = "0.37.3" -compose-bom = "2025.10.01" +compose-bom = "2025.11.01" confettikit = "0.6.0" +conscrypt = "2.5.3" dnsjava = "3.6.3" glance = "1.1.1" guava = "33.5.0-android" hilt = "2.57.2" # keep in sync with ksp version -kotlin = "2.2.20" +kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" -# see https://github.com/google/ksp/releases for version numbers -ksp = "2.2.20-2.0.3" +ksp = "2.3.3" +ktor = "3.3.3" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" -okhttp = "5.2.1" +okhttp = "5.3.2" openid-appauth = "0.11.1" -room = "2.8.2" +robolectric = "4.16" +room = "2.8.4" unifiedpush = "3.1.2" unifiedpush-fcm = "3.0.0" @@ -87,6 +89,7 @@ compose-materialIconsExtended = { module = "androidx.compose.material:material-i compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-toolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } confettikit = { module = "io.github.vinceglb:confettikit", version.ref = "confettikit" } +conscrypt = { module = "org.conscrypt:conscrypt-android", version.ref = "conscrypt" } dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" } glance-base = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } glance-material = { module = "androidx.glance:glance-material", version.ref = "glance" } @@ -98,6 +101,8 @@ junit = { module = "junit:junit", version = "4.13.2" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } @@ -106,6 +111,7 @@ okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = " okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } openid-appauth = { module = "net.openid:appauth", version.ref = "openid-appauth" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } room-base = { module = "androidx.room:room-ktx", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-paging = { module = "androidx.room:room-paging", version.ref = "room" }