Compare commits

...

129 Commits

Author SHA1 Message Date
Ricki Hirner
9754770238 [CI] Fix pre-populating configuration cache 2025-12-18 10:36:29 +01:00
Ricki Hirner
6be15fd366 [CI] Actually use configuration cache (#1891)
* Cache configurations per job

* Use separate job for Dependency submission

* Use GRADLE_OPTS to enable build and configuration cache

* Test .android

* Cache .android for configuration cache

* Disable CodeQL for PRs

* Fix AVD path
2025-12-18 10:27:01 +01:00
Ricki Hirner
0cb27f0c2f [CI] Add gradle remote build cache (bitfireAT/davx5#752)
* [CI] Add gradle remote build cache

* Update workflow

* Don't cache local build cache; pre-populate configuration cache

* Allow configuration caching of tasks

* Free some disk space before running instrumented tests; cache whole .android (not only .android/avd)

* Allow branches to update configuration cache

* Use dry run to pre-populate configuration cache

* Test runs: don't cache

* Fix remote build cache configuration for non-CI builds

* Add comment
2025-12-17 18:06:17 +01:00
Sunik Kupfer
776305bd12 Rename dismissInvalidResource to dismissCollectionError (#1887)
Rename method for clarity

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-17 13:14:06 +01:00
dependabot[bot]
01f54df3c0 [CI] Bump actions/cache from 4 to 5 in the ci-actions group (#1886)
Bumps the ci-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 13:38:54 +01:00
Ricki Hirner
4944ce59b1 Version bump to 4.5.8-alpha.1 2025-12-12 15:32:30 +01:00
Sunik Kupfer
0e455d8371 LocalTaskList: Stop subclassing DmfsTaskList (#1882)
* LocalTaskList: Stop subclassing DmfsTaskList

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

# Conflicts:
#	gradle/libs.versions.toml

* Dont touch agp

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-11 16:22:02 +01:00
Ricki Hirner
a938b511cd Nextcloud Login Flow: handle non-success status codes (#1878)
* Nextcloud Login Flow: handle non-success status codes

* Update error message to use class name when localized message is null

* Update dav4jvm to get HTTP reason phrases in HttpException
2025-12-11 15:30:32 +01:00
Ricki Hirner
d32b86789b Update AGP 2025-12-11 15:13:15 +01:00
Sunik Kupfer
84d58f73db Assume initial state for test updatesOwnerAccount (#1874)
* Assume initial state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Enhance comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Don't pass provider

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-11 14:16:03 +01:00
dependabot[bot]
cc43998148 Bump the app-dependencies group with 4 updates (#1867)
* Bump the app-dependencies group with 4 updates

Bumps the app-dependencies group with 4 updates: androidx.activity:activity-compose, androidx.compose:compose-bom, [io.mockk:mockk](https://github.com/mockk/mockk) and [io.mockk:mockk-android](https://github.com/mockk/mockk).


Updates `androidx.activity:activity-compose` from 1.12.0 to 1.12.1

Updates `androidx.compose:compose-bom` from 2025.11.01 to 2025.12.00

Updates `io.mockk:mockk` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.7
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.7)

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.12.00
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Override SDK level

* Suppress lint warnings for LaunchedEffect / Context.getString

* Suppress lint warnings for other Context.getStrings (or replace by stringResource if possible)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-12-11 11:27:37 +01:00
Sunik Kupfer
b354bfebc2 LocalTask: Don't subclass DmfsTask (#1862)
* LocalTask: Don't subclass DmfsTask

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Adapt usages

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-10 14:37:23 +01:00
Sunik Kupfer
29240ea16f Skip flaky test when not moving into anticipated forever pending sync state (#1872)
* Assume we moved into forever pending sync state

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Update comment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-10 10:25:58 +01:00
Ricki Hirner
e7b88f9aa8 Version bump to 4.5.7.1 2025-12-09 11:49:50 +01:00
Arnau Mora
4d71517cde Delegate fileName into DmfsTask.syncId (#1871)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-12-09 10:40:22 +01:00
Ricki Hirner
0d4f154baf Use Ktor for Nextcloud Login Flow (#1817)
* [WIP] Use Ktor for Nextcloud login flow

- Replace OkHttp with Ktor for HTTP requests
- Update URL handling to use Ktor's `Url` class
- Adjust `postForJson` method to use Ktor's HTTP client
- Refactor URL building logic for login flow initiation

* Use Ktor for Nextcloud login flow

- Migrate to Ktor's ContentNegotiation plugin for JSON handling
- Update dependencies and configuration for Ktor serialization
- Refactor `NextcloudLoginFlow` to use Ktor's JSON serialization

* Add tests

* Allow unit tests that mock/use HttpClient without Conscrypt

* KDoc

* Minor fixes

* Use toUrlOrNull from dav4jvm

* Don't change strings in this PR

* Update dav4jvm and synctools
2025-12-08 15:40:39 +01:00
Ricki Hirner
9eb70a5564 Bump version to 4.5.7 2025-12-08 12:19:38 +01:00
Ricki Hirner
24e0a864bd Update AVD caching in CI workflow (#1865)
- Split AVD cache handling into restore and save steps
- Add condition to save AVD cache only if restore misses
2025-12-05 16:25:25 +01:00
Ricki Hirner
10ec0c3b6d Fetch translations from Transifex 2025-12-05 15:20:46 +01:00
Ricki Hirner
bd3349cc38 Bump version to 4.5.7-rc.1 2025-12-05 15:19:43 +01:00
Ricki Hirner
c7bc2b317b Update synctools (#1864)
* Update synctools (fixes #1797, closes #1859)

* Use `com.github.bitfireAT:synctools` because `com.github.bitfireat:synctools` is not available on Jitpack for this commit
2025-12-05 15:16:29 +01:00
Sunik Kupfer
2d10cbb07d 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 <kupfer@bitfire.at>

* Remove unnecessary provider.use blocks

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add spaces

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Rename lambda param provider to client in LocalDataStore implementations

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Enhance kdoc

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Improve provider client usage

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Replace calling apply with assignment

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Remove whitespace

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Add nullable returns even though they never return null

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

* Apply WillNotClose annotation to client parameter instead of method

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-02 10:34:39 +01:00
dependabot[bot]
88928792af 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 18:36:53 +01:00
Sunik Kupfer
b5e8c80db1 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 <kupfer@bitfire.at>

* Rename methods registering the sync state observer

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-12-01 14:40:52 +01:00
Ricki Hirner
6f09f55e1a Update synctools (ignores DTEND < DTSTART and inverts negative event durations) (#1858) 2025-12-01 12:02:42 +01:00
Ricki Hirner
b08f10a98f 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
2025-12-01 11:54:36 +01:00
Sunik Kupfer
a3a952d875 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 <kupfer@bitfire.at>

* Update synctools

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-11-27 16:35:17 +01:00
Ricki Hirner
e9fc570895 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
2025-11-27 16:10:33 +01:00
Ricki Hirner
098b7d5b12 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.
2025-11-27 16:08:52 +01:00
Ricki Hirner
a38dc29cca 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
2025-11-27 11:46:10 +01:00
Ricki Hirner
84b9a14ba1 Update version to 4.5.6.1 2025-11-27 11:15:39 +01:00
Arnau Mora
cda95dc789 Fix HTTP Client provider for contact resource sync from URL (#1844)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-11-27 11:15:06 +01:00
dependabot[bot]
f64882ca2a 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 18:42:28 +01:00
Ricki Hirner
7c2dcf3d70 Comment out failing tests (#1836) 2025-11-24 16:00:53 +01:00
dependabot[bot]
66a34ebd9f [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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 16:00:24 +01:00
Ricki Hirner
365364aa89 Update version to 4.5.6 2025-11-24 13:26:18 +01:00
Ricki Hirner
114543f4c5 Fetch translations from Transifex 2025-11-24 13:22:09 +01:00
Ricki Hirner
3bd3f56e1b 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
2025-11-24 13:14:40 +01:00
Ricki Hirner
5263172376 [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.
2025-11-21 12:48:04 +01:00
Ricki Hirner
babd52cfb1 Bump version to 4.5.6-rc.1 2025-11-20 16:45:40 +01:00
Ricki Hirner
3d4d533b92 Update synctools (#1825)
* Update synctools

* Update commit ID
2025-11-20 16:42:40 +01:00
Ricki Hirner
76fc024ef6 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
2025-11-20 11:37:24 +01:00
Sunik Kupfer
794b4c1c7f 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
2025-11-20 09:33:24 +01:00
Ricki Hirner
aac6356722 Delete app/src/main/kotlin/at/bitfire/davdroid/network/HttpClient.kt
Remove empty file
2025-11-19 16:52:35 +01:00
Ricki Hirner
9bc46d4194 Update dependencies, including our libs (#1822) 2025-11-19 15:35:06 +01:00
Ricki Hirner
a3aac44775 Ignore failing test: testVerifySyncAlwaysPending_wrongBehaviour_android14 (#1824)
Comment out test
2025-11-19 15:14:41 +01:00
Arnau Mora
084ba3b630 Update dav4jvm to new okhttp package (#1786)
* Upgrade dav4jvm

* Exclude ktor from dav4jvm

* Fix imports and fix usages

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Fix imports for instrumented tests

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Fix imports

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Upgrade dav4jvm

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Do not exclude ktor in dav4jvm

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Upgrade dav4jvm

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-11-17 13:38:10 +01:00
Ricki Hirner
28dcf90775 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
2025-11-17 10:34:36 +01:00
Ricki Hirner
70766affd9 [Ktor] Allow building a Ktor client (#1810)
* Add Ktor dependency

* Add buildKtor method

* Add test and deprecation notice

* KDoc
2025-11-17 09:38:08 +01:00
Ricki Hirner
d00292f421 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
2025-11-12 11:04:13 +01:00
dependabot[bot]
6b5c4f191a 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 17:28:43 +01:00
Sunik Kupfer
5c7b792e7f 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
2025-11-10 15:46:58 +01:00
Ricki Hirner
c9da496142 Bump version to 4.5.6-beta.1 2025-11-06 14:30:07 +01:00
Ricki Hirner
ee098c4a83 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)
2025-11-06 10:07:40 +01:00
Ricki Hirner
a8bd296520 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
2025-11-05 13:20:02 +01:00
Sunik Kupfer
0959624dee 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
2025-11-05 12:40:34 +01:00
Ricki Hirner
bd13d27e38 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
2025-11-04 16:38:22 +01:00
dependabot[bot]
85548163ca 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 16:07:44 +01:00
Ricki Hirner
026750eca3 Bump version to 4.5.6-alpha.2 2025-11-01 21:56:47 +01:00
Ricki Hirner
d365a504e8 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
2025-11-01 21:55:28 +01:00
Ricki Hirner
c64cb1e7ec Remove okhttp caching (bitfireAT/davx5#715)
* Remove okhttp caching
* Simplification
2025-11-01 12:38:26 +01:00
Ricki Hirner
837b5e5d50 Bump version to 4.5.6-alpha.1 2025-10-30 14:29:29 +01:00
Ricki Hirner
98aefc4fee 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
2025-10-30 14:28:05 +01:00
Ricki Hirner
a8c8a8d2e0 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
2025-10-29 17:57:34 +01:00
Ricki Hirner
b839cbfe7f 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
2025-10-29 08:50:09 +01:00
Ricki Hirner
f0f9f58e49 Bump version to 4.5.5 2025-10-28 11:20:00 +01:00
Ricki Hirner
66f6e48e3b Fetch translations from Transifex 2025-10-28 11:18:54 +01:00
Ricki Hirner
d6feda1142 Fix OkHttp3 crash in release builds (bitfireAT/davx5#712)
- Add ProGuard rules to keep OkHttp3 IDN mapping classes
2025-10-28 11:12:51 +01:00
Ricki Hirner
05f058ab3f Version bump to 4.5.5-rc.1 2025-10-27 11:16:51 +01:00
Sunik Kupfer
4e4c0f5e31 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 <hirner@bitfire.at>
2025-10-27 11:15:21 +01:00
Arnau Mora
0304d7168a Migrate to SecureTextField (#1191)
* Using `SecureTextField`

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* `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 <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-10-27 11:15:11 +01:00
Ricki Hirner
19458aa95c Delete .github/workflows/dependent-issues.yml
Obsoleted by https://github.blog/changelog/2025-08-21-dependencies-on-issues/
2025-10-26 12:23:31 +01:00
dependabot[bot]
4412617079 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] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-26 11:04:22 +01:00
Ricki Hirner
76277dbfd5 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
2025-10-26 10:20:57 +01:00
Ricki Hirner
b0b99de56b Update Compose BOM and use PrimaryTabRow (#1772)
- Update Compose BOM version to 2025.10.01
- Replace `TabRow` with `PrimaryTabRow`
2025-10-25 11:15:01 +02:00
Ricki Hirner
b33c4750bb Bump version to 4.5.5-beta.1 2025-10-18 09:15:58 +02:00
Sunik Kupfer
25b749dd1b Rename account on background thread (#1727)
* Change context to default dispatcher on updating automatic sync when renaming account

* Minor changes
- add worker thread annotations
- use injected defaultDispatcher

* Add tests

* Apply withContext with default dispatcher on the whole rename method
2025-10-18 09:13:26 +02:00
Ricki Hirner
39a0fe3f98 Update AboutLibraries and other dependencies (#1760)
Update dependencies and modify AboutActivity to use dynamic library loading

- Remove outdated aboutlibraries.json
- Update AboutActivity to dynamically load libraries using LocalContext
- Replace InvalidRemoteResourceException with InvalidICalendarException in sync managers
- Bump dependency versions for various libraries including mikepenz-aboutLibraries, okhttp, and unifiedpush
- Adjust build.gradle.kts and gradle/libs.versions.toml for new plugin and library versions
2025-10-18 09:12:41 +02:00
Arnau Mora
dd798f8380 Update implementation to match guidelines (#1747) 2025-10-15 11:20:30 +02:00
Sunik Kupfer
47d380de62 Fix unreachable code possibly causing foreign key constraint violation exception (#1740)
* Add test

* Fix unreachable code possibly causing foreign key constraint violation exception

* Make code easier to understand

* Add comments

* Split up tests and add mockk verify
2025-10-14 10:57:15 +02:00
dependabot[bot]
019d32a9b7 [CI] Bump github/codeql-action from 3 to 4 in the ci-actions group (#1749)
Bumps the ci-actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 3 to 4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 20:04:01 +02:00
Sunik Kupfer
0acabd9c80 Update http error message strings (#1745) 2025-10-13 10:41:00 +02:00
dependabot[bot]
aa23980a59 Bump the app-dependencies group across 1 directory with 13 updates (#1744)
* Bump the app-dependencies group across 1 directory with 13 updates

Bumps the app-dependencies group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.compose:compose-bom | `2025.09.01` | `2025.10.00` |
| [io.mockk:mockk](https://github.com/mockk/mockk) | `1.14.5` | `1.14.6` |
| [io.mockk:mockk-android](https://github.com/mockk/mockk) | `1.14.5` | `1.14.6` |
| [com.squareup.okhttp3:okhttp](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| [com.squareup.okhttp3:okhttp-brotli](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| [com.squareup.okhttp3:logging-interceptor](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| [com.squareup.okhttp3:mockwebserver](https://github.com/square/okhttp) | `5.1.0` | `5.2.0` |
| androidx.room:room-ktx | `2.8.1` | `2.8.2` |
| androidx.room:room-compiler | `2.8.1` | `2.8.2` |
| androidx.room:room-paging | `2.8.1` | `2.8.2` |
| androidx.room:room-runtime | `2.8.1` | `2.8.2` |
| androidx.room:room-testing | `2.8.1` | `2.8.2` |
| org.unifiedpush.android:connector | `3.0.10` | `3.1.0` |



Updates `androidx.compose:compose-bom` from 2025.09.01 to 2025.10.00

Updates `io.mockk:mockk` from 1.14.5 to 1.14.6
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.6)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.6
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.6)

Updates `io.mockk:mockk-android` from 1.14.5 to 1.14.6
- [Release notes](https://github.com/mockk/mockk/releases)
- [Commits](https://github.com/mockk/mockk/compare/1.14.5...1.14.6)

Updates `com.squareup.okhttp3:okhttp` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:okhttp-brotli` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:logging-interceptor` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `com.squareup.okhttp3:mockwebserver` from 5.1.0 to 5.2.0
- [Changelog](https://github.com/square/okhttp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/square/okhttp/compare/parent-5.1.0...parent-5.2.0)

Updates `androidx.room:room-ktx` from 2.8.1 to 2.8.2

Updates `androidx.room:room-compiler` from 2.8.1 to 2.8.2

Updates `androidx.room:room-paging` from 2.8.1 to 2.8.2

Updates `androidx.room:room-runtime` from 2.8.1 to 2.8.2

Updates `androidx.room:room-testing` from 2.8.1 to 2.8.2

Updates `androidx.room:room-compiler` from 2.8.1 to 2.8.2

Updates `androidx.room:room-paging` from 2.8.1 to 2.8.2

Updates `androidx.room:room-runtime` from 2.8.1 to 2.8.2

Updates `androidx.room:room-testing` from 2.8.1 to 2.8.2

Updates `org.unifiedpush.android:connector` from 3.0.10 to 3.1.0

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.10.00
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk
  dependency-version: 1.14.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: io.mockk:mockk-android
  dependency-version: 1.14.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:okhttp
  dependency-version: 5.2.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.2.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.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.2.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.2.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.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: com.squareup.okhttp3:mockwebserver
  dependency-version: 5.2.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.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: org.unifiedpush.android:connector
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Downgrade mockk version to 1.14.5

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-10-09 13:10:31 +02:00
Sunik Kupfer
5674d6b954 ExceptionInfoDialog: show explanation on http 405 and others (#1739)
* Show explanation on http 405

* Also show message for http 5xx

* Correct spelling
2025-10-09 12:05:31 +02:00
dependabot[bot]
df56c8628a [CI] Bump gradle/actions from 4 to 5 in the ci-actions group (#1736)
Bumps the ci-actions group with 1 update: [gradle/actions](https://github.com/gradle/actions).


Updates `gradle/actions` from 4 to 5
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v4...v5)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-06 17:08:39 +02:00
dependabot[bot]
af4ecd3a1d Bump the app-dependencies group with 12 updates (#1732)
Bumps the app-dependencies group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| androidx.work:work-runtime-ktx | `2.10.4` | `2.10.5` |
| androidx.work:work-testing | `2.10.4` | `2.10.5` |
| androidx.compose:compose-bom | `2025.09.00` | `2025.09.01` |
| [com.google.dagger:hilt-android](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| [com.google.dagger:hilt-android-compiler](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| [com.google.dagger:hilt-android-testing](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| [com.google.dagger.hilt.android](https://github.com/google/dagger) | `2.57.1` | `2.57.2` |
| androidx.room:room-ktx | `2.8.0` | `2.8.1` |
| androidx.room:room-compiler | `2.8.0` | `2.8.1` |
| androidx.room:room-paging | `2.8.0` | `2.8.1` |
| androidx.room:room-runtime | `2.8.0` | `2.8.1` |
| androidx.room:room-testing | `2.8.0` | `2.8.1` |


Updates `androidx.work:work-runtime-ktx` from 2.10.4 to 2.10.5

Updates `androidx.work:work-testing` from 2.10.4 to 2.10.5

Updates `androidx.work:work-testing` from 2.10.4 to 2.10.5

Updates `androidx.compose:compose-bom` from 2025.09.00 to 2025.09.01

Updates `com.google.dagger:hilt-android` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-compiler` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-testing` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger.hilt.android` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-compiler` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `com.google.dagger:hilt-android-testing` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

Updates `androidx.room:room-ktx` from 2.8.0 to 2.8.1

Updates `androidx.room:room-compiler` from 2.8.0 to 2.8.1

Updates `androidx.room:room-paging` from 2.8.0 to 2.8.1

Updates `androidx.room:room-runtime` from 2.8.0 to 2.8.1

Updates `androidx.room:room-testing` from 2.8.0 to 2.8.1

Updates `androidx.room:room-compiler` from 2.8.0 to 2.8.1

Updates `androidx.room:room-paging` from 2.8.0 to 2.8.1

Updates `androidx.room:room-runtime` from 2.8.0 to 2.8.1

Updates `androidx.room:room-testing` from 2.8.0 to 2.8.1

Updates `com.google.dagger.hilt.android` from 2.57.1 to 2.57.2
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57.1...dagger-2.57.2)

---
updated-dependencies:
- dependency-name: androidx.work:work-runtime-ktx
  dependency-version: 2.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.09.01
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.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.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-29 22:15:39 +02:00
Arnau Mora
701e292ab5 Use IntentCompat for account fetching from intent extras (#1731) 2025-09-28 10:51:57 +02:00
Ricki Hirner
5da7d9e292 Add CODEOWNERS file (#1729) 2025-09-24 14:11:40 +02:00
Ricki Hirner
e6256764ac Bump version to 4.5.5-alpha.2 2025-09-24 14:00:30 +02:00
Ricki Hirner
e63f815416 Update synctools to work around eventStatus=null update problem (#1728)
Update synctools to work around eventStatus=null update problem; add synctools to Dependabot-ignore
2025-09-24 13:59:12 +02:00
Ricki Hirner
374dadfaaa Bump version to 4.5.5-alpha.1 2025-09-23 11:15:07 +02:00
Ricki Hirner
f6ef13f9fe Update synctools (new uses event builders/processors) and Kotlin/KSP (#1726) 2025-09-23 11:07:41 +02:00
dependabot[bot]
a88cfd2acf Bump the app-dependencies group with 4 updates (#1724)
Bumps the app-dependencies group with 4 updates: androidx.lifecycle:lifecycle-runtime-compose, androidx.lifecycle:lifecycle-viewmodel-ktx, androidx.lifecycle:lifecycle-viewmodel-compose and [com.google.guava:guava](https://github.com/google/guava).


Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.3 to 2.9.4

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.3 to 2.9.4

Updates `com.google.guava:guava` from 33.4.8-android to 33.5.0-android
- [Release notes](https://github.com/google/guava/releases)
- [Commits](https://github.com/google/guava/commits)

---
updated-dependencies:
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: com.google.guava:guava
  dependency-version: 33.5.0-android
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 18:40:14 +02:00
Michael Biebl
98fc946594 Remove duplicate lines in .gitignore (#1725) 2025-09-22 18:20:50 +02:00
Ricki Hirner
00523d9bc8 Fix state matching logic in AndroidSyncFrameworkTest (#1708)
* Fix state matching logic in AndroidSyncFrameworkTest

- Add `fullMatch` parameter to control whether all expected states must be present

* Ensure non-optional expected state matches actual state

* Remove unused rule / variable

* Adapt test

- Update `onStatusChanged` to override the interface method.
- Replace custom assertion with `assertTrue` for state comparison.
2025-09-16 10:42:11 +02:00
dependabot[bot]
53d338d03e Bump the app-dependencies group with 12 updates (#1714)
Bumps the app-dependencies group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| androidx.activity:activity-compose | `1.10.1` | `1.11.0` |
| androidx.hilt:hilt-compiler | `1.2.0` | `1.3.0` |
| androidx.hilt:hilt-navigation-compose | `1.2.0` | `1.3.0` |
| androidx.hilt:hilt-work | `1.2.0` | `1.3.0` |
| androidx.work:work-runtime-ktx | `2.10.3` | `2.10.4` |
| androidx.work:work-testing | `2.10.3` | `2.10.4` |
| androidx.compose:compose-bom | `2025.08.01` | `2025.09.00` |
| androidx.room:room-ktx | `2.7.2` | `2.8.0` |
| androidx.room:room-compiler | `2.7.2` | `2.8.0` |
| androidx.room:room-paging | `2.7.2` | `2.8.0` |
| androidx.room:room-runtime | `2.7.2` | `2.8.0` |
| androidx.room:room-testing | `2.7.2` | `2.8.0` |


Updates `androidx.activity:activity-compose` from 1.10.1 to 1.11.0

Updates `androidx.hilt:hilt-compiler` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-navigation-compose` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-work` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-navigation-compose` from 1.2.0 to 1.3.0

Updates `androidx.hilt:hilt-work` from 1.2.0 to 1.3.0

Updates `androidx.work:work-runtime-ktx` from 2.10.3 to 2.10.4

Updates `androidx.work:work-testing` from 2.10.3 to 2.10.4

Updates `androidx.work:work-testing` from 2.10.3 to 2.10.4

Updates `androidx.compose:compose-bom` from 2025.08.01 to 2025.09.00

Updates `androidx.room:room-ktx` from 2.7.2 to 2.8.0

Updates `androidx.room:room-compiler` from 2.7.2 to 2.8.0

Updates `androidx.room:room-paging` from 2.7.2 to 2.8.0

Updates `androidx.room:room-runtime` from 2.7.2 to 2.8.0

Updates `androidx.room:room-testing` from 2.7.2 to 2.8.0

Updates `androidx.room:room-compiler` from 2.7.2 to 2.8.0

Updates `androidx.room:room-paging` from 2.7.2 to 2.8.0

Updates `androidx.room:room-runtime` from 2.7.2 to 2.8.0

Updates `androidx.room:room-testing` from 2.7.2 to 2.8.0

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-compiler
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-navigation-compose
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-work
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-navigation-compose
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.hilt:hilt-work
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-runtime-ktx
  dependency-version: 2.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.work:work-testing
  dependency-version: 2.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: app-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.09.00
  dependency-type: direct:production
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-ktx
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-compiler
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-paging
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-runtime
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
- dependency-name: androidx.room:room-testing
  dependency-version: 2.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: app-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-12 10:35:17 +02:00
Ricki Hirner
0424999225 Update dav4jvm (#1709)
- Replace `code` with `statusCode` in multiple files to align with the updated library
- Add `dav4jvm` to Dependabot ignores
2025-09-12 10:25:26 +02:00
Ricki Hirner
fa09a0560f Use SensitiveString for passwords (#1692)
* Use SensitiveString for passwords to prevent them from being logged by `toString()`

* Add test

* Fix other tests

* Credentials: equals / hashCode not needed anymore

* Add tests for equals
2025-09-10 10:31:24 +02:00
Ricki Hirner
f4aa55d482 Version bump to 4.5.4 2025-09-09 11:38:10 +02:00
Ricki Hirner
1bffd5efe1 Version bump to 4.5.4-rc.3 2025-09-06 13:14:48 +02:00
Ricki Hirner
4850f2a5a5 Update synctools to fix #1701 (#1702) 2025-09-06 13:01:56 +02:00
Ricki Hirner
53f38ce2ec Rename Dependabot dependency group 2025-09-05 11:27:06 +02:00
Ricki Hirner
7cf6e30577 Bump version to 4.5.4-rc.2 2025-09-05 10:31:21 +02:00
Ricki Hirner
cec77c33cb Fix KotlinNotImplementedError in LocalGroup update method (#1696)
- Remove scheduleTag assignment in LocalGroup update method
- Refactor LocalGroupTest to use @Before and @After annotations
- Add new test case for update method in LocalGroupTest
2025-09-05 10:27:10 +02:00
dependabot[bot]
6c98e6d501 Bump the lib-dependencies group across 1 directory with 9 updates (#1695)
Bumps the lib-dependencies group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| androidx.lifecycle:lifecycle-runtime-compose | `2.9.2` | `2.9.3` |
| androidx.lifecycle:lifecycle-viewmodel-ktx | `2.9.2` | `2.9.3` |
| androidx.lifecycle:lifecycle-viewmodel-compose | `2.9.2` | `2.9.3` |
| androidx.compose:compose-bom | `2025.08.00` | `2025.08.01` |
| [com.google.dagger:hilt-android](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| [com.google.dagger:hilt-android-compiler](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| [com.google.dagger:hilt-android-testing](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| [com.google.dagger.hilt.android](https://github.com/google/dagger) | `2.57` | `2.57.1` |
| com.android.application | `8.12.1` | `8.13.0` |



Updates `androidx.lifecycle:lifecycle-runtime-compose` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-ktx` from 2.9.2 to 2.9.3

Updates `androidx.lifecycle:lifecycle-viewmodel-compose` from 2.9.2 to 2.9.3

Updates `androidx.compose:compose-bom` from 2025.08.00 to 2025.08.01

Updates `com.google.dagger:hilt-android` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-compiler` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-testing` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger.hilt.android` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-compiler` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.google.dagger:hilt-android-testing` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

Updates `com.android.application` from 8.12.1 to 8.13.0

Updates `com.google.dagger.hilt.android` from 2.57 to 2.57.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.57...dagger-2.57.1)

---
updated-dependencies:
- dependency-name: androidx.lifecycle:lifecycle-runtime-compose
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.lifecycle:lifecycle-viewmodel-compose
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2025.08.01
  dependency-type: direct:production
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-compiler
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
- dependency-name: com.android.application
  dependency-version: 8.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lib-dependencies
- dependency-name: com.google.dagger.hilt.android
  dependency-version: 2.57.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: lib-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 10:25:31 +02:00
Ricki Hirner
a7cd1cd49f Configure Dependabot for Gradle dependencies (#1680)
* Configure Dependabot for Gradle dependencies

Add Gradle dependency management configuration to Dependabot.

* Update dependabot.yml to ignore specific Kotlin and KSP dependencies
2025-09-05 09:26:30 +02:00
Ricki Hirner
0cc84dfd01 Update synctools (#1690) 2025-09-03 16:47:40 +02:00
Ricki Hirner
87239daaf6 Fix invalid translation 2025-09-03 15:53:43 +02:00
Ricki Hirner
81ceb57842 Version bump to 4.5.4-rc.1 2025-09-03 15:41:31 +02:00
Ricki Hirner
cd0b0c0804 Fetch translations from Transifex 2025-09-03 15:41:12 +02:00
Sunik Kupfer
48cbd4a05d Disable pending sync indicator on Android 14+ (#1689)
* Disable pending sync indicator on Android 14+

* Disable forever pending sync workaround on Android 14+
2025-09-03 15:32:29 +02:00
Ricki Hirner
beccc7a0d4 Version bump to 4.5.4-beta.1 2025-09-01 11:09:52 +02:00
Sunik Kupfer
2b629c8b18 Add comments on foreign key constraint enforcement (#1675)
* Add comments on foreign key constraint enforcement

* Fix grammar

* Add foreign key constraint comment to AppDatabase

* Update kdoc with better description
2025-08-28 11:47:49 +02:00
Arnau Mora
cd725479cd Handle null cases for Events.DIRTY (#1663)
* Handled nullity of `Calendar.dirty`

* Fixed ids

* Use multiline SQL queries

* Fix kdoc links

* Improve kdoc

* Fix capitalization

* Simplify kdoc

* Improved `LocalCalendarTest`
2025-08-28 11:25:57 +02:00
Ricki Hirner
44666d2138 Add groups section to dependabot configuration (#1677)
* Add groups section to dependabot configuration

* Fix patterns syntax in dependabot.yml
2025-08-27 15:56:34 +02:00
dependabot[bot]
8e67db7d54 [CI] Bump actions/setup-java from 4 to 5 (#1672)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4 to 5.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 10:10:21 +02:00
Sunik Kupfer
a58e3b9036 Don't disable sync-ability of address books when set to manual only (#1662)
Only disable sync on content changes; but leave syncable
2025-08-25 10:15:56 +02:00
Ricki Hirner
d63918ff42 Update dependencies to latest versions (including AGP) (#1665) 2025-08-19 16:45:27 +02:00
dependabot[bot]
f21c3de94a [CI] Bump actions/checkout from 4 to 5 (#1664)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [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/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-19 13:54:15 +02:00
Ricki Hirner
24d4ba65e5 Fix NPE when Event._ID is larger than Integer.MAX_VALUE (#1661)
* Add test that reproduces NPE

* Fix NPE when Event ID is larger than Integer.MAX_VALUE
2025-08-14 17:19:04 +02:00
Arnau Mora
ae96f1ffbb Using new suggested icons for tabs (#1657) 2025-08-14 10:20:08 +02:00
Arnau Mora
a08ecae635 Change "Account doesn't exist" toast message to "Account has been removed" for deleting accounts (#1650)
* Finish activity after deleting

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Added proper toast for when the account is deleted

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

* Simplify logic

* Missing fix

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-13 08:55:26 +02:00
Ricki Hirner
eb4224780a Update to Gradle 9.0 (#1647) 2025-08-07 09:30:37 +02:00
Ricki Hirner
0240e67dab Bump version to 4.5.4-alpha.1 2025-08-06 16:42:10 +02:00
Ricki Hirner
0ccd9d5eb3 Update synctools for events as Entity and EventAndExceptions (#1605)
* [WIP] Refactor LocalResource interface and implementations to use immutable properties and add deleteLocal method

* Use `Optional` for `fileName` in `clearDirty` methods, update syncManagers

* Update synctools

* Refactor LocalCalendar to use AndroidRecurringCalendar for event operations

* Use AndroidCalendar.findEvent

* Update SyncManager to process fileName, eTag, scheduleTag and flags

* SyncManager: make ETag and Schedule-Tag processing more understandable

* Make upload handling more clear

* Update synctools
2025-08-06 15:38:38 +02:00
Sunik Kupfer
438f967152 [Sync Framework] Android 14+: Cancel forever pending address book account syncs (#1643)
* Also cancel sync for address books accounts

* Cancel known account directly

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-08-06 12:07:49 +02:00
Ricki Hirner
a093238864 Make "sync pending" work in AccountsScreen and AccountScreen (#1615)
* Add sync pending check for Android 14+

* Update sync pending UI logic to use selected authorities only

* Fix isSyncPending not handling multiple dataTypes

* Extract the accounts flow map to boolean flow logic

* Rename method

* Enhance kdoc

* Pass only one authority for pending check

* Update kdoc

* Update kdoc

* Update kdoc

* Fix whitespace

* Rename authority method to currentAuthority

* Update kdoc

---------

Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2025-08-06 12:04:24 +02:00
Arnau Mora
293daf1e82 Fix bottom bar color on custom tabs (#1640)
* Fix bottom bar color on custom tabs

* Removed themind

Signed-off-by: Arnau Mora <arnyminerz@proton.me>

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-06 11:45:29 +02:00
Sunik Kupfer
3b50747ce9 Rename CollectionListRefresher to CollectionsWithoutHomeSetRefresher (#1644)
* CollectionListRefresher to CollectionsWithoutHomeSetRefresher

* Adapt tests

* Unify class/method naming

* Use "without homeset" wording when applicable

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-08-06 10:45:43 +02:00
213 changed files with 5863 additions and 3430 deletions

4
.github/CODEOWNERS vendored Normal file
View File

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

View File

@@ -8,4 +8,20 @@ updates:
schedule:
interval: "weekly"
commit-message:
prefix: "[CI] "
prefix: "[CI] "
groups:
ci-actions:
patterns: ["*"]
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
groups:
app-dependencies:
patterns: ["*"]
ignore:
# dependencies without semantic versioning
- dependency-name: "com.github.bitfireat:cert4android"
- dependency-name: "com.github.bitfireat:dav4jvm"
- dependency-name: "com.github.bitfireat:synctools"

View File

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

View File

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

View File

@@ -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 }}

View File

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

View File

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

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -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{ *; }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,26 +6,23 @@ 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
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.LegacyAndroidCalendar
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.bitfire.synctools.storage.calendar.EventsContract
import at.bitfire.synctools.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.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -45,6 +42,7 @@ class LocalCalendarTest {
lateinit var localCalendarFactory: LocalCalendar.Factory
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var androidCalendar: AndroidCalendar
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@@ -56,94 +54,89 @@ class LocalCalendarTest {
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
androidCalendar = provider.createAndGetCalendar(ContentValues())
calendar = localCalendarFactory.create(androidCalendar)
}
@After
fun tearDown() {
calendar.androidCalendar.delete()
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
})
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", flags = LocalResource.FLAG_REMOTELY_PRESENT)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
/**
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events._ID]
* - [Events.DIRTY]
*/
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
val entity = Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
EventsContract.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
)
val id = androidCalendar.addEvent(entity)
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
calendar.removeNotDirtyMarked(123)
// 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))
}
assertNull(androidCalendar.getEvent(id))
}
@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"))
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", flags = LocalResource.FLAG_REMOTELY_PRESENT)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
)
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@Test
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
@Test
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
)
// 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))
}
/**
* Verifies that [LocalCalendar.markNotDirty] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events.DIRTY]
*/
private fun testMarkNotDirty(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events._ID to 1,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
EventsContract.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
val updated = calendar.markNotDirty(321)
assertEquals(1, updated)
assertEquals(321, androidCalendar.getEvent(id)?.entityValues?.getAsInteger(EventsContract.COLUMN_FLAGS))
}
@Test
fun test_markNotDirty_DirtyIs0() = testMarkNotDirty(
contentValuesOf(
Events.DIRTY to 0
)
)
@Test
fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty(
contentValuesOf(
Events.DIRTY to null
)
)
}

View File

@@ -1,241 +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.LegacyAndroidCalendar
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.UUID
import javax.inject.Inject
@HiltAndroidTest
class LocalEventTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
}
@After
fun tearDown() {
calendar.androidCalendar.delete()
client.closeCompat()
}
@Test
fun testPrepareForUpload_NoUid() {
// create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event without uid"
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", 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
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", 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-"
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", flags = LocalResource.FLAG_REMOTELY_PRESENT)
val localEvent = calendar.findByName("filename.ics")!!
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
val fileName = fileNameWithSuffix.removeSuffix(".ics")
// throws an exception if fileName is not an UUID
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(event.uid, cursor.getString(0))
}
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
// TODO
}
@Test
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
// create recurring event with only deleted/cancelled instances
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220120T010203Z")
dtStart = DtStart("20220120T010203Z")
summary = "Cancelled exception on 1st day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220121T010203Z")
dtStart = DtStart("20220121T010203Z")
summary = "Cancelled exception on 2nd day"
status = Status.VEVENT_CANCELLED
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T010203Z")
summary = "Cancelled exception on 3rd day"
status = Status.VEVENT_CANCELLED
})
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", 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"))
}
val legacyCalendar = LegacyAndroidCalendar(calendar.androidCalendar)
legacyCalendar.add(event = event, syncId = "filename.ics", flags = LocalResource.FLAG_REMOTELY_PRESENT)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
}

View File

@@ -21,38 +21,47 @@ import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
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.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.util.Optional
import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
lateinit var provider: ContentProviderClient
val account = Account("Test Account", "Test Account Type")
@Before
fun setup() {
fun setUp() {
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@After
fun tearDown() {
provider.close()
}
@Test
fun testApplyPendingMemberships_addPendingMembership() {
@@ -145,7 +154,6 @@ class LocalGroupTest {
}
}
@Test
fun testClearDirty_addCachedGroupMembership() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
@@ -164,7 +172,7 @@ class LocalGroupTest {
}
)
group.clearDirty(null, null)
group.clearDirty(Optional.empty(), null)
// check cached group membership
ab.provider!!.query(
@@ -200,7 +208,7 @@ class LocalGroupTest {
}
)
group.clearDirty(null, null)
group.clearDirty(Optional.empty(), null)
// cached group membership should be gone
ab.provider!!.query(
@@ -213,7 +221,6 @@ class LocalGroupTest {
}
}
@Test
fun testMarkMembersDirty() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
@@ -233,17 +240,11 @@ class LocalGroupTest {
}
}
@Test
fun testPrepareForUpload() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
fun testUpdate() {
localTestAddressBookProvider.provide(account, provider) { ab ->
val group = newGroup(ab)
assertNull(group.getContact().uid)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
group.update(Contact(displayName = "New Group Name"), null, null, null, 0)
}
}
@@ -259,27 +260,4 @@ class LocalGroupTest {
add()
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -8,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
@@ -29,23 +30,7 @@ import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class CollectionListRefresherTest {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
class CollectionsWithoutHomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -53,7 +38,23 @@ class CollectionListRefresherTest {
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClientBuilder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: OkHttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@@ -80,15 +81,14 @@ class CollectionListRefresherTest {
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshHomelessCollections
// refreshCollectionsWithoutHomeSet
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -102,7 +102,7 @@ class CollectionListRefresherTest {
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
// Check the collection got updated - with display name and description
assertEquals(
@@ -121,7 +121,7 @@ class CollectionListRefresherTest {
}
@Test
fun refreshHomelessCollections_deletesInaccessibleCollections() {
fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -135,14 +135,14 @@ class CollectionListRefresherTest {
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -157,7 +157,7 @@ class CollectionListRefresherTest {
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
refresherFactory.create(service, client).refreshCollectionsWithoutHomeSet()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
@@ -172,20 +172,10 @@ class CollectionListRefresherTest {
companion object {
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher(
@@ -194,41 +184,11 @@ class CollectionListRefresherTest {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
logger.info("${request.method} on $path")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
"<current-user-principal>" +
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
"</current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
"</CARD:addressbook-home-set>" +
"<group-membership>" +
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>All address books</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
@@ -239,65 +199,19 @@ class CollectionListRefresherTest {
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>Freds Contacts (not mine)</displayname>" +
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
"</owner>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
"<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
" <href>mailto:email1@example.com</href>" +
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
var responseBody = ""
var responseCode = 207
when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
responseCode = 404
else ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
}
logger.info("Queried: $path")
logger.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
"</multistatus>")
}
return MockResponse().setResponseCode(404)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.os.Bundle
import android.provider.CalendarContract
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assume
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration21Test {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var migration: AccountSettingsMigration21
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
lateinit var account: Account
val authority = CalendarContract.AUTHORITY
private val inPendingState = MutableStateFlow(false)
private var statusChangeListener: Any? = null
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
// Enable sync globally and for the test account
ContentResolver.setIsSyncable(account, authority, 1)
// Start hot flow
registerSyncStateObserver()
}
@After
fun tearDown() {
unregisterSyncStateObserver()
TestAccount.remove(account)
}
@SdkSuppress(minSdkVersion = 34)
@Test
fun testCancelsSyncAndClearsPendingState() = runBlocking {
// Move into forever pending state
ContentResolver.requestSync(syncRequest())
// Wait until we are in forever pending state (with timeout)
withTimeout(10_000) {
inPendingState.first { it }
}
// Assume that we are now in the forever pending state (Skips test otherwise)
Assume.assumeTrue(ContentResolver.isSyncPending(account, authority))
// Run the migration which should cancel the forever pending sync for all accounts
migration.migrate(account)
// Wait for the state to change (with timeout)
withTimeout(10_000) {
inPendingState.first { !it }
}
// Check the sync is now not pending anymore
assertFalse(ContentResolver.isSyncPending(account, authority))
}
// helpers
private fun syncRequest() = SyncRequest.Builder()
.setSyncAdapter(account, authority)
.syncOnce()
.setExtras(Bundle()) // needed for Android 9
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
.build()
private fun registerSyncStateObserver() {
// listener pushes updates immediately when sync status changes
statusChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
) {
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
}
// Emit initial state
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
}
private fun unregisterSyncStateObserver() {
statusChangeListener?.let { ContentResolver.removeStatusChangeListener(it) }
}
companion object {
var globalAutoSyncBeforeTest = false
@BeforeClass
@JvmStatic
fun before() {
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
ContentResolver.setMasterSyncAutomatically(false)
}
@AfterClass
@JvmStatic
fun after() {
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
}
}
}

View File

@@ -6,22 +6,20 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.content.SyncStatusObserver
import android.os.Bundle
import android.provider.CalendarContract
import androidx.test.filters.SdkSuppress
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import junit.framework.AssertionFailedError
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
@@ -33,18 +31,11 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
class AndroidSyncFrameworkTest {
class AndroidSyncFrameworkTest: SyncStatusObserver {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@@ -68,7 +59,7 @@ class AndroidSyncFrameworkTest {
onStatusChanged(0) // record first entry (pending = false, active = false)
stateChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
::onStatusChanged
this
)
}
@@ -97,11 +88,11 @@ class AndroidSyncFrameworkTest {
)
}
/**
/* 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(
@@ -112,7 +103,7 @@ class AndroidSyncFrameworkTest {
State(pending = true, active = false) // ... and finishes, but stays pending
)
)
}
}*/
// helpers
@@ -129,6 +120,10 @@ class AndroidSyncFrameworkTest {
* Verifies that the given expected states match the recorded states.
*/
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
// Verify that last state is non-optional.
if (expectedStates.last().optional)
throw IllegalArgumentException("Last expected state must not be optional")
// We use runBlocking for these tests because it uses the default dispatcher
// which does not auto-advance virtual time and we need real system time to
// test the sync framework behavior.
@@ -143,47 +138,60 @@ class AndroidSyncFrameworkTest {
while (recordedStates.size < expectedStates.size) {
// verify already known states
if (recordedStates.isNotEmpty())
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
assertStatesEqual(expectedStates, recordedStates, fullMatch = false)
delay(500) // avoid busy-waiting
}
assertStatesEqual(expectedStates, recordedStates)
assertStatesEqual(expectedStates, recordedStates, fullMatch = true)
}
}
/**
* Asserts whether [actualStates] and [expectedStates] are the same, under the condition
* that expected states with the [State.optional] flag can be skipped.
*/
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>) {
fun fail() {
throw AssertionFailedError("Expected states=$expectedStates, actual=$actualStates")
}
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean) {
assertTrue("Expected states=$expectedStates, actual=$actualStates", statesMatch(expectedStates, actualStates, fullMatch))
}
/**
* Checks whether [actualStates] have matching [expectedStates], under the condition
* that expected states with the [State.optional] flag can be skipped.
*
* Note: When [fullMatch] is not set, this method can return _true_ even if not all expected states are used.
*
* @param expectedStates expected states (can include optional states which don't have to be present in actual states)
* @param actualStates actual states
* @param fullMatch whether all non-optional expected states must be present in actual states
*/
private fun statesMatch(expectedStates: List<State>, actualStates: List<State>, fullMatch: Boolean): Boolean {
// iterate through entries
val expectedIterator = expectedStates.iterator()
for (actual in actualStates) {
if (!expectedIterator.hasNext())
fail()
return false
var expected = expectedIterator.next()
// skip optional expected entries if they don't match the actual entry
while (!actual.stateEquals(expected) && expected.optional) {
if (!expectedIterator.hasNext())
fail()
return false
expected = expectedIterator.next()
}
// we now have a non-optional expected state and it must match
if (!actual.stateEquals(expected))
fail()
return false
}
// full match: all expected states must have been used
if (fullMatch && expectedIterator.hasNext())
return false
return true
}
// SyncStatusObserver implementation and data class
fun onStatusChanged(which: Int) {
override fun onStatusChanged(which: Int) {
val state = State(
pending = ContentResolver.isSyncPending(account, authority),
active = ContentResolver.isSyncActive(account, authority)

View File

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

View File

@@ -9,7 +9,7 @@ import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.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

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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 {
@@ -19,9 +21,9 @@ object TestAccount {
*
* Remove it with [remove].
*/
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
fun create(version: Int = AccountSettings.CURRENT_VERSION, accountName: String = "Test Account"): Account {
val accountType = targetContext.getString(R.string.account_type)
val account = Account("Test Account", accountType)
val account = Account(accountName, accountType)
val initialData = AccountSettings.initialUserData(null)
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
@@ -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.
*/

View File

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

View File

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

View File

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

View File

@@ -9,13 +9,14 @@ import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.HttpClientBuilder
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -48,13 +49,13 @@ class QueryChildDocumentsOperationTest {
lateinit var operation: QueryChildDocumentsOperation
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
lateinit var httpClientBuilder: HttpClientBuilder
@Inject
lateinit var testDispatcher: TestDispatcher
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var client: OkHttpClient
private lateinit var mount: WebDavMount
private lateinit var rootDocument: WebDavDocument
@@ -84,7 +85,6 @@ class QueryChildDocumentsOperationTest {
@After
fun tearDown() {
client.close()
server.shutdown()
runBlocking {

View File

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

View File

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

View File

@@ -3,9 +3,8 @@
*/
package at.bitfire.davdroid
import at.bitfire.ical4android.ical4jVersion
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}"
}

View File

@@ -35,6 +35,13 @@ import dagger.hilt.components.SingletonComponent
import java.io.Writer
import javax.inject.Singleton
/**
* The app database. Managed via android jetpack room. Room provides an abstraction
* layer over SQLite.
*
* Note: In SQLite PRAGMA foreign_keys is off by default. Room activates it for
* production (non-test) databases.
*/
@Database(entities = [
Service::class,
HomeSet::class,

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,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
}

View File

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

View File

@@ -8,10 +8,12 @@ import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
import at.bitfire.davdroid.di.DefaultDispatcher
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.servicedetection.DavResourceFinder
@@ -28,7 +30,7 @@ import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
@@ -47,6 +49,7 @@ class AccountRepository @Inject constructor(
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
@ApplicationContext private val context: Context,
private val collectionRepository: DavCollectionRepository,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
private val homeSetRepository: DavHomeSetRepository,
private val localCalendarStore: Lazy<LocalCalendarStore>,
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
@@ -70,6 +73,7 @@ class AccountRepository @Inject constructor(
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
@WorkerThread
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = fromName(accountName)
@@ -153,7 +157,7 @@ class AccountRepository @Inject constructor(
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
withContext(Dispatchers.Default) { // causes disk I/O
withContext(defaultDispatcher) { // causes disk I/O
accountManager.addOnAccountsUpdatedListener(listener, null, true)
}
@@ -165,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
@@ -175,7 +179,7 @@ class AccountRepository @Inject constructor(
* @throws IllegalArgumentException if the new account name already exists
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String) {
suspend fun rename(oldName: String, newName: String): Unit = withContext(defaultDispatcher) {
val oldAccount = fromName(oldName)
val newAccount = fromName(newName)
@@ -197,13 +201,10 @@ class AccountRepository @Inject constructor(
// rename account (also moves AccountSettings)
val future = accountManager.renameAccount(oldAccount, newName, null, null)
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
}
// wait for operation to complete (blocks calling thread)
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
// account renamed, cancel maybe running synchronization of old account
syncWorkerManager.get().cancelAllWork(oldAccount)
@@ -217,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)
}

View File

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

View File

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

View File

@@ -234,7 +234,7 @@ open class LocalAddressBook @AssistedInject constructor(
if (syncInterval != null)
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
else
syncFramework.disableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
}

View File

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

View File

@@ -4,16 +4,16 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.AndroidEvent
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.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
@@ -26,16 +26,18 @@ import java.util.logging.Logger
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalCalendar @AssistedInject constructor(
@Assisted val androidCalendar: AndroidCalendar,
@Assisted internal val androidCalendar: AndroidCalendar,
private val logger: Logger
) : LocalCollection<LocalEvent> {
@AssistedFactory
interface Factory {
fun create(androidCalendar: AndroidCalendar): LocalCalendar
fun create(calendar: AndroidCalendar): LocalCalendar
}
// properties
override val dbCollectionId: Long?
get() = androidCalendar.syncId?.toLongOrNull()
@@ -56,42 +58,48 @@ class LocalCalendar @AssistedInject constructor(
androidCalendar.writeSyncState(state.toString())
}
@VisibleForTesting
internal val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
fun add(event: EventAndExceptions): Long {
return recurringCalendar.addEventAndExceptions(event)
}
override fun findDeleted(): List<LocalEvent> {
val result = LinkedList<LocalEvent>()
androidCalendar.iterateEventRows(null, "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
// create legacy AndroidEvent from AndroidEvent2's content values
val legacyEvent = AndroidEvent(androidCalendar, values)
result += LocalEvent(legacyEvent)
recurringCalendar.iterateEventAndExceptions(
"${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null
) { eventAndExceptions ->
result += LocalEvent(recurringCalendar, eventAndExceptions)
}
return result
}
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
/*
* RFC 5545 3.8.7.4. Sequence Number
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
* CUA each time the "Organizer" makes a significant revision to the calendar component.
*/
androidCalendar.iterateEventRows(null, "${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
val legacyEvent = AndroidEvent(androidCalendar, values)
dirty += LocalEvent(legacyEvent)
recurringCalendar.iterateEventAndExceptions(
"${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null
) { eventAndExceptions ->
dirty += LocalEvent(recurringCalendar, eventAndExceptions)
}
return dirty
}
override fun findByName(name: String) =
androidCalendar.findEventRow(null, "${Events._SYNC_ID}=?", arrayOf(name))?.let { values ->
val legacyEvent = AndroidEvent(androidCalendar, values)
LocalEvent(legacyEvent)
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),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
contentValuesOf(EventsContract.COLUMN_FLAGS to flags),
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
"""
${Events.CALENDAR_ID}=?
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
AND ${Events.ORIGINAL_ID} IS NULL
""".trimIndent(),
arrayOf(androidCalendar.id.toString())
)
@@ -100,14 +108,20 @@ class LocalCalendar @AssistedInject constructor(
val batch = CalendarBatchOperation(androidCalendar.client)
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${AndroidEvent2.COLUMN_FLAGS}=?",
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
"""
${Events.CALENDAR_ID}=?
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
AND ${Events.ORIGINAL_ID} IS NULL
AND ${EventsContract.COLUMN_FLAGS}=?
""".trimIndent(),
arrayOf(androidCalendar.id.toString(), flags.toString())
) { values ->
val id = values.getAsInteger(Events._ID)
val id = values.getAsLong(Events._ID)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch += BatchOperation.CpoBuilder
.newDelete(Events.CONTENT_URI.asSyncAdapter(androidCalendar.account))
.newDelete(androidCalendar.eventsUri)
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
}
return batch.commit()
@@ -115,95 +129,9 @@ class LocalCalendar @AssistedInject constructor(
override fun forgetETags() {
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
contentValuesOf(EventsContract.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
batch.commit()
}
// process dirty exceptions
logger.info("Processing dirty exceptions")
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: set original event to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(originalID))
.withValue(Events.DIRTY, 1)
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
}
/**
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
*
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
// Iterate dirty main events without exceptions
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
) { values ->
val eventId = values.getAsLong(Events._ID)
// get number of instances
val numEventInstances = androidCalendar.numInstances(eventId)
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventId without instances as deleted")
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
}
}
}
}

View File

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

View File

@@ -4,7 +4,12 @@
package at.bitfire.davdroid.resource
interface LocalCollection<out T: LocalResource<*>> {
/**
* This is an interface between the Syncer/SyncManager and a collection in the local storage.
*
* It defines operations that are used during sync for all sync data types.
*/
interface LocalCollection<out T: LocalResource> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
val tag: String
@@ -48,10 +53,8 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
fun findByName(name: String): T?
/**
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
* and have an [Events.ORIGINAL_ID] of null.
* Updates the flags value for entries which are not dirty.
*
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
*
@@ -60,8 +63,7 @@ interface LocalCollection<out T: LocalResource<*>> {
fun markNotDirty(flags: Int): Int
/**
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
* a given flag combination.
* Removes entries which are not dirty with a given flag combination.
*
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
* all entries with exactly this flag will be removed)
@@ -76,4 +78,4 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
fun forgetETags()
}
}

View File

@@ -4,11 +4,16 @@
package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import android.provider.ContactsContract.RawContacts.getContactLookupUri
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
@@ -22,16 +27,16 @@ import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.MoreObjects
import java.io.FileNotFoundException
import java.util.UUID
import java.util.Optional
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
@@ -40,9 +45,8 @@ class LocalContact: AndroidContact, LocalAddress {
internal val cachedGroupMemberships = HashSet<Long>()
internal val groupMemberships = HashSet<Long>()
override var scheduleTag: String?
override val scheduleTag: String?
get() = null
set(_) = throw NotImplementedError()
override var flags: Int = 0
@@ -64,25 +68,6 @@ class LocalContact: AndroidContact, LocalAddress {
}
override fun prepareForUpload(): String {
val contact = getContact()
val uid: String = contact.uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in contacts provider
val values = contentValuesOf(COLUMN_UID to newUid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
// update this event
contact.uid = newUid
newUid
}
return "$uid.vcf"
}
/**
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
*/
@@ -90,34 +75,38 @@ class LocalContact: AndroidContact, LocalAddress {
_contact = null
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
val values = ContentValues(4)
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
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)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = eTag
}
override fun resetDeleted() {
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
fun resetDirty() {
val values = contentValuesOf(RawContacts.DIRTY to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
fun resetDirty() {
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
// processes this.{fileName, eTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
@@ -127,6 +116,46 @@ 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()
}
override fun resetDeleted() {
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
/*.add("contact",
try {
// too dangerous, may contain unknown properties and cause another OOM
Ascii.truncate(getContact().toString(), 1000, "…")
} catch (e: Exception) {
e
}
)*/
.toString()
override fun getViewUri(context: Context): Uri? =
id?.let { idNotNull ->
getContactLookupUri(
context.contentResolver,
ContentUris.withAppendedId(RawContacts.CONTENT_URI, idNotNull)
)
}
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
batch += BatchOperation.CpoBuilder
@@ -187,6 +216,7 @@ class LocalContact: AndroidContact, LocalAddress {
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
@@ -15,7 +16,6 @@ import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
@@ -24,8 +24,9 @@ import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.AndroidGroupFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import com.google.common.base.MoreObjects
import java.util.LinkedList
import java.util.UUID
import java.util.Optional
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull
@@ -111,7 +112,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
override var scheduleTag: String?
get() = null
set(value) = throw NotImplementedError()
set(_) = throw NotImplementedError()
override var flags: Int = 0
@@ -139,40 +140,20 @@ 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: String?, eTag: String?, scheduleTag: String?) {
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
val id = requireNotNull(id)
val values = ContentValues(3)
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
if (fileName.isPresent)
values.put(COLUMN_FILENAME, fileName.get())
values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated
values.put(Groups.DIRTY, 0)
update(values)
if (fileName != null)
this.fileName = fileName
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = null
// update cached group memberships
@@ -211,9 +192,12 @@ class LocalGroup: AndroidGroup, LocalAddress {
batch.commit()
}
override fun resetDeleted() {
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
// processes this.{fileName, eTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
@@ -223,6 +207,38 @@ 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()
}
override fun resetDeleted() {
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("flags", flags)
.add("contact",
try {
getContact().toString()
} catch (e: Exception) {
e
}
).toString()
override fun getViewUri(context: Context) = null
// helpers

View File

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

View File

@@ -5,21 +5,26 @@
package at.bitfire.davdroid.resource
import android.content.ContentValues
import android.content.Context
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxICalObject
import at.bitfire.ical4android.JtxICalObjectFactory
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.JtxICalObject.getViewIntentUriFor
import com.google.common.base.MoreObjects
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
/**
* Represents a Journal, Note or Task entry
*/
class LocalJtxICalObject(
collection: JtxCollection<*>,
fileName: String?,
eTag: String?,
scheduleTag: String?,
flags: Int
) :
JtxICalObject(collection),
LocalResource<JtxICalObject> {
) : JtxICalObject(collection), LocalResource {
init {
this.fileName = fileName
@@ -48,8 +53,41 @@ class LocalJtxICalObject(
}
fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
update(data)
}
override fun updateSequence(sequence: Int) = throw NotImplementedError()
override fun updateUid(uid: String) = throw NotImplementedError()
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}
override fun getDebugSummary() =
MoreObjects.toStringHelper(this)
.add("id", id)
.add("fileName", fileName)
.add("eTag", eTag)
.add("scheduleTag", scheduleTag)
.add("flags", flags)
.toString()
override fun getViewUri(context: Context) = getViewIntentUriFor(id)
}

View File

@@ -4,13 +4,18 @@
package at.bitfire.davdroid.resource
import android.content.Context
import android.content.Intent
import android.net.Uri
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
import java.util.Optional
/**
* Defines operations that are used by SyncManager for all sync data types.
* This is an interface between the SyncManager and a resource in the local storage.
*
* It defines operations that are used by SyncManager for all sync data types.
*/
interface LocalResource<in TData: Any> {
interface LocalResource {
companion object {
/**
@@ -36,60 +41,70 @@ interface LocalResource<in TData: Any> {
val fileName: String?
/** remote ETag for the resource */
var eTag: String?
val eTag: String?
/** remote Schedule-Tag for the resource */
var scheduleTag: String?
val scheduleTag: String?
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
val flags: Int
/**
* Prepares the resource for uploading:
* 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).
*
* 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 new file name of the resource (like "<uid>.vcf")
*/
fun prepareForUpload(): String
/**
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
* locally modified resource.
*
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
* @param fileName If this optional argument is present, [LocalResource.fileName] will be set to its value.
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
* @param scheduleTag CalDAV only: `Schedule-Tag` of the uploaded resource as returned by the server
* (null if not applicable or if the server didn't return one)
*/
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String? = null)
/**
* Sets (local) flags of the resource. At the moment, the only allowed values are
* 0 and [FLAG_REMOTELY_PRESENT].
* Sets (local) flags of the resource in the content provider.
* Does not affect `this` object itself (which is immutable).
*
* At the moment, the only allowed values are 0 and [FLAG_REMOTELY_PRESENT].
*/
fun updateFlags(flags: Int)
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the updated row (e.g. event URI)
* Updates the local UID of the resource in the content provider.
* Usually used to persist a UID that has been created during an upload of a locally created resource.
*/
fun update(data: TData): Uri
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.
*
* @return number of affected rows
*/
fun delete(): Int
fun deleteLocal()
/**
* Undoes deletion of the data object from the content provider.
*/
fun resetDeleted()
/**
* User-readable debug summary of this local resource (used in debug info)
*/
fun getDebugSummary(): String
/**
* Returns the content provider URI that opens the local resource for viewing ([Intent.ACTION_VIEW])
* in its respective app.
*
* For instance, in case of a local raw contact, this method could return the content provider URI
* that identifies the corresponding contact.
*
* @return content provider URI, or `null` if not available
*/
fun getViewUri(context: Context): Uri?
}

View File

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

View File

@@ -4,15 +4,10 @@
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
@@ -22,62 +17,38 @@ import java.util.logging.Logger
*
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalTaskList private constructor(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
class LocalTaskList (
val dmfsTaskList: DmfsTaskList
): LocalCollection<LocalTask> {
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() = dmfsTaskList.accessLevel?.let {
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
} ?: false
override val dbCollectionId: Long?
get() = syncId?.toLongOrNull()
get() = dmfsTaskList.syncId?.toLongOrNull()
override val tag: String
get() = "tasks-${account.name}-$id"
get() = "tasks-${dmfsTaskList.account.name}-${dmfsTaskList.id}"
override val title: String
get() = name ?: id.toString()
get() = dmfsTaskList.name ?: dmfsTaskList.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() = dmfsTaskList.readSyncState()?.let { SyncState.fromString(it) }
set(state) {
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
provider.update(taskListSyncUri(), values, null, null)
dmfsTaskList.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 findDeleted() = dmfsTaskList.queryTasks(Tasks._DELETED, null)
.map { LocalTask(it) }
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks(Tasks._DIRTY, null)
for (localTask in tasks) {
val dmfsTasks = dmfsTaskList.queryTasks(Tasks._DIRTY, null)
for (localTask in dmfsTasks) {
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
@@ -89,41 +60,32 @@ class LocalTaskList private constructor(
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
}
return tasks
return dmfsTasks.map { LocalTask(it) }
}
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
dmfsTaskList.queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
.firstOrNull()?.let {
LocalTask(it)
}
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
return provider.update(tasksSyncUri(), values,
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
return dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
arrayOf(dmfsTaskList.id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
dmfsTaskList.provider.delete(dmfsTaskList.tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
arrayOf(dmfsTaskList.id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}
object Factory: DmfsTaskListFactory<LocalTaskList> {
override fun newInstance(
account: Account,
provider: ContentProviderClient,
providerName: TaskProvider.ProviderName,
id: Long
) = LocalTaskList(account, provider, providerName, id)
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(dmfsTaskList.id.toString()))
}
}

View File

@@ -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 LocalTaskList(DmfsTaskList.findByID(account, client, providerName, ContentUris.parseId(uri)))
}
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
@@ -80,7 +81,7 @@ class LocalTaskListStore @AssistedInject constructor(
put(TaskLists.SYNC_ENABLED, 1)
put(TaskLists.VISIBLE, 1)
}
return DmfsTaskList.Companion.create(account, provider, providerName, values)
return DmfsTaskList.create(account, provider, providerName, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
@@ -100,25 +101,26 @@ 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, client, providerName, null, null)
.map { LocalTaskList(it) }
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()))
val accountSettings = accountSettingsFactory.create(localCollection.dmfsTaskList.account)
localCollection.dmfsTaskList.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) {
localCollection.delete()
localCollection.dmfsTaskList.delete()
}
}

View File

@@ -4,36 +4,33 @@
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
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
import java.util.logging.Logger
/**
* Logic for refreshing the list of collections and home-sets and related information.
* Logic for refreshing the list of collections (and their related information)
* which do not belong to a home set.
*/
class CollectionListRefresher @AssistedInject constructor(
class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
private val logger: Logger
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher
}
/**
@@ -41,9 +38,9 @@ class CollectionListRefresher @AssistedInject constructor(
*
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
*/
internal fun refreshHomelessCollections() {
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for((url, localCollection) in homelessCollections) try {
internal fun refreshCollectionsWithoutHomeSet() {
val withoutHomeSet = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for ((url, localCollection) in withoutHomeSet) try {
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
if (!response.isSuccess()) {
@@ -66,12 +63,11 @@ class CollectionListRefresher @AssistedInject constructor(
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
if (e.statusCode in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
}

View File

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

View File

@@ -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
@@ -48,10 +48,10 @@ class HomeSetRefresher @AssistedInject constructor(
* Refreshes home-sets and their collections.
*
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
* or marked as homeless - in case a collection was removed from its home-set.
* or marked as "without home-set" - in case a collection was removed from its home-set.
*
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionListRefresher.refreshHomelessCollections].
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet].
*/
internal fun refreshHomesetsAndTheirCollections() {
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
@@ -59,7 +59,7 @@ class HomeSetRefresher @AssistedInject constructor(
logger.fine("Listing home set $homeSetUrl")
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
// is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless.
// is successfully rediscovered. If there are collections left, after processing is done, these are marked as "without home-set".
val localHomesetCollections = db.collectionDao()
.getByServiceAndHomeset(service.id, localHomeset.id)
.associateBy { it.url }
@@ -103,14 +103,14 @@ class HomeSetRefresher @AssistedInject constructor(
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
if (e.statusCode in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
for ((_, homelessCollection) in localHomesetCollections)
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
for ((_, collection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlRememberSync(
homelessCollection.copy(homeSetId = null)
collection.copy(homeSetId = null)
)
}

View File

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

View File

@@ -22,9 +22,9 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.push.PushRegistrationManager
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
@@ -62,9 +62,9 @@ import java.util.logging.Logger
class RefreshCollectionsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val collectionListRefresherFactory: CollectionListRefresher.Factory,
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
private val httpClientBuilder: HttpClient.Builder,
private val httpClientBuilder: HttpClientBuilder,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry,
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
@@ -153,34 +153,31 @@ class RefreshCollectionsWorker @AssistedInject constructor(
.cancel(serviceId.toString(), NotificationRegistry.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
httpClientBuilder
val httpClient = httpClientBuilder
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible {
val httpClient = httpClient.okHttpClient
val refresher = collectionListRefresherFactory.create(service, httpClient)
runInterruptible {
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
// refresh home set list (from principal url)
service.principal?.let { principalUrl ->
logger.fine("Querying principal $principalUrl for home sets")
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
serviceRefresher.discoverHomesets(principalUrl)
}
// refresh home sets and their member collections
homeSetRefresherFactory.create(service, httpClient)
.refreshHomesetsAndTheirCollections()
// also refresh collections without a home set
refresher.refreshHomelessCollections()
// 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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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].**
*

View File

@@ -12,7 +12,7 @@ import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Reminders
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Binds
@@ -39,7 +39,7 @@ class AccountSettingsMigration10 @Inject constructor(
override fun migrate(account: Account) {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
val tasksUri = provider.tasksUri().asSyncAdapter(account)
val emptyETag = contentValuesOf(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)
}

View File

@@ -13,7 +13,7 @@ import android.util.Base64
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.synctools.storage.calendar.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
)
}

View File

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

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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<HttpClient>,
provideHttpClient: () -> OkHttpClient,
provider: ContentProviderClient,
syncResult: SyncResult,
collection: Collection
@@ -103,7 +104,7 @@ class AddressBookSyncer @AssistedInject constructor(
val syncManager = contactsSyncManagerFactory.contactsSyncManager(
account,
httpClient.value,
provideHttpClient(),
syncResult,
provider,
addressBook,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
))
}
}

View File

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

View File

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

View File

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

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