From 76277dbfd571672973c6371c77ca5c48915ad532 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sun, 26 Oct 2025 10:20:57 +0100 Subject: [PATCH 01/53] Update Kotlin and KSP versions (#1776) - Update Kotlin version to 2.2.21 - Update KSP version to 2.3.0 - Remove specific Kotlin and KSP dependencies from Dependabot ignore list --- .github/dependabot.yml | 6 +----- gradle/libs.versions.toml | 5 ++--- 2 files changed, 3 insertions(+), 8 deletions(-) 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44947c7d6..05961bfe7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,10 +28,9 @@ 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.0" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" okhttp = "5.2.1" From 4412617079bc4f55ab884bbffbb430f382d39c89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:04:22 +0100 Subject: [PATCH 02/53] Bump the app-dependencies group across 1 directory with 7 updates (#1778) Bumps the app-dependencies group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | androidx.work:work-runtime-ktx | `2.10.5` | `2.11.0` | | androidx.work:work-testing | `2.10.5` | `2.11.0` | | androidx.room:room-ktx | `2.8.2` | `2.8.3` | | androidx.room:room-compiler | `2.8.2` | `2.8.3` | | androidx.room:room-paging | `2.8.2` | `2.8.3` | | androidx.room:room-runtime | `2.8.2` | `2.8.3` | | androidx.room:room-testing | `2.8.2` | `2.8.3` | Updates `androidx.work:work-runtime-ktx` from 2.10.5 to 2.11.0 Updates `androidx.work:work-testing` from 2.10.5 to 2.11.0 Updates `androidx.work:work-testing` from 2.10.5 to 2.11.0 Updates `androidx.room:room-ktx` from 2.8.2 to 2.8.3 Updates `androidx.room:room-compiler` from 2.8.2 to 2.8.3 Updates `androidx.room:room-paging` from 2.8.2 to 2.8.3 Updates `androidx.room:room-runtime` from 2.8.2 to 2.8.3 Updates `androidx.room:room-testing` from 2.8.2 to 2.8.3 Updates `androidx.room:room-compiler` from 2.8.2 to 2.8.3 Updates `androidx.room:room-paging` from 2.8.2 to 2.8.3 Updates `androidx.room:room-runtime` from 2.8.2 to 2.8.3 Updates `androidx.room:room-testing` from 2.8.2 to 2.8.3 --- updated-dependencies: - dependency-name: androidx.work:work-runtime-ktx dependency-version: 2.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.work:work-testing dependency-version: 2.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.work:work-testing dependency-version: 2.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.room:room-ktx dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-compiler dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-paging dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-runtime dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-testing dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-compiler dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-paging dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-runtime dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-testing dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05961bfe7..db93a64ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ 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" +androidx-work = "2.11.0" bitfire-cert4android = "41009d48ed" bitfire-dav4jvm = "f11523619b" bitfire-synctools = "1a7f70b1a0" @@ -35,7 +35,7 @@ mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" okhttp = "5.2.1" openid-appauth = "0.11.1" -room = "2.8.2" +room = "2.8.3" unifiedpush = "3.1.2" unifiedpush-fcm = "3.0.0" From 19458aa95c11344033fbbcd10f92b131487f9792 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sun, 26 Oct 2025 12:23:31 +0100 Subject: [PATCH 03/53] Delete .github/workflows/dependent-issues.yml Obsoleted by https://github.blog/changelog/2025-08-21-dependencies-on-issues/ --- .github/workflows/dependent-issues.yml | 55 -------------------------- 1 file changed, 55 deletions(-) delete mode 100644 .github/workflows/dependent-issues.yml 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 }} From 0304d7168a0bc7960468317df75e8596362d05ee Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 27 Oct 2025 11:15:11 +0100 Subject: [PATCH 04/53] Migrate to `SecureTextField` (#1191) * Using `SecureTextField` Signed-off-by: Arnau Mora Gras * `OutlinedSecureTextField` doesn't support `readOnly` * fixed string conversions * - Update AddWebdavMountScreen to use enabled instead of readOnly - Ensure onKeyboardAction checks canContinue before proceeding - Update UrlLogin and EmailLogin to ensure onKeyboardAction checks canContinue before proceeding - Update InputDialogs to ensure confirmEnabled is checked before proceeding * Use get() for deriving things from a mutable state --------- Signed-off-by: Arnau Mora Gras Co-authored-by: Ricki Hirner --- .../davdroid/ui/composable/InputDialogs.kt | 35 +++++++----------- .../ui/composable/PasswordTextField.kt | 37 ++++++++----------- .../davdroid/ui/setup/AdvancedLogin.kt | 11 +++--- .../davdroid/ui/setup/AdvancedLoginModel.kt | 11 ++---- .../bitfire/davdroid/ui/setup/EmailLogin.kt | 15 ++++---- .../davdroid/ui/setup/EmailLoginModel.kt | 14 +++---- .../at/bitfire/davdroid/ui/setup/UrlLogin.kt | 15 ++++---- .../davdroid/ui/setup/UrlLoginModel.kt | 14 +++---- .../davdroid/ui/webdav/AddWebdavMountModel.kt | 9 ++--- .../ui/webdav/AddWebdavMountScreen.kt | 26 ++++++------- .../bitfire/davdroid/util/SensitiveString.kt | 4 +- .../at/bitfire/davdroid/util/StringUtils.kt | 3 +- 12 files changed, 83 insertions(+), 111 deletions(-) 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/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/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('/')) From 4e4c0f5e313ac0a06214ad90b98c6c0ccb50a800 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Mon, 27 Oct 2025 11:15:21 +0100 Subject: [PATCH 05/53] Move debug info notification action to debug info screen button (#1730) * Update view item on sync error string * Remove view item action from notification * Show button in debug info screen to jump to problematic event resource * Move companion object to the end of activity class * Add local resource dump to intent * Add kdoc * Add some comments for not yet implemented resources * Don't export DebugInfoActivity * Send intent instead of URI and launch from DebugInfoActivity * Add option to view problematic contact * Extract intent builder logic to another method * Add option to view problematic contact * Minor changes for readability * Extract dump string creation to interface method * Pass Uri instead of intent and create view local resource intent in DebugInfoActivity * Use androids existing getContactLookupUri method * Remove extra variable * Remove obsolete val declaration * Rename dump to summary * Refactor code structure for local resource URI handling * Update code structure to use getDebugSummary for local resource summaries * Update exception handling in SyncNotificationManager Change the catch block to handle all `Throwable` exceptions instead of just `OutOfMemoryError`. This ensures that any potential issues arising from providing information about the local resource are caught and ignored. * Add "copy remote URL" action * Use string resource * Truncate contact, task, and event strings to 1000 characters * Fix tests * Minor changes - Replace `ContactsContract.RawContacts` with `RawContacts` in `LocalContact.kt` - Remove unnecessary newline in `LocalJtxICalObject.kt` --------- Co-authored-by: Ricki Hirner --- .../davdroid/sync/LocalTestResource.kt | 5 + .../davdroid/ui/DebugInfoActivityTest.kt | 2 +- app/src/main/AndroidManifest.xml | 2 +- .../bitfire/davdroid/resource/LocalContact.kt | 38 +++++- .../bitfire/davdroid/resource/LocalEvent.kt | 23 ++++ .../bitfire/davdroid/resource/LocalGroup.kt | 18 +++ .../davdroid/resource/LocalJtxICalObject.kt | 13 ++ .../davdroid/resource/LocalResource.kt | 19 +++ .../at/bitfire/davdroid/resource/LocalTask.kt | 30 +++++ .../davdroid/sync/SyncNotificationManager.kt | 51 +------ .../bitfire/davdroid/ui/DebugInfoActivity.kt | 126 ++++++++++++++---- .../at/bitfire/davdroid/ui/DebugInfoScreen.kt | 34 ++++- app/src/main/res/values/strings.xml | 4 +- 13 files changed, 284 insertions(+), 81 deletions(-) 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..f41b3e80e 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt @@ -4,6 +4,7 @@ package at.bitfire.davdroid.sync +import android.content.Context import at.bitfire.davdroid.resource.LocalResource import java.util.Optional @@ -36,4 +37,8 @@ class LocalTestResource: LocalResource { 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/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/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/resource/LocalContact.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt index 0dbe3e3f7..6f7d2b91c 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,6 +27,8 @@ import at.bitfire.vcard4android.AndroidContact import at.bitfire.vcard4android.AndroidContactFactory import at.bitfire.vcard4android.CachedGroupMembership import at.bitfire.vcard4android.Contact +import com.google.common.base.Ascii +import com.google.common.base.MoreObjects import java.io.FileNotFoundException import java.util.Optional import java.util.UUID @@ -30,8 +37,8 @@ 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 @@ -97,7 +104,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 +117,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) } @@ -139,6 +146,28 @@ 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 { + 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 +228,7 @@ class LocalContact: AndroidContact, LocalAddress { super.buildContact(builder, update) } + // factory object Factory: AndroidContactFactory { 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..549f998c4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt @@ -4,6 +4,9 @@ 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 @@ -12,6 +15,8 @@ import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2 import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.storage.calendar.AndroidEvent2 import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar +import com.google.common.base.Ascii +import com.google.common.base.MoreObjects import java.util.Optional import java.util.UUID @@ -171,4 +176,22 @@ class LocalEvent( )) } + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("scheduleTag", scheduleTag) + .add("flags", flags) + .add("event", + try { + Ascii.truncate(getCachedEvent().toString(), 1000, "…") + } catch (e: Exception) { + e + } + ).toString() + + override fun getViewUri(context: Context) = + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id) + } \ 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..9413c2a6b 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 @@ -24,6 +25,7 @@ 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 @@ -236,6 +238,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/LocalJtxICalObject.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt index 437a0a492..e7c16069a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -5,10 +5,12 @@ package at.bitfire.davdroid.resource import android.content.ContentValues +import android.content.Context import at.bitfire.ical4android.JtxCollection import at.bitfire.ical4android.JtxICalObject import at.bitfire.ical4android.JtxICalObjectFactory import at.techbee.jtx.JtxContract +import com.google.common.base.MoreObjects import java.util.Optional import kotlin.jvm.optionals.getOrNull @@ -72,4 +74,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) = null + } \ 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..e57b1084b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt @@ -4,6 +4,9 @@ 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 @@ -93,4 +96,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..95dda5314 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -4,13 +4,19 @@ package at.bitfire.davdroid.resource +import android.content.ContentUris import android.content.ContentValues +import android.content.Context +import android.net.Uri import androidx.core.content.contentValuesOf import at.bitfire.ical4android.DmfsTask import at.bitfire.ical4android.DmfsTaskFactory import at.bitfire.ical4android.DmfsTaskList import at.bitfire.ical4android.Task +import at.bitfire.ical4android.TaskProvider import at.bitfire.synctools.storage.BatchOperation +import com.google.common.base.Ascii +import com.google.common.base.MoreObjects import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.Optional import java.util.UUID @@ -121,6 +127,30 @@ class LocalTask: DmfsTask, LocalResource { throw NotImplementedError() } + override fun getDebugSummary() = + MoreObjects.toStringHelper(this) + .add("id", id) + .add("fileName", fileName) + .add("eTag", eTag) + .add("scheduleTag", scheduleTag) + .add("flags", flags) + .add("task", + try { + Ascii.truncate(task.toString(), 1000, "…") + } catch (e: Exception) { + e + } + ).toString() + + override fun getViewUri(context: Context): Uri? { + val idNotNull = id ?: return null + if (taskList.providerName == TaskProvider.ProviderName.OpenTasks) { + val contentUri = Tasks.getContentUri(taskList.providerName.authority) + return ContentUris.withAppendedId(contentUri, idNotNull) + } + return null + } + object Factory: DmfsTaskFactory { override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) = 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..8f9a205e9 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 @@ -20,21 +19,15 @@ import at.bitfire.dav4jvm.exception.UnauthorizedException import at.bitfire.davdroid.R import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.resource.LocalCollection -import at.bitfire.davdroid.resource.LocalContact -import at.bitfire.davdroid.resource.LocalEvent import at.bitfire.davdroid.resource.LocalResource -import at.bitfire.davdroid.resource.LocalTask import at.bitfire.davdroid.ui.DebugInfoActivity import at.bitfire.davdroid.ui.NotificationRegistry import at.bitfire.davdroid.ui.account.AccountSettingsActivity -import at.bitfire.ical4android.TaskProvider -import com.google.common.base.Ascii import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.HttpUrl -import org.dmfs.tasks.contract.TaskContract import java.io.IOException import java.util.logging.Level import java.util.logging.Logger @@ -122,7 +115,6 @@ class SyncNotificationManager @AssistedInject constructor( 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() } @@ -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/ui/DebugInfoActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt index 991b49fb9..5e913ebda 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,14 @@ 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 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 +42,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 +133,39 @@ 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 event, task or contact at given Uri. + * + * Note that only OpenTasks is supported as tasks provider. TasksOrg and jtxBoard + * do not support viewing tasks via intent-filter (yet). See also [at.bitfire.davdroid.sync.SyncNotificationManager.getLocalResourceUri] + */ + private fun buildViewLocalResourceIntent(uri: Uri): Intent? { + val activeTasksAuthority = tasksAppManager.get().currentProvider()?.authority + return when (uri.authority) { + ContactsContract.AUTHORITY -> + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, ContactsContract.Contacts.CONTENT_ITEM_TYPE) + } + + CalendarContract.AUTHORITY, activeTasksAuthority -> + Intent(Intent.ACTION_VIEW, uri) + + else -> null + } + } /** * Builder for [DebugInfoActivity] intents @@ -167,12 +205,20 @@ 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) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return this + } + fun withLogs(logs: String?): IntentBuilder { if (logs != null) intent.putExtra( @@ -197,4 +243,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 = "localResourceId" + + /** 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/DebugInfoScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt index d34223483..ad6c2cbd0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt @@ -36,6 +36,7 @@ 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 @@ -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/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 From 05f058ab3ff445f3fd7a94d91f81066079f1090b Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 27 Oct 2025 11:16:51 +0100 Subject: [PATCH 06/53] Version bump to 4.5.5-rc.1 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 58f7b603d..40b920e64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405050002 - versionName = "4.5.5-beta.1" + versionCode = 405050003 + versionName = "4.5.5-rc.1" base.archivesName = "davx5-ose-$versionName" From d6feda11426bda0f69586dc207d636d0687c2de1 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Oct 2025 11:12:10 +0100 Subject: [PATCH 07/53] Fix OkHttp3 crash in release builds (bitfireAT/davx5#712) - Add ProGuard rules to keep OkHttp3 IDN mapping classes --- app/proguard-rules-release.pro | 5 +++++ 1 file changed, 5 insertions(+) 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{ *; } From 66f6e48e3b5e87b302d16856a128818fbd6894f2 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Oct 2025 11:18:54 +0100 Subject: [PATCH 08/53] Fetch translations from Transifex --- .tx/config | 2 +- app/src/main/res/values-ar/strings.xml | 2 +- app/src/main/res/values-bg/strings.xml | 11 +- app/src/main/res/values-ca/strings.xml | 9 +- app/src/main/res/values-cs/strings.xml | 6 +- app/src/main/res/values-da/strings.xml | 17 +- app/src/main/res/values-de/strings.xml | 11 +- app/src/main/res/values-el/strings.xml | 6 +- app/src/main/res/values-en-rGB/strings.xml | 6 +- app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-et/strings.xml | 6 +- app/src/main/res/values-eu/strings.xml | 6 +- app/src/main/res/values-fa/strings.xml | 6 +- app/src/main/res/values-fr/strings.xml | 6 +- app/src/main/res/values-gl/strings.xml | 6 +- app/src/main/res/values-hr/strings.xml | 6 +- app/src/main/res/values-hu/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 6 +- app/src/main/res/values-ja/strings.xml | 9 +- app/src/main/res/values-ka/strings.xml | 5 - app/src/main/res/values-ko/strings.xml | 6 +- app/src/main/res/values-nb/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 13 +- app/src/main/res/values-pl/strings.xml | 130 ++++- app/src/main/res/values-pt-rBR/strings.xml | 482 ++++++++++++++++++ app/src/main/res/values-pt/strings.xml | 6 +- app/src/main/res/values-ro/strings.xml | 5 - app/src/main/res/values-ru/strings.xml | 13 +- app/src/main/res/values-sk/strings.xml | 2 +- app/src/main/res/values-sl/strings.xml | 2 +- app/src/main/res/values-sr/strings.xml | 2 +- app/src/main/res/values-sv/strings.xml | 6 +- app/src/main/res/values-szl/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 6 +- app/src/main/res/values-zh-rTW/strings.xml | 207 +++++++- app/src/main/res/values-zh/strings.xml | 6 +- .../android/pt-rBR/full_description.txt | 5 + .../android/pt-rBR/short_description.txt | 1 + 39 files changed, 889 insertions(+), 146 deletions(-) create mode 100644 app/src/main/res/values-pt-rBR/strings.xml create mode 100644 fastlane/metadata/android/pt-rBR/full_description.txt create mode 100644 fastlane/metadata/android/pt-rBR/short_description.txt 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/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..c75c466cd 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,6 +422,7 @@ Дневници Налични са подробни дневници Преглед + Копиране на адреса Съобщение за защита на личните данни Дневниците и информацията за отстраняване на грешки могат да съдържат лична информация. Имайте го предвид, когато ги споделяте публично. @@ -463,7 +465,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..13ae64558 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -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,6 +422,7 @@ Protokoll Ausführliches Protokoll verfügbar Logs anzeigen + URL kopieren Datenschutzhinweis Protokolle und Debug-Informationen können private Daten enthalten. Seien Sie sich dessen bewusst, wenn Sie diese öffentlich weitergeben. @@ -463,7 +465,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..6328e92bd 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 @@ -463,7 +464,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..5a4c4e346 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,482 @@ + + + + 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 + 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. + + 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..03766a855 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -393,10 +393,6 @@ 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 Recursos envolvidos @@ -406,6 +402,7 @@ Registros Registros descritivos disponíveis Visualizar logs + Copiar URL Ocorreu um erro. Ocorreu um erro de HTTP. @@ -442,7 +439,6 @@ 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 Evento inválido recebido do servidor Tarefa inválida recebida do servidor diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 05c06a124..15c861071 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -411,10 +411,6 @@ 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. - 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 Au fost colectate informații de depanare Resurse implicate @@ -466,7 +462,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..92e124ca0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,7 +1,7 @@ - Аккаунт не существует (больше) + Аккаунт (больше) не существует Адресная книга DAVx⁵ Не меняйте аккаунт здесь! Вместо этого используйте приложение для управления учетными записями. Удалить @@ -258,7 +258,7 @@ Добавить аккаунт Название аккаунта Использование апострофов (\'), как оказалось, вызывает проблемы на некоторых устройствах. - Укажите ваш адрес email в качестве названия аккаунта, поскольку Android будет его использовать в поле ORGANIZER для создаваемых событий. У вас не может быть двух аккаунтов с тем же именем. + Используйте свой адрес email в качестве названия аккаунта, поскольку Android будет использовать его в качестве поля ОРГАНИЗАТОР для создаваемых вами событий. Не допускается наличие двух аккаунтов с одинаковыми названиями. Метод группировки контактов: Название аккаунта обязательно Название аккаунта уже используется @@ -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..d8d2dc751 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -400,10 +400,6 @@ 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. Visa detaljer Felsökningsinformation har samlats in Inblandade resurser @@ -413,6 +409,7 @@ Loggar Utförliga loggar finns tillgängliga Visa loggar + Kopiera URL Integritetspolicy Loggar och felsökningsinformation kan innehålla privat information. Var medveten om detta när du delar offentligt. @@ -452,7 +449,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..8dc2772c9 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -405,10 +405,6 @@ 服务器错误 WebDAV错误 I/O错误 - 该请求已被拒绝。 请检查涉及的资源和调试信息,以了解详情。 - 所请求的资源不再存在。请检查涉及的资源和调试信息,以了解详情。 - 发生服务器端问题。 请联系您的服务器支持 - 发生意外错误。 查看调试信息以获取详细信息。 查看细节 已收集调试信息 所涉资源 @@ -418,6 +414,7 @@ 日志 详细日志可用 查看日志 + 复制 URL 隐私声明 日志和调试信息可能包含私密信息。公开分享时请意识到这一点 @@ -460,7 +457,6 @@ HTTP 服务器错误 – %s 本地存储错误 – %s 软错误(达到最大重试次数) - 显示项目 从服务器收到无效的通讯录 从服务器收到无效的日历事件 从服务器收到无效的任务项 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 From f0f9f58e49d22e3319c7dff709dfb59e17046c23 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 28 Oct 2025 11:20:00 +0100 Subject: [PATCH 09/53] Bump version to 4.5.5 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 40b920e64..45fd00520 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405050003 - versionName = "4.5.5-rc.1" + versionCode = 405050004 + versionName = "4.5.5" base.archivesName = "davx5-ose-$versionName" From b839cbfe7fe7dc7435663fe4b48337d3eab56e3a Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 29 Oct 2025 08:50:09 +0100 Subject: [PATCH 10/53] Simplify LocalResource interface (#1784) * Simplify LocalResource interface - Remove generic parameter from LocalResource interface - Update all implementations to reflect the change - Adjust related test cases and exception handling * Fix tests --- .../bitfire/davdroid/sync/LocalTestResource.kt | 3 +-- .../at/bitfire/davdroid/resource/LocalAddress.kt | 6 +++++- .../bitfire/davdroid/resource/LocalCollection.kt | 2 +- .../at/bitfire/davdroid/resource/LocalEvent.kt | 4 ++-- .../davdroid/resource/LocalJtxICalObject.kt | 4 ++-- .../bitfire/davdroid/resource/LocalResource.kt | 10 +--------- .../at/bitfire/davdroid/resource/LocalTask.kt | 4 ++-- .../at/bitfire/davdroid/sync/SyncException.kt | 8 ++++---- .../at/bitfire/davdroid/sync/SyncManager.kt | 6 +++--- .../davdroid/sync/SyncNotificationManager.kt | 4 ++-- .../bitfire/davdroid/sync/SyncExceptionTest.kt | 16 ++++++++-------- 11 files changed, 31 insertions(+), 36 deletions(-) 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 f41b3e80e..ab04a0f7b 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt @@ -8,7 +8,7 @@ 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 @@ -33,7 +33,6 @@ class LocalTestResource: LocalResource { this.flags = flags } - override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError() override fun deleteLocal() = throw NotImplementedError() override fun resetDeleted() = throw NotImplementedError() 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/LocalCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt index 5a4e95635..aa6982e13 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,7 @@ package at.bitfire.davdroid.resource -interface LocalCollection> { +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/LocalEvent.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt index 549f998c4..1218ea97a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt @@ -23,7 +23,7 @@ import java.util.UUID class LocalEvent( val recurringCalendar: AndroidRecurringCalendar, val androidEvent: AndroidEvent2 -) : LocalResource { +) : LocalResource { override val id: Long get() = androidEvent.id @@ -41,7 +41,7 @@ class LocalEvent( get() = androidEvent.flags - override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { + fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { val eventAndExceptions = LegacyAndroidEventBuilder2( calendar = androidEvent.calendar, event = data, 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 e7c16069a..b199aeefe 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -22,7 +22,7 @@ class LocalJtxICalObject( flags: Int ) : JtxICalObject(collection), - LocalResource { + LocalResource { init { @@ -52,7 +52,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 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 e57b1084b..98d98c8f7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt @@ -13,7 +13,7 @@ import java.util.Optional /** * Defines operations that are used by SyncManager for all sync data types. */ -interface LocalResource { +interface LocalResource { companion object { /** @@ -78,14 +78,6 @@ 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) - */ - fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) - /** * Deletes the data object from the content provider. */ 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 95dda5314..6435f4c01 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -21,7 +21,7 @@ import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.Optional import java.util.UUID -class LocalTask: DmfsTask, LocalResource { +class LocalTask: DmfsTask, LocalResource { companion object { const val COLUMN_ETAG = Tasks.SYNC1 @@ -100,7 +100,7 @@ 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) { this.fileName = fileName this.eTag = eTag this.scheduleTag = scheduleTag 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..fa03fce08 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -74,7 +74,7 @@ import javax.net.ssl.SSLHandshakeException * @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 dataType: SyncDataType, @@ -247,7 +247,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) { @@ -745,7 +745,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 8f9a205e9..0197f8013 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt @@ -111,7 +111,7 @@ 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 @@ -218,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) 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 { From a8c8a8d2e09a327e3e4d21770635ecc731acc6d6 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 29 Oct 2025 17:57:34 +0100 Subject: [PATCH 11/53] Refactor SEQUENCE and UID handling on successful uploads (#1785) * Define interfaces * [WIP] Refactor sequence and UID handling in event uploads - Refactor `generateUpload` method to return `GeneratedResource`. - Update `SyncManager` to handle `GeneratedResource`. - Implement sequence and UID management in `CalendarSyncManager`. - Remove redundant `prepareForUpload` method from `LocalEvent`. * Refactor sequence handling in uploads - Move UID validation to `DavUtils.isGoodFileBaseName` - Update sequence directly in iCalendar for group-scheduled events - Rename `resourceBaseName` to `suggestedBaseName` for clarity * Refactor sequence / UID handling in contact uploads - Update `LocalAddress` interface to include `updateUid` method - Modify `ContactsSyncManager` to handle UID generation and update - Remove redundant UID handling in `LocalContact` and `LocalGroup` - Adjust code style settings for right margin * Remove redundant UID update from `ContactsSyncManager` and `CalendarSyncManager` * Implement UID handling in `TasksSyncManager` for uploads * Update JtxSyncManager - Update `JtxSyncManager.kt` to implement the `generateUpload` function. - Update `LocalJtxICalObject.kt` to include the `updateUid` method for updating UIDs in the collection. * Remove deprecated `prepareForUpload` method from `LocalResource` - Remove `prepareForUpload` implementations from `LocalContact`, `LocalEvent`, `LocalGroup`, and `LocalTask` * Rename `isGoodFileBaseName` to `isGoodFileName` in `DavUtils` * Fix tests * Move UID generation logic to `DavUtils.generateUidIfNecessary` - Use `DavUtils.fileNameFromUid` for generating file names - Update `ContactsSyncManager`, `CalendarSyncManager`, `TasksSyncManager`, and `JtxSyncManager` to use new utility methods * Add tests for DavUtils * Some tests * Refactor onSuccessfulUpload * Update KDoc * Logging * Remove unnecessary LocalEvent method * KDoc --- .../davdroid/resource/LocalEventTest.kt | 108 ------------------ .../davdroid/resource/LocalGroupTest.kt | 15 --- .../davdroid/sync/CalendarSyncManagerTest.kt | 103 +++++++++++++++++ .../davdroid/sync/LocalTestResource.kt | 5 +- .../bitfire/davdroid/sync/TestSyncManager.kt | 9 +- .../bitfire/davdroid/resource/LocalContact.kt | 27 ++--- .../bitfire/davdroid/resource/LocalEvent.kt | 74 +----------- .../bitfire/davdroid/resource/LocalGroup.kt | 29 ++--- .../davdroid/resource/LocalJtxICalObject.kt | 9 +- .../davdroid/resource/LocalResource.kt | 25 ++-- .../at/bitfire/davdroid/resource/LocalTask.kt | 26 ++--- .../davdroid/sync/CalendarSyncManager.kt | 56 ++++++--- .../davdroid/sync/ContactsSyncManager.kt | 67 ++++++----- .../davdroid/sync/GeneratedResource.kt | 34 ++++++ .../bitfire/davdroid/sync/JtxSyncManager.kt | 21 ++-- .../at/bitfire/davdroid/sync/SyncManager.kt | 75 +++++++----- .../bitfire/davdroid/sync/TasksSyncManager.kt | 30 +++-- .../at/bitfire/davdroid/util/DavUtils.kt | 64 +++++++++++ .../davdroid/{ => util}/DavUtilsTest.kt | 39 +++++-- 19 files changed, 445 insertions(+), 371 deletions(-) create mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt rename app/src/test/kotlin/at/bitfire/davdroid/{ => util}/DavUtilsTest.kt (62%) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt index 053f03547..5df0bb36a 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt @@ -29,7 +29,6 @@ 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 @@ -66,113 +65,6 @@ class LocalEventTest { } - @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 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/sync/CalendarSyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt new file mode 100644 index 000000000..024161d87 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import android.accounts.Account +import android.content.Context +import at.bitfire.davdroid.sync.account.TestAccount +import at.bitfire.ical4android.Event +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import net.fortuna.ical4j.model.property.DtStart +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) + + @Inject @ApplicationContext + lateinit var context: Context + + @Inject + lateinit var syncManagerFactory: CalendarSyncManager.Factory + + lateinit var account: Account + + @Before + fun setUp() { + hiltRule.inject() + account = TestAccount.create() + } + + @After + fun tearDown() { + TestAccount.remove(account) + } + + + @Test + fun generateUpload_existingUid() { + val result = syncManager().generateUpload(mockk(relaxed = true) { + every { getCachedEvent() } returns Event(uid = "existing-uid", dtStart = DtStart()) + }) + + assertEquals("existing-uid.ics", result.suggestedFileName) + assertTrue(result.onSuccessContext.uid.isEmpty) + + 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(mockk(relaxed = true) { + every { getCachedEvent() } returns Event(dtStart = DtStart()) + }) + + assertTrue(result.suggestedFileName.matches(UUID_FILENAME_REGEX)) + val uuid = result.suggestedFileName.removeSuffix(".ics") + + assertEquals(uuid, result.onSuccessContext.uid.get()) + + 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/LocalTestResource.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt index ab04a0f7b..dcb27e4cd 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/LocalTestResource.kt @@ -19,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) @@ -33,6 +31,9 @@ class LocalTestResource: LocalResource { this.flags = flags } + override fun updateUid(uid: String) { /* no-op */ } + override fun updateSequence(sequence: Int) = throw NotImplementedError() + override fun deleteLocal() = throw NotImplementedError() override fun resetDeleted() = 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..831fd2ad1 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt @@ -20,7 +20,6 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher import okhttp3.HttpUrl -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.junit.Assert.assertEquals @@ -76,9 +75,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/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt index 6f7d2b91c..a92d0b42a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt @@ -31,7 +31,6 @@ import com.google.common.base.Ascii import com.google.common.base.MoreObjects import java.io.FileNotFoundException import java.util.Optional -import java.util.UUID import kotlin.jvm.optionals.getOrNull class LocalContact: AndroidContact, LocalAddress { @@ -70,25 +69,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. */ @@ -137,6 +117,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() } 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 1218ea97a..716e4f513 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt @@ -18,7 +18,6 @@ import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar import com.google.common.base.Ascii import com.google.common.base.MoreObjects import java.util.Optional -import java.util.UUID class LocalEvent( val recurringCalendar: AndroidRecurringCalendar, @@ -75,80 +74,19 @@ class LocalEvent( 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?) { + override 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 updateUid(uid: String) { + androidEvent.update(contentValuesOf( + Events.UID_2445 to uid + )) } + override fun clearDirty(fileName: Optional, eTag: String?, scheduleTag: String?) { val values = contentValuesOf( Events.DIRTY to 0, 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 9413c2a6b..e0bf45515 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt @@ -16,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 @@ -28,7 +27,6 @@ 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 @@ -142,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") @@ -229,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() } 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 b199aeefe..b6a5b38cc 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -20,10 +20,7 @@ class LocalJtxICalObject( eTag: String?, scheduleTag: String?, flags: Int -) : - JtxICalObject(collection), - LocalResource { - +) : JtxICalObject(collection), LocalResource { init { this.fileName = fileName @@ -62,6 +59,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) } 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 98d98c8f7..1ff29fcdf 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt @@ -47,18 +47,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). @@ -78,6 +66,19 @@ interface LocalResource { */ fun updateFlags(flags: Int) + /** + * Updates the local UID of the resource in the content provider. + * Usually used to persist a UID that has been created during an upload of a locally created resource. + */ + fun updateUid(uid: String) + + /** + * Updates the local SEQUENCE of the resource in the content provider. + * + * @throws NotImplementedError if SEQUENCE update is not supported + */ + fun updateSequence(sequence: Int) + /** * Deletes the data object from the content provider. */ 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 6435f4c01..ba74f4c6a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -19,7 +19,6 @@ import com.google.common.base.Ascii import com.google.common.base.MoreObjects import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.Optional -import java.util.UUID class LocalTask: DmfsTask, LocalResource { @@ -65,24 +64,6 @@ class LocalTask: DmfsTask, LocalResource { /* 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") @@ -119,6 +100,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() } 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..7bcb94a6e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -27,6 +27,7 @@ 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 @@ -42,7 +43,6 @@ import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.property.Action import okhttp3.HttpUrl -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.Reader import java.io.StringReader @@ -178,24 +178,52 @@ 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 event = resource.getCachedEvent() + logger.log(Level.FINE, "Preparing upload of iCalendar ${resource.fileName}", event) - // update local SEQUENCE to new value after successful upload - local.updateSequence(local.getCachedEvent().sequence) - } + // get/create UID + val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(event.uid) + if (uidIsGenerated) + event.uid = uid - override fun generateUpload(resource: LocalEvent): RequestBody = - SyncException.wrapWithLocalResource(resource) { - val event = resource.eventToUpload() - logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event) + // Increase sequence, if necessary: + // - 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 groupScheduled = event.attendees.isNotEmpty() + val weAreOrganizer = event.isOrganizer == true + val sequence = event.sequence + val newSequence: Optional = when { + // first upload, set to 0 after upload + sequence == null -> + Optional.of(0) - // 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) + // re-upload of group-scheduled event (and we're ORGANIZER), increase sequence in iCalendar and after upload + groupScheduled && weAreOrganizer -> { + event.sequence = sequence + 1 + Optional.of(sequence + 1) + } + + // standard re-upload, don't update sequence + else -> + Optional.empty() } + // generate iCalendar and convert to request body + val iCalWriter = StringWriter() + EventWriter(Constants.iCalProdId).write(event, iCalWriter) + val requestBody = iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + + return GeneratedResource( + suggestedFileName = DavUtils.fileNameFromUid(uid, "ics"), + requestBody = requestBody, + onSuccessContext = GeneratedResource.OnSuccessContext( + uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty(), + sequence = newSequence + ) + ) + } + override suspend fun listAllRemote(callback: MultiResponseCallback) { // calculate time range limits val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays -> 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..0a221f6ab 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -51,7 +51,6 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType import okhttp3.Request -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream import java.io.IOException @@ -272,35 +271,45 @@ 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.fileName}", contact) + + // get/create UID + val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(contact.uid) + if (uidIsGenerated) + contact.uid = 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), + onSuccessContext = GeneratedResource.OnSuccessContext( + uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty() + ) + ) + } override suspend fun listAllRemote(callback: MultiResponseCallback) = SyncException.wrapWithRemoteResourceSuspending(collection.url) { 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..c899a4cfe --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt @@ -0,0 +1,34 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.sync + +import okhttp3.RequestBody +import java.util.Optional + +/** + * 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 + */ +class GeneratedResource( + val suggestedFileName: String, + val requestBody: RequestBody, + val onSuccessContext: OnSuccessContext +) { + + /** + * Contains information that has been created for a [GeneratedResource], but has not been saved yet. + * + * @param uid new UID to persist on successful upload (empty: UID not modified) + * @param sequence new SEQUENCE to persist on successful upload (empty: SEQUENCE not modified) + */ + data class OnSuccessContext( + val uid: Optional = Optional.empty(), + val sequence: Optional = Optional.empty() + ) + +} \ 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..c9b9455ae 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -25,6 +25,7 @@ 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 @@ -34,7 +35,6 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream import java.io.Reader @@ -96,13 +96,18 @@ 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.uid}") + + val os = ByteArrayOutputStream() + resource.write(os, Constants.iCalProdId) + + return GeneratedResource( + suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"), + requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8), + onSuccessContext = GeneratedResource.OnSuccessContext() // nothing special to update after upload + ) + } override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT 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 fa03fce08..cf6a9d97a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -8,6 +8,7 @@ import android.accounts.Account import android.content.Context import android.os.DeadObjectException import android.os.RemoteException +import androidx.annotation.VisibleForTesting import at.bitfire.dav4jvm.DavCollection import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.Error @@ -46,7 +47,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.HttpUrl -import okhttp3.RequestBody import java.io.IOException import java.net.HttpURLConnection import java.security.cert.CertificateException @@ -388,15 +388,10 @@ abstract class SyncManager $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 +415,8 @@ abstract class SyncManager $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 +440,8 @@ abstract class SyncManager.`. 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/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 From 98aefc4feea884015f1c8b64fa40c00a9711b773 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 30 Oct 2025 14:28:05 +0100 Subject: [PATCH 12/53] Synchronize without `Event` data class (#1783) * [WIP] Proof of concept for syncing without `Event` data class * Replace AndroidEvent2 with EventsContract * Update synctools, refactor upload logic in `CalendarSyncManager` * KDoc * Update UID immediately in `ContactsSyncManager`, `CalendarSyncManager`, and `TasksSyncManager` - Remove `OnSuccessContext.uid` from `GeneratedResource` * Minor changes * Handle multiple events in a single iCalendar - Rename `processVEvent` to `processICalendar` - Add default alarm for non-full-day events again - Prevent NPE on null flags (used for debug info) * Fix tests --- .../davdroid/resource/LocalCalendarTest.kt | 157 ++++++++---------- .../davdroid/resource/LocalEventTest.kt | 157 ------------------ .../davdroid/sync/CalendarSyncManagerTest.kt | 74 +++++++-- .../kotlin/at/bitfire/davdroid/Constants.kt | 3 +- .../repository/DavCollectionRepository.kt | 3 +- .../davdroid/resource/LocalCalendar.kt | 62 +++---- .../bitfire/davdroid/resource/LocalContact.kt | 7 +- .../bitfire/davdroid/resource/LocalEvent.kt | 99 ++++------- .../at/bitfire/davdroid/resource/LocalTask.kt | 7 +- .../migration/AccountSettingsMigration12.kt | 6 +- .../davdroid/sync/CalendarSyncManager.kt | 151 ++++++++--------- .../davdroid/sync/ContactsSyncManager.kt | 12 +- .../davdroid/sync/GeneratedResource.kt | 12 +- .../bitfire/davdroid/sync/JtxSyncManager.kt | 8 +- .../at/bitfire/davdroid/sync/SyncManager.kt | 11 +- .../bitfire/davdroid/sync/TasksSyncManager.kt | 16 +- gradle/libs.versions.toml | 2 +- 17 files changed, 306 insertions(+), 481 deletions(-) delete mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt 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..63465cb55 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,16 @@ 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.EventAndExceptions +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 @@ -74,90 +67,83 @@ class LocalCalendarTest { @Test - fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() { + fun testDeleteDirtyEventsWithoutInstances_NoInstances() { // 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) + val now = System.currentTimeMillis() + val id = calendar.add(EventAndExceptions( + main = Entity(contentValuesOf( + Events._SYNC_ID to "event-without-instances", + Events.CALENDAR_ID to calendar.androidCalendar.id, + Events.ALL_DAY to 0, + Events.DTSTART to now, + Events.RRULE to "FREQ=DAILY;COUNT=3", + Events.DIRTY to 1 + )), + exceptions = listOf( + Entity(contentValuesOf( // first instance: cancelled + Events.CALENDAR_ID to calendar.androidCalendar.id, + Events.ORIGINAL_INSTANCE_TIME to now, + Events.ORIGINAL_ALL_DAY to 0, + Events.DTSTART to now + 86400000, + Events.STATUS to Events.STATUS_CANCELED + )), + Entity(contentValuesOf( // second instance: cancelled + Events.CALENDAR_ID to calendar.androidCalendar.id, + Events.ORIGINAL_INSTANCE_TIME to now + 86400000, + Events.ORIGINAL_ALL_DAY to 0, + Events.DTSTART to now + 86400000, + Events.STATUS to Events.STATUS_CANCELED + )), + Entity(contentValuesOf( // third and last instance: cancelled + Events.CALENDAR_ID to calendar.androidCalendar.id, + Events.ORIGINAL_INSTANCE_TIME to now + 2*86400000, + Events.ORIGINAL_ALL_DAY to 0, + Events.DTSTART to now + 2*86400000, + Events.STATUS to Events.STATUS_CANCELED + )) + ) + )) // 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)) - } + val result = calendar.androidCalendar.getEventRow(id)!! + assertEquals(1, result.getAsInteger(Events.DELETED)) } @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) + fun testDeleteDirtyEventsWithoutInstances_OneInstanceRemaining() { + // create recurring event with only deleted/cancelled instances + val now = System.currentTimeMillis() + val id = calendar.add(EventAndExceptions( + main = Entity(contentValuesOf( + Events._SYNC_ID to "event-with-instances", + Events.CALENDAR_ID to calendar.androidCalendar.id, + Events.ALL_DAY to 0, + Events.DTSTART to now, + Events.RRULE to "FREQ=DAILY;COUNT=2", + Events.DIRTY to 1 + )), + exceptions = listOf( + Entity(contentValuesOf( // first instance: cancelled + Events.CALENDAR_ID to calendar.androidCalendar.id, + Events.ORIGINAL_INSTANCE_TIME to now, + Events.ORIGINAL_ALL_DAY to 0, + Events.DTSTART to now + 86400000, + Events.STATUS to Events.STATUS_CANCELED + )) + // however second instance is NOT cancelled + ) + )) // 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)) - } + // verify that event is still marked as dirty, but not as deleted + val result = calendar.androidCalendar.getEventRow(id)!! + assertEquals(1, result.getAsInteger(Events.DIRTY)) + assertEquals(0, result.getAsInteger(Events.DELETED)) } /** @@ -167,15 +153,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 +197,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 5df0bb36a..000000000 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalEventTest.kt +++ /dev/null @@ -1,157 +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 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 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/sync/CalendarSyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt index 024161d87..b9c9ba815 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/CalendarSyncManagerTest.kt @@ -4,16 +4,27 @@ 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.Event +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.every import io.mockk.mockk -import net.fortuna.ical4j.model.property.DtStart import okio.Buffer import org.junit.After import org.junit.Assert.assertEquals @@ -29,34 +40,64 @@ 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 generateUpload_existingUid() { - val result = syncManager().generateUpload(mockk(relaxed = true) { - every { getCachedEvent() } returns Event(uid = "existing-uid", dtStart = DtStart()) - }) + 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) - assertTrue(result.onSuccessContext.uid.isEmpty) val iCal = Buffer().also { result.requestBody.writeTo(it) @@ -66,19 +107,26 @@ class CalendarSyncManagerTest { @Test fun generateUpload_noUid() { - val result = syncManager().generateUpload(mockk(relaxed = true) { - every { getCachedEvent() } returns Event(dtStart = DtStart()) - }) + 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") - assertEquals(uuid, result.onSuccessContext.uid.get()) - val iCal = Buffer().also { result.requestBody.writeTo(it) }.readString(Charsets.UTF_8) assertTrue(iCal.contains("UID:$uuid\r\n")) + } 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/repository/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt index ddb4a70a3..2a1247d39 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -43,6 +43,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 @@ -376,7 +377,7 @@ class DavCollectionRepository @Inject constructor( Calendar( PropertyList().apply { add(Version.VERSION_2_0) - add(Constants.iCalProdId) + add(ProdId(Constants.iCalProdId)) }, ComponentList( listOf(vTimezone) 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..e54186f8c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -7,15 +7,15 @@ 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 +60,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 +115,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,7 +131,7 @@ 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()) ) } @@ -152,7 +142,7 @@ class LocalCalendar @AssistedInject constructor( logger.info("Processing deleted exceptions") androidCalendar.iterateEventRows( - arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE), + arrayOf(Events._ID, Events.ORIGINAL_ID, EventsContract.COLUMN_SEQUENCE), "${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL", arrayOf(androidCalendar.id.toString()) ) { values -> @@ -164,12 +154,12 @@ class LocalCalendar @AssistedInject constructor( 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 + val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(EventsContract.COLUMN_SEQUENCE)) + val originalSequence = originalEventValues?.getAsInteger(EventsContract.COLUMN_SEQUENCE) ?: 0 batch += BatchOperation.CpoBuilder .newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account)) - .withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1) + .withValue(EventsContract.COLUMN_SEQUENCE, originalSequence + 1) .withValue(Events.DIRTY, 1) // completely remove deleted exception @@ -180,7 +170,7 @@ class LocalCalendar @AssistedInject constructor( // process dirty exceptions logger.info("Processing dirty exceptions") androidCalendar.iterateEventRows( - arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE), + arrayOf(Events._ID, Events.ORIGINAL_ID, EventsContract.COLUMN_SEQUENCE), "${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL", arrayOf(androidCalendar.id.toString()) ) { values -> @@ -188,7 +178,7 @@ class LocalCalendar @AssistedInject constructor( 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 sequence = values.getAsInteger(EventsContract.COLUMN_SEQUENCE) ?: 0 val batch = CalendarBatchOperation(androidCalendar.client) @@ -200,7 +190,7 @@ class LocalCalendar @AssistedInject constructor( // enqueue: increase exception SEQUENCE and set DIRTY to 0 batch += BatchOperation.CpoBuilder .newUpdate(androidCalendar.eventUri(id)) - .withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1) + .withValue(EventsContract.COLUMN_SEQUENCE, sequence + 1) .withValue(Events.DIRTY, 0) batch.commit() 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 a92d0b42a..6aa28a574 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt @@ -27,7 +27,6 @@ import at.bitfire.vcard4android.AndroidContact import at.bitfire.vcard4android.AndroidContactFactory import at.bitfire.vcard4android.CachedGroupMembership import at.bitfire.vcard4android.Contact -import com.google.common.base.Ascii import com.google.common.base.MoreObjects import java.io.FileNotFoundException import java.util.Optional @@ -139,13 +138,15 @@ class LocalContact: AndroidContact, LocalAddress { .add("fileName", fileName) .add("eTag", eTag) .add("flags", flags) - .add("contact", + /*.add("contact", try { + // too dangerous, may contain unknown properties and cause another OOM Ascii.truncate(getContact().toString(), 1000, "…") } catch (e: Exception) { e } - ).toString() + )*/ + .toString() override fun getViewUri(context: Context): Uri? = id?.let { idNotNull -> 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 716e4f513..55bdf9dd3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt @@ -9,98 +9,70 @@ 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 com.google.common.base.Ascii +import at.bitfire.synctools.storage.calendar.EventAndExceptions +import at.bitfire.synctools.storage.calendar.EventsContract import com.google.common.base.MoreObjects import java.util.Optional class LocalEvent( val recurringCalendar: AndroidRecurringCalendar, - val androidEvent: AndroidEvent2 + 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 - fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) { - val eventAndExceptions = LegacyAndroidEventBuilder2( - calendar = androidEvent.calendar, - event = data, - syncId = fileName, - eTag = eTag, - scheduleTag = scheduleTag, - flags = flags - ).build() - recurringCalendar.updateEventAndExceptions(id, eventAndExceptions) - } - - - private var _event: Event? = null - /** - * Retrieves the event from the content provider and converts it to a legacy data object. - * - * Caches the result: the content provider is only queried at the first call and then - * this method always returns the same object. - * - * @throws LocalStorageException if there is no local event with the ID from [androidEvent] - */ - @Synchronized - fun getCachedEvent(): Event { - _event?.let { return it } - - val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar) - val event = legacyCalendar.getEvent(androidEvent.id) - ?: throw LocalStorageException("Event ${androidEvent.id} not found") - - _event = event - return event - } - - override fun updateSequence(sequence: Int) { - androidEvent.update(contentValuesOf( - AndroidEvent2.COLUMN_SEQUENCE to sequence - )) - } - - override fun updateUid(uid: String) { - androidEvent.update(contentValuesOf( - Events.UID_2445 to uid - )) + fun update(data: EventAndExceptions) { + recurringCalendar.updateEventAndExceptions(id, data) } 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 )) } @@ -109,7 +81,7 @@ class LocalEvent( } override fun resetDeleted() { - androidEvent.update(contentValuesOf( + calendar.updateEventRow(id, contentValuesOf( Events.DELETED to 0 )) } @@ -123,7 +95,8 @@ class LocalEvent( .add("flags", flags) .add("event", try { - Ascii.truncate(getCachedEvent().toString(), 1000, "…") + // only include truncated main event row (won't contain attachments, unknown properties etc.) + androidEvent.main.entityValues.toString().take(1000) } catch (e: Exception) { e } 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 ba74f4c6a..2148c19c4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -15,7 +15,6 @@ import at.bitfire.ical4android.DmfsTaskList import at.bitfire.ical4android.Task import at.bitfire.ical4android.TaskProvider import at.bitfire.synctools.storage.BatchOperation -import com.google.common.base.Ascii import com.google.common.base.MoreObjects import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.Optional @@ -122,13 +121,15 @@ class LocalTask: DmfsTask, LocalResource { .add("eTag", eTag) .add("scheduleTag", scheduleTag) .add("flags", flags) - .add("task", + /*.add("task", try { + // too dangerous, may contain unknown properties and cause another OOM Ascii.truncate(task.toString(), 1000, "…") } catch (e: Exception) { e } - ).toString() + )*/ + .toString() override fun getViewUri(context: Context): Uri? { val idNotNull = id ?: return 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/sync/CalendarSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt index 7bcb94a6e..441876232 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -29,11 +29,14 @@ 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.AndroidEventProcessor +import at.bitfire.synctools.mapping.calendar.DefaultProdIdGenerator import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -41,6 +44,7 @@ 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.component.VEvent import net.fortuna.ical4j.model.property.Action import okhttp3.HttpUrl import okhttp3.RequestBody.Companion.toRequestBody @@ -179,47 +183,30 @@ class CalendarSyncManager @AssistedInject constructor( } override fun generateUpload(resource: LocalEvent): GeneratedResource { - val event = resource.getCachedEvent() - logger.log(Level.FINE, "Preparing upload of iCalendar ${resource.fileName}", event) + val localEvent = resource.androidEvent + logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent) - // get/create UID - val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(event.uid) - if (uidIsGenerated) - event.uid = uid + // map Android event to iCalendar (also generates UID and increases SEQUENCE, if necessary) + val processor = AndroidEventProcessor( + accountName = resource.recurringCalendar.calendar.account.name, + prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId) + ) + val mappedEvents = processor.mapToVEvents(localEvent) - // Increase sequence, if necessary: - // - 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 groupScheduled = event.attendees.isNotEmpty() - val weAreOrganizer = event.isOrganizer == true - val sequence = event.sequence - val newSequence: Optional = when { - // first upload, set to 0 after upload - sequence == null -> - Optional.of(0) - - // re-upload of group-scheduled event (and we're ORGANIZER), increase sequence in iCalendar and after upload - groupScheduled && weAreOrganizer -> { - event.sequence = sequence + 1 - Optional.of(sequence + 1) - } - - // standard re-upload, don't update sequence - else -> - Optional.empty() - } + // persist UID if it was generated + if (mappedEvents.generatedUid) + resource.updateUid(mappedEvents.uid) // generate iCalendar and convert to request body val iCalWriter = StringWriter() - EventWriter(Constants.iCalProdId).write(event, iCalWriter) + ICalendarGenerator().write(mappedEvents.associatedEvents, iCalWriter) val requestBody = iCalWriter.toString().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) return GeneratedResource( - suggestedFileName = DavUtils.fileNameFromUid(uid, "ics"), + suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"), requestBody = requestBody, onSuccessContext = GeneratedResource.OnSuccessContext( - uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty(), - sequence = newSequence + sequence = mappedEvents.updatedSequence ) ) } @@ -272,11 +259,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) ) } } @@ -289,56 +276,56 @@ 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() - - // set default reminder for non-full-day events, if requested - val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() - if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) { - val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply { - // Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider. - // Needed for calendars to actually show a notification. - properties += Action.DISPLAY - } - logger.log(Level.FINE, "${event.uid}: Adding default alarm", alarm) - event.alarms += alarm + val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() + val mainEvent = event.main + if (mainEvent != null && defaultAlarmMinBefore != null && DateUtils.isDateTime(mainEvent.startDate) && mainEvent.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, "${mainEvent.uid}: Adding default alarm", alarm) + mainEvent.components += alarm + } - // update local event, if it exists - val local = localCollection.findByName(fileName) + // map AssociatedEvents (VEVENTs) to EventAndExceptions (Android events) + val androidEvent = AndroidEventBuilder( + calendar = localCollection.androidCalendar, + syncId = fileName, + eTag = eTag, + scheduleTag = scheduleTag, + flags = LocalResource.FLAG_REMOTELY_PRESENT + ).build(event) + + // update local event, if it exists + 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/ContactsSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt index 0a221f6ab..fe53601dc 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -277,12 +277,15 @@ class ContactsSyncManager @AssistedInject constructor( is LocalGroup -> resource.getContact() else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup") } - logger.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact) + logger.log(Level.FINE, "Preparing upload of vCard #${resource.id}", contact) // get/create UID val (uid, uidIsGenerated) = DavUtils.generateUidIfNecessary(contact.uid) - if (uidIsGenerated) + 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() @@ -304,10 +307,7 @@ class ContactsSyncManager @AssistedInject constructor( return GeneratedResource( suggestedFileName = DavUtils.fileNameFromUid(uid, "vcf"), - requestBody = os.toByteArray().toRequestBody(mimeType), - onSuccessContext = GeneratedResource.OnSuccessContext( - uid = if (uidIsGenerated) Optional.of(uid) else Optional.empty() - ) + requestBody = os.toByteArray().toRequestBody(mimeType) ) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt index c899a4cfe..ba436c5b6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/GeneratedResource.kt @@ -5,30 +5,28 @@ package at.bitfire.davdroid.sync import okhttp3.RequestBody -import java.util.Optional /** * 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 + * @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 + val onSuccessContext: OnSuccessContext? = null ) { /** * Contains information that has been created for a [GeneratedResource], but has not been saved yet. * - * @param uid new UID to persist on successful upload (empty: UID not modified) - * @param sequence new SEQUENCE to persist on successful upload (empty: SEQUENCE not modified) + * @param sequence new SEQUENCE to persist on successful upload (*null*: SEQUENCE not modified) */ data class OnSuccessContext( - val uid: Optional = Optional.empty(), - val sequence: Optional = Optional.empty() + 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 c9b9455ae..4f3157028 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -34,6 +34,7 @@ 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.Companion.toRequestBody import java.io.ByteArrayOutputStream @@ -97,15 +98,14 @@ class JtxSyncManager @AssistedInject constructor( } override fun generateUpload(resource: LocalJtxICalObject): GeneratedResource { - logger.log(Level.FINE, "Preparing upload of icalobject ${resource.uid}") + logger.log(Level.FINE, "Preparing upload of icalobject #${resource.id}") val os = ByteArrayOutputStream() - resource.write(os, Constants.iCalProdId) + resource.write(os, ProdId(Constants.iCalProdId)) return GeneratedResource( suggestedFileName = DavUtils.fileNameFromUid(resource.uid, "ics"), - requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8), - onSuccessContext = GeneratedResource.OnSuccessContext() // nothing special to update after upload + requestBody = os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) ) } 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 cf6a9d97a..d15607fbb 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -506,7 +506,7 @@ abstract class SyncManager Date: Thu, 30 Oct 2025 14:29:29 +0100 Subject: [PATCH 13/53] Bump version to 4.5.6-alpha.1 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45fd00520..b8be669b7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405050004 - versionName = "4.5.5" + versionCode = 405060000 + versionName = "4.5.6-alpha.1" base.archivesName = "davx5-ose-$versionName" From c64cb1e7ec357d4ede043eb8d9818d34824185e4 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 31 Oct 2025 20:36:31 +0100 Subject: [PATCH 14/53] Remove okhttp caching (bitfireAT/davx5#715) * Remove okhttp caching * Simplification --- .../at/bitfire/davdroid/network/HttpClient.kt | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index a1f733735..b5728619c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState import okhttp3.Authenticator -import okhttp3.Cache import okhttp3.ConnectionSpec import okhttp3.CookieJar import okhttp3.Interceptor @@ -32,7 +31,6 @@ import okhttp3.Protocol import okhttp3.brotli.BrotliInterceptor import okhttp3.internal.tls.OkHostnameVerifier import okhttp3.logging.HttpLoggingInterceptor -import java.io.File import java.net.InetSocketAddress import java.net.Proxy import java.util.concurrent.TimeUnit @@ -47,7 +45,7 @@ class HttpClient( ): AutoCloseable { override fun close() { - okHttpClient.cache?.close() + // nothing to do, can be removed } @@ -136,21 +134,6 @@ class HttpClient( 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 @@ -218,9 +201,6 @@ class HttpClient( // 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) From d365a504e88b523b90efc4a5af2ad4dd9f22cc0d Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 1 Nov 2025 21:55:28 +0100 Subject: [PATCH 15/53] Refactor sequence handling in calendar sync (#1789) * Refactor sequence handling in calendar sync - Move sequence update logic to SequenceUpdater - Update LocalCalendar to use new SequenceUpdater - Remove redundant methods from LocalCalendar - Update tests and dependencies * Minor KDoc --- .../davdroid/resource/LocalCalendarTest.kt | 81 ----------------- .../davdroid/resource/LocalCalendar.kt | 88 ------------------- .../davdroid/resource/LocalCollection.kt | 5 ++ .../davdroid/resource/LocalResource.kt | 4 +- .../davdroid/sync/CalendarSyncManager.kt | 14 ++- gradle/libs.versions.toml | 2 +- 6 files changed, 19 insertions(+), 175 deletions(-) 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 63465cb55..a28151168 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -16,7 +16,6 @@ import androidx.test.platform.app.InstrumentationRegistry 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 at.bitfire.synctools.storage.calendar.EventsContract import at.bitfire.synctools.test.InitCalendarProviderRule import dagger.hilt.android.testing.HiltAndroidRule @@ -66,86 +65,6 @@ class LocalCalendarTest { } - @Test - fun testDeleteDirtyEventsWithoutInstances_NoInstances() { - // create recurring event with only deleted/cancelled instances - val now = System.currentTimeMillis() - val id = calendar.add(EventAndExceptions( - main = Entity(contentValuesOf( - Events._SYNC_ID to "event-without-instances", - Events.CALENDAR_ID to calendar.androidCalendar.id, - Events.ALL_DAY to 0, - Events.DTSTART to now, - Events.RRULE to "FREQ=DAILY;COUNT=3", - Events.DIRTY to 1 - )), - exceptions = listOf( - Entity(contentValuesOf( // first instance: cancelled - Events.CALENDAR_ID to calendar.androidCalendar.id, - Events.ORIGINAL_INSTANCE_TIME to now, - Events.ORIGINAL_ALL_DAY to 0, - Events.DTSTART to now + 86400000, - Events.STATUS to Events.STATUS_CANCELED - )), - Entity(contentValuesOf( // second instance: cancelled - Events.CALENDAR_ID to calendar.androidCalendar.id, - Events.ORIGINAL_INSTANCE_TIME to now + 86400000, - Events.ORIGINAL_ALL_DAY to 0, - Events.DTSTART to now + 86400000, - Events.STATUS to Events.STATUS_CANCELED - )), - Entity(contentValuesOf( // third and last instance: cancelled - Events.CALENDAR_ID to calendar.androidCalendar.id, - Events.ORIGINAL_INSTANCE_TIME to now + 2*86400000, - Events.ORIGINAL_ALL_DAY to 0, - Events.DTSTART to now + 2*86400000, - Events.STATUS to Events.STATUS_CANCELED - )) - ) - )) - - // this method should mark the event as deleted - calendar.deleteDirtyEventsWithoutInstances() - - // verify that event is now marked as deleted - val result = calendar.androidCalendar.getEventRow(id)!! - assertEquals(1, result.getAsInteger(Events.DELETED)) - } - - @Test - fun testDeleteDirtyEventsWithoutInstances_OneInstanceRemaining() { - // create recurring event with only deleted/cancelled instances - val now = System.currentTimeMillis() - val id = calendar.add(EventAndExceptions( - main = Entity(contentValuesOf( - Events._SYNC_ID to "event-with-instances", - Events.CALENDAR_ID to calendar.androidCalendar.id, - Events.ALL_DAY to 0, - Events.DTSTART to now, - Events.RRULE to "FREQ=DAILY;COUNT=2", - Events.DIRTY to 1 - )), - exceptions = listOf( - Entity(contentValuesOf( // first instance: cancelled - Events.CALENDAR_ID to calendar.androidCalendar.id, - Events.ORIGINAL_INSTANCE_TIME to now, - Events.ORIGINAL_ALL_DAY to 0, - Events.DTSTART to now + 86400000, - Events.STATUS to Events.STATUS_CANCELED - )) - // however second instance is NOT cancelled - ) - )) - - // this method should mark the event as deleted - calendar.deleteDirtyEventsWithoutInstances() - - // verify that event is still marked as dirty, but not as deleted - val result = calendar.androidCalendar.getEventRow(id)!! - assertEquals(1, result.getAsInteger(Events.DIRTY)) - assertEquals(0, result.getAsInteger(Events.DELETED)) - } - /** * Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected. * @param contentValues values to set on the event. Required: 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 e54186f8c..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,12 +4,10 @@ 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.util.MiscUtils.asSyncAdapter import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar @@ -136,90 +134,4 @@ class LocalCalendar @AssistedInject constructor( ) } - - fun processDirtyExceptions() { - // process deleted exceptions - logger.info("Processing deleted exceptions") - - androidCalendar.iterateEventRows( - arrayOf(Events._ID, Events.ORIGINAL_ID, EventsContract.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(EventsContract.COLUMN_SEQUENCE)) - val originalSequence = originalEventValues?.getAsInteger(EventsContract.COLUMN_SEQUENCE) ?: 0 - - batch += BatchOperation.CpoBuilder - .newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account)) - .withValue(EventsContract.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, EventsContract.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(EventsContract.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(EventsContract.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/LocalCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCollection.kt index aa6982e13..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,6 +4,11 @@ package at.bitfire.davdroid.resource +/** + * This is an interface between the Syncer/SyncManager and a collection in the local storage. + * + * It defines operations that are used during sync for all sync data types. + */ interface LocalCollection { /** a tag that uniquely identifies the collection (DAVx5-wide) */ 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 1ff29fcdf..39147c10a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalResource.kt @@ -11,7 +11,9 @@ import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESEN 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 { 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 441876232..73093872c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -37,6 +37,7 @@ import at.bitfire.synctools.icalendar.ICalendarParser import at.bitfire.synctools.mapping.calendar.AndroidEventBuilder import at.bitfire.synctools.mapping.calendar.AndroidEventProcessor 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 @@ -98,10 +99,12 @@ class CalendarSyncManager @AssistedInject constructor( davCollection = DavCalendar(httpClient.okHttpClient, 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 } @@ -186,7 +189,10 @@ class CalendarSyncManager @AssistedInject constructor( val localEvent = resource.androidEvent logger.log(Level.FINE, "Preparing upload of event #${resource.id}", localEvent) - // map Android event to iCalendar (also generates UID and increases SEQUENCE, if necessary) + // 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 processor = AndroidEventProcessor( accountName = resource.recurringCalendar.calendar.account.name, prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId) @@ -206,7 +212,7 @@ class CalendarSyncManager @AssistedInject constructor( suggestedFileName = DavUtils.fileNameFromUid(mappedEvents.uid, "ics"), requestBody = requestBody, onSuccessContext = GeneratedResource.OnSuccessContext( - sequence = mappedEvents.updatedSequence + sequence = updatedSequence ) ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b1c61a452..d9f51f64c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "41009d48ed" bitfire-dav4jvm = "f11523619b" -bitfire-synctools = "e48bdbd330" +bitfire-synctools = "5fc6688ff6" compose-accompanist = "0.37.3" compose-bom = "2025.10.01" dnsjava = "3.6.3" From 026750eca381b184d750d5bcbbcd34019570e0b4 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Sat, 1 Nov 2025 21:56:47 +0100 Subject: [PATCH 16/53] Bump version to 4.5.6-alpha.2 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8be669b7..6cd69994e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405060000 - versionName = "4.5.6-alpha.1" + versionCode = 405060001 + versionName = "4.5.6-alpha.2" base.archivesName = "davx5-ose-$versionName" From 85548163cad8db7b88567747c556a9e5070d9991 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:07:44 +0100 Subject: [PATCH 17/53] Bump the app-dependencies group with 4 updates (#1790) Bumps the app-dependencies group with 4 updates: [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp), [com.squareup.okhttp3:okhttp-brotli](https://github.com/square/okhttp), [com.squareup.okhttp3:logging-interceptor](https://github.com/square/okhttp) and [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp). Updates `com.squareup.okhttp3:okhttp` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) Updates `com.squareup.okhttp3:okhttp-brotli` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) Updates `com.squareup.okhttp3:logging-interceptor` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) Updates `com.squareup.okhttp3:mockwebserver` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) Updates `com.squareup.okhttp3:okhttp-brotli` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) Updates `com.squareup.okhttp3:logging-interceptor` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) Updates `com.squareup.okhttp3:mockwebserver` from 5.2.1 to 5.3.0 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.2.1...parent-5.3.0) --- updated-dependencies: - dependency-name: com.squareup.okhttp3:okhttp dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:okhttp-brotli dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:logging-interceptor dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:mockwebserver dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:okhttp-brotli dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:logging-interceptor dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:mockwebserver dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9f51f64c..90d42379e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ kotlinx-coroutines = "1.10.2" ksp = "2.3.0" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" -okhttp = "5.2.1" +okhttp = "5.3.0" openid-appauth = "0.11.1" room = "2.8.3" unifiedpush = "3.1.2" From bd13d27e386c76db070e480a42ff784e1fc06e14 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 4 Nov 2025 16:38:22 +0100 Subject: [PATCH 18/53] HttpClient: remove unnecessary `close()` (#1792) * Remove unnecessary AutoCloseable implementations and client.close() calls - Remove AutoCloseable from NextcloudLoginFlow and DavResourceFinder - Remove client.close() calls in various classes and tests - Update HttpClient to remove close() method * Fix test * Fix annotations / KDoc --- .../at/bitfire/davdroid/db/CollectionTest.kt | 6 -- .../davdroid/network/HttpClientTest.kt | 1 - .../davdroid/network/OkhttpClientTest.kt | 19 ++--- .../CollectionsWithoutHomeSetRefresherTest.kt | 1 - .../servicedetection/DavResourceFinderTest.kt | 1 - .../servicedetection/HomeSetRefresherTest.kt | 1 - .../PrincipalsRefresherTest.kt | 1 - .../servicedetection/ServiceRefresherTest.kt | 1 - .../QueryChildDocumentsOperationTest.kt | 1 - .../at/bitfire/davdroid/network/HttpClient.kt | 7 +- .../davdroid/network/NextcloudLoginFlow.kt | 6 +- .../davdroid/push/PushRegistrationManager.kt | 44 +++++----- .../repository/DavCollectionRepository.kt | 45 +++++----- .../servicedetection/DavResourceFinder.kt | 6 +- .../RefreshCollectionsWorker.kt | 44 +++++----- .../davdroid/sync/ContactsSyncManager.kt | 28 +++---- .../kotlin/at/bitfire/davdroid/sync/Syncer.kt | 2 - .../davdroid/ui/setup/LoginScreenModel.kt | 5 +- .../davdroid/ui/setup/NextcloudLoginModel.kt | 4 - .../davdroid/webdav/RandomAccessCallback.kt | 3 +- .../webdav/StreamingFileDescriptor.kt | 5 +- .../davdroid/webdav/WebDavMountRepository.kt | 14 ++-- .../webdav/operation/CopyDocumentOperation.kt | 55 ++++++------ .../operation/CreateDocumentOperation.kt | 75 +++++++++-------- .../operation/DeleteDocumentOperation.kt | 25 +++--- .../webdav/operation/MoveDocumentOperation.kt | 27 +++--- .../OpenDocumentThumbnailOperation.kt | 27 +++--- .../operation/QueryChildDocumentsOperation.kt | 83 +++++++++---------- .../operation/RenameDocumentOperation.kt | 41 +++++---- 29 files changed, 261 insertions(+), 317 deletions(-) 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..9e1350b8c 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt @@ -14,7 +14,6 @@ import dagger.hilt.android.testing.HiltAndroidTest import okhttp3.HttpUrl.Companion.toHttpUrl 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 @@ -45,11 +44,6 @@ class CollectionTest { Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) } - @After - fun teardown() { - httpClient.close() - } - @Test @SmallTest diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt index 1c1209904..4c1088e02 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt @@ -45,7 +45,6 @@ class HttpClientTest { @After fun tearDown() { server.shutdown() - httpClient.close() } 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..7ca00868e 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/OkhttpClientTest.kt @@ -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.okHttpClient + .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/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt index aa4fcf5e4..f1422a697 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/CollectionsWithoutHomeSetRefresherTest.kt @@ -80,7 +80,6 @@ class CollectionsWithoutHomeSetRefresherTest { @After fun tearDown() { - client.close() mockServer.shutdown() } 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..f915b526c 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -83,7 +83,6 @@ class DavResourceFinderTest { @After fun tearDown() { - client.close() server.shutdown() } 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..37a763a14 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/HomeSetRefresherTest.kt @@ -86,7 +86,6 @@ class HomeSetRefresherTest { @After fun tearDown() { - client.close() mockServer.shutdown() } 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..d8bded34a 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresherTest.kt @@ -81,7 +81,6 @@ class PrincipalsRefresherTest { @After fun tearDown() { - client.close() mockServer.shutdown() } 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..43cc6afa5 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresherTest.kt @@ -68,7 +68,6 @@ class ServiceRefresherTest { @After fun tearDown() { - client.close() mockServer.shutdown() } 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..39a7a0fed 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 @@ -84,7 +84,6 @@ class QueryChildDocumentsOperationTest { @After fun tearDown() { - client.close() server.shutdown() runBlocking { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt index b5728619c..ab383570d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt @@ -42,12 +42,7 @@ import javax.net.ssl.SSLContext class HttpClient( val okHttpClient: OkHttpClient -): AutoCloseable { - - override fun close() { - // nothing to do, can be removed - } - +) { // builder 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..324b09f7c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt @@ -33,7 +33,7 @@ import javax.inject.Inject */ class NextcloudLoginFlow @Inject constructor( httpClientBuilder: HttpClient.Builder -): AutoCloseable { +) { companion object { const val FLOW_V1_PATH = "index.php/login/flow" @@ -46,10 +46,6 @@ class NextcloudLoginFlow @Inject constructor( val httpClient = httpClientBuilder .build() - override fun close() { - httpClient.close() - } - // Login flow state var loginUrl: HttpUrl? = null 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..645ad4908 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -180,25 +180,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) } } @@ -294,15 +292,13 @@ 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) } } 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 2a1247d39..ef1fd9c05 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -173,21 +173,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.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 } } } @@ -290,19 +289,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.okHttpClient, url).mkCol( + xmlBody = xmlBody, + method = method + ) { + // success, otherwise an exception would have been thrown } + } } private fun generateMkColXml( 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..f6d098af3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -63,7 +63,7 @@ class DavResourceFinder @AssistedInject constructor( @ApplicationContext val context: Context, private val dnsRecordResolver: DnsRecordResolver, httpClientBuilder: HttpClient.Builder -): AutoCloseable { +) { @AssistedFactory interface Factory { @@ -93,10 +93,6 @@ class DavResourceFinder @AssistedInject constructor( } .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()!! 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..4dce7083b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -153,34 +153,32 @@ 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 httpClient = httpClient.okHttpClient + val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient) - // refresh home set list (from principal url) - service.principal?.let { principalUrl -> - logger.fine("Querying principal $principalUrl for home sets") - val serviceRefresher = serviceRefresherFactory.create(service, httpClient) - serviceRefresher.discoverHomesets(principalUrl) - } - - // refresh home sets and their member collections - homeSetRefresherFactory.create(service, httpClient) - .refreshHomesetsAndTheirCollections() - - // also refresh collections without a home set - refresher.refreshCollectionsWithoutHomeSet() - - // Lastly, refresh the principals (collection owners) - val principalsRefresher = principalsRefresherFactory.create(service, httpClient) - principalsRefresher.refreshPrincipals() - } + // 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/sync/ContactsSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt index fe53601dc..af67d1435 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -485,25 +485,23 @@ class ContactsSyncManager @AssistedInject constructor( } // authenticate only against a certain host, and only upon request - httpClientBuilder + val hostHttpClient = 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() + try { + val response = hostHttpClient.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) - } - } + 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 } 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..191ebe451 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt @@ -273,8 +273,6 @@ abstract class Syncer, CollectionType: 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/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/webdav/RandomAccessCallback.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt index a4c391b22..16273451c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt @@ -43,7 +43,7 @@ import javax.annotation.WillClose @RequiresApi(26) class RandomAccessCallback @AssistedInject constructor( - @Assisted @WillClose private val httpClient: HttpClient, + @Assisted private val httpClient: HttpClient, @Assisted private val url: HttpUrl, @Assisted private val mimeType: MediaType?, @Assisted headResponse: HeadResponse, @@ -127,7 +127,6 @@ class RandomAccessCallback @AssistedInject constructor( // free resources ioThread.quitSafely() - httpClient.close() } 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..3abe7a223 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -27,10 +27,10 @@ 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: HttpClient, @Assisted private val url: HttpUrl, @Assisted private val mimeType: MediaType?, @Assisted private val externalScope: CoroutineScope, @@ -75,7 +75,6 @@ class StreamingFileDescriptor @AssistedInject constructor( writeFd.close() } catch (_: IOException) {} - client.close() finishedCallback.onFinished(transferred, success) } } 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..7799b9599 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -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, 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.okHttpClient, 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..6192c7b43 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 @@ -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.okHttpClient, 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..d76d6d5d6 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 @@ -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.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) } } 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..2db5cdd4c 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 @@ -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.okHttpClient, 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..d1b883453 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 @@ -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.okHttpClient, 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/OpenDocumentThumbnailOperation.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/operation/OpenDocumentThumbnailOperation.kt index d267ed47a..c8d2b3d20 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 @@ -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.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() } } } - 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..c1dc91090 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 @@ -128,58 +128,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.okHttpClient, 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(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) } - } 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) 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..66bc99808 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 @@ -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.okHttpClient, 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) } } From 0959624deeb04e782e7abd654c0c9908633931c0 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Wed, 5 Nov 2025 12:40:34 +0100 Subject: [PATCH 19/53] Update `Calendars.OWNER_ACCOUNT` when renaming an account (#1751) * Fix typo * Also set OWNER_ACCOUNT when updating calendar because renaming account * Add test * Update comment clarifying content values * Assume calendar provider is present and drop null checks --- .../resource/LocalCalendarStoreTest.kt | 118 ++++++++++++++++++ .../davdroid/sync/account/TestAccount.kt | 12 ++ .../davdroid/repository/AccountRepository.kt | 2 +- .../davdroid/resource/LocalCalendarStore.kt | 8 +- 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt 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..50027ec0a --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.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("InitialAccountName") + + // Rename account + val oldAccount = account + account = TestAccount.rename(account, "ChangedAccountName") + + // Update account name in local calendar + localCalendarStore.updateAccount(oldAccount, account) + + // Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated + verifyOwnerAccountIs("ChangedAccountName") + } + + + // helpers + + private fun createCalendarForAccount(account: Account): Uri { + var uri: Uri? = null + provider.use { providerClient -> + val values = 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", + ) + + uri = providerClient.insert( + Calendars.CONTENT_URI.asSyncAdapter(account), + values + )!!.asSyncAdapter(account) + } + return uri!! + } + + private fun verifyOwnerAccountIs(expectedOwnerAccount: String) = provider.use { + it.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/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/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 080b91c54..3f4770fae 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 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..efd3c7c75 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt @@ -139,7 +139,13 @@ class LocalCalendarStore @Inject constructor( } override fun updateAccount(oldAccount: Account, newAccount: Account) { - val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name) + 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)) From a8bd29652069dbaed394ac5df543e14e976c7811 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 5 Nov 2025 13:20:02 +0100 Subject: [PATCH 20/53] Merge HttpClient and HttpClientBuilder (#1795) * Merge HttpClient and HttpClientBuilder - Remove `HttpClient` class and replace with `OkHttpClient` - Update all references to `HttpClient` to use `OkHttpClient` - Add new `HttpClientBuilder` class for building `OkHttpClient` instances - Update all builder usages to use the new `HttpClientBuilder` class * KDoc --- .../at/bitfire/davdroid/db/CollectionTest.kt | 15 +- .../davdroid/network/HttpClientTest.kt | 11 +- .../davdroid/network/OkhttpClientTest.kt | 4 +- .../CollectionsWithoutHomeSetRefresherTest.kt | 13 +- .../servicedetection/DavResourceFinderTest.kt | 11 +- .../servicedetection/HomeSetRefresherTest.kt | 29 +- .../PrincipalsRefresherTest.kt | 13 +- .../servicedetection/ServiceRefresherTest.kt | 9 +- .../davdroid/sync/JtxSyncManagerTest.kt | 4 +- .../bitfire/davdroid/sync/SyncManagerTest.kt | 4 +- .../bitfire/davdroid/sync/TestSyncManager.kt | 8 +- .../QueryChildDocumentsOperationTest.kt | 7 +- .../at/bitfire/davdroid/network/HttpClient.kt | 290 ----------------- .../davdroid/network/HttpClientBuilder.kt | 307 ++++++++++++++++++ .../davdroid/network/NextcloudLoginFlow.kt | 7 +- .../davdroid/push/PushRegistrationManager.kt | 13 +- .../repository/DavCollectionRepository.kt | 8 +- .../servicedetection/DavResourceFinder.kt | 12 +- .../RefreshCollectionsWorker.kt | 5 +- .../davdroid/sync/AddressBookSyncer.kt | 17 +- .../davdroid/sync/CalendarSyncManager.kt | 8 +- .../bitfire/davdroid/sync/CalendarSyncer.kt | 2 +- .../davdroid/sync/ContactsSyncManager.kt | 13 +- .../bitfire/davdroid/sync/JtxSyncManager.kt | 8 +- .../at/bitfire/davdroid/sync/JtxSyncer.kt | 2 +- .../at/bitfire/davdroid/sync/SyncManager.kt | 8 +- .../kotlin/at/bitfire/davdroid/sync/Syncer.kt | 6 +- .../at/bitfire/davdroid/sync/TaskSyncer.kt | 2 +- .../bitfire/davdroid/sync/TasksSyncManager.kt | 8 +- .../davdroid/webdav/DavHttpClientBuilder.kt | 7 +- .../bitfire/davdroid/webdav/HeadResponse.kt | 6 +- .../davdroid/webdav/RandomAccessCallback.kt | 9 +- .../webdav/RandomAccessCallbackWrapper.kt | 6 +- .../webdav/StreamingFileDescriptor.kt | 11 +- .../davdroid/webdav/WebDavMountRepository.kt | 6 +- .../webdav/operation/CopyDocumentOperation.kt | 2 +- .../operation/CreateDocumentOperation.kt | 2 +- .../operation/DeleteDocumentOperation.kt | 2 +- .../webdav/operation/MoveDocumentOperation.kt | 2 +- .../webdav/operation/OpenDocumentOperation.kt | 4 +- .../OpenDocumentThumbnailOperation.kt | 2 +- .../operation/QueryChildDocumentsOperation.kt | 2 +- .../operation/RenameDocumentOperation.kt | 2 +- 43 files changed, 466 insertions(+), 441 deletions(-) delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt 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 9e1350b8c..5ca3d622f 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt @@ -8,10 +8,11 @@ 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.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.Assert.assertEquals @@ -28,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 @@ -63,7 +64,7 @@ class CollectionTest { "")) lateinit var info: Collection - DavResource(httpClient.okHttpClient, server.url("/")) + DavResource(httpClient, server.url("/")) .propfind(0, ResourceType.NAME) { response, _ -> info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() } @@ -119,7 +120,7 @@ class CollectionTest { "")) lateinit var info: Collection - DavResource(httpClient.okHttpClient, server.url("/")) + DavResource(httpClient, server.url("/")) .propfind(0, ResourceType.NAME) { response, _ -> info = Collection.fromDavResponse(response)!! } @@ -155,7 +156,7 @@ class CollectionTest { "")) lateinit var info: Collection - DavResource(httpClient.okHttpClient, server.url("/")) + DavResource(httpClient, server.url("/")) .propfind(0, ResourceType.NAME) { response, _ -> info = Collection.fromDavResponse(response)!! } @@ -189,7 +190,7 @@ class CollectionTest { "")) lateinit var info: Collection - DavResource(httpClient.okHttpClient, server.url("/")) + DavResource(httpClient, server.url("/")) .propfind(0, ResourceType.NAME) { response, _ -> info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() } diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt index 4c1088e02..f08125b1f 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt @@ -7,6 +7,7 @@ package at.bitfire.davdroid.network import android.security.NetworkSecurityPolicy import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -27,9 +28,9 @@ class HttpClientTest { var hiltRule = HiltAndroidRule(this) @Inject - lateinit var httpClientBuilder: HttpClient.Builder + lateinit var httpClientBuilder: HttpClientBuilder - lateinit var httpClient: HttpClient + lateinit var httpClient: OkHttpClient lateinit var server: MockWebServer @Before @@ -59,7 +60,7 @@ class HttpClientTest { .addHeader("Set-Cookie", "cookie1=1; path=/") .addHeader("Set-Cookie", "cookie2=2") .setBody("Cookie set")) - httpClient.okHttpClient.newCall(Request.Builder() + httpClient.newCall(Request.Builder() .get().url(url) .build()).execute() assertNull(server.takeRequest().getHeader("Cookie")) @@ -70,7 +71,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") @@ -78,7 +79,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 7ca00868e..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) @@ -32,7 +32,7 @@ class OkhttpClientTest { @SdkSuppress(maxSdkVersion = 34) fun testIcloudWithSettings() { val client = httpClientBuilder.build() - client.okHttpClient + client .newCall( Request.Builder() .get() 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 f1422a697..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 @@ -101,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( @@ -134,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)) @@ -156,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 f915b526c..5445c9837 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -8,12 +8,13 @@ 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.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 @@ -91,7 +92,7 @@ class DavResourceFinderTest { fun testRememberIfAddressBookOrHomeset() { // recognize home set var info = ServiceInfo() - DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)) + DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)) .propfind(0, AddressbookHomeSet.NAME) { response, _ -> finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) } @@ -101,7 +102,7 @@ class DavResourceFinderTest { // recognize address book info = ServiceInfo() - DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK)) + DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK)) .propfind(0, ResourceType.NAME) { response, _ -> finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) } 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 37a763a14..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 @@ -100,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 @@ -136,7 +137,7 @@ class HomeSetRefresherTest { ) // Refresh - homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections() + homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections() // Check the collection got updated assertEquals( @@ -173,7 +174,7 @@ class HomeSetRefresherTest { ) // Refresh - homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections() + homeSetRefresherFactory.create(service, client).refreshHomesetsAndTheirCollections() // Check the collection got updated assertEquals( @@ -213,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) @@ -240,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) @@ -277,7 +278,7 @@ class HomeSetRefresherTest { ) ) - val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + val refresher = homeSetRefresherFactory.create(service, client) assertFalse(refresher.shouldPreselect(collection, homesets)) } @@ -302,7 +303,7 @@ class HomeSetRefresherTest { ) ) - val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + val refresher = homeSetRefresherFactory.create(service, client) assertTrue(refresher.shouldPreselect(collection, homesets)) } @@ -329,7 +330,7 @@ class HomeSetRefresherTest { ) ) - val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + val refresher = homeSetRefresherFactory.create(service, client) assertFalse(refresher.shouldPreselect(collection, homesets)) } @@ -354,7 +355,7 @@ class HomeSetRefresherTest { ) ) - val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + val refresher = homeSetRefresherFactory.create(service, client) assertFalse(refresher.shouldPreselect(collection, homesets)) } @@ -379,7 +380,7 @@ class HomeSetRefresherTest { ) ) - val refresher = homeSetRefresherFactory.create(service, client.okHttpClient) + val refresher = homeSetRefresherFactory.create(service, client) assertTrue(refresher.shouldPreselect(collection, homesets)) } @@ -406,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 d8bded34a..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 @@ -109,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) @@ -142,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) @@ -163,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 43cc6afa5..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 @@ -77,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/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/SyncManagerTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt index fefbd0cf8..6677492b5 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/SyncManagerTest.kt @@ -15,7 +15,7 @@ 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/TestSyncManager.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt index 831fd2ad1..e21e64548 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt @@ -11,7 +11,6 @@ import at.bitfire.dav4jvm.Response import at.bitfire.dav4jvm.property.caldav.GetCTag import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.di.SyncDispatcher -import at.bitfire.davdroid.network.HttpClient import at.bitfire.davdroid.resource.LocalResource import at.bitfire.davdroid.resource.SyncState import at.bitfire.davdroid.util.DavUtils.lastSegment @@ -20,12 +19,13 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher import okhttp3.HttpUrl +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, @@ -45,7 +45,7 @@ class TestSyncManager @AssistedInject constructor( interface Factory { fun create( account: Account, - httpClient: HttpClient, + httpClient: OkHttpClient, syncResult: SyncResult, localCollection: LocalTestCollection, collection: Collection @@ -53,7 +53,7 @@ class TestSyncManager @AssistedInject constructor( } override fun prepare(): Boolean { - davCollection = DavCollection(httpClient.okHttpClient, collection.url) + davCollection = DavCollection(httpClient, collection.url) return true } 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 39a7a0fed..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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt deleted file mode 100644 index ab383570d..000000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt +++ /dev/null @@ -1,290 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.network - -import android.accounts.Account -import android.content.Context -import androidx.annotation.WorkerThread -import at.bitfire.cert4android.CustomCertManager -import at.bitfire.dav4jvm.BasicDigestAuthHandler -import at.bitfire.dav4jvm.UrlUtils -import at.bitfire.davdroid.BuildConfig -import at.bitfire.davdroid.di.IoDispatcher -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.Credentials -import at.bitfire.davdroid.settings.Settings -import at.bitfire.davdroid.settings.SettingsManager -import at.bitfire.davdroid.ui.ForegroundTracker -import com.google.common.net.HttpHeaders -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import net.openid.appauth.AuthState -import okhttp3.Authenticator -import okhttp3.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.util.concurrent.TimeUnit -import java.util.logging.Level -import java.util.logging.Logger -import javax.inject.Inject -import javax.net.ssl.KeyManager -import javax.net.ssl.SSLContext - -class HttpClient( - val okHttpClient: OkHttpClient -) { - - // builder - - /** - * Builder for the [HttpClient]. - * - * **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then - * there's only one [Builder] object and setting properties from one location would influence the others. - * - * To generate multiple clients, inject and use `Provider` 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 - } - - - // 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) - - // 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..1c84e19bd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -0,0 +1,307 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import android.accounts.Account +import android.content.Context +import androidx.annotation.WorkerThread +import at.bitfire.cert4android.CustomCertManager +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.davdroid.BuildConfig +import at.bitfire.davdroid.di.IoDispatcher +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.Credentials +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.ui.ForegroundTracker +import com.google.common.net.HttpHeaders +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthState +import okhttp3.Authenticator +import okhttp3.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.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger +import javax.inject.Inject +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext + +/** + * Builder for the [OkHttpClient]. + * + * **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then + * there's only one [Builder] object and setting properties from one location would influence the others. + * + * To generate multiple clients, inject and use `Provider` 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 +) { + + /** + * 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(host: 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 = 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): 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 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): HttpClientBuilder { + 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): HttpClientBuilder = withContext(ioDispatcher) { + fromAccount(account, onlyHost) + } + + + // actual builder + + /** + * Builds the [OkHttpClient]. + * + * Must be called only once because multiple calls indicate this wrong usage pattern: + * + * ``` + * val builder = HttpClientBuilder(/*injected*/) + * val client1 = builder.configure().builder() + * val client2 = builder.configureOtherwise().builder() + * ``` + * + * However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`, + * which is usually not desired. + * + * @throws IllegalStateException on second and later calls + */ + fun build(): OkHttpClient { + if (alreadyBuilt) + throw IllegalStateException("build() must only be called once; use Provider") + + 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.Companion.CLEARTEXT, + ConnectionSpec.Companion.MODERN_TLS + )) + + // offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`) + .addInterceptor(BrotliInterceptor) + + // 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) + } + + alreadyBuilt = true + return 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/NextcloudLoginFlow.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt index 324b09f7c..8010fb117 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/NextcloudLoginFlow.kt @@ -32,7 +32,7 @@ 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 + httpClientBuilder: HttpClientBuilder ) { companion object { @@ -43,8 +43,7 @@ class NextcloudLoginFlow @Inject constructor( const val DAV_PATH = "remote.php/dav" } - val httpClient = httpClientBuilder - .build() + val httpClient = httpClientBuilder.build() // Login flow state @@ -116,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/PushRegistrationManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt index 645ad4908..3fe787fb0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -26,7 +26,7 @@ import at.bitfire.dav4jvm.property.push.WebPushSubscription import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.di.IoDispatcher -import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.network.HttpClientBuilder import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex import at.bitfire.davdroid.repository.AccountRepository import at.bitfire.davdroid.repository.DavCollectionRepository @@ -41,6 +41,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 +66,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 @@ -228,7 +229,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) @@ -263,7 +264,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") @@ -302,10 +303,10 @@ class PushRegistrationManager @Inject constructor( } } - 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/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt index ef1fd9c05..533a27ab6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -30,7 +30,7 @@ import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.CollectionType import at.bitfire.davdroid.db.HomeSet import at.bitfire.davdroid.di.IoDispatcher -import at.bitfire.davdroid.network.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 @@ -59,7 +59,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 ) { @@ -176,7 +176,7 @@ class DavCollectionRepository @Inject constructor( val httpClient = httpClientBuilder.get().fromAccount(account).build() runInterruptible(ioDispatcher) { try { - DavResource(httpClient.okHttpClient, collection.url).delete { + DavResource(httpClient, collection.url).delete { // success, otherwise an exception would have been thrown → delete locally, too delete(collection) } @@ -293,7 +293,7 @@ class DavCollectionRepository @Inject constructor( .fromAccount(account) .build() runInterruptible(ioDispatcher) { - DavResource(httpClient.okHttpClient, url).mkCol( + DavResource(httpClient, url).mkCol( xmlBody = xmlBody, method = method ) { 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 f6d098af3..7777ffed5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -29,7 +29,7 @@ import at.bitfire.dav4jvm.property.webdav.ResourceType import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.log.StringHandler import at.bitfire.davdroid.network.DnsRecordResolver -import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.network.HttpClientBuilder import at.bitfire.davdroid.settings.Credentials import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -62,7 +62,7 @@ class DavResourceFinder @AssistedInject constructor( @Assisted private val credentials: Credentials? = null, @ApplicationContext val context: Context, private val dnsRecordResolver: DnsRecordResolver, - httpClientBuilder: HttpClient.Builder + httpClientBuilder: HttpClientBuilder ) { @AssistedFactory @@ -224,7 +224,7 @@ 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 -> { @@ -262,7 +262,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, CalendarUserAddressSet.NAME) { response, _ -> response[CalendarUserAddressSet::class.java]?.let { addressSet -> for (href in addressSet.hrefs) try { @@ -361,7 +361,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 @@ -446,7 +446,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, CurrentUserPrincipal.NAME) { 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/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index 4dce7083b..99b1bbb4a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -24,7 +24,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import at.bitfire.dav4jvm.exception.UnauthorizedException import at.bitfire.davdroid.R -import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.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, @@ -157,7 +157,6 @@ class RefreshCollectionsWorker @AssistedInject constructor( .fromAccount(account) .build() runInterruptible { - val httpClient = httpClient.okHttpClient val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient) // refresh home set list (from principal url) 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 73093872c..c3c6537b1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -21,7 +21,6 @@ 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 @@ -48,6 +47,7 @@ import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VEvent import net.fortuna.ical4j.model.property.Action import okhttp3.HttpUrl +import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import java.io.Reader import java.io.StringReader @@ -62,7 +62,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, @@ -84,7 +84,7 @@ class CalendarSyncManager @AssistedInject constructor( interface Factory { fun calendarSyncManager( account: Account, - httpClient: HttpClient, + httpClient: OkHttpClient, syncResult: SyncResult, localCalendar: LocalCalendar, collection: Collection, @@ -96,7 +96,7 @@ 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 val recurringCalendar = localCollection.recurringCalendar 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 af67d1435..6cb08756b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -24,7 +24,7 @@ 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.network.HttpClientBuilder import at.bitfire.davdroid.resource.LocalAddress import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalContact @@ -50,6 +50,7 @@ import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream @@ -99,7 +100,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, @@ -108,7 +109,7 @@ class ContactsSyncManager @AssistedInject constructor( @Assisted val syncFrameworkUpload: Boolean, val dirtyVerifier: Optional, accountSettingsFactory: AccountSettings.Factory, - private val httpClientBuilder: HttpClient.Builder, + private val httpClientBuilder: HttpClientBuilder, @SyncDispatcher syncDispatcher: CoroutineDispatcher ): SyncManager( account, @@ -125,7 +126,7 @@ class ContactsSyncManager @AssistedInject constructor( interface Factory { fun contactsSyncManager( account: Account, - httpClient: HttpClient, + httpClient: OkHttpClient, syncResult: SyncResult, provider: ContentProviderClient, localAddressBook: LocalAddressBook, @@ -161,7 +162,7 @@ class ContactsSyncManager @AssistedInject constructor( return false } - davCollection = DavAddressBook(httpClient.okHttpClient, collection.url) + davCollection = DavAddressBook(httpClient, collection.url) resourceDownloader = ResourceDownloader(davCollection.location) logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}") @@ -490,7 +491,7 @@ class ContactsSyncManager @AssistedInject constructor( .followRedirects(true) // allow redirects .build() try { - val response = hostHttpClient.okHttpClient.newCall(Request.Builder() + val response = hostHttpClient.newCall(Request.Builder() .get() .url(httpUrl) .build()).execute() 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 4f3157028..140b91040 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -20,7 +20,6 @@ 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 @@ -36,6 +35,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runInterruptible import net.fortuna.ical4j.model.property.ProdId import okhttp3.HttpUrl +import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream import java.io.Reader @@ -44,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, @@ -65,7 +65,7 @@ class JtxSyncManager @AssistedInject constructor( interface Factory { fun jtxSyncManager( account: Account, - httpClient: HttpClient, + httpClient: OkHttpClient, syncResult: SyncResult, localCollection: LocalJtxCollection, collection: Collection, @@ -75,7 +75,7 @@ class JtxSyncManager @AssistedInject constructor( override fun prepare(): Boolean { - davCollection = DavCalendar(httpClient.okHttpClient, collection.url) + davCollection = DavCalendar(httpClient, collection.url) return true } 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/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt index d15607fbb..86b598d8a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -30,7 +30,6 @@ import at.bitfire.dav4jvm.property.webdav.GetETag import at.bitfire.dav4jvm.property.webdav.SyncToken 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 @@ -47,6 +46,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import okhttp3.HttpUrl +import okhttp3.OkHttpClient import java.io.IOException import java.net.HttpURLConnection import java.security.cert.CertificateException @@ -76,7 +76,7 @@ import javax.net.ssl.SSLHandshakeException */ abstract class SyncManager, RemoteType: DavCollection>( val account: Account, - val httpClient: HttpClient, + val httpClient: OkHttpClient, val dataType: SyncDataType, val syncResult: SyncResult, val localCollection: CollectionType, @@ -331,7 +331,7 @@ abstract class SyncManager 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 { @@ -393,7 +393,7 @@ abstract class SyncManager, CollectionType: lateinit var collectionRepository: DavCollectionRepository @Inject - lateinit var httpClientBuilder: HttpClient.Builder + lateinit var httpClientBuilder: HttpClientBuilder @Inject lateinit var logger: Logger @@ -65,7 +65,7 @@ abstract class Syncer, CollectionType: syncNotificationManagerFactory.create(account) } - val httpClient = lazy { + val httpClient by lazy { httpClientBuilder.fromAccount(account).build() } 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 d17bfca04..9d5e5f67e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt @@ -19,7 +19,6 @@ 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 @@ -35,6 +34,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runInterruptible import net.fortuna.ical4j.model.property.ProdId import okhttp3.HttpUrl +import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream import java.io.Reader @@ -46,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, @@ -67,7 +67,7 @@ class TasksSyncManager @AssistedInject constructor( interface Factory { fun tasksSyncManager( account: Account, - httpClient: HttpClient, + httpClient: OkHttpClient, syncResult: SyncResult, localCollection: LocalTaskList, collection: Collection, @@ -77,7 +77,7 @@ class TasksSyncManager @AssistedInject constructor( override fun prepare(): Boolean { - davCollection = DavCalendar(httpClient.okHttpClient, collection.url) + davCollection = DavCalendar(httpClient, collection.url) return true } 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..07c8311cd 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() } 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..45296a1af 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/HeadResponse.kt @@ -8,8 +8,8 @@ import androidx.annotation.WorkerThread import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.HttpUtils 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 16273451c..a8327d19d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/RandomAccessCallback.kt @@ -18,7 +18,6 @@ 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.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 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") 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 3abe7a223..d6a3d866d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -8,7 +8,6 @@ import android.os.ParcelFileDescriptor import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.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 to use */ class StreamingFileDescriptor @AssistedInject constructor( - @Assisted 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) @@ -123,7 +122,7 @@ class StreamingFileDescriptor @AssistedInject constructor( } } } - 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 7799b9599..950936a2a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -12,7 +12,7 @@ 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() @@ -135,7 +135,7 @@ class WebDavMountRepository @Inject constructor( val httpClient = builder.build() var webdavUrl: HttpUrl? = null - val dav = DavResource(httpClient.okHttpClient, url) + val dav = DavResource(httpClient, url) runInterruptible { dav.options(followRedirects = true) { davCapabilities, response -> if (davCapabilities.any { it in validVersions }) 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 6192c7b43..8525e39c6 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 @@ -41,7 +41,7 @@ class CopyDocumentOperation @Inject constructor( throw UnsupportedOperationException("Can't COPY between WebDAV servers") val client = httpClientBuilder.build(srcDoc.mountId) - val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db)) + val dav = DavResource(client, srcDoc.toHttpUrl(db)) val dstUrl = dstFolder.toHttpUrl(db).newBuilder() .addPathSegment(name) .build() 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 d76d6d5d6..6dc95392a 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 @@ -48,7 +48,7 @@ class CreateDocumentOperation @Inject constructor( val newLocation = parentUrl.newBuilder() .addPathSegment(newName) .build() - val doc = DavResource(client.okHttpClient, newLocation) + val doc = DavResource(client, newLocation) try { runInterruptible(ioDispatcher) { if (createDirectory) 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 2db5cdd4c..9d6bb6028 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 @@ -35,7 +35,7 @@ class DeleteDocumentOperation @Inject constructor( val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException() val client = httpClientBuilder.build(doc.mountId) - val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) + val dav = DavResource(client, doc.toHttpUrl(db)) try { runInterruptible(ioDispatcher) { dav.delete { 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 d1b883453..f0be138de 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 @@ -43,7 +43,7 @@ class MoveDocumentOperation @Inject constructor( .build() val client = httpClientBuilder.build(doc.mountId) - val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db)) + val dav = DavResource(client, doc.toHttpUrl(db)) try { runInterruptible(ioDispatcher) { dav.move(newLocation, false) { 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 c8d2b3d20..31281a524 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 @@ -78,7 +78,7 @@ class OpenDocumentThumbnailOperation @Inject constructor( withTimeout(THUMBNAIL_TIMEOUT_MS) { val client = httpClientBuilder.build(doc.mountId, logBody = false) val url = doc.toHttpUrl(db) - val dav = DavResource(client.okHttpClient, url) + val dav = DavResource(client, url) var result: ByteArray? = null runInterruptible(ioDispatcher) { dav.get("image/*", null) { response -> 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 c1dc91090..6622a2526 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 @@ -129,7 +129,7 @@ class QueryChildDocumentsOperation @Inject constructor( val parentUrl = parent.toHttpUrl(db) val client = httpClientBuilder.build(parent.mountId) - val folder = DavCollection(client.okHttpClient, parentUrl) + val folder = DavCollection(client, parentUrl) try { runInterruptible(ioDispatcher) { 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 66bc99808..06033988c 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 @@ -44,7 +44,7 @@ class RenameDocumentOperation @Inject constructor( .addPathSegment(newName) .build() try { - val dav = DavResource(client.okHttpClient, oldUrl) + val dav = DavResource(client, oldUrl) runInterruptible(ioDispatcher) { dav.move(newLocation, false) { // successfully renamed From ee098c4a83c4029c51cd7b9b245713cd0e67eab6 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 6 Nov 2025 10:07:40 +0100 Subject: [PATCH 21/53] Explicitly integrate Conscrypt (#1796) * Merge HttpClient and HttpClientBuilder - Remove `HttpClient` class and replace with `OkHttpClient` - Update all references to `HttpClient` to use `OkHttpClient` - Add new `HttpClientBuilder` class for building `OkHttpClient` instances - Update all builder usages to use the new `HttpClientBuilder` class * KDoc * Integrate Conscrypt for TLS - Add Conscrypt dependency - Initialize Conscrypt in HttpClientBuilder - Create ConscryptIntegration utility * KDoc * Make object a class, better test * Update cert4android to the latest version (doesn't bundle Conscrypt anymore) --- app/build.gradle.kts | 2 +- .../network/ConscryptIntegrationTest.kt | 31 +++++++++++ .../davdroid/network/ConscryptIntegration.kt | 53 +++++++++++++++++++ .../davdroid/network/HttpClientBuilder.kt | 9 +++- gradle/libs.versions.toml | 4 +- 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/network/ConscryptIntegrationTest.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6cd69994e..b934ff9ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -186,7 +186,7 @@ dependencies { } // third-party libs - @Suppress("RedundantSuppression") + implementation(libs.conscrypt) implementation(libs.dnsjava) implementation(libs.guava) implementation(libs.mikepenz.aboutLibraries.m3) 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/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt new file mode 100644 index 000000000..c01b61281 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/ConscryptIntegration.kt @@ -0,0 +1,53 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.network + +import androidx.annotation.VisibleForTesting +import org.conscrypt.Conscrypt +import java.security.Security +import java.util.logging.Logger +import javax.net.ssl.SSLContext + +/** + * Integration with the Conscrypt library that provides recent TLS versions and ciphers, + * regardless of the device Android version. + */ +class ConscryptIntegration { + + private val logger + get() = Logger.getLogger(javaClass.name) + + private var initialized = false + + /** + * Loads and initializes Conscrypt (if not already done). Safe to be called multiple times. + */ + fun initialize() { + synchronized(ConscryptIntegration::javaClass) { + if (initialized) + return + + val alreadyInstalled = conscryptInstalled() + if (!alreadyInstalled) { + // install Conscrypt as most preferred provider + Security.insertProviderAt(Conscrypt.newProvider(), 1) + + val version = Conscrypt.version() + logger.info("Using Conscrypt/${version.major()}.${version.minor()}.${version.patch()} for TLS") + + val engine = SSLContext.getDefault().createSSLEngine() + logger.info("Enabled protocols: ${engine.enabledProtocols.joinToString(", ")}") + logger.info("Enabled ciphers: ${engine.enabledCipherSuites.joinToString(", ")}") + } + + initialized = true + } + } + + @VisibleForTesting + internal fun conscryptInstalled() = + Security.getProviders().any { Conscrypt.isConscrypt(it) } + +} \ 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 index 1c84e19bd..2b231d0d9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -44,7 +44,7 @@ import javax.net.ssl.SSLContext * Builder for the [OkHttpClient]. * * **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then - * there's only one [Builder] object and setting properties from one location would influence the others. + * 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. */ @@ -58,6 +58,13 @@ class HttpClientBuilder @Inject constructor( 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 */ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 90d42379e..ca43e139b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,11 +18,12 @@ androidx-test-runner = "1.7.0" androidx-test-rules = "1.7.0" androidx-test-junit = "1.3.0" androidx-work = "2.11.0" -bitfire-cert4android = "41009d48ed" +bitfire-cert4android = "b3160b02b8" bitfire-dav4jvm = "f11523619b" bitfire-synctools = "5fc6688ff6" compose-accompanist = "0.37.3" compose-bom = "2025.10.01" +conscrypt = "2.5.3" dnsjava = "3.6.3" glance = "1.1.1" guava = "33.5.0-android" @@ -80,6 +81,7 @@ compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-materialIconsExtended = { module = "androidx.compose.material:material-icons-extended" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-toolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } +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" } From c9da49614287f7e3c86e240b82c47ff0baa75417 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 6 Nov 2025 14:30:07 +0100 Subject: [PATCH 22/53] Bump version to 4.5.6-beta.1 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b934ff9ed..90bad164a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405060001 - versionName = "4.5.6-alpha.2" + versionCode = 405060002 + versionName = "4.5.6-beta.1" base.archivesName = "davx5-ose-$versionName" From 5c7b792e7f8b0cc3db11896d808d3e83990e901b Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Mon, 10 Nov 2025 15:46:58 +0100 Subject: [PATCH 23/53] Support sync adapter pending sync indication on Android 14+ (#1676) * Add AccountSettingsMigration21 to cancel pending address book syncs * Add application context annotation * Add log statement * Increase account settings current version * Add and update kdoc * Call cancelSync via integration * Optimize imports * Update kdoc * Updating log statement * Also cancel calendar syncs * Don't infer authority from account type * Update kdoc * Cancel only on Android 14+ * Cancel for all authorities and update kdoc * Use cancelSync directly in migration * Enable forever pending sync workaround by canceling sync adapter framework syncs on Android 14+ * Stop always returning false for pending sync state of sync adapter framework * Cancel by request and empty bundle * Cancel syncs for calendar, tasks, and contacts separately * Minor edits to log statement and kdoc * Add migration test; Update migration * Log all extras instead of just upload flag * Use lazy on syncFrameworkIntegration injection * Multiple changes - don't cancel address book accounts of all main accounts - merge loops * Add authority to log statement * Replace complex state verification logic by status changed flow * Cancel syncs account wide across all authorities * Add some delay to allow dummy sync requests to be created * Reduce wait until pending * Drop Thread.sleep() * Use a callback flow instead of mutable state flow * Shorten first true filter * Shorten remaining first true filter --- .../AccountSettingsMigration21Test.kt | 139 ++++++++++++++++++ .../davdroid/settings/AccountSettings.kt | 7 +- .../migration/AccountSettingsMigration.kt | 3 +- .../migration/AccountSettingsMigration21.kt | 83 +++++++++++ .../davdroid/sync/adapter/SyncAdapterImpl.kt | 13 +- .../sync/adapter/SyncFrameworkIntegration.kt | 14 +- 6 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21.kt 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..e732626d0 --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt @@ -0,0 +1,139 @@ +/* + * 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.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +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 = callbackFlow { + val stateChangeListener = ContentResolver.addStatusChangeListener( + ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE + ) { + trySend(ContentResolver.isSyncPending(account, authority)) + } + trySend(ContentResolver.isSyncPending(account, authority)) + awaitClose { + ContentResolver.removeStatusChangeListener(stateChangeListener) + } + } + + @Before + fun setUp() { + hiltRule.inject() + + account = TestAccount.create() + + // Enable sync globally and for the test account + ContentResolver.setIsSyncable(account, authority, 1) + } + + @After + fun tearDown() { + 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() + + 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/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/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/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..b92fcb6dc 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 @@ -101,11 +101,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 +191,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 From 6b5c4f191ac306c08c459fc2b0738a1f12be8445 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:28:43 +0100 Subject: [PATCH 24/53] Bump the app-dependencies group with 2 updates (#1803) Bumps the app-dependencies group with 2 updates: androidx.compose:compose-bom and [com.google.devtools.ksp](https://github.com/google/ksp). Updates `androidx.compose:compose-bom` from 2025.10.01 to 2025.11.00 Updates `com.google.devtools.ksp` from 2.3.0 to 2.3.2 - [Release notes](https://github.com/google/ksp/releases) - [Commits](https://github.com/google/ksp/compare/2.3.0...2.3.2) --- updated-dependencies: - dependency-name: androidx.compose:compose-bom dependency-version: 2025.11.00 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: com.google.devtools.ksp dependency-version: 2.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca43e139b..303674d1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ bitfire-cert4android = "b3160b02b8" bitfire-dav4jvm = "f11523619b" bitfire-synctools = "5fc6688ff6" compose-accompanist = "0.37.3" -compose-bom = "2025.10.01" +compose-bom = "2025.11.00" conscrypt = "2.5.3" dnsjava = "3.6.3" glance = "1.1.1" @@ -31,7 +31,7 @@ hilt = "2.57.2" # keep in sync with ksp version kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" -ksp = "2.3.0" +ksp = "2.3.2" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" okhttp = "5.3.0" From d00292f4211383c85eb0ae30ec49b7ecedb21b07 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 12 Nov 2025 11:04:13 +0100 Subject: [PATCH 25/53] Only use cert4android when needed (#1802) * Refactor ClientCertKeyManager and HttpClientBuilder - Add logging to `ClientCertKeyManager` for better error handling. - Update `HttpClientBuilder` to conditionally use custom trust manager and hostname verifier based on `allowCustomCerts` flag. - Rename `customCertsUI` to `allowCustomCerts` in build configuration. * Update trust manager and hostname verifier selection logic - Improve logging and error handling in `ClientCertKeyManager` * App settings: hide certificate settings when custom certificates are not allowed * Typo --- app/build.gradle.kts | 3 +- .../davdroid/network/ClientCertKeyManager.kt | 52 ++++++++++--- .../davdroid/network/HttpClientBuilder.kt | 77 +++++++++++++----- .../bitfire/davdroid/ui/AppSettingsScreen.kt | 78 ++++++++++--------- 4 files changed, 143 insertions(+), 67 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90bad164a..32232ee16 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,7 +27,8 @@ android { 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" } 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 Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder { val credentials = getCredentials() if (credentials.authState != null) { @@ -130,6 +137,7 @@ class HttpClientBuilder @Inject constructor( } private var followRedirects = false + fun followRedirects(follow: Boolean): HttpClientBuilder { followRedirects = follow return this @@ -224,7 +232,8 @@ class HttpClientBuilder @Inject constructor( // app-wide custom proxy support buildProxy(okBuilder) - // add authentication + // add connection security (including client certificates) and authentication + buildConnectionSecurity(okBuilder) buildAuthentication(okBuilder) // add network logging, if requested @@ -246,15 +255,17 @@ class HttpClientBuilder @Inject constructor( // basic/digest auth and OAuth authenticationInterceptor?.let { okBuilder.addInterceptor(it) } authenticator?.let { okBuilder.authenticator(it) } + } + private fun buildConnectionSecurity(okBuilder: OkHttpClient.Builder) { // client certificate - val keyManager: KeyManager? = certificateAlias?.let { alias -> + 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://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04 + // see https://datatracker.ietf.org/doc/draft-ietf-httpbis-secondary-server-certs/ okBuilder.protocols(listOf(Protocol.HTTP_1_1)) manager @@ -264,25 +275,49 @@ class HttpClientBuilder @Inject constructor( } } - // 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 - ) + // select trust manager and hostname verifier depending on whether custom certificates are allowed + val customTrustManager: X509TrustManager? + val customHostnameVerifier: HostnameVerifier? - 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)) + if (BuildConfig.allowCustomCerts) { + // use cert4android for custom certificate handling + customTrustManager = CustomCertManager( + context = 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) { 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 From 70766affd9d18599682e8424d40ac348058053fe Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 17 Nov 2025 09:38:08 +0100 Subject: [PATCH 26/53] [Ktor] Allow building a Ktor client (#1810) * Add Ktor dependency * Add buildKtor method * Add test and deprecation notice * KDoc --- app/build.gradle.kts | 2 + ...ClientTest.kt => HttpClientBuilderTest.kt} | 26 +++- .../davdroid/network/HttpClientBuilder.kt | 117 +++++++++++++----- gradle/libs.versions.toml | 3 + 4 files changed, 110 insertions(+), 38 deletions(-) rename app/src/androidTest/kotlin/at/bitfire/davdroid/network/{HttpClientTest.kt => HttpClientBuilderTest.kt} (77%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32232ee16..9ca90fa2c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -190,6 +190,8 @@ dependencies { 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) 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 77% 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 f08125b1f..1e8cfe836 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt @@ -7,7 +7,9 @@ package at.bitfire.davdroid.network import android.security.NetworkSecurityPolicy import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import okhttp3.OkHttpClient +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 @@ -20,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: HttpClientBuilder + lateinit var httpClientBuilder: Provider - lateinit var httpClient: OkHttpClient lateinit var server: MockWebServer @Before fun setUp() { hiltRule.inject() - httpClient = httpClientBuilder.build() - server = MockWebServer() server.start(30000) } @@ -49,6 +49,18 @@ class HttpClientTest { } + @Test + fun testBuildKtor_CreatesWorkingClient() = runTest { + server.enqueue(MockResponse() + .setResponseCode(200) + .setBody("Some Content")) + + val client = httpClientBuilder.get().buildKtor() + 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,6 +72,8 @@ class HttpClientTest { .addHeader("Set-Cookie", "cookie1=1; path=/") .addHeader("Set-Cookie", "cookie2=2") .setBody("Cookie set")) + + val httpClient = httpClientBuilder.get().build() httpClient.newCall(Request.Builder() .get().url(url) .build()).execute() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index 6ec95ac5a..f4558f718 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -19,6 +19,8 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.ui.ForegroundTracker import com.google.common.net.HttpHeaders import dagger.hilt.android.qualifiers.ApplicationContext +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import net.openid.appauth.AuthState @@ -181,17 +183,17 @@ class HttpClientBuilder @Inject constructor( } - // actual builder + // okhttp builder /** - * Builds the [OkHttpClient]. + * Builds an [OkHttpClient] with the configured settings. * - * Must be called only once because multiple calls indicate this wrong usage pattern: + * [build] or [buildKtor] must be called only once because multiple calls indicate this wrong usage pattern: * * ``` * val builder = HttpClientBuilder(/*injected*/) - * val client1 = builder.configure().builder() - * val client2 = builder.configureOtherwise().builder() + * 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`, @@ -199,42 +201,39 @@ class HttpClientBuilder @Inject constructor( * * @throws IllegalStateException on second and later calls */ + @Deprecated("Use buildKtor instead", replaceWith = ReplaceWith("buildKtor()")) fun build(): OkHttpClient { if (alreadyBuilt) throw IllegalStateException("build() must only be called once; use Provider") - 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 + val builder = OkHttpClient.Builder() + configureOkHttp(builder) - // don't allow redirects by default because it would break PROPFIND handling - .followRedirects(followRedirects) + alreadyBuilt = true + return builder.build() + } - // add User-Agent to every request - .addInterceptor(UserAgentInterceptor) + private fun configureOkHttp(builder: OkHttpClient.Builder) { + buildTimeouts(builder) - // connection-private cookie store - .cookieJar(cookieStore) + // don't allow redirects by default because it would break PROPFIND handling + builder.followRedirects(followRedirects) - // allow cleartext and TLS 1.2+ - .connectionSpecs(listOf( - ConnectionSpec.Companion.CLEARTEXT, - ConnectionSpec.Companion.MODERN_TLS - )) + // add User-Agent to every request + builder.addInterceptor(UserAgentInterceptor) - // offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`) - .addInterceptor(BrotliInterceptor) + // 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(okBuilder) + buildProxy(builder) // add connection security (including client certificates) and authentication - buildConnectionSecurity(okBuilder) - buildAuthentication(okBuilder) + buildConnectionSecurity(builder) + buildAuthentication(builder) // add network logging, if requested if (logger.isLoggable(Level.FINEST)) { @@ -244,11 +243,8 @@ class HttpClientBuilder @Inject constructor( loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE) loggingInterceptor.redactHeader(HttpHeaders.SET_COOKIE2) loggingInterceptor.level = loggerInterceptorLevel - okBuilder.addNetworkInterceptor(loggingInterceptor) + builder.addNetworkInterceptor(loggingInterceptor) } - - alreadyBuilt = true - return okBuilder.build() } private fun buildAuthentication(okBuilder: OkHttpClient.Builder) { @@ -258,6 +254,12 @@ class HttpClientBuilder @Inject constructor( } 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 { @@ -346,4 +348,55 @@ class HttpClientBuilder @Inject constructor( } } + /** + * 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. + */ + fun buildKtor(): HttpClient { + if (alreadyBuilt) + throw IllegalStateException("build() must only be called once; use Provider") + + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 303674d1e..04a5bfb9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ hilt = "2.57.2" kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" ksp = "2.3.2" +ktor = "3.3.2" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" okhttp = "5.3.0" @@ -93,6 +94,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" } From 28dcf907750d59dd2e453dce047ef73acbd1b229 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 17 Nov 2025 10:34:36 +0100 Subject: [PATCH 27/53] Refactor default reminder builder to dedicated class + unit tests (#1815) * [WIP] Move default reminder builder to dedicated class + unit tests * Add tests * Just turn off Conscrypt for now * Fix library name --- app/build.gradle.kts | 1 + .../davdroid/sync/CalendarSyncManager.kt | 24 ++--- .../davdroid/sync/DefaultReminderBuilder.kt | 60 ++++++++++++ .../sync/DefaultReminderBuilderTest.kt | 95 +++++++++++++++++++ gradle/libs.versions.toml | 2 + 5 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilder.kt create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/sync/DefaultReminderBuilderTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ca90fa2c..42a8769c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -227,4 +227,5 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.robolectric) } 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 c3c6537b1..f1a32edb6 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -28,7 +28,6 @@ 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.util.DateUtils import at.bitfire.synctools.exception.InvalidICalendarException import at.bitfire.synctools.icalendar.CalendarUidSplitter import at.bitfire.synctools.icalendar.ICalendarGenerator @@ -43,16 +42,13 @@ 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.component.VEvent -import net.fortuna.ical4j.model.property.Action import okhttp3.HttpUrl 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 @@ -300,18 +296,6 @@ class CalendarSyncManager @AssistedInject constructor( // Event: main VEVENT and potentially attached exceptions (further VEVENTs with RECURRENCE-ID) val event = uidsAndEvents.values.first() - val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() - val mainEvent = event.main - if (mainEvent != null && defaultAlarmMinBefore != null && DateUtils.isDateTime(mainEvent.startDate) && mainEvent.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, "${mainEvent.uid}: Adding default alarm", alarm) - mainEvent.components += alarm - } - // map AssociatedEvents (VEVENTs) to EventAndExceptions (Android events) val androidEvent = AndroidEventBuilder( calendar = localCollection.androidCalendar, @@ -321,7 +305,13 @@ class CalendarSyncManager @AssistedInject constructor( flags = LocalResource.FLAG_REMOTELY_PRESENT ).build(event) - // update local event, if it exists + // add default reminder (if desired) + accountSettings.getDefaultAlarm()?.let { minBefore -> + logger.log(Level.INFO, "Adding default alarm ($minBefore min before)", event) + DefaultReminderBuilder(minBefore = minBefore).add(to = androidEvent) + } + + // create/update local event in calendar provider val local = localCollection.findByName(fileName) if (local != null) { SyncException.wrapWithLocalResource(local) { 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04a5bfb9f..c2a16f529 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" okhttp = "5.3.0" openid-appauth = "0.11.1" +robolectric = "4.16" room = "2.8.3" unifiedpush = "3.1.2" unifiedpush-fcm = "3.0.0" @@ -104,6 +105,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" } From 084ba3b630143fc3fbcda7abfbe0011d0fe8d3a4 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 17 Nov 2025 13:38:10 +0100 Subject: [PATCH 28/53] Update dav4jvm to new okhttp package (#1786) * Upgrade dav4jvm * Exclude ktor from dav4jvm * Fix imports and fix usages Signed-off-by: Arnau Mora * Fix imports for instrumented tests Signed-off-by: Arnau Mora * Fix imports Signed-off-by: Arnau Mora * Upgrade dav4jvm Signed-off-by: Arnau Mora * Do not exclude ktor in dav4jvm Signed-off-by: Arnau Mora * Upgrade dav4jvm Signed-off-by: Arnau Mora --------- Signed-off-by: Arnau Mora --- .../at/bitfire/davdroid/db/CollectionTest.kt | 2 +- .../servicedetection/DavResourceFinderTest.kt | 2 +- .../bitfire/davdroid/sync/SyncManagerTest.kt | 6 ++--- .../bitfire/davdroid/sync/TestSyncManager.kt | 6 ++--- .../at/bitfire/davdroid/db/Collection.kt | 4 +-- .../at/bitfire/davdroid/db/Principal.kt | 4 +-- .../at/bitfire/davdroid/network/HttpClient.kt | 0 .../davdroid/network/HttpClientBuilder.kt | 4 +-- .../davdroid/network/NextcloudLoginFlow.kt | 4 +-- .../davdroid/push/PushRegistrationManager.kt | 6 ++--- .../repository/DavCollectionRepository.kt | 8 +++--- .../CollectionsWithoutHomeSetRefresher.kt | 4 +-- .../servicedetection/DavResourceFinder.kt | 12 ++++----- .../servicedetection/HomeSetRefresher.kt | 6 ++--- .../servicedetection/PrincipalsRefresher.kt | 4 +-- .../RefreshCollectionsWorker.kt | 2 +- .../servicedetection/ServiceRefresher.kt | 6 ++--- .../davdroid/sync/CalendarSyncManager.kt | 8 +++--- .../davdroid/sync/ContactsSyncManager.kt | 11 ++++---- .../bitfire/davdroid/sync/JtxSyncManager.kt | 8 +++--- .../at/bitfire/davdroid/sync/SyncManager.kt | 26 +++++++++---------- .../davdroid/sync/SyncNotificationManager.kt | 2 +- .../bitfire/davdroid/sync/TasksSyncManager.kt | 8 +++--- .../bitfire/davdroid/ui/DebugInfoGenerator.kt | 2 +- .../at/bitfire/davdroid/ui/DebugInfoScreen.kt | 4 +-- .../ui/composable/ExceptionInfoDialog.kt | 9 +------ .../davdroid/webdav/DocumentProviderUtils.kt | 2 +- .../bitfire/davdroid/webdav/HeadResponse.kt | 2 +- .../davdroid/webdav/RandomAccessCallback.kt | 6 ++--- .../webdav/StreamingFileDescriptor.kt | 4 +-- .../davdroid/webdav/WebDavMountRepository.kt | 2 +- .../webdav/operation/CopyDocumentOperation.kt | 4 +-- .../operation/CreateDocumentOperation.kt | 4 +-- .../operation/DeleteDocumentOperation.kt | 4 +-- .../webdav/operation/MoveDocumentOperation.kt | 4 +-- .../OpenDocumentThumbnailOperation.kt | 2 +- .../operation/QueryChildDocumentsOperation.kt | 7 ++--- .../operation/RenameDocumentOperation.kt | 4 +-- gradle/libs.versions.toml | 2 +- 39 files changed, 100 insertions(+), 105 deletions(-) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt 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 5ca3d622f..c46f0818d 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt @@ -6,7 +6,7 @@ package at.bitfire.davdroid.db import android.security.NetworkSecurityPolicy import androidx.test.filters.SmallTest -import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.dav4jvm.property.webdav.ResourceType import at.bitfire.davdroid.network.HttpClientBuilder import dagger.hilt.android.testing.HiltAndroidRule 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 5445c9837..bc4ee18d6 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -5,7 +5,7 @@ package at.bitfire.davdroid.servicedetection import android.security.NetworkSecurityPolicy -import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet import at.bitfire.dav4jvm.property.webdav.ResourceType import at.bitfire.davdroid.network.HttpClientBuilder 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 6677492b5..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,9 +8,9 @@ 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 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 e21e64548..6797fe6af 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt @@ -5,9 +5,9 @@ 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.GetCTag import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.di.SyncDispatcher 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..5fae2b924 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,8 @@ 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.CalendarColor import at.bitfire.dav4jvm.property.caldav.CalendarDescription import at.bitfire.dav4jvm.property.caldav.CalendarTimezone 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..c76c5f0f3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt @@ -8,8 +8,8 @@ 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.davdroid.util.trimToNull diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index f4558f718..c0118e78b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -8,8 +8,8 @@ import android.accounts.Account import android.content.Context import androidx.annotation.WorkerThread import at.bitfire.cert4android.CustomCertManager -import at.bitfire.dav4jvm.BasicDigestAuthHandler -import at.bitfire.dav4jvm.UrlUtils +import at.bitfire.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 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 8010fb117..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 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 3fe787fb0..475b79d52 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -11,12 +11,12 @@ 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.okhttp.DavCollection +import at.bitfire.dav4jvm.okhttp.DavResource +import at.bitfire.dav4jvm.okhttp.exception.DavException import at.bitfire.dav4jvm.property.push.AuthSecret import at.bitfire.dav4jvm.property.push.PushRegister import at.bitfire.dav4jvm.property.push.PushResource 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 533a27ab6..b853a41c9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -6,12 +6,12 @@ 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.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.CalendarColor import at.bitfire.dav4jvm.property.caldav.CalendarDescription import at.bitfire.dav4jvm.property.caldav.CalendarTimezone 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 7777ffed5..6e486d913 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -6,13 +6,13 @@ 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.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.CalendarColor import at.bitfire.dav4jvm.property.caldav.CalendarDescription import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet 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..9c1c15934 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.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.DisplayName import at.bitfire.dav4jvm.property.webdav.ResourceType import at.bitfire.davdroid.db.AppDatabase 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 99b1bbb4a..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,7 +22,7 @@ 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.HttpClientBuilder import at.bitfire.davdroid.push.PushRegistrationManager 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..e06c24d75 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt @@ -4,10 +4,10 @@ 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.CalendarHomeSet import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor 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 f1a32edb6..49a5ddae7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -6,10 +6,10 @@ 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.CalendarData import at.bitfire.dav4jvm.property.caldav.GetCTag import at.bitfire.dav4jvm.property.caldav.MaxResourceSize 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 6cb08756b..666bdcda4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -7,10 +7,10 @@ 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.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.GetCTag import at.bitfire.dav4jvm.property.carddav.AddressData import at.bitfire.dav4jvm.property.carddav.MaxResourceSize @@ -51,6 +51,7 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType import okhttp3.OkHttpClient +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream @@ -357,7 +358,7 @@ 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) } 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 140b91040..729874c64 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -7,10 +7,10 @@ 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.CalendarData import at.bitfire.dav4jvm.property.caldav.GetCTag import at.bitfire.dav4jvm.property.caldav.MaxResourceSize 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 86b598d8a..30d8f7884 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -9,21 +9,21 @@ import android.content.Context import android.os.DeadObjectException import android.os.RemoteException import androidx.annotation.VisibleForTesting -import at.bitfire.dav4jvm.DavCollection -import at.bitfire.dav4jvm.DavResource import at.bitfire.dav4jvm.Error -import at.bitfire.dav4jvm.MultiResponseCallback import at.bitfire.dav4jvm.QuotedStringUtils -import at.bitfire.dav4jvm.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.GetCTag import at.bitfire.dav4jvm.property.caldav.ScheduleTag import at.bitfire.dav4jvm.property.webdav.GetETag 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 0197f8013..8f161a0ff 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncNotificationManager.kt @@ -15,7 +15,7 @@ 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 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 9d5e5f67e..82d62250f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt @@ -6,10 +6,10 @@ 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.CalendarData import at.bitfire.dav4jvm.property.caldav.GetCTag import at.bitfire.dav4jvm.property.caldav.MaxResourceSize 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 ad6c2cbd0..422b6ba24 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt @@ -43,8 +43,8 @@ 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 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/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 45296a1af..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,8 +5,8 @@ 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 okhttp3.HttpUrl import okhttp3.OkHttpClient 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 a8327d19d..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,10 +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.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 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 d6a3d866d..9c0c938ca 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -5,8 +5,8 @@ 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.util.DavUtils import dagger.assisted.Assisted 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 950936a2a..61a3c837e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -7,7 +7,7 @@ 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 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 8525e39c6..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 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 6dc95392a..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 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 9d6bb6028..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 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 f0be138de..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 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 31281a524..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 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 6622a2526..6f70e7bb5 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 @@ -33,6 +33,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 @@ -155,7 +156,7 @@ class QueryChildDocumentsOperation @Inject constructor( isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) ?: resource.isDirectory, displayName = response[DisplayName::class.java]?.displayName, - mimeType = response[GetContentType::class.java]?.type, + 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, 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 06033988c..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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2a16f529..e49c24d97 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0" androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "b3160b02b8" -bitfire-dav4jvm = "f11523619b" +bitfire-dav4jvm = "0979bd7e56" bitfire-synctools = "5fc6688ff6" compose-accompanist = "0.37.3" compose-bom = "2025.11.00" From a3aac447751cdd84fcafb0962675275dd5ddf57b Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 19 Nov 2025 15:14:41 +0100 Subject: [PATCH 29/53] Ignore failing test: `testVerifySyncAlwaysPending_wrongBehaviour_android14` (#1824) Comment out test --- .../at/bitfire/davdroid/sync/AndroidSyncFrameworkTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 9bc46d4194edb8e08d42dcf286d63453bc66ac1d Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 19 Nov 2025 15:35:06 +0100 Subject: [PATCH 30/53] Update dependencies, including our libs (#1822) --- .../at/bitfire/davdroid/network/HttpClientBuilder.kt | 3 ++- .../at/bitfire/davdroid/sync/CalendarSyncManager.kt | 6 +++--- gradle/libs.versions.toml | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index c0118e78b..3e3ca6883 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -8,6 +8,7 @@ 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 @@ -284,7 +285,7 @@ class HttpClientBuilder @Inject constructor( if (BuildConfig.allowCustomCerts) { // use cert4android for custom certificate handling customTrustManager = CustomCertManager( - context = context, + certStore = CustomCertStore.getInstance(context), trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES), appInForeground = ForegroundTracker.inForeground ) 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 49a5ddae7..2d22210e8 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -33,7 +33,7 @@ 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.AndroidEventProcessor +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 @@ -189,11 +189,11 @@ class CalendarSyncManager @AssistedInject constructor( val updatedSequence = SequenceUpdater().increaseSequence(localEvent.main) // map Android event to iCalendar (also generates UID, if necessary) - val processor = AndroidEventProcessor( + val handler = AndroidEventHandler( accountName = resource.recurringCalendar.calendar.account.name, prodIdGenerator = DefaultProdIdGenerator(Constants.iCalProdId) ) - val mappedEvents = processor.mapToVEvents(localEvent) + val mappedEvents = handler.mapToVEvents(localEvent) // persist UID if it was generated if (mappedEvents.generatedUid) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e49c24d97..4d684f638 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ # 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-appcompat = "1.7.1" @@ -18,9 +18,9 @@ androidx-test-runner = "1.7.0" androidx-test-rules = "1.7.0" androidx-test-junit = "1.3.0" androidx-work = "2.11.0" -bitfire-cert4android = "b3160b02b8" -bitfire-dav4jvm = "0979bd7e56" -bitfire-synctools = "5fc6688ff6" +bitfire-cert4android = "42d883e958" +bitfire-dav4jvm = "ad80cdccac" +bitfire-synctools = "730c3c5f0a" compose-accompanist = "0.37.3" compose-bom = "2025.11.00" conscrypt = "2.5.3" From aac635672292dbbdedd5e7e4628a15aee1a7ec2f Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Wed, 19 Nov 2025 16:52:35 +0100 Subject: [PATCH 31/53] Delete app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt Remove empty file --- app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt deleted file mode 100644 index e69de29bb..000000000 From 794b4c1c7f328ae3f1ae7af21492aeb3c2248639 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 20 Nov 2025 09:33:24 +0100 Subject: [PATCH 32/53] DebugInfo: Support viewing jtx Board resources (#1818) * Support viewing jtxBoard resources from debug info * Correct value of EXTRA_LOCAL_RESOURCE_URI * Correct comment * Use the same intent for journals, notes, calendar and tasks * State working task authorities explicitly * Use edit action to not crash opentasks * Use getViewIntentUriFor for jtx Board tasks * Remove explicit tasks authority for jtx Board * Remove explicit tasks authority for jtx Board * Remove early return statement * Dont handle jtxBoard tasks in LocalTask which is only for Dmfs tasks * Add some kdoc to LocalTask and LocalJtxICalObject * Use when with in list * Add FLAG_GRANT_READ_URI_PERMISSION to the correct intent * Correct line endings to from CRLF to LF --- .../davdroid/resource/LocalJtxICalObject.kt | 6 ++- .../at/bitfire/davdroid/resource/LocalTask.kt | 19 +++++++--- .../bitfire/davdroid/ui/DebugInfoActivity.kt | 37 +++++++++++-------- 3 files changed, 40 insertions(+), 22 deletions(-) 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 b6a5b38cc..f492e4f0e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -10,10 +10,14 @@ 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?, @@ -84,6 +88,6 @@ class LocalJtxICalObject( .add("flags", flags) .toString() - override fun getViewUri(context: Context) = null + override fun getViewUri(context: Context) = getViewIntentUriFor(id) } \ 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 2148c19c4..60b19c36d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -15,10 +15,14 @@ import at.bitfire.ical4android.DmfsTaskList import at.bitfire.ical4android.Task import at.bitfire.ical4android.TaskProvider import at.bitfire.synctools.storage.BatchOperation +import at.techbee.jtx.JtxContract import com.google.common.base.MoreObjects import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.Optional +/** + * Represents a Dmfs Task (OpenTasks and Tasks.org) entry + */ class LocalTask: DmfsTask, LocalResource { companion object { @@ -131,13 +135,16 @@ class LocalTask: DmfsTask, LocalResource { )*/ .toString() - override fun getViewUri(context: Context): Uri? { - val idNotNull = id ?: return null - if (taskList.providerName == TaskProvider.ProviderName.OpenTasks) { - val contentUri = Tasks.getContentUri(taskList.providerName.authority) - return ContentUris.withAppendedId(contentUri, idNotNull) + 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 } - return null } 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 5e913ebda..5e69a4f14 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -23,6 +23,8 @@ 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 @@ -147,25 +149,31 @@ class DebugInfoActivity: AppCompatActivity() { } /** - * Builds intent to view the problematic local event, task or contact at given Uri. + * Builds intent to view the problematic local resource at given Uri. * - * Note that only OpenTasks is supported as tasks provider. TasksOrg and jtxBoard - * do not support viewing tasks via intent-filter (yet). See also [at.bitfire.davdroid.sync.SyncNotificationManager.getLocalResourceUri] + * 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? { - val activeTasksAuthority = tasksAppManager.get().currentProvider()?.authority - return when (uri.authority) { - ContactsContract.AUTHORITY -> - Intent(Intent.ACTION_VIEW).apply { + 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) } - CalendarContract.AUTHORITY, activeTasksAuthority -> - Intent(Intent.ACTION_VIEW, uri) - else -> null - } - } + }?.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) /** * Builder for [DebugInfoActivity] intents @@ -215,7 +223,6 @@ class DebugInfoActivity: AppCompatActivity() { if (uri == null) return this intent.putExtra(EXTRA_LOCAL_RESOURCE_URI, uri) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) return this } @@ -257,7 +264,7 @@ class DebugInfoActivity: AppCompatActivity() { 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 = "localResourceId" + internal const val EXTRA_LOCAL_RESOURCE_URI = "localResourceUri" /** logs related to the problem (plain-text [String]) */ private const val EXTRA_LOGS = "logs" From 76fc024ef6fc258cc0650fbbf61a50097d3d6af5 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 20 Nov 2025 11:37:24 +0100 Subject: [PATCH 33/53] Lower default minimum log level from INFO to FINE (#1827) * Increase logging level * Adjust log levels for visibility in non-verbose logs - Change log level from FINER to FINE in StreamingFileDescriptor - Update log level from FINER to FINE in AccountSettingsMigration8 - Add note about log levels in LogManager documentation * Fix KDoc typo * Update comment --- .../main/kotlin/at/bitfire/davdroid/log/LogManager.kt | 9 ++++++++- .../settings/migration/AccountSettingsMigration8.kt | 3 +-- .../davdroid/sync/adapter/SyncFrameworkIntegration.kt | 1 - .../bitfire/davdroid/webdav/StreamingFileDescriptor.kt | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) 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/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/adapter/SyncFrameworkIntegration.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/adapter/SyncFrameworkIntegration.kt index b92fcb6dc..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 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 9c0c938ca..a4545b289 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/StreamingFileDescriptor.kt @@ -97,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 @@ -118,7 +118,7 @@ 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") } } } From 3d4d533b92d87852bc16a3d8c822fdcb9e6ac227 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 20 Nov 2025 16:42:40 +0100 Subject: [PATCH 34/53] Update synctools (#1825) * Update synctools * Update commit ID --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d684f638..9c1720d10 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "42d883e958" bitfire-dav4jvm = "ad80cdccac" -bitfire-synctools = "730c3c5f0a" +bitfire-synctools = "017187c6d8" compose-accompanist = "0.37.3" compose-bom = "2025.11.00" conscrypt = "2.5.3" From babd52cfb1cabc5193b736dacd154b95a7f0be7e Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 20 Nov 2025 16:45:40 +0100 Subject: [PATCH 35/53] Bump version to 4.5.6-rc.1 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42a8769c0..b76f3f201 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405060002 - versionName = "4.5.6-beta.1" + versionCode = 405060003 + versionName = "4.5.6-rc.1" base.archivesName = "davx5-ose-$versionName" From 52631723762fc3edb537f708c5bcaffcfd047ff1 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Fri, 21 Nov 2025 12:48:04 +0100 Subject: [PATCH 36/53] [Ktor] Add MustBeClosed annotation to buildKtor method (#1829) Add @MustBeClosed annotation to buildKtor method This commit adds the `@MustBeClosed` annotation to the `buildKtor` method in `HttpClientBuilder.kt` to indicate that the returned `HttpClient` instance must be closed by the caller. It also updates the test in `HttpClientBuilderTest.kt` to use the `use` function to ensure proper resource management. --- .../at/bitfire/davdroid/network/HttpClientBuilderTest.kt | 9 +++++---- .../at/bitfire/davdroid/network/HttpClientBuilder.kt | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt index 1e8cfe836..323d04666 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/network/HttpClientBuilderTest.kt @@ -55,10 +55,11 @@ class HttpClientBuilderTest { .setResponseCode(200) .setBody("Some Content")) - val client = httpClientBuilder.get().buildKtor() - val response = client.get(server.url("/").toString()) - assertEquals(200, response.status.value) - assertEquals("Some Content", response.bodyAsText()) + httpClientBuilder.get().buildKtor().use { client -> + val response = client.get(server.url("/").toString()) + assertEquals(200, response.status.value) + assertEquals("Some Content", response.bodyAsText()) + } } @Test diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index 3e3ca6883..50ec066de 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -19,6 +19,7 @@ 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 @@ -202,7 +203,6 @@ class HttpClientBuilder @Inject constructor( * * @throws IllegalStateException on second and later calls */ - @Deprecated("Use buildKtor instead", replaceWith = ReplaceWith("buildKtor()")) fun build(): OkHttpClient { if (alreadyBuilt) throw IllegalStateException("build() must only be called once; use Provider") @@ -378,7 +378,10 @@ class HttpClientBuilder @Inject constructor( * * 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) throw IllegalStateException("build() must only be called once; use Provider") From 3bd3f56e1bc3f5f8e110d347c4418b8decb6d26f Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 24 Nov 2025 13:14:40 +0100 Subject: [PATCH 37/53] Fix DeadObjectException handling in SyncManager and Syncer (#1834) - Update SyncManager to handle LocalStorageException with DeadObjectException cause - Refactor Syncer to catch all exceptions and handle specific cases --- .../at/bitfire/davdroid/sync/SyncManager.kt | 7 +++-- .../kotlin/at/bitfire/davdroid/sync/Syncer.kt | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 13 deletions(-) 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 30d8f7884..2876bc2bc 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -256,9 +256,10 @@ abstract class SyncManager + /* 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 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 3249b8aca..573bb87fe 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/Syncer.kt @@ -17,6 +17,7 @@ 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 @@ -259,18 +260,28 @@ 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 { logger.info("${dataStore.authority} sync of $account finished") From 114543f4c53f1337efa262b6815c337d68bc820a Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 24 Nov 2025 13:22:09 +0100 Subject: [PATCH 38/53] Fetch translations from Transifex --- app/src/main/res/values-bg/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-et/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-pt/strings.xml | 280 +++++++++--------- app/src/main/res/values-ro/strings.xml | 8 + app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-sv/strings.xml | 19 ++ app/src/main/res/values-zh/strings.xml | 7 + .../metadata/android/pt/full_description.txt | 6 +- .../metadata/android/pt/short_description.txt | 2 +- 11 files changed, 191 insertions(+), 148 deletions(-) diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index c75c466cd..f93a153a4 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -423,8 +423,10 @@ Налични са подробни дневници Преглед Копиране на адреса + Проверка на ресурса Съобщение за защита на личните данни Дневниците и информацията за отстраняване на грешки могат да съдържат лична информация. Имайте го предвид, когато ги споделяте публично. + Ресурсът не може да бъде прегледан Възникна грешка. Възникна грешка на HTTP. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 13ae64558..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 @@ -423,8 +423,10 @@ 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. diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 6328e92bd..44889309b 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -422,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. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5a4c4e346..35518df53 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -426,8 +426,10 @@ 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. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 03766a855..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,23 +385,23 @@ 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 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. @@ -409,29 +409,29 @@ 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) @@ -439,10 +439,10 @@ Erro no servidor HTTP – %s Erro de armazenamento local – %s Erro simples (número de tentativas máximo atingido) - 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 15c861071..7c54e72ed 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -411,6 +411,11 @@ Eroare de server Eroare WebDAV Eroare I/O + 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 Au fost colectate informații de depanare Resurse implicate @@ -420,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. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 92e124ca0..7270d435e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -165,7 +165,7 @@ Соединение Тип прокси - Определен системой + Системный Без прокси HTTP SOCKS (для Orbot) @@ -187,7 +187,7 @@ Управление каналами уведомлений и их настройками Выбор темы - Определена системой + Системная Светлая Темная @@ -324,7 +324,7 @@ VPN без основного интернета достаточно для выполнения синхронизации Аутентификация Имя пользователя - Пароль или пароль приложения + Пароль аккаунта или пароль приложения пароль приложения.]]> Новый пароль Обновить пароль diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index d8d2dc751..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,6 +409,11 @@ Server-fel WebDAV-fel I/O-fel + 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 @@ -410,8 +424,10 @@ 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. @@ -428,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 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8dc2772c9..fd48c9c3e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -405,6 +405,11 @@ 服务器错误 WebDAV错误 I/O错误 + 服务器拒绝了该请求 + 所请求的资源(不再存在)不存在。 + 服务器不允许请求的操作类型。 + 发生服务器端问题。 请联系您的服务器支持 + 发生意外错误。详情见调试信息。 查看细节 已收集调试信息 所涉资源 @@ -415,8 +420,10 @@ 详细日志可用 查看日志 复制 URL + 查看资源 隐私声明 日志和调试信息可能包含私密信息。公开分享时请意识到这一点 + 无法查看资源 出现错误 出现 HTTP 错误 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 From 365364aa896febed65ced4183479eb8ec34f7ea0 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 24 Nov 2025 13:26:18 +0100 Subject: [PATCH 39/53] Update version to 4.5.6 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b76f3f201..335e33129 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405060003 - versionName = "4.5.6-rc.1" + versionCode = 405060004 + versionName = "4.5.6" base.archivesName = "davx5-ose-$versionName" From 66a34ebd9f40ee4fcded929c6f2843fb684e85d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:00:24 +0100 Subject: [PATCH 40/53] [CI] Bump actions/checkout from 5 to 6 in the ci-actions group (#1837) Bumps the ci-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: ci-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test-dev.yml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) 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/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 From 7c2dcf3d70ed94a7ef31830a244037c403726554 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 24 Nov 2025 16:00:53 +0100 Subject: [PATCH 41/53] Comment out failing tests (#1836) --- .../at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt | 2 ++ .../settings/migration/AccountSettingsMigration21Test.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt index 50027ec0a..86996f7f9 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt @@ -21,6 +21,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -61,6 +62,7 @@ class LocalCalendarStoreTest { } + @Ignore("Sometimes failing, see https://github.com/bitfireAT/davx5-ose/issues/1828") @Test fun testUpdateAccount_updatesOwnerAccount() { // Verify initial state 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 index e732626d0..f135b5cb3 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt @@ -26,6 +26,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.BeforeClass +import org.junit.Ignore import org.junit.Rule import org.junit.Test import java.util.logging.Logger @@ -78,6 +79,7 @@ class AccountSettingsMigration21Test { } + @Ignore("Sometimes failing, see https://github.com/bitfireAT/davx5-ose/issues/1835") @SdkSuppress(minSdkVersion = 34) @Test fun testCancelsSyncAndClearsPendingState() = runBlocking { From f64882ca2a5f439fded9af9672e324d42a5d7a73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:42:28 +0100 Subject: [PATCH 42/53] Bump the app-dependencies group with 15 updates (#1840) Bumps the app-dependencies group with 15 updates: | Package | From | To | | --- | --- | --- | | androidx.activity:activity-compose | `1.11.0` | `1.12.0` | | androidx.lifecycle:lifecycle-runtime-compose | `2.9.4` | `2.10.0` | | androidx.lifecycle:lifecycle-viewmodel-ktx | `2.9.4` | `2.10.0` | | androidx.lifecycle:lifecycle-viewmodel-compose | `2.9.4` | `2.10.0` | | androidx.compose:compose-bom | `2025.11.00` | `2025.11.01` | | [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` | | [com.squareup.okhttp3:okhttp-brotli](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` | | [com.squareup.okhttp3:logging-interceptor](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` | | [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp) | `5.3.0` | `5.3.2` | | androidx.room:room-ktx | `2.8.3` | `2.8.4` | | androidx.room:room-compiler | `2.8.3` | `2.8.4` | | androidx.room:room-paging | `2.8.3` | `2.8.4` | | androidx.room:room-runtime | `2.8.3` | `2.8.4` | | androidx.room:room-testing | `2.8.3` | `2.8.4` | | [com.google.devtools.ksp](https://github.com/google/ksp) | `2.3.2` | `2.3.3` | Updates `androidx.activity:activity-compose` from 1.11.0 to 1.12.0 Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.9.4 to 2.10.0 Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.4 to 2.10.0 Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.4 to 2.10.0 Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.4 to 2.10.0 Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.4 to 2.10.0 Updates `androidx.compose:compose-bom` from 2025.11.00 to 2025.11.01 Updates `com.squareup.okhttp3:okhttp` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `com.squareup.okhttp3:okhttp-brotli` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `com.squareup.okhttp3:logging-interceptor` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `com.squareup.okhttp3:mockwebserver` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `com.squareup.okhttp3:okhttp-brotli` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `com.squareup.okhttp3:logging-interceptor` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `com.squareup.okhttp3:mockwebserver` from 5.3.0 to 5.3.2 - [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md) - [Commits](https://github.com/square/okhttp/compare/parent-5.3.0...parent-5.3.2) Updates `androidx.room:room-ktx` from 2.8.3 to 2.8.4 Updates `androidx.room:room-compiler` from 2.8.3 to 2.8.4 Updates `androidx.room:room-paging` from 2.8.3 to 2.8.4 Updates `androidx.room:room-runtime` from 2.8.3 to 2.8.4 Updates `androidx.room:room-testing` from 2.8.3 to 2.8.4 Updates `androidx.room:room-compiler` from 2.8.3 to 2.8.4 Updates `androidx.room:room-paging` from 2.8.3 to 2.8.4 Updates `androidx.room:room-runtime` from 2.8.3 to 2.8.4 Updates `androidx.room:room-testing` from 2.8.3 to 2.8.4 Updates `com.google.devtools.ksp` from 2.3.2 to 2.3.3 - [Release notes](https://github.com/google/ksp/releases) - [Commits](https://github.com/google/ksp/compare/2.3.2...2.3.3) --- updated-dependencies: - dependency-name: androidx.activity:activity-compose dependency-version: 1.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.lifecycle:lifecycle-runtime-compose dependency-version: 2.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx dependency-version: 2.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose dependency-version: 2.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx dependency-version: 2.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose dependency-version: 2.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: app-dependencies - dependency-name: androidx.compose:compose-bom dependency-version: 2025.11.01 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:okhttp dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:okhttp-brotli dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:logging-interceptor dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:mockwebserver dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:okhttp-brotli dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:logging-interceptor dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.squareup.okhttp3:mockwebserver dependency-version: 5.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-ktx dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-compiler dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-paging dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-runtime dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-testing dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-compiler dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-paging dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-runtime dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: androidx.room:room-testing dependency-version: 2.8.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: com.google.devtools.ksp dependency-version: 2.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c1720d10..c12d7afd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,13 +3,13 @@ [versions] 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" @@ -22,7 +22,7 @@ bitfire-cert4android = "42d883e958" bitfire-dav4jvm = "ad80cdccac" bitfire-synctools = "017187c6d8" compose-accompanist = "0.37.3" -compose-bom = "2025.11.00" +compose-bom = "2025.11.01" conscrypt = "2.5.3" dnsjava = "3.6.3" glance = "1.1.1" @@ -31,14 +31,14 @@ hilt = "2.57.2" # keep in sync with ksp version kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" -ksp = "2.3.2" +ksp = "2.3.3" ktor = "3.3.2" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" -okhttp = "5.3.0" +okhttp = "5.3.2" openid-appauth = "0.11.1" robolectric = "4.16" -room = "2.8.3" +room = "2.8.4" unifiedpush = "3.1.2" unifiedpush-fcm = "3.0.0" From cda95dc78911c60d3721cbe6a7032448079eede1 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Thu, 27 Nov 2025 11:15:06 +0100 Subject: [PATCH 43/53] Fix HTTP Client provider for contact resource sync from URL (#1844) Signed-off-by: Arnau Mora --- .../kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 666bdcda4..6f4ba5e30 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -50,8 +50,8 @@ import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType -import okhttp3.OkHttpClient import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream @@ -60,6 +60,7 @@ import java.io.Reader import java.io.StringReader import java.util.Optional import java.util.logging.Level +import javax.inject.Provider import kotlin.jvm.optionals.getOrNull /** @@ -110,7 +111,7 @@ class ContactsSyncManager @AssistedInject constructor( @Assisted val syncFrameworkUpload: Boolean, val dirtyVerifier: Optional, accountSettingsFactory: AccountSettings.Factory, - private val httpClientBuilder: HttpClientBuilder, + private val httpClientBuilder: Provider, @SyncDispatcher syncDispatcher: CoroutineDispatcher ): SyncManager( account, @@ -488,6 +489,7 @@ class ContactsSyncManager @AssistedInject constructor( // authenticate only against a certain host, and only upon request val hostHttpClient = httpClientBuilder + .get() .fromAccount(account, onlyHost = baseUrl.host) .followRedirects(true) // allow redirects .build() From 84b9a14ba1435f00ae9d6ea3cef3f04703e20bfd Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 27 Nov 2025 11:15:39 +0100 Subject: [PATCH 44/53] Update version to 4.5.6.1 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 335e33129..29ad2ee10 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { defaultConfig { applicationId = "at.bitfire.davdroid" - versionCode = 405060004 - versionName = "4.5.6" + versionCode = 405060100 + versionName = "4.5.6.1" base.archivesName = "davx5-ose-$versionName" From a38dc29ccac5b8601388412158d478f2e11a6b54 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 27 Nov 2025 11:46:10 +0100 Subject: [PATCH 45/53] Update WebDAV property names (#1841) * Update WebDAV property names according to new dav4jvm naming scheme * Use new supported-report-set; fix `MaxResourceSize` property reference in CalendarSyncManager * Remove comment --- .../at/bitfire/davdroid/db/CollectionTest.kt | 10 ++-- .../servicedetection/DavResourceFinderTest.kt | 12 ++-- .../bitfire/davdroid/sync/TestSyncManager.kt | 3 +- .../at/bitfire/davdroid/db/Collection.kt | 8 ++- .../at/bitfire/davdroid/db/Principal.kt | 3 +- .../davdroid/push/PushMessageHandler.kt | 3 +- .../davdroid/push/PushRegistrationManager.kt | 21 +++---- .../repository/DavCollectionRepository.kt | 60 ++++++++----------- .../servicedetection/DavResourceFinder.kt | 38 ++++++------ .../servicedetection/PrincipalsRefresher.kt | 7 +-- .../servicedetection/ServiceDetectionUtils.kt | 45 ++++++-------- .../servicedetection/ServiceRefresher.kt | 22 +++---- .../davdroid/sync/CalendarSyncManager.kt | 14 +++-- .../davdroid/sync/ContactsSyncManager.kt | 19 ++++-- .../bitfire/davdroid/sync/JtxSyncManager.kt | 6 +- .../at/bitfire/davdroid/sync/SyncManager.kt | 37 ++++++------ .../bitfire/davdroid/sync/TasksSyncManager.kt | 6 +- .../operation/QueryChildDocumentsOperation.kt | 21 +++---- gradle/libs.versions.toml | 2 +- 19 files changed, 168 insertions(+), 169 deletions(-) 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 c46f0818d..fc11cb4d2 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/db/CollectionTest.kt @@ -7,7 +7,7 @@ package at.bitfire.davdroid.db import android.security.NetworkSecurityPolicy import androidx.test.filters.SmallTest import at.bitfire.dav4jvm.okhttp.DavResource -import at.bitfire.dav4jvm.property.webdav.ResourceType +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 @@ -65,7 +65,7 @@ class CollectionTest { lateinit var info: Collection DavResource(httpClient, server.url("/")) - .propfind(0, ResourceType.NAME) { response, _ -> + .propfind(0, WebDAV.ResourceType) { response, _ -> info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException() } assertEquals(Collection.TYPE_ADDRESSBOOK, info.type) @@ -121,7 +121,7 @@ class CollectionTest { lateinit var info: Collection DavResource(httpClient, server.url("/")) - .propfind(0, ResourceType.NAME) { response, _ -> + .propfind(0, WebDAV.ResourceType) { response, _ -> info = Collection.fromDavResponse(response)!! } assertEquals(Collection.TYPE_CALENDAR, info.type) @@ -157,7 +157,7 @@ class CollectionTest { lateinit var info: Collection DavResource(httpClient, server.url("/")) - .propfind(0, ResourceType.NAME) { response, _ -> + .propfind(0, WebDAV.ResourceType) { response, _ -> info = Collection.fromDavResponse(response)!! } assertEquals(Collection.TYPE_CALENDAR, info.type) @@ -191,7 +191,7 @@ class CollectionTest { lateinit var info: Collection DavResource(httpClient, server.url("/")) - .propfind(0, ResourceType.NAME) { response, _ -> + .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/servicedetection/DavResourceFinderTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt index bc4ee18d6..20d31ab4a 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -6,8 +6,8 @@ package at.bitfire.davdroid.servicedetection import android.security.NetworkSecurityPolicy import at.bitfire.dav4jvm.okhttp.DavResource -import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet -import at.bitfire.dav4jvm.property.webdav.ResourceType +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 @@ -93,8 +93,8 @@ class DavResourceFinderTest { // recognize home set var info = ServiceInfo() DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)) - .propfind(0, AddressbookHomeSet.NAME) { response, _ -> - finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) + .propfind(0, CardDAV.AddressbookHomeSet) { response, _ -> + finder.scanResponse(CardDAV.Addressbook, response, info) } assertEquals(0, info.collections.size) assertEquals(1, info.homeSets.size) @@ -103,8 +103,8 @@ class DavResourceFinderTest { // recognize address book info = ServiceInfo() DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK)) - .propfind(0, ResourceType.NAME) { response, _ -> - finder.scanResponse(ResourceType.ADDRESSBOOK, response, info) + .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/sync/TestSyncManager.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt index 6797fe6af..8dbad51fd 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/sync/TestSyncManager.kt @@ -8,6 +8,7 @@ import android.accounts.Account import at.bitfire.dav4jvm.okhttp.DavCollection import at.bitfire.dav4jvm.okhttp.MultiResponseCallback import at.bitfire.dav4jvm.okhttp.Response +import at.bitfire.dav4jvm.property.caldav.CalDAV import at.bitfire.dav4jvm.property.caldav.GetCTag import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.di.SyncDispatcher @@ -64,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) 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 5fae2b924..85fddb1df 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt @@ -12,6 +12,7 @@ import androidx.room.Index import androidx.room.PrimaryKey import at.bitfire.dav4jvm.okhttp.Response import at.bitfire.dav4jvm.okhttp.UrlUtils +import at.bitfire.dav4jvm.property.caldav.CalDAV import at.bitfire.dav4jvm.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 c76c5f0f3..8c312a095 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Principal.kt @@ -12,6 +12,7 @@ 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/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 475b79d52..3f9cd7a9e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/push/PushRegistrationManager.kt @@ -17,12 +17,7 @@ import at.bitfire.dav4jvm.XmlUtils.insertTag import at.bitfire.dav4jvm.okhttp.DavCollection import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.dav4jvm.okhttp.exception.DavException -import at.bitfire.dav4jvm.property.push.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.property.push.WebDAVPush import at.bitfire.davdroid.db.Collection import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.di.IoDispatcher @@ -237,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)) } } 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 b853a41c9..6dec4270c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -12,17 +12,9 @@ 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.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.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 @@ -318,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) } } @@ -346,7 +338,7 @@ class DavCollectionRepository @Inject constructor( if (addressBook) { // addressbook-specific properties description?.let { - insertTag(AddressbookDescription.NAME) { + insertTag(CardDAV.AddressbookDescription) { text(it) } } @@ -354,21 +346,21 @@ 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( @@ -386,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) } } @@ -407,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/servicedetection/DavResourceFinder.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt index 6e486d913..b3e2298d1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -13,19 +13,15 @@ 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.CalendarColor -import at.bitfire.dav4jvm.property.caldav.CalendarDescription +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 @@ -230,21 +226,23 @@ class DavResourceFinder @AssistedInject constructor( 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) } } } @@ -262,7 +260,7 @@ class DavResourceFinder @AssistedInject constructor( fun queryEmailAddress(principal: HttpUrl): List { val mailboxes = LinkedList() try { - DavResource(httpClient, 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 { @@ -301,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 } @@ -326,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 } @@ -446,7 +444,7 @@ class DavResourceFinder @AssistedInject constructor( */ fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? { var principal: HttpUrl? = null - DavResource(httpClient, 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/PrincipalsRefresher.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt index 9c1c15934..53c107131 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/PrincipalsRefresher.kt @@ -6,8 +6,7 @@ package at.bitfire.davdroid.servicedetection import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.dav4jvm.okhttp.exception.HttpException -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.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/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 e06c24d75..e00ea520e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/ServiceRefresher.kt @@ -8,14 +8,16 @@ import at.bitfire.dav4jvm.Property import at.bitfire.dav4jvm.okhttp.DavResource import at.bitfire.dav4jvm.okhttp.UrlUtils import at.bitfire.dav4jvm.okhttp.exception.HttpException +import at.bitfire.dav4jvm.property.caldav.CalDAV import at.bitfire.dav4jvm.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/sync/CalendarSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt index 2d22210e8..53c6262db 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/CalendarSyncManager.kt @@ -10,13 +10,13 @@ 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 @@ -109,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) } 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 6f4ba5e30..e0662b2f9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt @@ -11,15 +11,15 @@ 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.GetCTag +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 @@ -175,7 +175,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)}") @@ -188,7 +195,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) } @@ -317,7 +324,7 @@ class ContactsSyncManager @AssistedInject constructor( 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) } } 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 729874c64..2e502034c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/JtxSyncManager.kt @@ -11,11 +11,11 @@ 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 @@ -84,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)}") 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 2876bc2bc..6a879bb19 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/SyncManager.kt @@ -24,10 +24,13 @@ 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.repository.AccountRepository @@ -62,19 +65,19 @@ 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, RemoteType: DavCollection>( +abstract class SyncManager, RemoteType: DavCollection>( val account: Account, val httpClient: OkHttpClient, val dataType: SyncDataType, @@ -209,7 +212,7 @@ abstract class SyncManager { // 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 @@ -490,7 +493,7 @@ abstract class SyncManager when (relation) { Response.HrefRelation.SELF -> @@ -747,7 +750,7 @@ abstract class SyncManager + davCollection.propfind(0, CalDAV.GetCTag, WebDAV.SyncToken) { response, relation -> if (relation == Response.HrefRelation.SELF) state = syncState(response) } 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 82d62250f..dc14b2ed3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/TasksSyncManager.kt @@ -10,11 +10,11 @@ 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 @@ -86,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)}") 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 6f70e7bb5..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 @@ -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 @@ -153,7 +154,7 @@ class QueryChildDocumentsOperation @Inject constructor( } val updatedResource = resource.copy( - isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) + isDirectory = response[ResourceType::class.java]?.types?.contains(WebDAV.Collection) ?: resource.isDirectory, displayName = response[DisplayName::class.java]?.displayName, mimeType = response[GetContentType::class.java]?.type?.toMediaTypeOrNull(), @@ -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/gradle/libs.versions.toml b/gradle/libs.versions.toml index c12d7afd1..4694197db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0" androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "42d883e958" -bitfire-dav4jvm = "ad80cdccac" +bitfire-dav4jvm = "acd9bca096" bitfire-synctools = "017187c6d8" compose-accompanist = "0.37.3" compose-bom = "2025.11.01" From 098b7d5b12e527910e97268215b112bd1af2f6cf Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 27 Nov 2025 16:08:52 +0100 Subject: [PATCH 46/53] Log warning instead of throwing exception on multiple build calls (#1847) Log warning instead of throwing IllegalStateException on multiple build calls - Change `build()` to log a warning instead of throwing an exception on subsequent calls. - Change `buildKtor()` to log a warning instead of throwing an exception on subsequent calls. --- .../at/bitfire/davdroid/network/HttpClientBuilder.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index 50ec066de..87bd93a2e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -190,7 +190,7 @@ class HttpClientBuilder @Inject constructor( /** * Builds an [OkHttpClient] with the configured settings. * - * [build] or [buildKtor] must be called only once because multiple calls indicate this wrong usage pattern: + * [build] or [buildKtor] is usually called only once because multiple calls indicate this wrong usage pattern: * * ``` * val builder = HttpClientBuilder(/*injected*/) @@ -200,12 +200,10 @@ class HttpClientBuilder @Inject constructor( * * However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`, * which is usually not desired. - * - * @throws IllegalStateException on second and later calls */ fun build(): OkHttpClient { if (alreadyBuilt) - throw IllegalStateException("build() must only be called once; use Provider") + logger.warning("build() should only be called once; use Provider instead") val builder = OkHttpClient.Builder() configureOkHttp(builder) @@ -384,7 +382,7 @@ class HttpClientBuilder @Inject constructor( @MustBeClosed fun buildKtor(): HttpClient { if (alreadyBuilt) - throw IllegalStateException("build() must only be called once; use Provider") + logger.warning("buildKtor() should only be called once; use Provider instead") val client = HttpClient(OkHttp) { // Ktor-level configuration here From e9fc5708956bf0a52337710003f6a0cd9fbd9fea Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Thu, 27 Nov 2025 16:10:33 +0100 Subject: [PATCH 47/53] Extract `ResourceDownloader` from `ContactsSyncManager`, add tests (#1849) * Add ResourceDownloader and tests - Introduce `ResourceDownloader` class for downloading external resources - Add unit tests for `ResourceDownloader` - Refactor `ContactsSyncManager` to use `ResourceDownloader` * KDoc - Add detailed documentation for `download` method - Clarify authentication handling and return behavior * Minor changes --- .../davdroid/sync/ResourceDownloaderTest.kt | 109 ++++++++++++++++++ .../davdroid/sync/ContactsSyncManager.kt | 71 +++--------- .../davdroid/sync/ResourceDownloader.kt | 74 ++++++++++++ 3 files changed, 200 insertions(+), 54 deletions(-) create mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/sync/ResourceDownloaderTest.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt 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/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ContactsSyncManager.kt index e0662b2f9..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,6 +7,7 @@ package at.bitfire.davdroid.sync import android.accounts.Account import android.content.ContentProviderClient import android.text.format.Formatter +import at.bitfire.dav4jvm.ktor.toUrlOrNull import at.bitfire.dav4jvm.okhttp.DavAddressBook import at.bitfire.dav4jvm.okhttp.MultiResponseCallback import at.bitfire.dav4jvm.okhttp.Response @@ -24,7 +25,6 @@ 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.HttpClientBuilder import at.bitfire.davdroid.resource.LocalAddress import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.resource.LocalContact @@ -46,21 +46,18 @@ import dagger.assisted.AssistedInject import ezvcard.VCardVersion import ezvcard.io.CannotParseException import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayOutputStream -import java.io.IOException import java.io.Reader import java.io.StringReader import java.util.Optional import java.util.logging.Level -import javax.inject.Provider import kotlin.jvm.optionals.getOrNull /** @@ -111,7 +108,7 @@ class ContactsSyncManager @AssistedInject constructor( @Assisted val syncFrameworkUpload: Boolean, val dirtyVerifier: Optional, accountSettingsFactory: AccountSettings.Factory, - private val httpClientBuilder: Provider, + private val resourceDownloaderFactory: ResourceDownloader.Factory, @SyncDispatcher syncDispatcher: CoroutineDispatcher ): SyncManager( account, @@ -151,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) { @@ -165,7 +157,6 @@ class ContactsSyncManager @AssistedInject constructor( } davCollection = DavAddressBook(httpClient, collection.url) - resourceDownloader = ResourceDownloader(davCollection.location) logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}") return true @@ -371,11 +362,20 @@ class ContactsSyncManager @AssistedInject constructor( } 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) + } + } + } ) } } @@ -481,43 +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 - val hostHttpClient = httpClientBuilder - .get() - .fromAccount(account, onlyHost = baseUrl.host) - .followRedirects(true) // allow redirects - .build() - try { - val response = hostHttpClient.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/ResourceDownloader.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt new file mode 100644 index 000000000..5d680beb3 --- /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, onlyHost = 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 From a3a952d875e3a886ee7319d0f9ff23c93eff7176 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 27 Nov 2025 16:35:17 +0100 Subject: [PATCH 48/53] LocalTaskList/LocalTask: Consume fields provided by synctools (#1811) * Move companion object to end of class * Update synctools * Make DmfsTaskList final * Use DmfsTaskList SyncState * Drop fields now provided in DmfsTask and adapt constructors * Use column constants from DmfsTask instead * Use DmfsTask column constants * Update synctools * Don't handle scheduleTag Signed-off-by: Sunik Kupfer * Update synctools Signed-off-by: Sunik Kupfer --------- Signed-off-by: Sunik Kupfer Co-authored-by: Ricki Hirner --- .../at/bitfire/davdroid/resource/LocalTask.kt | 47 +++++-------------- .../davdroid/resource/LocalTaskList.kt | 41 ++++------------ .../migration/AccountSettingsMigration10.kt | 3 +- gradle/libs.versions.toml | 2 +- 4 files changed, 23 insertions(+), 70 deletions(-) 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 60b19c36d..9c54f04ca 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt @@ -14,8 +14,6 @@ import at.bitfire.ical4android.DmfsTaskFactory import at.bitfire.ical4android.DmfsTaskList import at.bitfire.ical4android.Task import at.bitfire.ical4android.TaskProvider -import at.bitfire.synctools.storage.BatchOperation -import at.techbee.jtx.JtxContract import com.google.common.base.MoreObjects import org.dmfs.tasks.contract.TaskContract.Tasks import java.util.Optional @@ -25,51 +23,26 @@ import java.util.Optional */ class LocalTask: DmfsTask, LocalResource { - companion object { - const val COLUMN_ETAG = Tasks.SYNC1 - const val COLUMN_FLAGS = Tasks.SYNC2 - } - 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 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) @@ -85,9 +58,11 @@ class LocalTask: DmfsTask, LocalResource { } 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 @@ -123,7 +98,6 @@ class LocalTask: DmfsTask, LocalResource { .add("id", id) .add("fileName", fileName) .add("eTag", eTag) - .add("scheduleTag", scheduleTag) .add("flags", flags) /*.add("task", try { @@ -152,4 +126,5 @@ class LocalTask: DmfsTask, LocalResource { 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4694197db..c2e2cd37a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "42d883e958" bitfire-dav4jvm = "acd9bca096" -bitfire-synctools = "017187c6d8" +bitfire-synctools = "6ff0b64485" compose-accompanist = "0.37.3" compose-bom = "2025.11.01" conscrypt = "2.5.3" From b08f10a98fab0374605e7c87386a889bc291cb1e Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 1 Dec 2025 11:54:36 +0100 Subject: [PATCH 49/53] `HttpClientBuilder`: clarify documentation for `authDomain` (#1857) * Update authentication domain parameter - Rename `onlyHost` to `authDomain` in `fromAccount` - Update `authenticate` method to use `domain` instead of `host` - Clarify documentation for `authDomain` parameter * More KDoc * Fix other calls / tests --- .../servicedetection/DavResourceFinderTest.kt | 2 +- .../davdroid/network/HttpClientBuilder.kt | 18 +++++++++++------- .../servicedetection/DavResourceFinder.kt | 2 +- .../davdroid/sync/ResourceDownloader.kt | 2 +- .../davdroid/webdav/DavHttpClientBuilder.kt | 5 ++++- .../davdroid/webdav/WebDavMountRepository.kt | 2 +- 6 files changed, 19 insertions(+), 12 deletions(-) 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 20d31ab4a..76f2dee15 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinderTest.kt @@ -74,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) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt index 87bd93a2e..e233683a9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/network/HttpClientBuilder.kt @@ -49,7 +49,7 @@ import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager /** - * Builder for the [OkHttpClient]. + * 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. @@ -105,7 +105,7 @@ class HttpClientBuilder @Inject constructor( private var authenticator: Authenticator? = null private var certificateAlias: String? = null - fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder { + fun authenticate(domain: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder { val credentials = getCredentials() if (credentials.authState != null) { // OAuth @@ -124,7 +124,7 @@ class HttpClientBuilder @Inject constructor( } else if (credentials.username != null && credentials.password != null) { // basic/digest auth val authHandler = BasicDigestAuthHandler( - domain = UrlUtils.hostToDomain(host), + domain = domain, username = credentials.username, password = credentials.password.asCharArray(), insecurePreemptive = true @@ -155,16 +155,20 @@ class HttpClientBuilder @Inject constructor( * * **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 + * @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, onlyHost: String? = null): HttpClientBuilder { + fun fromAccount(account: Account, authDomain: String? = null): HttpClientBuilder { val accountSettings = accountSettingsFactory.create(account) authenticate( - host = onlyHost, + domain = UrlUtils.hostToDomain(authDomain), getCredentials = { accountSettings.credentials() }, 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 b3e2298d1..3f1177605 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt @@ -83,7 +83,7 @@ class DavResourceFinder @AssistedInject constructor( .apply { if (credentials != null) authenticate( - host = null, + domain = null, getCredentials = { credentials } ) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt index 5d680beb3..6706fced5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/sync/ResourceDownloader.kt @@ -54,7 +54,7 @@ class ResourceDownloader @AssistedInject constructor( suspend fun download(url: Url): ByteArray? { httpClientBuilder .get() - .fromAccount(account, onlyHost = originalHost) // restricts authentication to original domain + .fromAccount(account, authDomain = originalHost) // restricts authentication to original domain .followRedirects(true) // allow redirects .buildKtor() .use { httpClient -> 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 07c8311cd..5ea906b24 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavHttpClientBuilder.kt @@ -32,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/WebDavMountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt index 61a3c837e..638bb23b5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -129,7 +129,7 @@ class WebDavMountRepository @Inject constructor( val builder = httpClientBuilder.get() if (credentials != null) builder.authenticate( - host = null, + domain = null, getCredentials = { credentials } ) val httpClient = builder.build() From 6f09f55e1abc9cbc1f4c26a5a49d01ee288df679 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Mon, 1 Dec 2025 12:02:42 +0100 Subject: [PATCH 50/53] Update synctools (ignores DTEND < DTSTART and inverts negative event durations) (#1858) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2e2cd37a..b07a5876a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ androidx-test-junit = "1.3.0" androidx-work = "2.11.0" bitfire-cert4android = "42d883e958" bitfire-dav4jvm = "acd9bca096" -bitfire-synctools = "6ff0b64485" +bitfire-synctools = "ad0c68d820" compose-accompanist = "0.37.3" compose-bom = "2025.11.01" conscrypt = "2.5.3" From b5e8c80db1d3edfe1a1d1ef8ce93bfe3e718affe Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Mon, 1 Dec 2025 14:40:52 +0100 Subject: [PATCH 51/53] Try to fix pending sync state test failures by using a hot flow (#1839) * Remove the ignore annotation * Turn inPendingState in to a hot state flow for the test duration Signed-off-by: Sunik Kupfer * Rename methods registering the sync state observer Signed-off-by: Sunik Kupfer --------- Signed-off-by: Sunik Kupfer --- .../AccountSettingsMigration21Test.kt | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) 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 index f135b5cb3..456548fb7 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/settings/migration/AccountSettingsMigration21Test.kt @@ -15,8 +15,7 @@ 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.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -26,7 +25,6 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.BeforeClass -import org.junit.Ignore import org.junit.Rule import org.junit.Test import java.util.logging.Logger @@ -51,17 +49,8 @@ class AccountSettingsMigration21Test { lateinit var account: Account val authority = CalendarContract.AUTHORITY - private val inPendingState = callbackFlow { - val stateChangeListener = ContentResolver.addStatusChangeListener( - ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE - ) { - trySend(ContentResolver.isSyncPending(account, authority)) - } - trySend(ContentResolver.isSyncPending(account, authority)) - awaitClose { - ContentResolver.removeStatusChangeListener(stateChangeListener) - } - } + private val inPendingState = MutableStateFlow(false) + private var statusChangeListener: Any? = null @Before fun setUp() { @@ -71,15 +60,18 @@ class AccountSettingsMigration21Test { // Enable sync globally and for the test account ContentResolver.setIsSyncable(account, authority, 1) + + // Start hot flow + registerSyncStateObserver() } @After fun tearDown() { + unregisterSyncStateObserver() TestAccount.remove(account) } - @Ignore("Sometimes failing, see https://github.com/bitfireAT/davx5-ose/issues/1835") @SdkSuppress(minSdkVersion = 34) @Test fun testCancelsSyncAndClearsPendingState() = runBlocking { @@ -117,6 +109,22 @@ class AccountSettingsMigration21Test { .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 From 88928792af145bae00dec30d4f849df72ad04337 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:36:53 +0100 Subject: [PATCH 52/53] Bump the app-dependencies group with 2 updates (#1860) Bumps the app-dependencies group with 2 updates: [io.ktor:ktor-client-core](https://github.com/ktorio/ktor) and [io.ktor:ktor-client-okhttp](https://github.com/ktorio/ktor). Updates `io.ktor:ktor-client-core` from 3.3.2 to 3.3.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/3.3.2...3.3.3) Updates `io.ktor:ktor-client-okhttp` from 3.3.2 to 3.3.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/3.3.2...3.3.3) Updates `io.ktor:ktor-client-okhttp` from 3.3.2 to 3.3.3 - [Release notes](https://github.com/ktorio/ktor/releases) - [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md) - [Commits](https://github.com/ktorio/ktor/compare/3.3.2...3.3.3) --- updated-dependencies: - dependency-name: io.ktor:ktor-client-core dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: io.ktor:ktor-client-okhttp dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies - dependency-name: io.ktor:ktor-client-okhttp dependency-version: 3.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: app-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b07a5876a..9bdae503e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ hilt = "2.57.2" kotlin = "2.2.21" kotlinx-coroutines = "1.10.2" ksp = "2.3.3" -ktor = "3.3.2" +ktor = "3.3.3" mikepenz-aboutLibraries = "13.1.0" mockk = "1.14.5" okhttp = "5.3.2" From 2d10cbb07dc67de902c3ba18e87708b8c4a30847 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Tue, 2 Dec 2025 10:34:39 +0100 Subject: [PATCH 53/53] Improve closing of content provider in verify account owner test (#1838) * Optimize imports * Remove the ignore annotation * Move provider use out of verify method Signed-off-by: Sunik Kupfer * Remove unnecessary provider.use blocks Signed-off-by: Sunik Kupfer * Add spaces Signed-off-by: Sunik Kupfer * Rename lambda param provider to client in LocalDataStore implementations Signed-off-by: Sunik Kupfer * Enhance kdoc Signed-off-by: Sunik Kupfer * Improve provider client usage Signed-off-by: Sunik Kupfer * Replace calling apply with assignment Signed-off-by: Sunik Kupfer * Remove whitespace Signed-off-by: Sunik Kupfer * Add nullable returns even though they never return null Signed-off-by: Sunik Kupfer * Apply WillNotClose annotation to client parameter instead of method Signed-off-by: Sunik Kupfer --------- Signed-off-by: Sunik Kupfer --- .../repository/AccountRepositoryTest.kt | 7 +++-- .../resource/LocalCalendarStoreTest.kt | 30 +++++++------------ .../at/bitfire/davdroid/sync/SyncerTest.kt | 8 ++--- .../davdroid/repository/AccountRepository.kt | 13 +++++--- .../resource/LocalAddressBookStore.kt | 13 ++++---- .../davdroid/resource/LocalCalendarStore.kt | 9 +++--- .../davdroid/resource/LocalDataStore.kt | 4 ++- .../resource/LocalJtxCollectionStore.kt | 26 ++++++++-------- .../davdroid/resource/LocalTaskListStore.kt | 25 ++++++++-------- 9 files changed, 69 insertions(+), 66 deletions(-) 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 index 86996f7f9..2ec3b4e91 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/resource/LocalCalendarStoreTest.kt @@ -21,7 +21,6 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -62,30 +61,30 @@ class LocalCalendarStoreTest { } - @Ignore("Sometimes failing, see https://github.com/bitfireAT/davx5-ose/issues/1828") @Test fun testUpdateAccount_updatesOwnerAccount() { // Verify initial state - verifyOwnerAccountIs("InitialAccountName") + verifyOwnerAccountIs(provider, "InitialAccountName") // Rename account val oldAccount = account account = TestAccount.rename(account, "ChangedAccountName") // Update account name in local calendar - localCalendarStore.updateAccount(oldAccount, account) + localCalendarStore.updateAccount(oldAccount, account, provider) // Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated - verifyOwnerAccountIs("ChangedAccountName") + verifyOwnerAccountIs(provider, "ChangedAccountName") + } // helpers - private fun createCalendarForAccount(account: Account): Uri { - var uri: Uri? = null - provider.use { providerClient -> - val values = contentValuesOf( + 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, @@ -94,17 +93,10 @@ class LocalCalendarStoreTest { Calendars._SYNC_ID to 999, Calendars.CALENDAR_DISPLAY_NAME to "displayName", ) + )!!.asSyncAdapter(account) - uri = providerClient.insert( - Calendars.CONTENT_URI.asSyncAdapter(account), - values - )!!.asSyncAdapter(account) - } - return uri!! - } - - private fun verifyOwnerAccountIs(expectedOwnerAccount: String) = provider.use { - it.query( + private fun verifyOwnerAccountIs(provider: ContentProviderClient, expectedOwnerAccount: String) { + provider.query( calendarUri, arrayOf(Calendars.OWNER_ACCOUNT), "${Calendars.ACCOUNT_NAME}=?", 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/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt index 3f4770fae..28218505b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -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/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/LocalCalendarStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendarStore.kt index efd3c7c75..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,7 +139,9 @@ class LocalCalendarStore @Inject constructor( return values } - override fun updateAccount(oldAccount: Account, newAccount: Account) { + 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, @@ -147,9 +150,7 @@ class LocalCalendarStore @Inject constructor( 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/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/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/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) {