Compare commits

..

70 Commits

Author SHA1 Message Date
Sunik Kupfer
7f36e826d8 Cancel syncs for calendar, tasks, and contacts separately 2025-09-04 12:13:18 +02:00
Sunik Kupfer
3737d69397 Add FAB to cancel sync adapter syncs 2025-09-04 11:43:10 +02:00
Sunik Kupfer
2dbd5c02b6 Cancel by request and empty bundle 2025-09-04 11:09:23 +02:00
Sunik Kupfer
c12e9311f7 Stop always returning false for pending sync state of sync adapter framework 2025-09-04 10:58:40 +02:00
Sunik Kupfer
b663912feb Enable forever pending sync workaround by canceling sync adapter framework syncs on Android 14+ 2025-09-04 10:56:54 +02:00
Sunik Kupfer
3c484f253f Use cancelSync directly in migration 2025-09-04 10:53:50 +02:00
Sunik Kupfer
de7f8d2964 Cancel for all authorities and update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
a79a39c25d Cancel only on Android 14+ 2025-09-04 10:53:50 +02:00
Sunik Kupfer
20675ed71b Update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
881588f8e8 Don't infer authority from account type 2025-09-04 10:53:50 +02:00
Sunik Kupfer
0c31758880 Also cancel calendar syncs 2025-09-04 10:53:50 +02:00
Sunik Kupfer
9ffd59cd00 Updating log statement 2025-09-04 10:53:50 +02:00
Sunik Kupfer
c40b2b38bc Update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
3025ea7491 Optimize imports 2025-09-04 10:53:50 +02:00
Sunik Kupfer
b84a812d7a Call cancelSync via integration 2025-09-04 10:53:50 +02:00
Sunik Kupfer
562afc5666 Add and update kdoc 2025-09-04 10:53:50 +02:00
Sunik Kupfer
8992859b63 Increase account settings current version 2025-09-04 10:53:50 +02:00
Sunik Kupfer
03013b5576 Add log statement 2025-09-04 10:53:50 +02:00
Sunik Kupfer
0028fc8722 Add application context annotation 2025-09-04 10:53:50 +02:00
Sunik Kupfer
1b4ebde896 Add AccountSettingsMigration21 to cancel pending address book syncs 2025-09-04 10:53:50 +02:00
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
Arnau Mora
51d6ed279a Fetch translations from Transifex (#1638) 2025-08-04 11:50:15 +02:00
Arnau Mora
2c6842ac0c Version bump to 4.5.3 (#1637)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-04 11:38:40 +02:00
Ricki Hirner
0e6644305a Bump version to 4.5.3-rc.2 2025-08-01 10:49:28 +02:00
Ricki Hirner
10e3b0a723 Update dependencies, including AGP (#1632) 2025-08-01 10:49:00 +02:00
Arnau Mora
2f45b705b3 Replaced Webcal tab icon in collections list (#1628)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-08-01 10:43:09 +02:00
Ricki Hirner
be6c3311d7 WebDAV: remove notifications, timeout logic (#1630)
* Refactor openDocument operation to use OsConstants for mode parsing

* RandomAccessCallbackWrapper: refactor so that it's only purpose is to avoid the memory leak

* Use main looper instead of a new thread per RandomAccessCallback

* Remove WebDAV access notification

* Remove nsk90-kstatemachine dependency

* Simplify fileDescriptor() method

* Use dedicated I/O thread again; use Kotlin `copyTo` for copying
2025-08-01 10:38:24 +02:00
Ricki Hirner
755863778b Bump version to 4.5.3-rc.1 2025-07-30 10:49:15 +02:00
Ricki Hirner
0e81866d3a Fix ETag update logic in QueryChildDocumentsOperation (#1626) 2025-07-30 10:48:39 +02:00
Arnau Mora
93a256ee75 Icons next to CalDAV/CardDAV tab titles (#1599)
* Icons next to CalDAV/CardDAV tab titles

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

* Use shared element transitions

* Switch to `sharedBounds` to allow font size changes

* Update app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Minor changes

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-30 10:30:16 +02:00
Arnau Mora
61e9d60b7c Correctly handle SecurityException for acquireContentProvider (#1622)
* Handle `SecurityException` for `acquireContentProvider`

* Added optional `throwOnMissingPermissions` arg to `acquireContentProvider`

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

* Set `throwOnMissingPermissions` to `true`

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

* Adapt comments, remove now unnecessary try/catch in AccountSettingsMigration20

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-30 10:07:54 +02:00
Ricki Hirner
dc9fb7b608 AndroidSyncFrameworkTest: allow optional states (#1625)
* Update sync state verification to allow optional states

* Refactor AndroidSyncFrameworkTest to use stateEquals for comparison
2025-07-30 09:42:16 +02:00
Ricki Hirner
44b52f65a2 [WebDAV] Implement command pattern, streamline lifecycle, remove WebdavScope (#1617)
* Remove WebdavScope as it is no longer needed

* [WIP] DavDocumentsProviderImpl

* [WIP] Move DavDocumentsProvider to BaseDavDocumentsProvider and implementation to DavDocumentsProvider

* Adapt tests and DI

* [WIP] Implement Command pattern

* Finish Command pattern, add deprecation notices

* Unify DavDocumentsProvider with wrapper again

* Get rid of DavDocumentsActor

* Add notes about lifecycle, remove shutdown
2025-07-29 10:17:05 +02:00
Sunik Kupfer
e13c140554 Extract refreshHomesetsAndTheirCollections to HomeSetRefresher (#1606)
* Extract refreshHomesetsAndTheirCollections to HomeSetRefresher

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

* Add kdoc

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

* Minor changes

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-28 09:59:40 +02:00
Ricki Hirner
cdb50205f4 Don't warn on newer Commons versions 2025-07-24 18:12:06 +02:00
Sunik Kupfer
2ba4a2a510 Version bump to 4.5.3-alpha.1 2025-07-24 14:07:12 +02:00
Sunik Kupfer
38b2377760 Follow redirects when adding webdav mount (#1582)
* Follow redirects when adding webdav mount

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

* Update dav4jvm version

* Remove unused null

* Fix tests

* Add Kdoc

* Add Kdoc

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-24 14:02:50 +02:00
Sunik Kupfer
10f6356a6e Extract refreshPrincipals() to PrincipalsRefresher (#1607)
* Extract refreshPrincipals to PrincipalsRefresher

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

* Make method public

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-07-24 13:49:23 +02:00
Ricki Hirner
df4b6d3fbc Use early entry point for sync adapter services (#1610) 2025-07-24 13:19:56 +02:00
Ricki Hirner
dab948730e Choose real or fake (for tests) SyncAdapter over DI (#1608)
* Choose real or fake (for tests) SyncAdapter over DI

* Minor changes

* Rename SyncAdapterServicesTest.kt to RealSyncAdapterTest.kt

* Group sync adapter / sync framework classes into new package

* Cache SyncAdapter in SyncAdapterServices

* Add documentation to SyncAdapter interface and rename RealSyncAdapterTest to SyncAdapterImplTest
2025-07-24 12:19:54 +02:00
Sunik Kupfer
288583bfad Extract discoverHomesets() to ServiceRefresher (#1604)
* Extract discoverHomesets to ServiceRefresher

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

* Move hiltRule to top; Add space

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

* Log every request with method and path

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-07-24 11:33:48 +02:00
Sunik Kupfer
98c0b0c36a [Sync framework] Fix sync always pending on Android 14+ (#1463)
* Android 14 and 15 workaround: tell the sync framework to cancel any pending syncs

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

* Add test which documents wrong pending sync check behaviour

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

* Exclude android 13 and below

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

* Cancel only own sync request

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

* Cancel only after enqueuing sync worker

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

* Move test to AndroidSyncFrameworkTest

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

* Reset master sync state

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

* Remove limited parallelism and increase test timeout

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

* Rename test method

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

* Add assert message

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

* Update comment

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

* Add sdk suppress annotation

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

* Use runBlocking to be able to catch the timeout exception

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

* Extract pending sync cancellation to method

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

* Enhance sync framework tests for Android versions 9 to 16, verifying correct and incorrect pending states

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

* Add tests for sync always pending behavior on Android 10 and 11

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

* Remove obsolete unmockkAll call

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

* Make tests a bit more reliable

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

* Extract cancelSync method call to utils to stub the call more easily

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

* Remove some unnecessary calls and update stub

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

* Update expected states lists

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

* Move cancelSyncInSyncFramework to SyncFrameworkIntegration

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

* Pass the whole sync extras bundle when cancelling sync

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

* [WIP] Initialize pending sync state reporting wrong behaviour

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

* Optimize SyncAdapterServicesTest

* Remove unused property

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

* Reset master sync state

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

* Revert "Reset master sync state"

This reverts commit 4bfe73a25a.

* Revert "Remove unused property"

This reverts commit 7c0fdbf392.

* Reapply "Reset master sync state"

This reverts commit 5f7f0f9bce.

* Reapply "Remove unused property"

This reverts commit f1d5009f8a.

* Increase timeout to 2 min

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

* [WIP] Optimize tests

* Optimize sync framework tests

* SyncAdapterServices FakeSyncAdapter: support interrupting

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-07-23 13:03:02 +02:00
Sunik Kupfer
ed7a477d3f [UI] Use "(optional)" in labels to indicate optional textfields (#1571)
* Clarify optional fields in UI

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

* Add icons to add webdav mount screen

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

* Add title to group mount point address and name

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

* Use assistant composable

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-07-22 09:20:06 +02:00
Ricki Hirner
b0609fafb2 Update synctools for AndroidEvent2 (#1601)
* [WIP] Update synctools

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Update cert4android to get 16 kB page size support over Conscrypt 2.5.3 (#1581)

* Move SyncState to resource package because it's not in the database (#1585)

* Update dependencies, including dav4jvm that updates okhttp to 5.x (#1593)

* Update dependencies, including dav4jvm that updates okhttp to 5.x

* Update mockk and okhttp

* Bump version to 4.5.2-beta.1

* Move Insert/update to DAO (#1587)

* Move homeset insert/update logic from repository to DAO; add thread-safety test

* Rename insertOrUpdateByUrlRememberSync

* Fix exceptions being fetched as `Parcelable` instead of `Serializable` in `DebugInfoActivity` (#1597)

Typo: replace `getParcelableExtra` with `getSerializableExtra`

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

* Bump version to 4.5.2

* Fetch translations from Transifex

* Add documentation and handle missing event in LocalEvent

* Minor changes

* Rename `event` to `getCachedEvent()` to make it more clear what it does

* Update SEQUENCE after successful event upload more explicitly

* Update sequence after successful calendar event upload

* Remove deprecated add() method from LocalResource

* Update KDoc

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2025-07-22 09:15:10 +02:00
Ricki Hirner
94a85833bc Bump version to 4.5.2.1 2025-07-21 18:13:31 +02:00
Ricki Hirner
4c5c8c3ed0 Don't disable AndroidX startup completely (#1603)
* Add test

* Update AndroidManifest.xml to configure Hilt/WorkManager integration and remove default WorkManagerInitializer
2025-07-21 18:09:37 +02:00
Ricki Hirner
4685ab6d0c Revert "Update synctools" (#1600)
Revert "Update synctools (#1579)"

This reverts commit 62a0ba3520.
2025-07-21 11:57:34 +02:00
Ricki Hirner
62a0ba3520 Update synctools (#1579)
* [WIP] Update synctools

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Refactor LocalEvent to use LegacyAndroidCalendar for event operations

* Update cert4android to get 16 kB page size support over Conscrypt 2.5.3 (#1581)

* Move SyncState to resource package because it's not in the database (#1585)

* Update dependencies, including dav4jvm that updates okhttp to 5.x (#1593)

* Update dependencies, including dav4jvm that updates okhttp to 5.x

* Update mockk and okhttp

* Bump version to 4.5.2-beta.1

* Move Insert/update to DAO (#1587)

* Move homeset insert/update logic from repository to DAO; add thread-safety test

* Rename insertOrUpdateByUrlRememberSync

* Fix exceptions being fetched as `Parcelable` instead of `Serializable` in `DebugInfoActivity` (#1597)

Typo: replace `getParcelableExtra` with `getSerializableExtra`

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

* Bump version to 4.5.2

* Fetch translations from Transifex

* Add documentation and handle missing event in LocalEvent

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2025-07-21 11:54:20 +02:00
139 changed files with 5031 additions and 3348 deletions

View File

@@ -8,4 +8,7 @@ updates:
schedule:
interval: "weekly"
commit-message:
prefix: "[CI] "
prefix: "[CI] "
groups:
ci-actions:
patterns: ["*"]

View File

@@ -28,8 +28,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-java@v4
uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

View File

@@ -19,8 +19,8 @@ jobs:
discussions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

View File

@@ -15,8 +15,8 @@ jobs:
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
@@ -35,8 +35,8 @@ jobs:
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
@@ -56,8 +56,8 @@ jobs:
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

View File

@@ -19,10 +19,10 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 405020002
versionName = "4.5.2"
versionCode = 405040002
versionName = "4.5.4-rc.1"
setProperty("archivesBaseName", "davx5-ose-$versionName")
base.archivesName = "davx5-ose-$versionName"
minSdk = 24 // Android 7.0
targetSdk = 36 // Android 16
@@ -188,7 +188,6 @@ dependencies {
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.mikepenz.aboutLibraries)
implementation(libs.nsk90.kstatemachine)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)

View File

@@ -1,20 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.util.Xml
import at.bitfire.dav4jvm.XmlUtils
import org.junit.Assert.assertTrue
import org.junit.Test
class Dav4jvmTest {
@Test
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
val parser = XmlUtils.newPullParser()
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.util.Xml
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalLibrariesTest {
@Test
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
val parser = XmlUtils.newPullParser()
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
}
@Test
fun testOkhttpHttpUrl_PublicSuffixList() {
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
// In Android, loading the PublicSuffixList is done over AndroidX startup.
// This test verifies that everything is working.
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
}
}

View File

@@ -10,7 +10,6 @@ import android.os.Build
import android.os.Bundle
import androidx.test.runner.AndroidJUnitRunner
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
import at.bitfire.davdroid.sync.SyncAdapterService
import at.bitfire.davdroid.test.BuildConfig
import at.bitfire.synctools.log.LogcatHandler
import dagger.hilt.android.testing.HiltTestApplication
@@ -36,9 +35,6 @@ class HiltTestRunner : AndroidJUnitRunner() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
// disable sync adapters
SyncAdapterService.syncActive.set(false)
// set main dispatcher for tests (especially runTest)
TestCoroutineDispatchersModule.initMainDispatcher()
}

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,20 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.sync.FakeSyncAdapter
import at.bitfire.davdroid.sync.adapter.SyncAdapter
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
abstract class FakeSyncAdapterModule {
@Binds
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
}

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

@@ -8,15 +8,18 @@ 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.AndroidEvent
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.test.InitCalendarProviderRule
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -26,6 +29,8 @@ import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -45,6 +50,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,12 +62,13 @@ 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()
}
@@ -92,9 +99,15 @@ class LocalCalendarTest {
status = Status.VEVENT_CANCELLED
})
}
val localEvent = AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
@@ -122,26 +135,102 @@ class LocalCalendarTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
val localEvent = AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
localEvent.add()
val eventId = localEvent.id!!
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventUrl = androidCalendar.eventUri(localEvent.id)
// set event as dirty
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
client.update(eventUrl, contentValuesOf(
Events.DIRTY to 1
), null, null)
// this method should mark the event as deleted
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
cursor.moveToNext()
assertEquals(0, cursor.getInt(0))
}
}
/**
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
* @param contentValues values to set on the event. Required:
* - [Events._ID]
* - [Events.DIRTY]
*/
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
val id = androidCalendar.addEvent(Entity(
contentValuesOf(
Events.CALENDAR_ID to androidCalendar.id,
Events.DTSTART to System.currentTimeMillis(),
Events.DTEND to System.currentTimeMillis(),
Events.TITLE to "Some Event",
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
calendar.removeNotDirtyMarked(123)
assertNull(androidCalendar.getEvent(id))
}
@Test
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
)
@Test
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
)
@Test
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
)
/**
* 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",
AndroidEvent2.COLUMN_FLAGS to 123
).apply { putAll(contentValues) }
))
val updated = calendar.markNotDirty(321)
assertEquals(1, updated)
assertEquals(321, androidCalendar.getEvent(id)?.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

@@ -14,7 +14,6 @@ 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.AndroidEvent
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
@@ -74,8 +73,15 @@ class LocalEventTest {
dtStart = DtStart("20220120T010203Z")
summary = "Event without uid"
}
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
localEvent.add() // save it to calendar storage
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
@@ -102,8 +108,14 @@ class LocalEventTest {
summary = "Event with normal uid"
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
}
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
localEvent.add() // save it to calendar storage
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
// prepare for upload - this should use the UID for the file name
val fileNameWithSuffix = localEvent.prepareForUpload()
@@ -129,8 +141,14 @@ class LocalEventTest {
summary = "Event with funny uid"
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
}
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
localEvent.add() // save it to calendar storage
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
// prepare for upload - this should generate a new random uuid, returned as filename
val fileNameWithSuffix = localEvent.prepareForUpload()
@@ -181,8 +199,14 @@ class LocalEventTest {
status = Status.VEVENT_CANCELLED
})
}
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT))
localEvent.add()
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
// set event as dirty
@@ -210,8 +234,14 @@ class LocalEventTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT))
localEvent.add()
calendar.add(
event = event,
fileName = "filename.ics",
eTag = null,
scheduleTag = null,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
val localEvent = calendar.findByName("filename.ics")!!
val eventId = localEvent.id!!
// set event as dirty

View File

@@ -32,6 +32,7 @@ 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
@@ -164,7 +165,7 @@ class LocalGroupTest {
}
)
group.clearDirty(null, null)
group.clearDirty(Optional.empty(), null)
// check cached group membership
ab.provider!!.query(
@@ -200,7 +201,7 @@ class LocalGroupTest {
}
)
group.clearDirty(null, null)
group.clearDirty(Optional.empty(), null)
// cached group membership should be gone
ab.provider!!.query(

View File

@@ -10,7 +10,7 @@ import android.content.Context
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory

View File

@@ -1,749 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
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.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
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
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun testDiscoverHomesets() {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
// Check home set has been saved correctly to database
val savedHomesets = db.homeSetDao().getByService(service.id)
assertEquals(2, savedHomesets.size)
// Home set from current-user-principal
val personalHomeset = savedHomesets[1]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
assertEquals(service.id, personalHomeset.serviceId)
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
assertEquals(true, personalHomeset.personal)
// Home set found in a group principal
val groupHomeset = savedHomesets[0]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
assertEquals(service.id, groupHomeset.serviceId)
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
assertEquals(false, groupHomeset.personal)
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().getByService(service.id).first()
)
}
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshHomelessCollections
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomelessCollections_deletesInaccessibleCollections() {
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// refreshPrincipals
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
assertEquals(null, principals[0].displayName)
}
@Test
fun refreshPrincipals_updatesPrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
assertEquals(0, principals.size)
}
// Others
@Test
fun shouldPreselect_none() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val url = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
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(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
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 ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <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'>" +
"<response>" +
" <href>$path</href>" +
" <propstat><prop>"+
properties +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
}
logger.info("Queried: $path")
logger.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
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.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.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
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 java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class CollectionsWithoutHomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshCollectionsWithoutHomeSet
@Test
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got updated - with display name and description
assertEquals(
Collection(
collectionId,
service.id,
null,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
)
)
// Refresh - should delete collection
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
}
@Test
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
)
)
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
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_CARDDAV + SUBPATH_ADDRESSBOOK ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
else -> ""
}
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 +
" </prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -0,0 +1,473 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
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.settings.Settings
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.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
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 java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class HomeSetRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
Collection(
1,
service.id,
homesetId,
1, // will have gotten an owner too
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().getByService(service.id).first()
)
}
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
)
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description"
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
)
)
// Refresh
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
Collection(
collectionId,
service.id,
null,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
displayName = "My Contacts",
description = "My Contacts Description",
forceReadOnly = true,
sync = true
),
db.collectionDao().get(collectionId)
)
}
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId,
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty.
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
}
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// place collection in DB - as part of the homeset
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
homesetId, // part of above home set
null,
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
)
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals(null, principals[0].displayName)
assertEquals(
principals[0].id,
db.collectionDao().get(collectionId)!!.ownerId
)
}
// other
@Test
fun shouldPreselect_none() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val url = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>My Contacts</displayname>" +
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
logger.info("Queried: $path")
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_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
" <propstat><prop>" +
properties +
" </prop></propstat>" +
" <status>HTTP/1.1 200 OK</status>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
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.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 junit.framework.TestCase.assertEquals
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class PrincipalsRefresherTest {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var principalsRefresher: PrincipalsRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
assertEquals(null, principals[0].displayName)
}
@Test
fun refreshPrincipals_updatesPrincipal() {
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
null // no display name for now
)
)
// add an associated collection - as the principal is rightfully removed otherwise
db.collectionDao().insertOrUpdateByUrl(
Collection(
0,
service.id,
null,
principalId, // create association with principal
Collection.TYPE_ADDRESSBOOK,
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
)
)
// Refresh principals
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
assertEquals(1, principals.size)
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
assertEquals("Mr. Wobbles", principals[0].displayName)
}
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
// place principal without collections in DB
db.principalDao().insert(
Principal(
0,
service.id,
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
)
)
// Refresh principals - detecting it does not own collections
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
assertEquals(0, principals.size)
}
companion object {
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_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
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>"
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
else -> ""
}
logger.info("Queried: $path")
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 +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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 dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
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 java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class ServiceRefresherTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun tearDown() {
client.close()
mockServer.shutdown()
}
@Test
fun testDiscoverHomesets() {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
serviceRefresherFactory.create(service, client.okHttpClient)
.discoverHomesets(baseUrl)
// Check home set has been saved correctly to database
val savedHomesets = db.homeSetDao().getByService(service.id)
assertEquals(2, savedHomesets.size)
// Home set from current-user-principal
val personalHomeset = savedHomesets[1]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
assertEquals(service.id, personalHomeset.serviceId)
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
assertEquals(true, personalHomeset.personal)
// Home set found in a group principal
val groupHomeset = savedHomesets[0]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
assertEquals(service.id, groupHomeset.serviceId)
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
assertEquals(false, groupHomeset.personal)
}
companion object {
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
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"
}
class TestDispatcher(
private val logger: Logger
) : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path!!.trimEnd('/')
logger.info("Query: ${request.method} on $path ")
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
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_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>"
else -> ""
}
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 +
" </prop></propstat>" +
"</response>" +
"</multistatus>"
)
}
return MockResponse().setResponseCode(404)
}
}
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.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 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.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.util.Collections
import java.util.LinkedList
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
class AndroidSyncFrameworkTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
lateinit var account: Account
val authority = CalendarContract.AUTHORITY
private lateinit var stateChangeListener: Any
private val recordedStates = Collections.synchronizedList(LinkedList<State>())
@Before
fun setUp() {
hiltRule.inject()
account = TestAccount.create()
// Enable sync globally and for the test account
ContentResolver.setIsSyncable(account, authority, 1)
// Remember states the sync framework reports as pairs of (sync pending, sync active).
recordedStates.clear()
onStatusChanged(0) // record first entry (pending = false, active = false)
stateChangeListener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
::onStatusChanged
)
}
@After
fun tearDown() {
ContentResolver.removeStatusChangeListener(stateChangeListener)
TestAccount.remove(account)
}
/**
* Correct behaviour of the sync framework on Android 13 and below.
* Pending state is correctly reflected
*/
@SdkSuppress(maxSdkVersion = 33)
@Test
fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
verifySyncStates(
listOf(
State(pending = false, active = false), // no sync pending or active
State(pending = true, active = false, optional = true), // sync becomes pending
State(pending = true, active = true), // ... and pending and active at the same time
State(pending = false, active = true), // ... and then only active
State(pending = false, active = false) // sync finished
)
)
}
/**
* 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 */)
@Test
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
verifySyncStates(
listOf(
State(pending = false, active = false), // no sync pending or active
State(pending = true, active = false, optional = true), // sync becomes pending
State(pending = true, active = true), // ... and pending and active at the same time
State(pending = true, active = false) // ... and finishes, but stays pending
)
)
}
// 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()
/**
* Verifies that the given expected states match the recorded states.
*/
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
// 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.
ContentResolver.requestSync(syncRequest())
// Even though the always-pending-bug is present on Android 14+, the sync active
// state behaves correctly, so we can record the state changes as pairs (pending,
// active) and expect a certain sequence of state pairs to verify the presence or
// absence of the bug on different Android versions.
withTimeout(60.seconds) { // Usually takes less than 30 seconds
while (recordedStates.size < expectedStates.size) {
// verify already known states
if (recordedStates.isNotEmpty())
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
delay(500) // avoid busy-waiting
}
assertStatesEqual(expectedStates, recordedStates)
}
}
/**
* 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")
}
// iterate through entries
val expectedIterator = expectedStates.iterator()
for (actual in actualStates) {
if (!expectedIterator.hasNext())
fail()
var expected = expectedIterator.next()
// skip optional expected entries if they don't match the actual entry
while (!actual.stateEquals(expected) && expected.optional) {
if (!expectedIterator.hasNext())
fail()
expected = expectedIterator.next()
}
if (!actual.stateEquals(expected))
fail()
}
}
// SyncStatusObserver implementation and data class
fun onStatusChanged(which: Int) {
val state = State(
pending = ContentResolver.isSyncPending(account, authority),
active = ContentResolver.isSyncActive(account, authority)
)
synchronized(recordedStates) {
if (recordedStates.lastOrNull() != state) {
logger.info("$account syncState = $state")
recordedStates += state
}
}
}
data class State(
val pending: Boolean,
val active: Boolean,
val optional: Boolean = false
) {
fun stateEquals(other: State) =
pending == other.pending && active == other.active
}
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

@@ -0,0 +1,51 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.os.IBinder
import at.bitfire.davdroid.sync.adapter.SyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class FakeSyncAdapter @Inject constructor(
@ApplicationContext context: Context,
private val logger: Logger
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
init {
logger.info("FakeSyncAdapter created")
}
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
logger.log(
Level.INFO,
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
)
// fake 5 sec sync
try {
Thread.sleep(5000)
} catch (_: InterruptedException) {
logger.info("onPerformSync($account) cancelled")
}
logger.info("onPerformSync($account) finished")
}
// SyncAdapter implementation and Hilt module
override fun getBinder(): IBinder = syncAdapterBinder
}

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.resource.LocalResource
import java.util.Optional
class LocalTestResource: LocalResource<Any> {
@@ -19,10 +20,10 @@ class LocalTestResource: LocalResource<Any> {
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,9 +32,8 @@ class LocalTestResource: LocalResource<Any> {
this.flags = flags
}
override fun add() = throw NotImplementedError()
override fun update(data: Any) = throw NotImplementedError()
override fun delete() = throw NotImplementedError()
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
override fun deleteLocal() = throw NotImplementedError()
override fun resetDeleted() = throw NotImplementedError()
}

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
@@ -13,17 +14,17 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
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.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
@@ -39,36 +40,12 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import kotlin.coroutines.cancellation.CancellationException
@HiltAndroidTest
class SyncAdapterServicesTest {
lateinit var account: Account
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var syncConditionsFactory: SyncConditions.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
class SyncAdapterImplTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -76,10 +53,21 @@ class SyncAdapterServicesTest {
@get:Rule
val mockkRule = MockKRule(this)
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
@get:Rule
val timeoutRule: Timeout = Timeout.seconds(5)
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
@BindValue @MockK
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()
@Before
fun setUp() {
@@ -87,33 +75,23 @@ class SyncAdapterServicesTest {
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
ContentResolver.setMasterSyncAutomatically(true)
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
}
@After
fun tearDown() {
ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest)
TestAccount.remove(account)
}
private fun syncAdapter(
syncWorkerManager: SyncWorkerManager
): SyncAdapterService.SyncAdapter =
SyncAdapterService.SyncAdapter(
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
serviceRepository = serviceRepository,
context = context,
logger = logger,
syncConditionsFactory = syncConditionsFactory,
syncWorkerManager = syncWorkerManager
)
@Test
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -136,9 +114,8 @@ class SyncAdapterServicesTest {
@Test
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -158,9 +135,8 @@ class SyncAdapterServicesTest {
@Test
fun testSyncAdapter_onPerformSync_runsInTime() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
val syncAdapter = syncAdapterImplProvider.get()
mockkObject(workManager) {
// don't actually create a worker
@@ -179,4 +155,4 @@ class SyncAdapterServicesTest {
}
}
}
}

View File

@@ -189,7 +189,7 @@ class SyncerTest {
override val authority: String
get() = throw NotImplementedError()
override fun acquireContentProvider(): ContentProviderClient? {
override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? {
throw NotImplementedError()
}

View File

@@ -6,11 +6,11 @@ package at.bitfire.davdroid.webdav
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -36,7 +36,7 @@ class WebDavMountRepositoryTest {
@Test
fun testHasWebDav_NoDavHeader() = runTest {
web.enqueue(MockResponse().setResponseCode(200))
assertFalse(repository.hasWebDav(url, null))
assertNull(repository.hasWebDav(url, null))
}
@Test
@@ -44,7 +44,7 @@ class WebDavMountRepositoryTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1"))
assertTrue(repository.hasWebDav(url, null))
assertEquals(url, repository.hasWebDav(url, null))
}
@Test
@@ -52,7 +52,7 @@ class WebDavMountRepositoryTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1, 2"))
assertTrue(repository.hasWebDav(url, null))
assertEquals(url,repository.hasWebDav(url, null))
}
@Test
@@ -60,7 +60,7 @@ class WebDavMountRepositoryTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1, 3"))
assertTrue(repository.hasWebDav(url, null))
assertEquals(url,repository.hasWebDav(url, null))
}
}

View File

@@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.security.NetworkSecurityPolicy
@@ -14,6 +14,7 @@ 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.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -29,29 +30,7 @@ import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class DavDocumentsProviderTest {
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var credentialsStore: CredentialsStore
@Inject
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var testDispatcher: TestDispatcher
class QueryChildDocumentsOperationTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -59,13 +38,32 @@ class DavDocumentsProviderTest {
@get:Rule
val mockkRule = MockKRule(this)
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var operation: QueryChildDocumentsOperation
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var testDispatcher: TestDispatcher
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var mount: WebDavMount
private lateinit var rootDocument: WebDavDocument
@Before
fun setUp() {
hiltRule.inject()
// create server and client
server = MockWebServer().apply {
dispatcher = testDispatcher
start()
@@ -75,50 +73,49 @@ class DavDocumentsProviderTest {
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// create WebDAV mount and root document in DB
runBlocking {
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
mount = db.webDavMountDao().getById(mountId)
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
}
}
@After
fun tearDown() {
client.close()
server.shutdown()
runBlocking {
db.webDavMountDao().deleteAsync(mount)
}
}
@Test
fun testDoQueryChildren_insert() = runTest {
// Create parent and root in database
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(id)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Query
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert new children were inserted into db
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
}
@Test
fun testDoQueryChildren_update() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(
0,
mountId,
parent.id,
mount.id,
rootDocument.id,
"My_Books",
true,
"My Books",
@@ -128,38 +125,25 @@ class DavDocumentsProviderTest {
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
// Query - should update the parent displayname and folder name
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert parent and children were updated in database
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
}
@Test
fun testDoQueryChildren_delete() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Create a folder
val folderId = db.webDavDocumentDao().insert(
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
)
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert folder got deleted
assertEquals(null, db.webDavDocumentDao().get(folderId))
@@ -167,26 +151,17 @@ class DavDocumentsProviderTest {
@Test
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
// Create root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
// Create two directories
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true))
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
val parent2 = db.webDavDocumentDao().get(parent2Id)!!
assertEquals("parent1", parent1.name)
assertEquals("parent2", parent2.name)
// Query - find children of two nodes simultaneously
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent1)
actor.queryChildren(parent2)
operation.queryChildren(parent1)
operation.queryChildren(parent2)
// Assert the two folders names have changed
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
@@ -214,7 +189,7 @@ class DavDocumentsProviderTest {
PATH_WEBDAV_ROOT to arrayOf(
Resource("",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Cats WebDAV</displayname>"
"<displayname>Cats WebDAV</displayname>"
),
Resource("Secret_Document.pages",
"<displayname>Secret_Document.pages</displayname>",
@@ -224,7 +199,7 @@ class DavDocumentsProviderTest {
),
Resource("Library",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Library</displayname>"
"<displayname>Library</displayname>"
)
),
@@ -243,15 +218,15 @@ class DavDocumentsProviderTest {
val responses = propsMap[requestPath]?.joinToString { resource ->
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
resource.props +
"</prop></propstat></response>"
"</prop></propstat></response>"
}
val multistatus =
"<multistatus xmlns='DAV:' " +
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
responses +
"</multistatus>"
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
responses +
"</multistatus>"
logger.info("Response: $multistatus")
return MockResponse()
@@ -264,4 +239,9 @@ class DavDocumentsProviderTest {
}
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
}

View File

@@ -53,11 +53,19 @@
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
<!-- required for Hilt/WorkManager integration -->
<!-- Required for Hilt/WorkManager integration. See
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
@@ -178,7 +186,7 @@
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".sync.CalendarsSyncAdapterService"
android:name=".sync.adapter.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -189,7 +197,7 @@
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".sync.JtxSyncAdapterService"
android:name=".sync.adapter.JtxSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -200,7 +208,7 @@
android:resource="@xml/sync_notes"/>
</service>
<service
android:name=".sync.OpenTasksSyncAdapterService"
android:name=".sync.adapter.OpenTasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -211,7 +219,7 @@
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".sync.TasksOrgSyncAdapterService"
android:name=".sync.adapter.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -246,7 +254,7 @@
android:resource="@xml/account_authenticator_address_book"/>
</service>
<service
android:name=".sync.ContactsSyncAdapterService"
android:name=".sync.adapter.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>

View File

@@ -3,7 +3,7 @@
*/
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

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

@@ -23,9 +23,9 @@ import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ON
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
@@ -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

@@ -78,8 +78,14 @@ class LocalAddressBookStore @Inject constructor(
return sb.toString()
}
override fun acquireContentProvider() =
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")

View File

@@ -8,16 +8,18 @@ import android.content.ContentUris
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.calendar.AndroidCalendar
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
/**
@@ -26,16 +28,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,11 +60,29 @@ class LocalCalendar @AssistedInject constructor(
androidCalendar.writeSyncState(state.toString())
}
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
override fun findDeleted() =
androidCalendar
.findEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
.map { LocalEvent(it) }
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
val mapped = LegacyAndroidEventBuilder2(
calendar = androidCalendar,
event = event,
id = null,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.addEventAndExceptions(mapped)
}
override fun findDeleted(): List<LocalEvent> {
val result = LinkedList<LocalEvent>()
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
}
return result
}
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
@@ -70,62 +92,57 @@ class LocalCalendar @AssistedInject constructor(
* 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.
*/
for (androidEvent in androidCalendar.findEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
val localEvent = LocalEvent(androidEvent)
try {
val event = requireNotNull(androidEvent.event)
val nonGroupScheduled = event.attendees.isEmpty()
val weAreOrganizer = localEvent.weAreOrganizer
val sequence = event.sequence
if (sequence == null)
// sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
event.sequence = sequence + 1
} catch(e: Exception) {
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
dirty += localEvent
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
}
return dirty
}
override fun findByName(name: String) =
androidCalendar.findEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()?.let { LocalEvent(it) }
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
LocalEvent(recurringCalendar, it)
}
override fun markNotDirty(flags: Int) =
androidCalendar.updateEvents(
contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.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())
)
override fun removeNotDirtyMarked(flags: Int): Int {
// list all non-dirty events with the given flags and delete every row + its exceptions
val batch = CalendarBatchOperation(androidCalendar.client)
androidCalendar.iterateEvents(
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${AndroidEvent.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 ${AndroidEvent2.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()
}
override fun forgetETags() {
androidCalendar.updateEvents(
contentValuesOf(AndroidEvent.COLUMN_ETAG to null),
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
@@ -135,8 +152,8 @@ class LocalCalendar @AssistedInject constructor(
// process deleted exceptions
logger.info("Processing deleted exceptions")
androidCalendar.iterateEvents(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
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 ->
@@ -148,12 +165,12 @@ class LocalCalendar @AssistedInject constructor(
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventValues(originalID, arrayOf(AndroidEvent.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
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(AndroidEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
@@ -163,8 +180,8 @@ class LocalCalendar @AssistedInject constructor(
// process dirty exceptions
logger.info("Processing dirty exceptions")
androidCalendar.iterateEvents(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
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 ->
@@ -172,7 +189,7 @@ class LocalCalendar @AssistedInject constructor(
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val sequence = values.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
@@ -184,7 +201,7 @@ class LocalCalendar @AssistedInject constructor(
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(AndroidEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
@@ -198,7 +215,7 @@ class LocalCalendar @AssistedInject constructor(
*/
fun deleteDirtyEventsWithoutInstances() {
// Iterate dirty main events without exceptions
androidCalendar.iterateEvents(
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
@@ -211,7 +228,7 @@ class LocalCalendar @AssistedInject constructor(
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventId without instances as deleted")
androidCalendar.updateEvent(eventId, contentValuesOf(Events.DELETED to 1))
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
}
}
}

View File

@@ -39,8 +39,14 @@ class LocalCalendarStore @Inject constructor(
override val authority: String
get() = CalendarContract.AUTHORITY
override fun acquireContentProvider() =
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")

View File

@@ -48,10 +48,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 +58,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 +73,4 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
fun forgetETags()
}
}

View File

@@ -23,13 +23,13 @@ import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import java.io.FileNotFoundException
import java.util.Optional
import java.util.UUID
import kotlin.jvm.optionals.getOrNull
class LocalContact: AndroidContact, LocalAddress {
companion object {
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
@@ -40,9 +40,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
@@ -90,13 +89,13 @@ 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)
@@ -105,21 +104,25 @@ class LocalContact: AndroidContact, LocalAddress {
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)
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) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
@@ -127,6 +130,15 @@ class LocalContact: AndroidContact, LocalAddress {
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
batch += BatchOperation.CpoBuilder

View File

@@ -25,16 +25,19 @@ interface LocalDataStore<T: LocalCollection<*>> {
*
* **The caller is responsible for closing the content provider client!**
*
* @return the content provider client, or `null` if the content provider could not be acquired
* @param throwOnMissingPermissions If `true`, the function will throw [SecurityException] if permissions are not granted.
*
* @return the content provider client, or `null` if the content provider could not be acquired (or permissions are not
* granted and [throwOnMissingPermissions] is `false`)
*
* @throws SecurityException on missing permissions
*/
fun acquireContentProvider(): ContentProviderClient?
fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient?
/**
* Creates a new local collection from the given (remote) collection info.
*
* @param provider the content provider client
* @param client the content provider client
* @param fromCollection collection info
*
* @return the new local collection, or `null` if creation failed
@@ -46,7 +49,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
* [Collection] entry.
*
* @param account the account that the data store is associated with
* @param provider the content provider client
* @param client the content provider client
*
* @return a list of all local collections
*/
@@ -55,7 +58,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
/**
* Updates the local collection with the data from the given (remote) collection info.
*
* @param provider the content provider client
* @param client the content provider client
* @param localCollection the local collection to update
* @param fromCollection collection info
*/

View File

@@ -4,50 +4,110 @@
package at.bitfire.davdroid.resource
import android.content.ContentValues
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.mapping.calendar.LegacyAndroidEventBuilder2
import at.bitfire.synctools.storage.LocalStorageException
import at.bitfire.synctools.storage.calendar.AndroidEvent2
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
import java.util.Optional
import java.util.UUID
class LocalEvent(
val androidEvent: AndroidEvent
val recurringCalendar: AndroidRecurringCalendar,
val androidEvent: AndroidEvent2
) : LocalResource<Event> {
// LocalResource implementation
override val id: Long?
override val id: Long
get() = androidEvent.id
override var fileName: String?
override val fileName: String?
get() = androidEvent.syncId
private set(value) {
androidEvent.syncId = value
}
override var eTag: String?
override val eTag: String?
get() = androidEvent.eTag
set(value) { androidEvent.eTag = value }
override var scheduleTag: String?
override val scheduleTag: String?
get() = androidEvent.scheduleTag
set(value) { androidEvent.scheduleTag = value }
override val flags: Int
get() = androidEvent.flags
override fun add() = androidEvent.add()
override fun update(data: Event) = androidEvent.update(data)
override fun delete() = androidEvent.delete()
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
val eventAndExceptions = LegacyAndroidEventBuilder2(
calendar = androidEvent.calendar,
event = data,
id = id,
syncId = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = flags
).build()
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
}
// 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 weAreOrganizer
get() = androidEvent.event!!.isOrganizer == true
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
))
}
/**
@@ -58,16 +118,16 @@ class LocalEvent(
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = androidEvent.event!!.uid ?: run {
val uid: String = getCachedEvent().uid ?: run {
// generate new UID
val newUid = UUID.randomUUID().toString()
// update in calendar provider
// persist to calendar provider
val values = contentValuesOf(Events.UID_2445 to newUid)
androidEvent.update(values)
// update this event
androidEvent.event?.uid = newUid
// update in cached event data object
getCachedEvent().uid = newUid
newUid
}
@@ -85,33 +145,31 @@ class LocalEvent(
"${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(AndroidEvent.COLUMN_ETAG, eTag)
values.put(AndroidEvent.COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(AndroidEvent.COLUMN_SEQUENCE, androidEvent.event!!.sequence)
values.put(Events.DIRTY, 0)
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
val values = contentValuesOf(
Events.DIRTY to 0,
AndroidEvent2.COLUMN_ETAG to eTag,
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
)
if (fileName.isPresent)
values.put(Events._SYNC_ID, fileName.get())
androidEvent.update(values)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
override fun updateFlags(flags: Int) {
val values = contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags)
androidEvent.update(values)
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_FLAGS to flags
))
}
androidEvent.flags = flags
override fun deleteLocal() {
recurringCalendar.deleteEventAndExceptions(id)
}
override fun resetDeleted() {
val values = contentValuesOf(Events.DELETED to 0)
androidEvent.update(values)
androidEvent.update(contentValuesOf(
Events.DELETED to 0
))
}
}

View File

@@ -25,6 +25,7 @@ import at.bitfire.vcard4android.AndroidGroupFactory
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import java.util.LinkedList
import java.util.Optional
import java.util.UUID
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
@@ -159,20 +160,20 @@ class LocalGroup: AndroidGroup, LocalAddress {
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 +212,13 @@ 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
this.scheduleTag = scheduleTag
// processes this.{fileName, eTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
@@ -223,6 +228,15 @@ class LocalGroup: AndroidGroup, LocalAddress {
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
// helpers

View File

@@ -37,8 +37,14 @@ class LocalJtxCollectionStore @Inject constructor(
override val authority: String
get() = JtxContract.AUTHORITY
override fun acquireContentProvider() =
context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")

View File

@@ -9,6 +9,8 @@ import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxICalObject
import at.bitfire.ical4android.JtxICalObjectFactory
import at.techbee.jtx.JtxContract
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class LocalJtxICalObject(
collection: JtxCollection<*>,
@@ -48,6 +50,24 @@ class LocalJtxICalObject(
}
override 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 clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}

View File

@@ -4,8 +4,12 @@
package at.bitfire.davdroid.resource
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.
*/
interface LocalResource<in TData: Any> {
companion object {
@@ -32,10 +36,10 @@ 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
@@ -48,47 +52,41 @@ interface LocalResource<in TData: Any> {
* 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")
* @return suggestion for 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.
* 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).
*
* @param fileName If this argument is not *null*, [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 Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
* @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)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the created row (e.g. event URI)
*/
fun add(): Uri
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
* Does not affect `this` or the [data] object (which are both immutable).
*
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData): Uri
fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: 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.

View File

@@ -12,6 +12,7 @@ import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.synctools.storage.BatchOperation
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.Optional
import java.util.UUID
class LocalTask: DmfsTask, LocalResource<Task> {
@@ -76,23 +77,33 @@ class LocalTask: DmfsTask, LocalResource<Task> {
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")
val values = ContentValues(4)
if (fileName != null)
values.put(Tasks._SYNC_ID, fileName)
if (fileName.isPresent)
values.put(Tasks._SYNC_ID, fileName.get())
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
values.put(Tasks._DIRTY, 0)
taskList.provider.update(taskSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
if (fileName.isPresent)
this.fileName = fileName.get()
this.eTag = eTag
}
override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
update(data)
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
@@ -102,6 +113,10 @@ class LocalTask: DmfsTask, LocalResource<Task> {
this.flags = flags
}
override fun deleteLocal() {
delete()
}
override fun resetDeleted() {
throw NotImplementedError()
}
@@ -111,4 +126,4 @@ class LocalTask: DmfsTask, LocalResource<Task> {
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}
}

View File

@@ -47,8 +47,14 @@ class LocalTaskListStore @AssistedInject constructor(
override val authority: String
get() = providerName.authority
override fun acquireContentProvider() =
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
context.contentResolver.acquireContentProviderClient(authority)
} catch (e: SecurityException) {
if (throwOnMissingPermissions)
throw e
else
/* return */ null
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")

View File

@@ -1,421 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
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.HttpException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
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.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.common.HrefListProperty
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.GroupMembership
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
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 at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.DavUtils.parent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.logging.Level
import java.util.logging.Logger
/**
* Logic for refreshing the list of collections and home-sets and related information.
*/
class CollectionListRefresher @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,
private val settings: SettingsManager
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
}
/**
* Principal properties to ask the server for.
*/
private val principalProperties = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* Home-set class to use depending on the given service type.
*/
private val homeSetClass: Class<out HrefListProperty> =
when (service.type) {
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
else -> throw IllegalArgumentException()
}
/**
* Home-set properties to ask for in a PROPFIND request to the principal URL,
* depending on the given service type.
*/
private val homeSetProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
DisplayName.NAME,
GroupMembership.NAME,
ResourceType.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookHomeSet.NAME,
)
Service.TYPE_CALDAV -> arrayOf(
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Collection properties to ask for in a PROPFIND request on a collection.
*/
private val collectionProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
ResourceType.NAME,
PushTransports.NAME, // WebDAV-Push
Topic.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookDescription.NAME
)
Service.TYPE_CALDAV -> arrayOf(
CalendarColor.NAME,
CalendarDescription.NAME,
CalendarTimezone.NAME,
CalendarTimezoneId.NAME,
SupportedCalendarComponentSet.NAME,
Source.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
*
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
* @param level Current recursion level (limited to 0, 1 or 2):
* - 0: We assume found home sets belong to the current-user-principal
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
* more than once, which could overwrite the already set "personal" flag with `false`.
*
* @throws java.io.IOException on I/O errors
* @throws HttpException on HTTP errors
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
*/
internal fun discoverHomesets(
principalUrl: HttpUrl,
level: Int = 0,
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
) {
logger.fine("Discovering homesets of $principalUrl")
val relatedResources = mutableSetOf<HttpUrl>()
// Query the URL
val principal = DavResource(httpClient, principalUrl)
val personal = level == 0
try {
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
alreadyQueriedPrincipals += davResponse.href
// If response holds home sets, save them
davResponse[homeSetClass]?.let { homeSets ->
for (homeSetHref in homeSets.hrefs)
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
homeSetRepository.insertOrUpdateByUrlBlocking(
// HomeSet is considered personal if this is the outer recursion call,
// This is because we assume the first call to query the current-user-principal
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
// other principals while still being considered "personal" (belonging to the current-user-principal)
// and an owned home set need not always be personal either.
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
)
alreadySavedHomeSets += resolvedHomeSetUrl
}
}
}
// Add related principals to be queried afterwards
if (personal) {
val relatedResourcesTypes = listOf(
// current resource is a read/write-proxy for other principals
CalendarProxyReadFor::class.java,
CalendarProxyWriteFor::class.java,
// current resource is a member of a group (principal that can also have proxies)
GroupMembership::class.java
)
for (type in relatedResourcesTypes)
davResponse[type]?.let {
for (href in it.hrefs)
principal.location.resolve(href)?.let { url ->
relatedResources += url
}
}
}
// 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,
)
if (proxyProperties.any { resourceType.types.contains(it) })
relatedResources += davResponse.href.parent()
}
}
} catch (e: HttpException) {
if (e.code/100 == 4)
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e
}
// query related resources
if (level <= 1)
for (resource in relatedResources)
if (alreadyQueriedPrincipals.contains(resource))
logger.warning("$resource already queried, skipping")
else
discoverHomesets(
principalUrl = resource,
level = level + 1,
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
alreadySavedHomeSets = alreadySavedHomeSets
)
}
/**
* 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.
*
* 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 [refreshHomelessCollections].
*/
internal fun refreshHomesetsAndTheirCollections() {
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
for((homeSetUrl, localHomeset) in homesets) {
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.
val localHomesetCollections = db.collectionDao()
.getByServiceAndHomeset(service.id, localHomeset.id)
.associateBy { it.url }
.toMutableMap()
try {
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
// Note: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF)
// this response is about the home set itself
homeSetRepository.insertOrUpdateByUrlBlocking(localHomeset.copy(
displayName = response[DisplayName::class.java]?.displayName,
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
))
// in any case, check whether the response is about a usable collection
var collection = Collection.fromDavResponse(response) ?: return@propfind
collection = collection.copy(
serviceId = service.id,
homeSetId = localHomeset.id,
sync = shouldPreselect(collection, homesets.values),
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
)
logger.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
if (isUsableCollection(collection))
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
// Remove this collection from queue - because it was found in the home set
localHomesetCollections.remove(collection.url)
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
for ((_, homelessCollection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlRememberSync(
homelessCollection.copy(homeSetId = null)
)
}
}
/**
* Refreshes collections which don't have a homeset.
*
* 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 {
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
if (!response.isSuccess()) {
collectionRepository.delete(localCollection)
return@propfind
}
// Save or update the collection, if usable, otherwise delete it
Collection.fromDavResponse(response)?.let { collection ->
if (!isUsableCollection(collection))
return@let
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
serviceId = localCollection.serviceId, // use same service ID as previous entry
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
))
} ?: collectionRepository.delete(localCollection)
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
/**
* Refreshes the principals (get their current display names).
* Also removes principals which do not own any collections anymore.
*/
internal fun refreshPrincipals() {
// Refresh principals (collection owner urls)
val principals = db.principalDao().getByService(service.id)
for (oldPrincipal in principals) {
val principalUrl = oldPrincipal.url
logger.fine("Querying principal $principalUrl")
try {
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
if (!response.isSuccess())
return@propfind
Principal.fromDavResponse(service.id, response)?.let { principal ->
logger.fine("Got principal: $principal")
db.principalDao().insertOrUpdate(service.id, principal)
}
}
} catch (e: HttpException) {
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach {principal ->
db.principalDao().delete(principal)
}
}
/**
* Finds out whether given collection is usable, by checking that either
* - CalDAV/CardDAV: service and collection type match, or
* - WebCal: subscription source URL is not empty
*/
private fun isUsableCollection(collection: Collection) =
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
/**
* Whether to preselect the given collection for synchronisation, according to the
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
*
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
*
* Before a collection is pre-selected, we check whether its URL matches the regexp in
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homeSets list of personal home-sets
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
if (!excludedRegex.isNullOrEmpty())
Regex(excludedRegex).containsMatchIn(collection.url.toString())
else
false
}
return when (shouldPreselect) {
Settings.PRESELECT_COLLECTIONS_ALL ->
// preselect if collection url is not excluded
!excluded
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homeSets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)
&& !excluded
else -> // don't preselect
false
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
/**
* Logic for refreshing the list of collections (and their related information)
* which do not belong to a home set.
*/
class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val collectionRepository: DavCollectionRepository,
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher
}
/**
* Refreshes collections which don't have a homeset.
*
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
*/
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()) {
collectionRepository.delete(localCollection)
return@propfind
}
// Save or update the collection, if usable, otherwise delete it
Collection.fromDavResponse(response)?.let { collection ->
if (!ServiceDetectionUtils.isUsableCollection(service, collection))
return@let
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
serviceId = localCollection.serviceId, // use same service ID as previous entry
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
))
} ?: collectionRepository.delete(localCollection)
}
} catch (e: HttpException) {
// delete collection locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
collectionRepository.delete(localCollection)
else
throw e
}
}
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
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 at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
import java.util.logging.Level
import java.util.logging.Logger
/**
* Used to update the list of synchronizable collections
*/
class HomeSetRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val logger: Logger,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
private val settings: SettingsManager
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): HomeSetRefresher
}
/**
* Refreshes home-sets and their collections.
*
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
* 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 [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet].
*/
internal fun refreshHomesetsAndTheirCollections() {
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
for ((homeSetUrl, localHomeset) in homesets) {
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 as "without home-set".
val localHomesetCollections = db.collectionDao()
.getByServiceAndHomeset(service.id, localHomeset.id)
.associateBy { it.url }
.toMutableMap()
try {
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
// Note: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF)
// this response is about the home set itself
homeSetRepository.insertOrUpdateByUrlBlocking(
localHomeset.copy(
displayName = response[DisplayName::class.java]?.displayName,
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
)
)
// in any case, check whether the response is about a usable collection
var collection = Collection.fromDavResponse(response) ?: return@propfind
collection = collection.copy(
serviceId = service.id,
homeSetId = localHomeset.id,
sync = shouldPreselect(collection, homesets.values),
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
)
logger.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
if (ServiceDetectionUtils.isUsableCollection(service, collection))
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
// Remove this collection from queue - because it was found in the home set
localHomesetCollections.remove(collection.url)
}
} catch (e: HttpException) {
// delete home set locally if it was not accessible (40x)
if (e.code in arrayOf(403, 404, 410))
homeSetRepository.deleteBlocking(localHomeset)
}
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
for ((_, collection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlRememberSync(
collection.copy(homeSetId = null)
)
}
}
/**
* Whether to preselect the given collection for synchronisation, according to the
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
*
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
*
* Before a collection is pre-selected, we check whether its URL matches the regexp in
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homeSets list of personal home-sets
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
if (!excludedRegex.isNullOrEmpty())
Regex(excludedRegex).containsMatchIn(collection.url.toString())
else
false
}
return when (shouldPreselect) {
Settings.PRESELECT_COLLECTIONS_ALL ->
// preselect if collection url is not excluded
!excluded
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homeSets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)
&& !excluded
else -> // don't preselect
false
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.OkHttpClient
import java.util.logging.Logger
/**
* Used to update the principals (their current display names) and delete those without collections.
*/
class PrincipalsRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val logger: Logger
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): PrincipalsRefresher
}
/**
* Principal properties to ask the server for.
*/
private val principalProperties = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* Refreshes the principals (get their current display names).
* Also removes principals which do not own any collections anymore.
*/
fun refreshPrincipals() {
// Refresh principals (collection owner urls)
val principals = db.principalDao().getByService(service.id)
for (oldPrincipal in principals) {
val principalUrl = oldPrincipal.url
logger.fine("Querying principal $principalUrl")
try {
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
if (!response.isSuccess())
return@propfind
Principal.fromDavResponse(service.id, response)?.let { principal ->
logger.fine("Got principal: $principal")
db.principalDao().insertOrUpdate(service.id, principal)
}
}
} catch (e: HttpException) {
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
}
}
// Delete principals which don't own any collections
db.principalDao().getAllWithoutCollections().forEach { principal ->
db.principalDao().delete(principal)
}
}
}

View File

@@ -62,11 +62,14 @@ 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 logger: Logger,
private val notificationRegistry: NotificationRegistry,
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
private val pushRegistrationManager: PushRegistrationManager,
private val serviceRefresherFactory: ServiceRefresher.Factory,
serviceRepository: DavServiceRepository
): CoroutineWorker(appContext, workerParams) {
@@ -156,22 +159,25 @@ class RefreshCollectionsWorker @AssistedInject constructor(
.use { httpClient ->
runInterruptible {
val httpClient = httpClient.okHttpClient
val refresher = collectionListRefresherFactory.create(service, httpClient)
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")
refresher.discoverHomesets(principalUrl)
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
serviceRefresher.discoverHomesets(principalUrl)
}
// refresh home sets and their member collections
refresher.refreshHomesetsAndTheirCollections()
homeSetRefresherFactory.create(service, httpClient)
.refreshHomesetsAndTheirCollections()
// also refresh collections without a home set
refresher.refreshHomelessCollections()
refresher.refreshCollectionsWithoutHomeSet()
// Lastly, refresh the principals (collection owners)
refresher.refreshPrincipals()
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
principalsRefresher.refreshPrincipals()
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
object ServiceDetectionUtils {
/**
* WebDAV properties to ask for in a PROPFIND request on a collection.
*/
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
Service.TYPE_CARDDAV -> arrayOf(
AddressbookDescription.NAME
)
Service.TYPE_CALDAV -> arrayOf(
CalendarColor.NAME,
CalendarDescription.NAME,
CalendarTimezone.NAME,
CalendarTimezoneId.NAME,
SupportedCalendarComponentSet.NAME,
Source.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Finds out whether given collection is usable for synchronization, by checking that either
*
* - CalDAV/CardDAV: service and collection type match, or
* - WebCal: subscription source URL is not empty.
*/
fun isUsableCollection(service: Service, collection: Collection) =
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.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.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.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.davdroid.util.DavUtils.parent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.util.logging.Level
import java.util.logging.Logger
/**
* ServiceRefresher is used to discover and save home sets of a given service.
*/
class ServiceRefresher @AssistedInject constructor(
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val logger: Logger,
private val homeSetRepository: DavHomeSetRepository
) {
@AssistedFactory
interface Factory {
fun create(service: Service, httpClient: OkHttpClient): ServiceRefresher
}
/**
* Home-set class to use depending on the given service type.
*/
private val homeSetClass: Class<out HrefListProperty> =
when (service.type) {
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
else -> throw IllegalArgumentException()
}
/**
* Home-set properties to ask for in a PROPFIND request to the principal URL,
* depending on the given service type.
*/
private val homeSetProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
DisplayName.NAME,
GroupMembership.NAME,
ResourceType.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookHomeSet.NAME,
)
Service.TYPE_CALDAV -> arrayOf(
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
*
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
* @param level Current recursion level (limited to 0, 1 or 2):
* - 0: We assume found home sets belong to the current-user-principal
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
* more than once, which could overwrite the already set "personal" flag with `false`.
*
* @throws java.io.IOException on I/O errors
* @throws HttpException on HTTP errors
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
*/
internal fun discoverHomesets(
principalUrl: HttpUrl,
level: Int = 0,
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
) {
logger.fine("Discovering homesets of $principalUrl")
val relatedResources = mutableSetOf<HttpUrl>()
// Query the URL
val principal = DavResource(httpClient, principalUrl)
val personal = level == 0
try {
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
alreadyQueriedPrincipals += davResponse.href
// If response holds home sets, save them
davResponse[homeSetClass]?.let { homeSets ->
for (homeSetHref in homeSets.hrefs)
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
homeSetRepository.insertOrUpdateByUrlBlocking(
// HomeSet is considered personal if this is the outer recursion call,
// This is because we assume the first call to query the current-user-principal
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
// other principals while still being considered "personal" (belonging to the current-user-principal)
// and an owned home set need not always be personal either.
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
)
alreadySavedHomeSets += resolvedHomeSetUrl
}
}
}
// Add related principals to be queried afterwards
if (personal) {
val relatedResourcesTypes = listOf(
// current resource is a read/write-proxy for other principals
CalendarProxyReadFor::class.java,
CalendarProxyWriteFor::class.java,
// current resource is a member of a group (principal that can also have proxies)
GroupMembership::class.java
)
for (type in relatedResourcesTypes)
davResponse[type]?.let {
for (href in it.hrefs)
principal.location.resolve(href)?.let { url ->
relatedResources += url
}
}
}
// 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,
)
if (proxyProperties.any { resourceType.types.contains(it) })
relatedResources += davResponse.href.parent()
}
}
} catch (e: HttpException) {
if (e.code / 100 == 4)
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
else
throw e
}
// query related resources
if (level <= 1)
for (resource in relatedResources)
if (alreadyQueriedPrincipals.contains(resource))
logger.warning("$resource already queried, skipping")
else
discoverHomesets(
principalUrl = resource,
level = level + 1,
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
alreadySavedHomeSets = alreadySavedHomeSets
)
}
}

View File

@@ -354,7 +354,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

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

View File

@@ -65,12 +65,7 @@ class AccountSettingsMigration20 @Inject constructor(
@OpenForTesting
internal fun migrateAddressBooks(account: Account, cardDavServiceId: Long) {
try {
addressBookStore.acquireContentProvider()
} catch (_: SecurityException) {
// no contacts permission
null
}?.use { provider ->
addressBookStore.acquireContentProvider()?.use { provider ->
for (addressBook in addressBookStore.getAll(account, provider)) {
val url = accountManager.getUserData(addressBook.addressBookAccount, ADDRESS_BOOK_USER_DATA_URL) ?: continue
val collection = collectionRepository.getByServiceAndUrl(cardDavServiceId, url) ?: continue
@@ -81,12 +76,7 @@ class AccountSettingsMigration20 @Inject constructor(
@OpenForTesting
internal fun migrateCalendars(account: Account, calDavServiceId: Long) {
try {
calendarStore.acquireContentProvider()
} catch (_: SecurityException) {
// no contacts permission
null
}?.use { client ->
calendarStore.acquireContentProvider()?.use { client ->
val calendarProvider = AndroidCalendarProvider(account, client)
// for each calendar, assign _SYNC_ID := ID if collection (identified by NAME field = URL)
for (calendar in calendarProvider.findCalendars()) {
@@ -104,12 +94,7 @@ class AccountSettingsMigration20 @Inject constructor(
@OpenForTesting
internal fun migrateTaskLists(account: Account, calDavServiceId: Long) {
val taskListStore = tasksAppManager.getDataStore() ?: /* no tasks app */ return
try {
taskListStore.acquireContentProvider()
} catch (_: SecurityException) {
// no tasks permission
null
}?.use { provider ->
taskListStore.acquireContentProvider()?.use { provider ->
for (taskList in taskListStore.getAll(account, provider)) {
when (taskList) {
is LocalTaskList -> { // tasks.org, OpenTasks

View File

@@ -0,0 +1,76 @@
/*
* 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.accounts.AccountManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
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(
@ApplicationContext private val context: Context,
private val syncFrameworkIntegration: SyncFrameworkIntegration,
private val logger: Logger
): AccountSettingsMigration {
private val accountManager = AccountManager.get(context)
private val calendarAccountType = context.getString(R.string.account_type)
private val addressBookAccountType = context.getString(R.string.account_type_address_book)
override fun migrate(account: Account) {
if (Build.VERSION.SDK_INT >= 34) {
// Cancel any (after an update) possibly forever pending calendar (+tasks) account syncs
cancelSyncs(calendarAccountType, CalendarContract.AUTHORITY)
// Cancel any (after an update) possibly forever pending address book account syncs
cancelSyncs(addressBookAccountType, ContactsContract.AUTHORITY)
}
}
/**
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
* authorities.
*/
private fun cancelSyncs(accountType: String, authority: String) {
accountManager.getAccountsByType(accountType).forEach { account ->
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
syncFrameworkIntegration.cancelSync(account, authority, Bundle())
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AccountSettingsMigrationModule {
@Binds @IntoMap
@IntKey(21)
abstract fun provide(impl: AccountSettingsMigration21): AccountSettingsMigration
}
}

View File

@@ -11,6 +11,7 @@ import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import kotlinx.coroutines.runBlocking
import javax.inject.Inject

View File

@@ -28,7 +28,6 @@ import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.EventReader
import at.bitfire.ical4android.EventWriter
@@ -50,6 +49,7 @@ 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
/**
@@ -159,7 +159,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,9 +178,16 @@ 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)
// update local SEQUENCE to new value after successful upload
local.updateSequence(local.getCachedEvent().sequence)
}
override fun generateUpload(resource: LocalEvent): RequestBody =
SyncException.wrapWithLocalResource(resource) {
val event = requireNotNull(resource.androidEvent.event)
val event = resource.eventToUpload()
logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
// write iCalendar to string and convert to request body
@@ -284,15 +291,22 @@ class CalendarSyncManager @AssistedInject constructor(
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)
local.update(
data = event,
fileName = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
} else {
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
val newLocal = LocalEvent(AndroidEvent(localCollection.androidCalendar, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT))
SyncException.wrapWithLocalResource(newLocal) {
newLocal.add()
}
localCollection.add(
event = event,
fileName = fileName,
eTag = eTag,
scheduleTag = scheduleTag,
flags = LocalResource.FLAG_REMOTELY_PRESENT
)
}
}
} else

View File

@@ -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
}
@@ -391,56 +391,73 @@ 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
}
} 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
}
}
}
}
}
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)
}
}
// 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)
}
}

View File

@@ -1,221 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Service
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.os.Bundle
import android.os.IBinder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
abstract class SyncAdapterService: Service() {
/**
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
* create a [SyncAdapterService] instance before Hilt is initialized during the tests.
*/
@dagger.hilt.EntryPoint
@InstallIn(SingletonComponent::class)
interface EntryPoint {
fun syncAdapter(): SyncAdapter
}
override fun onBind(intent: Intent?): IBinder {
if (BuildConfig.DEBUG && !syncActive.get()) {
// only for debug builds/testing: syncActive flag
val logger = Logger.getLogger(this@SyncAdapterService::class.java.name)
logger.log(Level.WARNING, "SyncAdapterService.onBind() was called but syncActive = false. Ignoring")
val fakeAdapter = object: AbstractThreadedSyncAdapter(this, false) {
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
val message = StringBuilder()
message.append("FakeSyncAdapter onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)")
for (key in extras.keySet())
message.append("\n\textras[$key] = ${extras[key]}")
logger.warning(message.toString())
}
}
return fakeAdapter.syncAdapterBinder
}
// create sync adapter via Hilt
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(this)
val syncAdapter = entryPoint.syncAdapter()
return syncAdapter.syncAdapterBinder
}
companion object {
/**
* Flag to indicate whether the sync adapter should be active. When it is `false`, synchronization will not be run
* (only intended for tests).
*/
val syncActive = AtomicBoolean(true)
}
/**
* Entry point for the Sync Adapter Framework.
*
* Handles incoming sync requests from the Sync Adapter Framework.
*
* Although we do not use the sync adapter for syncing anymore, we keep this sole
* adapter to provide exported services, which allow android system components and calendar,
* contacts or task apps to sync via DAVx5.
*
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
*/
class SyncAdapter @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
private val serviceRepository: DavServiceRepository,
@ApplicationContext context: Context,
private val logger: Logger,
private val syncConditionsFactory: SyncConditions.Factory,
private val syncWorkerManager: SyncWorkerManager
): AbstractThreadedSyncAdapter(
/* context = */ context,
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
) {
/**
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
* requests cancellation.
*/
private val waitScope = CoroutineScope(Dispatchers.Default)
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
// We have to pass this old SyncFramework extra for an Android 7 workaround
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
// If we should sync an address book account - find the account storing the settings
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
AccountManager.get(context)
.getUserData(accountOrAddressBookAccount, USER_DATA_COLLECTION_ID)
?.toLongOrNull()
?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
}
else
accountOrAddressBookAccount
if (account == null) {
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
return
}
// Check sync conditions
val accountSettings = try {
accountSettingsFactory.create(account)
} catch (e: InvalidAccountException) {
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
return
}
val syncConditions = syncConditionsFactory.create(accountSettings)
// Should we run the sync at all?
if (!syncConditions.wifiConditionsMet()) {
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
return
}
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.fromAuthority(authority), fromUpload = upload)
/* Because we are not allowed to observe worker state on a background thread, we can not
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
has finished. */
val workManager = WorkManager.getInstance(context)
try {
val waitJob = waitScope.launch {
// wait for finished worker state
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
for (info in infoList)
if (info.state.isFinished) {
if (info.state == WorkInfo.State.FAILED) {
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
syncResult.tooManyRetries = true
else
syncResult.databaseError = true
}
cancel("$workerName has finished")
}
}
}
runBlocking {
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
waitJob.join() // wait until worker has finished
}
}
} catch (_: CancellationException) {
// waiting for work was cancelled, either by timeout or because the worker has finished
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
}
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
logger.log(Level.WARNING, "Security exception for $account/$authority")
}
override fun onSyncCanceled() {
logger.info("Sync adapter requested cancellation won't cancel sync, but also won't block sync framework anymore")
// unblock sync framework
waitScope.cancel()
}
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
}
}
// exported sync adapter services; we need a separate class for each authority
class CalendarsSyncAdapterService: SyncAdapterService()
class ContactsSyncAdapterService: SyncAdapterService()
class JtxSyncAdapterService: SyncAdapterService()
class OpenTasksSyncAdapterService: SyncAdapterService()
class TasksOrgSyncAdapterService: SyncAdapterService()

View File

@@ -4,11 +4,13 @@
package at.bitfire.davdroid.sync
import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
enum class SyncDataType {
@@ -23,19 +25,38 @@ enum class SyncDataType {
fun tasksAppManager(): TasksAppManager
}
/**
* Returns authorities which exist for this sync data type. Used on [TASKS] the method
* may return an empty list if there are no tasks providers (installed tasks apps).
*
* @return list of authorities matching this data type
*/
fun possibleAuthorities(): List<String> =
when (this) {
CONTACTS -> listOf(
ContactsContract.AUTHORITY
)
EVENTS -> listOf(
CalendarContract.AUTHORITY
)
TASKS ->
TaskProvider.ProviderName.entries.map { it.authority }
CONTACTS -> listOf(ContactsContract.AUTHORITY)
EVENTS -> listOf(CalendarContract.AUTHORITY)
TASKS -> TaskProvider.ProviderName.entries.map { it.authority }
}
/**
* Returns the authority corresponding to this datatype.
* When more than one tasks provider exists (tasks apps installed) the authority for the active
* tasks provider (user selected tasks app) is returned.
*
* @param context android context used to determine the active/selected tasks provider
* @return the authority matching this data type or *null* for [TASKS] if no tasks app is installed
*/
fun currentAuthority(context: Context): String? =
when (this) {
CONTACTS -> ContactsContract.AUTHORITY
EVENTS -> CalendarContract.AUTHORITY
TASKS -> EntryPointAccessors.fromApplication<SyncDataTypeEntryPoint>(context)
.tasksAppManager()
.currentProvider()
?.authority
}
companion object {
fun fromAuthority(authority: String): SyncDataType {

View File

@@ -51,6 +51,7 @@ import java.io.IOException
import java.net.HttpURLConnection
import java.security.cert.CertificateException
import java.util.LinkedList
import java.util.Optional
import java.util.concurrent.CancellationException
import java.util.concurrent.LinkedBlockingQueue
import java.util.logging.Level
@@ -347,7 +348,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
}
} else
logger.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
local.deleteLocal()
}
}
logger.info("Removed $numDeleted record(s) from server")
@@ -378,57 +379,83 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
return numUploaded > 0
}
protected suspend fun uploadDirty(local: ResourceType) {
/**
* Uploads a dirty local resource.
*
* @param local resource to upload
* @param forceAsNew whether the ETag (and Schedule-Tag) of [local] are ignored and the resource
* is created as a new resource on the server
*/
protected open suspend fun uploadDirty(local: ResourceType, forceAsNew: Boolean = false) {
val existingFileName = local.fileName
var newFileName: String? = null
var eTag: String? = null
var scheduleTag: String? = null
val readTagsFromResponse: (okhttp3.Response) -> Unit = { response ->
eTag = GetETag.fromResponse(response)?.eTag
scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
val fileName = if (existingFileName != null) {
// prepare upload (for UID etc), but ignore returned file name suggestion
local.prepareForUpload()
existingFileName
} else {
// prepare upload and use returned file name suggestion as new file name
local.prepareForUpload()
}
val uploadUrl = collection.url.newBuilder().addPathSegment(fileName).build()
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
try {
if (existingFileName == null) { // new resource
newFileName = local.prepareForUpload()
val uploadUrl = collection.url.newBuilder().addPathSegment(newFileName).build()
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
logger.info("Uploading new record ${local.id} -> $newFileName")
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
if (existingFileName == null || forceAsNew) {
// create new resource on server
logger.info("Uploading new resource ${local.id} -> $fileName")
val bodyToUpload = generateUpload(local)
var newETag: String? = null
var newScheduleTag: String? = null
runInterruptible {
remote.put(
bodyToUpload,
ifNoneMatch = true,
callback = readTagsFromResponse,
ifNoneMatch = true, // fails if there's already a resource with that name
callback = { response ->
newETag = GetETag.fromResponse(response)?.eTag
newScheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
},
headers = pushDontNotifyHeader
)
}
}
} else /* existingFileName != null */ { // updated resource
local.prepareForUpload()
logger.fine("Upload successful; new ETag=$newETag / Schedule-Tag=$newScheduleTag")
val uploadUrl = collection.url.newBuilder().addPathSegment(existingFileName).build()
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
val lastScheduleTag = local.scheduleTag
val lastETag = if (lastScheduleTag == null) local.eTag else null
logger.info("Uploading modified record ${local.id} -> $existingFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)")
// success (no exception thrown)
onSuccessfulUpload(local, fileName, newETag, newScheduleTag)
} else {
// update resource on server
val ifScheduleTag = local.scheduleTag
val ifETag = if (ifScheduleTag == null) local.eTag else null
logger.info("Uploading modified resource ${local.id} -> $fileName (if ETag=$ifETag / Schedule-Tag=$ifScheduleTag)")
val bodyToUpload = generateUpload(local)
var updatedETag: String? = null
var updatedScheduleTag: String? = null
runInterruptible {
remote.put(
bodyToUpload,
ifETag = lastETag,
ifScheduleTag = lastScheduleTag,
callback = readTagsFromResponse,
ifETag = ifETag,
ifScheduleTag = ifScheduleTag,
callback = { response ->
updatedETag = GetETag.fromResponse(response)?.eTag
updatedScheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
},
headers = pushDontNotifyHeader
)
}
logger.fine("Upload successful; updated ETag=$updatedETag / Schedule-Tag=$updatedScheduleTag")
// success (no exception thrown)
onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag)
}
}
} catch (e: SyncException) {
when (val ex = e.cause) {
is ForbiddenException -> {
@@ -441,15 +468,14 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
}
is NotFoundException, is GoneException -> {
// HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore)
if (local.scheduleTag != null || local.eTag != null) { // this was an update of a previously existing resource
if (!forceAsNew) { // first try; if this fails with 404, too, the collection is gone
logger.info("Original version of locally modified resource is not there (anymore), trying as fresh upload")
if (local.scheduleTag != null) // contacts don't support scheduleTag, don't try to set it without check
local.scheduleTag = null
local.eTag = null
uploadDirty(local) // if this fails with 404, too, the collection is gone
uploadDirty(local, forceAsNew = true)
return
} else
throw e // the collection is probably gone
} else {
// we tried with forceAsNew, collection probably gone
throw e
}
}
is ConflictException -> {
// HTTP 409 Conflict
@@ -464,13 +490,16 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
else -> throw e
}
}
}
if (eTag != null)
logger.fine("Received new ETag=$eTag after uploading")
else
logger.fine("Didn't receive new ETag after uploading, setting to null")
local.clearDirty(newFileName, eTag, scheduleTag)
/**
* Called after a successful upload (either of a new or an updated resource) so that the local
* _dirty_ state can be reset.
*
* Note: [CalendarSyncManager] overrides this method to additionally store the updated SEQUENCE.
*/
protected open fun onSuccessfulUpload(local: ResourceType, newFileName: String, eTag: String?, scheduleTag: String?) {
local.clearDirty(Optional.of(newFileName), eTag, scheduleTag)
}
/**
@@ -607,7 +636,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
localCollection.findByName(name)?.let { local ->
SyncException.wrapWithLocalResource(local) {
logger.info("$name has been deleted on server, deleting locally")
local.delete()
local.deleteLocal()
}
}
}

View File

@@ -234,7 +234,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
logger.info("${dataStore.authority} sync of $account initiated (resync=$resync)")
try {
dataStore.acquireContentProvider()
dataStore.acquireContentProvider(throwOnMissingPermissions = true)
} catch (e: SecurityException) {
logger.log(Level.WARNING, "Missing permissions for content provider authority ${dataStore.authority}", e)
/* Don't show a notification here without possibility to permanently dismiss it!

View File

@@ -0,0 +1,19 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.adapter
import android.os.IBinder
/**
* Interface for an Android sync adapter, as created by [SyncAdapterService].
*
* Sync adapters are bound services that communicate over IPC, so the only method is
* [getBinder], which returns the sync adapter binder.
*/
interface SyncAdapter {
fun getBinder(): IBinder
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.adapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.R
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncConditions
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
/**
* Entry point for the Sync Adapter Framework.
*
* Handles incoming sync requests from the Sync Adapter Framework.
*
* Although we do not use the sync adapter for syncing anymore, we keep this sole
* adapter to provide exported services, which allow android system components and calendar,
* contacts or task apps to sync via DAVx5.
*
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
*/
class SyncAdapterImpl @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
private val serviceRepository: DavServiceRepository,
@ApplicationContext context: Context,
private val logger: Logger,
private val syncConditionsFactory: SyncConditions.Factory,
private val syncFrameworkIntegration: SyncFrameworkIntegration,
private val syncWorkerManager: SyncWorkerManager
): AbstractThreadedSyncAdapter(
/* context = */ context,
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
), SyncAdapter {
/**
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
* requests cancellation.
*/
private val waitScope = CoroutineScope(Dispatchers.Default)
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
// We have to pass this old SyncFramework extra for an Android 7 workaround
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
// If we should sync an address book account - find the account storing the settings
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
AccountManager.get(context)
.getUserData(accountOrAddressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)
?.toLongOrNull()
?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
}
else
accountOrAddressBookAccount
if (account == null) {
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
return
}
// Check sync conditions
val accountSettings = try {
accountSettingsFactory.create(account)
} catch (e: InvalidAccountException) {
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
return
}
val syncConditions = syncConditionsFactory.create(accountSettings)
// Should we run the sync at all?
if (!syncConditions.wifiConditionsMet()) {
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
return
}
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.Companion.fromAuthority(authority), fromUpload = upload)
// Android 14+ does not handle pending sync state correctly.
// As a defensive workaround, we can cancel specifically this still pending sync only
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
if (Build.VERSION.SDK_INT >= 34) {
logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
"account=$accountOrAddressBookAccount authority=$authority upload=$upload")
syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras)
}
/* Because we are not allowed to observe worker state on a background thread, we can not
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
has finished. */
val workManager = WorkManager.getInstance(context)
try {
val waitJob = waitScope.launch {
// wait for finished worker state
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
for (info in infoList)
if (info.state.isFinished) {
if (info.state == WorkInfo.State.FAILED) {
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
syncResult.tooManyRetries = true
else
syncResult.databaseError = true
}
cancel("$workerName has finished")
}
}
}
runBlocking {
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
waitJob.join() // wait until worker has finished
}
}
} catch (_: CancellationException) {
// waiting for work was cancelled, either by timeout or because the worker has finished
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
}
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
logger.log(Level.WARNING, "Security exception for $account/$authority")
}
override fun onSyncCanceled() {
logger.info("Sync adapter requested cancellation won't cancel sync, but also won't block sync framework anymore")
// unblock sync framework
waitScope.cancel()
}
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
// SyncAdapter implementation and Hilt module
override fun getBinder(): IBinder = syncAdapterBinder
@Module
@InstallIn(SingletonComponent::class)
abstract class RealSyncAdapterModule {
@Binds
abstract fun provide(impl: SyncAdapterImpl): SyncAdapter
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.adapter
import android.app.Service
import android.content.Intent
import dagger.hilt.InstallIn
import dagger.hilt.android.EarlyEntryPoint
import dagger.hilt.android.EarlyEntryPoints
import dagger.hilt.components.SingletonComponent
abstract class SyncAdapterService: Service() {
/**
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
* create a [SyncAdapterService] instance before Hilt is initialized by the HiltTestRunner.
*/
@EarlyEntryPoint
@InstallIn(SingletonComponent::class)
interface SyncAdapterServicesEntryPoint {
fun syncAdapter(): SyncAdapter
}
// create syncAdapter on demand and cache it
val syncAdapter by lazy {
val entryPoint = EarlyEntryPoints.get(applicationContext, SyncAdapterServicesEntryPoint::class.java)
entryPoint.syncAdapter()
}
override fun onBind(intent: Intent?) = syncAdapter.getBinder()
}
// exported sync adapter services; we need a separate class for each authority
class CalendarsSyncAdapterService: SyncAdapterService()
class ContactsSyncAdapterService: SyncAdapterService()
class JtxSyncAdapterService: SyncAdapterService()
class OpenTasksSyncAdapterService: SyncAdapterService()
class TasksOrgSyncAdapterService: SyncAdapterService()

View File

@@ -2,19 +2,24 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
package at.bitfire.davdroid.sync.adapter
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncRequest
import android.os.Build
import android.provider.CalendarContract
import android.os.Bundle
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.sync.SyncDataType
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -29,6 +34,7 @@ import javax.inject.Inject
* Sync requests from the Sync Adapter Framework are handled by [SyncAdapterService].
*/
class SyncFrameworkIntegration @Inject constructor(
@ApplicationContext private val context: Context,
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
private val logger: Logger
) {
@@ -94,6 +100,27 @@ class SyncFrameworkIntegration @Inject constructor(
setSyncOnContentChange(account, authority, false)
}
/**
* Cancels the sync request in the Sync Adapter Framework by sync request. This
* is the defensive approach canceling only one specific sync request with matching
* sync extras.
*
* @param account The account for which the sync request should be canceled.
* @param authority The authority for which the sync request should be canceled.
* @param extras The original extras Bundle used to start the sync.
*/
fun cancelSync(account: Account, authority: String, extras: Bundle) {
// Recreate the sync request which was used to start this sync
val syncRequest = SyncRequest.Builder()
.setSyncAdapter(account, authority)
.setExtras(extras)
.syncOnce()
.build()
// Cancel it
ContentResolver.cancelSync(syncRequest)
}
/**
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
@@ -107,7 +134,7 @@ class SyncFrameworkIntegration @Inject constructor(
*
* @param account account to enable/disable content change sync triggers for
* @param enable *true* enables automatic sync; *false* disables it
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY])
* @return whether the content triggered sync was enabled successfully
*/
@WorkerThread
@@ -138,7 +165,7 @@ class SyncFrameworkIntegration @Inject constructor(
*
* @param account account to enable/disable content change sync triggers for
* @param enable *true* enables automatic sync; *false* disables it
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY])
* @return whether the content triggered sync was enabled successfully
*/
private fun setContentTrigger(account: Account, authority: String, enable: Boolean): Boolean =
@@ -153,55 +180,82 @@ class SyncFrameworkIntegration @Inject constructor(
/**
* Observe whether any of the given data types is currently pending for sync.
*
* Note: On Android 14+ finished syncs stay by default pending. This is why we
* explicitly cancel the active sync in [SyncAdapterImpl] for Android 14+. Doing
* so allows us to have a reliable "pending" flag again, which is used in this method.
*
* @param account account to observe sync status for
* @param dataTypes data types to observe sync status for
*
* @return flow emitting true if any of the given data types has a sync pending, false otherwise
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> =
if (Build.VERSION.SDK_INT >= 34) {
// On Android 14+ pending sync checks always return true (bug), so we don't need to check.
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
flowOf(false)
} else {
val authorities = dataTypes.flatMap { it.possibleAuthorities() }
// Use address book accounts if needed
val accountsFlow = if (dataTypes.contains(SyncDataType.CONTACTS))
localAddressBookStore.get().getAddressBookAccountsFlow(account)
else
flowOf(listOf(account))
// Observe sync pending state for the given accounts and authorities
accountsFlow.flatMapLatest { accounts ->
callbackFlow {
// Observe sync pending state
val listener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING
) {
trySend(anyPendingSync(accounts, authorities))
}
// Emit initial value
trySend(anyPendingSync(accounts, authorities))
// Clean up listener on close
awaitClose { ContentResolver.removeStatusChangeListener(listener) }
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> {
// Determine the pending state for each data type of the account as separate flows
val pendingStateFlows: List<Flow<Boolean>> = dataTypes.mapNotNull { dataType ->
// Map datatype to authority
dataType.currentAuthority(context)?.let { authority ->
// If checking contacts, we need to check all address book accounts instead of the single main account
val accountsFlow: Flow<List<Account>> = when (dataType) {
SyncDataType.CONTACTS -> localAddressBookStore.get().getAddressBookAccountsFlow(account)
else -> flowOf(listOf(account))
}
}.distinctUntilChanged()
// Return the pending state flow for accounts with this authority
anyPendingSyncFlow(accountsFlow, authority)
}
}
// Combine the different per data type pending state flows into one
return combine(pendingStateFlows) { pendingStates ->
pendingStates.any { pending -> pending }
}.distinctUntilChanged()
}
/**
* Check if any of the given accounts and authorities have a sync pending.
* Maps the given accounts flow to a simple boolean flow telling us whether any of the accounts
* has a pending sync for given authority.
*
* @param accountsFlow accounts to check sync status for
* @param authority authority to check sync status for
*
* @return returns flow which emits *true* if any of the accounts has a sync pending for
* the given authority and *false* otherwise
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun anyPendingSyncFlow(
accountsFlow: Flow<List<Account>>,
authority: String
): Flow<Boolean> = accountsFlow.flatMapLatest { accounts ->
// Observe sync pending state for the given accounts and data types
callbackFlow {
// Observe sync pending state
val listener = ContentResolver.addStatusChangeListener(
ContentResolver.SYNC_OBSERVER_TYPE_PENDING
) {
trySend(anyPendingSync(accounts, authority))
}
// Emit initial value
trySend(anyPendingSync(accounts, authority))
// Clean up listener on close
awaitClose { ContentResolver.removeStatusChangeListener(listener) }
}
}
/**
* Check if any of the given accounts have a sync pending for given authority.
*
* @param accounts accounts to check sync status for
* @param authorities authorities to check sync status for
* @return true if any of the given accounts and authorities has a sync pending, false otherwise
* @param authority authority to check sync status for
*
* @return *true* if any of the given accounts has a sync pending for given authority; *false* otherwise
*/
private fun anyPendingSync(accounts: List<Account>, authorities: List<String>): Boolean =
private fun anyPendingSync(accounts: List<Account>, authority: String): Boolean =
accounts.any { account ->
authorities.any { authority ->
ContentResolver.isSyncPending(account, authority)
ContentResolver.isSyncPending(account, authority).also { pending ->
logger.finer("Sync pending($account, $authority) = $pending")
}
}

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.sync.groups
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.vcard4android.Contact
import java.util.Optional
import java.util.logging.Logger
class CategoriesStrategy(val addressBook: LocalAddressBook): ContactGroupStrategy {
@@ -25,7 +26,7 @@ class CategoriesStrategy(val addressBook: LocalAddressBook): ContactGroupStrateg
for (group in addressBook.findDirtyGroups()) {
logger.fine("Marking members of modified group $group as dirty")
group.markMembersDirty()
group.clearDirty(null, null)
group.clearDirty(Optional.empty(), null)
}
}

View File

@@ -5,6 +5,8 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
@@ -13,6 +15,7 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.PowerManager
import android.provider.CalendarContract
import android.provider.ContactsContract
@@ -22,13 +25,14 @@ import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
@@ -296,4 +300,34 @@ class AccountsModel @AssistedInject constructor(
false
}
fun cancelSyncAdapterSyncs() {
if (Build.VERSION.SDK_INT >= 34) {
val calendarAccountType = context.getString(R.string.account_type)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
// Cancel any (after an update) possibly forever pending calendar account syncs
cancelSyncs(calendarAccountType, SyncDataType.EVENTS.possibleAuthorities())
// Cancel any (after an update) possibly forever pending tasks account syncs
cancelSyncs(calendarAccountType, SyncDataType.TASKS.possibleAuthorities())
// Cancel any (after an update) possibly forever pending address book account syncs
cancelSyncs(addressBookAccountType, SyncDataType.CONTACTS.possibleAuthorities())
}
}
/**
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
* authorities.
*/
private fun cancelSyncs(accountType: String, authorities: List<String>) {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(accountType).forEach { account ->
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
for (authority in authorities)
ContentResolver.cancelSync(account, authority)
}
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.CancelScheduleSend
import androidx.compose.material.icons.filled.DataSaverOn
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.NotificationsOff
@@ -111,6 +112,7 @@ fun AccountsScreen(
}
AccountsScreen(
cancelSyncAdapterSyncs = { model.cancelSyncAdapterSyncs() },
accountsDrawerHandler = accountsDrawerHandler,
accounts = accounts,
showSyncAll = showSyncAll,
@@ -131,6 +133,7 @@ fun AccountsScreen(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun AccountsScreen(
cancelSyncAdapterSyncs: () -> Unit,
accountsDrawerHandler: AccountsDrawerHandler,
accounts: List<AccountsModel.AccountInfo>,
showSyncAll: Boolean = true,
@@ -228,6 +231,17 @@ fun AccountsScreen(
contentDescription = stringResource(R.string.accounts_sync_all)
)
}
FloatingActionButton(
onClick = cancelSyncAdapterSyncs,
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier.padding(top = 24.dp)
) {
Icon(
Icons.Default.CancelScheduleSend,
contentDescription = stringResource(R.string.accounts_sync_all)
)
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
@@ -321,6 +335,7 @@ fun AccountsScreen(
@Preview
fun AccountsScreen_Preview_Empty() {
AccountsScreen(
cancelSyncAdapterSyncs = {},
accountsDrawerHandler = object: AccountsDrawerHandler() {
@Composable
override fun MenuEntries(snackbarHostState: SnackbarHostState) {
@@ -337,6 +352,7 @@ fun AccountsScreen_Preview_Empty() {
@Preview
fun AccountsScreen_Preview_OneAccount() {
AccountsScreen(
cancelSyncAdapterSyncs = {},
accountsDrawerHandler = object: AccountsDrawerHandler() {
@Composable
override fun MenuEntries(snackbarHostState: SnackbarHostState) {

View File

@@ -41,8 +41,8 @@ import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract

View File

@@ -47,7 +47,6 @@ class NotificationRegistry @Inject constructor(
const val NOTIFY_DATABASE_CORRUPTED = 4
const val NOTIFY_SYNC_ERROR = 10
const val NOTIFY_INVALID_RESOURCE = 11
const val NOTIFY_WEBDAV_ACCESS = 12
const val NOTIFY_SYNC_EXPEDITED = 14
const val NOTIFY_TASKS_PROVIDER_TOO_OLD = 20
const val NOTIFY_PERMISSIONS = 21

View File

@@ -10,7 +10,7 @@ import androidx.work.WorkInfo
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext

View File

@@ -7,18 +7,29 @@ import android.Manifest
import android.accounts.Account
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.SyncProblem
@@ -56,6 +67,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
@@ -78,6 +90,7 @@ import at.bitfire.davdroid.ui.account.CollectionsList
import at.bitfire.davdroid.ui.account.RenameAccountDialog
import at.bitfire.davdroid.ui.composable.ActionCard
import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.davdroid.ui.icon.CalendarImport
import at.bitfire.ical4android.TaskProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@@ -161,7 +174,7 @@ fun AccountScreen(
)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun AccountScreen(
accountName: String,
@@ -238,6 +251,10 @@ fun AccountScreen(
(if (idxWebcal != null) 1 else 0)
val pagerState = rememberPagerState(pageCount = { nrPages })
val calDavScrollState = rememberLazyListState()
val cardDavScrollState = rememberLazyListState()
val webcalScrollState = rememberLazyListState()
Scaffold(
topBar = {
TopAppBar(
@@ -310,52 +327,61 @@ fun AccountScreen(
modifier = Modifier.padding(padding)
) {
if (nrPages > 0) {
TabRow(selectedTabIndex = pagerState.currentPage) {
if (idxCalDav != null) {
Tab(
selected = pagerState.currentPage == idxCalDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCalDav)
}
}
) {
Text(
stringResource(R.string.account_caldav),
modifier = Modifier.padding(8.dp)
)
}
}
SharedTransitionLayout {
val idxCurrentPage = pagerState.currentPage
if (idxCardDav != null) {
Tab(
selected = pagerState.currentPage == idxCardDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCardDav)
}
}
) {
Text(
stringResource(R.string.account_carddav),
modifier = Modifier.padding(8.dp)
)
}
// The icon shall be shown when the scroll state is at the top (= we can't scroll backward)
val currentPageScrollState = when (idxCurrentPage) {
idxCalDav -> calDavScrollState
idxCardDav -> cardDavScrollState
idxWebcal -> webcalScrollState
else -> null
}
if (idxWebcal != null) {
Tab(
selected = pagerState.currentPage == idxWebcal,
onClick = {
scope.launch {
pagerState.scrollToPage(idxWebcal)
AnimatedContent(
targetState = currentPageScrollState?.canScrollBackward != true
) { showIcon ->
TabRow(selectedTabIndex = idxCurrentPage) {
if (idxCalDav != null)
AccountScreen_Tab(
selected = idxCurrentPage == idxCalDav,
showIcon = showIcon,
icon = Icons.Default.CalendarToday,
text = stringResource(R.string.account_caldav),
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout,
) {
scope.launch {
pagerState.scrollToPage(idxCalDav)
}
}
if (idxCardDav != null)
AccountScreen_Tab(
selected = idxCurrentPage == idxCardDav,
showIcon = showIcon,
icon = Icons.Default.Group,
text = stringResource(R.string.account_carddav),
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout,
) {
scope.launch {
pagerState.scrollToPage(idxCardDav)
}
}
if (idxWebcal != null)
AccountScreen_Tab(
selected = idxCurrentPage == idxWebcal,
showIcon = showIcon,
icon = Icons.Default.Link,
text = stringResource(R.string.account_webcal),
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout,
) {
scope.launch {
pagerState.scrollToPage(idxWebcal)
}
}
}
) {
Text(
stringResource(R.string.account_webcal),
modifier = Modifier.padding(8.dp)
)
}
}
}
@@ -378,7 +404,8 @@ fun AccountScreen(
progress = cardDavProgress,
collections = addressBooks,
onUpdateCollectionSync = onUpdateCollectionSync,
onCollectionDetails = onCollectionDetails
onCollectionDetails = onCollectionDetails,
state = cardDavScrollState
)
idxCalDav -> {
@@ -390,7 +417,8 @@ fun AccountScreen(
progress = calDavProgress,
collections = calendars,
onUpdateCollectionSync = onUpdateCollectionSync,
onCollectionDetails = onCollectionDetails
onCollectionDetails = onCollectionDetails,
state = calDavScrollState
)
}
@@ -425,7 +453,8 @@ fun AccountScreen(
requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR),
progress = calDavProgress,
collections = subscriptions,
onSubscribe = onSubscribe
onSubscribe = onSubscribe,
state = webcalScrollState
)
}
}
@@ -438,6 +467,55 @@ fun AccountScreen(
}
}
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
fun AccountScreen_Tab(
selected: Boolean,
showIcon: Boolean,
icon: ImageVector,
text: String,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onClick: () -> Unit,
) {
with(sharedTransitionScope) {
if (showIcon) {
Tab(
selected = selected,
onClick = onClick,
icon = { Icon(imageVector = icon, contentDescription = text) },
text = {
Text(
text,
modifier = Modifier
.sharedBounds(
rememberSharedContentState(key = text),
animatedVisibilityScope = animatedVisibilityScope,
)
.padding(8.dp)
)
}
)
} else {
Tab(
selected = selected,
onClick = onClick,
content = {
Text(
text,
modifier = Modifier
.sharedBounds(
rememberSharedContentState(key = text),
animatedVisibilityScope = animatedVisibilityScope,
)
.padding(8.dp)
)
}
)
}
}
}
@Composable
fun AccountScreen_Actions(
accountName: String,
@@ -601,7 +679,8 @@ fun AccountScreen_ServiceTab(
collections: LazyPagingItems<Collection>?,
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
onSubscribe: (Collection) -> Unit = {},
onCollectionDetails: ((Collection) -> Unit)? = null
onCollectionDetails: ((Collection) -> Unit)? = null,
state: LazyListState = rememberLazyListState()
) {
val context = LocalContext.current
@@ -646,7 +725,8 @@ fun AccountScreen_ServiceTab(
onChangeSync = onUpdateCollectionSync,
onSubscribe = onSubscribe,
onCollectionDetails = onCollectionDetails,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
state = state
)
}
}

View File

@@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.EventNote
@@ -61,12 +63,14 @@ fun CollectionsList(
onChangeSync: (collectionId: Long, sync: Boolean) -> Unit,
modifier: Modifier = Modifier,
onSubscribe: (collection: Collection) -> Unit = {},
onCollectionDetails: ((collection: Collection) -> Unit)? = null
onCollectionDetails: ((collection: Collection) -> Unit)? = null,
state: LazyListState = rememberLazyListState()
) {
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
modifier = modifier
modifier = modifier,
state = state
) {
items(
count = collections.itemCount,

View File

@@ -150,7 +150,6 @@ fun CreateAddressBookScreen(
value = description,
onValueChange = onSetDescription,
label = { Text(stringResource(R.string.create_collection_description_optional)) },
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
singleLine = true,
enabled = !isCreating,
keyboardOptions = KeyboardOptions(

View File

@@ -228,7 +228,6 @@ fun CreateCalendarScreen(
value = description,
onValueChange = onSetDescription,
label = { Text(stringResource(R.string.create_collection_description_optional)) },
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
@@ -251,7 +250,6 @@ fun CreateCalendarScreen(
label = { Text(stringResource(R.string.create_calendar_time_zone_optional)) },
value = timeZone ?: stringResource(R.string.create_calendar_time_zone_none),
onValueChange = { /* read-only */ },
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
readOnly = true,
modifier = Modifier

View File

@@ -0,0 +1,67 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.icon
import androidx.compose.material.icons.Icons
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Icons.Filled.CalendarImport: ImageVector
get() {
if (_CalendarImport != null) {
return _CalendarImport!!
}
_CalendarImport = ImageVector.Builder(
name = "Filled.CalendarImport",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(12f, 12f)
lineTo(8f, 16f)
horizontalLineTo(11f)
verticalLineTo(22f)
horizontalLineTo(13f)
verticalLineTo(16f)
horizontalLineTo(16f)
moveTo(19f, 3f)
horizontalLineTo(18f)
verticalLineTo(1f)
horizontalLineTo(16f)
verticalLineTo(3f)
horizontalLineTo(8f)
verticalLineTo(1f)
horizontalLineTo(6f)
verticalLineTo(3f)
horizontalLineTo(5f)
curveTo(3.9f, 3f, 3f, 3.9f, 3f, 5f)
verticalLineTo(19f)
curveTo(3f, 20.11f, 3.9f, 21f, 5f, 21f)
horizontalLineTo(9f)
verticalLineTo(19f)
horizontalLineTo(5f)
verticalLineTo(8f)
horizontalLineTo(19f)
verticalLineTo(19f)
horizontalLineTo(15f)
verticalLineTo(21f)
horizontalLineTo(19f)
curveTo(20.11f, 21f, 21f, 20.11f, 21f, 19f)
verticalLineTo(5f)
curveTo(21f, 3.9f, 20.11f, 3f, 19f, 3f)
close()
}
}.build()
return _CalendarImport!!
}
@Suppress("ObjectPropertyName")
private var _CalendarImport: ImageVector? = null

View File

@@ -179,12 +179,6 @@ fun AdvancedLoginScreen(
chosenAlias = certAlias,
onAliasChosen = onSetCertAlias
)
Text(
stringResource(R.string.optional_label),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
}

View File

@@ -89,7 +89,6 @@ object NextcloudLogin : LoginType {
// Custom Tabs are available
@Suppress("DEPRECATION")
val browser = CustomTabsIntent.Builder()
.setToolbarColor(context.resources.getColor(R.color.primaryColor))
.build()
browser.intent.data = loginUri
browser.intent.putExtra(

View File

@@ -5,18 +5,17 @@
package at.bitfire.davdroid.ui.webdav
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material3.Button
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.Sell
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -42,8 +41,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.composable.ProgressBar
import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard
@Composable
@@ -136,21 +135,27 @@ fun AddWebDavMountScreen(
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
Assistant(
nextLabel = stringResource(R.string.webdav_add_mount_add),
nextEnabled = canContinue && !isLoading,
isLoading = isLoading,
onNext = onAddMount
) {
if (isLoading)
ProgressBar(modifier = Modifier.fillMaxWidth())
Column(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
.padding(8.dp)
) {
val focusRequester = remember { FocusRequester() }
Text(
text = stringResource(R.string.webdav_add_mount_mountpoint_displayname),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
OutlinedTextField(
label = { Text(stringResource(R.string.webdav_add_mount_url)) },
leadingIcon = { Icon(Icons.Default.Cloud, contentDescription = null) },
@@ -177,6 +182,9 @@ fun AddWebDavMountScreen(
value = displayName,
onValueChange = onSetDisplayName,
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Sell, null)
},
readOnly = isLoading,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier
@@ -192,10 +200,13 @@ fun AddWebDavMountScreen(
.padding(bottom = 8.dp)
)
OutlinedTextField(
label = { Text(stringResource(R.string.login_user_name)) },
label = { Text(stringResource(R.string.login_user_name_optional)) },
value = username,
onValueChange = onSetUsername,
singleLine = true,
leadingIcon = {
Icon(Icons.Default.AccountCircle, null)
},
readOnly = isLoading,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
@@ -208,8 +219,11 @@ fun AddWebDavMountScreen(
PasswordTextField(
password = password,
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password),
labelText = stringResource(R.string.login_password_optional),
readOnly = isLoading,
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
@@ -229,15 +243,6 @@ fun AddWebDavMountScreen(
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Button(
enabled = canContinue && !isLoading,
onClick = { onAddMount() }
) {
Text(
text = stringResource(R.string.webdav_add_mount_add)
)
}
}
}
}

View File

@@ -4,817 +4,103 @@
package at.bitfire.davdroid.webdav
import android.app.AuthenticationRequiredException
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.database.MatrixCursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Point
import android.media.ThumbnailUtils
import android.net.ConnectivityManager
import android.os.Build
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GetContentLength
import at.bitfire.dav4jvm.property.webdav.GetContentType
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.GetLastModified
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavDocumentDao
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.MemoryCookieStore
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import at.bitfire.davdroid.webdav.operation.CopyDocumentOperation
import at.bitfire.davdroid.webdav.operation.CreateDocumentOperation
import at.bitfire.davdroid.webdav.operation.DeleteDocumentOperation
import at.bitfire.davdroid.webdav.operation.IsChildDocumentOperation
import at.bitfire.davdroid.webdav.operation.MoveDocumentOperation
import at.bitfire.davdroid.webdav.operation.OpenDocumentOperation
import at.bitfire.davdroid.webdav.operation.OpenDocumentThumbnailOperation
import at.bitfire.davdroid.webdav.operation.QueryChildDocumentsOperation
import at.bitfire.davdroid.webdav.operation.QueryDocumentOperation
import at.bitfire.davdroid.webdav.operation.QueryRootsOperation
import at.bitfire.davdroid.webdav.operation.RenameDocumentOperation
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withTimeout
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
/**
* Provides functionality on WebDav documents.
*
* Actual implementation should go into [DavDocumentsActor].
* Hilt constructor injection can't be used for content providers because SingletonComponent
* may not ready yet when the content provider is created. So we use an explicit EntryPoint.
*
* Note: A DocumentsProvider is a ContentProvider and thus has no well-defined lifecycle. It
* is created by Android when it's first accessed and then stays in memory until the process
* is killed.
*/
class DavDocumentsProvider(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
): DocumentsProvider() {
class DavDocumentsProvider: DocumentsProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface DavDocumentsProviderEntryPoint {
fun appDatabase(): AppDatabase
fun davDocumentsActorFactory(): DavDocumentsActor.Factory
fun documentSortByMapper(): DocumentSortByMapper
fun logger(): Logger
fun randomAccessCallbackWrapperFactory(): RandomAccessCallbackWrapper.Factory
fun streamingFileDescriptorFactory(): StreamingFileDescriptor.Factory
fun webdavComponentBuilder(): WebdavComponentBuilder
fun copyDocumentOperation(): CopyDocumentOperation
fun createDocumentOperation(): CreateDocumentOperation
fun deleteDocumentOperation(): DeleteDocumentOperation
fun isChildDocumentOperation(): IsChildDocumentOperation
fun moveDocumentOperation(): MoveDocumentOperation
fun openDocumentOperation(): OpenDocumentOperation
fun openDocumentThumbnailOperation(): OpenDocumentThumbnailOperation
fun queryChildDocumentsOperation(): QueryChildDocumentsOperation
fun queryDocumentOperation(): QueryDocumentOperation
fun queryRootsOperation(): QueryRootsOperation
fun renameDocumentOperation(): RenameDocumentOperation
}
@EntryPoint
@InstallIn(WebdavComponent::class)
interface DavDocumentsProviderWebdavEntryPoint {
fun credentialsStore(): CredentialsStore
fun thumbnailCache(): ThumbnailCache
private val entryPoint: DavDocumentsProviderEntryPoint by lazy {
EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(context!!)
}
companion object {
val DAV_FILE_FIELDS = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
GetETag.NAME,
GetContentType.NAME,
GetContentLength.NAME,
GetLastModified.NAME,
QuotaAvailableBytes.NAME,
QuotaUsedBytes.NAME,
)
const val MAX_NAME_ATTEMPTS = 5
const val THUMBNAIL_TIMEOUT_MS = 15000L
fun notifyMountsChanged(context: Context) {
context.contentResolver.notifyChange(buildRootsUri(context.getString(R.string.webdav_authority)), null)
}
}
val documentProviderScope = CoroutineScope(SupervisorJob())
private val ourContext by lazy { context!! } // requireContext() requires API level 30
private val authority by lazy { ourContext.getString(R.string.webdav_authority) }
private val globalEntryPoint by lazy { EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(ourContext) }
private val webdavEntryPoint by lazy {
EntryPoints.get(
globalEntryPoint.webdavComponentBuilder().build(),
DavDocumentsProviderWebdavEntryPoint::class.java
)
}
private val logger by lazy { globalEntryPoint.logger() }
private val db by lazy { globalEntryPoint.appDatabase() }
private val mountDao by lazy { db.webDavMountDao() }
private val documentDao by lazy { db.webDavDocumentDao() }
private val thumbnailCache by lazy { webdavEntryPoint.thumbnailCache() }
private val connectivityManager by lazy { ourContext.getSystemService<ConnectivityManager>()!! }
private val storageManager by lazy { ourContext.getSystemService<StorageManager>()!! }
/** List of currently active [queryChildDocuments] runners.
*
* Key: document ID (directory) for which children are listed.
* Value: whether the runner is still running (*true*) or has already finished (*false*).
*/
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
private val credentialsStore by lazy { webdavEntryPoint.credentialsStore() }
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
private val actor by lazy { globalEntryPoint.davDocumentsActorFactory().create(cookieStore, credentialsStore) }
override fun onCreate() = true
override fun shutdown() {
documentProviderScope.cancel()
}
/* Note: shutdown() is NOT called automatically by Android; a content provider lives until
the process is killed. */
/*** query ***/
override fun queryRoots(projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryRoots")
val roots = MatrixCursor(projection ?: arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_FLAGS,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_SUMMARY
))
override fun queryRoots(projection: Array<out String>?) =
entryPoint.queryRootsOperation().invoke(projection)
runBlocking {
for (mount in mountDao.getAll()) {
val rootDocument = documentDao.getOrCreateRoot(mount)
logger.info("Root ID: $rootDocument")
override fun queryDocument(documentId: String, projection: Array<out String>?) =
entryPoint.queryDocumentOperation().invoke(documentId, projection)
roots.newRow().apply {
add(Root.COLUMN_ROOT_ID, mount.id)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, ourContext.getString(R.string.webdav_provider_root_title))
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
add(Root.COLUMN_SUMMARY, mount.name)
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
entryPoint.queryChildDocumentsOperation().invoke(parentDocumentId, projection, sortOrder)
val quotaAvailable = rootDocument.quotaAvailable
if (quotaAvailable != null)
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
val quotaUsed = rootDocument.quotaUsed
if (quotaAvailable != null && quotaUsed != null)
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
}
}
}
return roots
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val parent = doc.parentId?.let { parentId ->
documentDao.get(parentId)
}
return DocumentsCursor(projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_ICON,
Document.COLUMN_SUMMARY
)).apply {
val bundle = doc.toBundle(parent)
logger.fine("queryDocument($documentId) = $bundle")
// override display names of root documents
if (parent == null) {
val mount = runBlocking { mountDao.getById(doc.mountId) }
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
}
addRow(bundle)
}
}
/**
* Gets old or new children of given parent.
*
* Dispatches a worker querying the server for new children of given parent, and instantly
* returns old children (or nothing, on initial call).
* Once the worker finishes its query, it notifies the [android.content.ContentResolver] about
* change, which calls this method again. The worker being done
*/
@Synchronized
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
val parentId = parentDocumentId.toLong()
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
val columns = projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED
)
// Register watcher
val result = DocumentsCursor(columns)
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
result.setNotificationUri(ourContext.contentResolver, notificationUri)
// Dispatch worker querying for the children and keep track of it
val running = runningQueryChildren.getOrPut(parentId) {
documentProviderScope.launch {
actor.queryChildren(parent)
// Once the query is done, set query as finished (not running)
runningQueryChildren[parentId] = false
// .. and notify - effectively calling this method again
ourContext.contentResolver.notifyChange(notificationUri, null)
}
true
}
if (running) // worker still running
result.loading = true
else // remove worker from list if done
runningQueryChildren.remove(parentId)
// Prepare SORT BY clause
val mapper = globalEntryPoint.documentSortByMapper()
val sqlSortBy = if (sortOrder != null)
mapper.mapContentProviderToSql(sortOrder)
else
WebDavDocumentDao.DEFAULT_ORDER
// Regardless of whether the worker is done, return the children we already have
val children = documentDao.getChildren(parentId, sqlSortBy)
for (child in children) {
val bundle = child.toBundle(parent)
result.addRow(bundle)
}
return result
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
while (iter != null) {
val currentParentId = iter.parentId
if (currentParentId == parent.id)
return true
iter = if (currentParentId != null)
documentDao.get(currentParentId)
else
null
}
return false
}
override fun isChildDocument(parentDocumentId: String, documentId: String) =
entryPoint.isChildDocumentOperation().invoke(parentDocumentId, documentId)
/*** copy/create/delete/move/rename ***/
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
val name = srcDoc.name
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String) =
entryPoint.copyDocumentOperation().invoke(sourceDocumentId, targetParentDocumentId)
if (srcDoc.mountId != dstFolder.mountId)
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? =
entryPoint.createDocumentOperation().invoke(parentDocumentId, mimeType, displayName)
actor.httpClient(srcDoc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
.addPathSegment(name)
.build()
override fun deleteDocument(documentId: String) =
entryPoint.deleteDocumentOperation().invoke(documentId)
try {
runInterruptible(ioDispatcher) {
dav.copy(dstUrl, false) {
// successfully copied
}
}
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String) =
entryPoint.moveDocumentOperation().invoke(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId)
val dstDocId = documentDao.insertOrReplace(
WebDavDocument(
mountId = dstFolder.mountId,
parentId = dstFolder.id,
name = name,
isDirectory = srcDoc.isDirectory,
displayName = srcDoc.displayName,
mimeType = srcDoc.mimeType,
size = srcDoc.size
)
).toString()
actor.notifyFolderChanged(targetParentDocumentId)
/* return */ dstDocId
}
}
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
val createDirectory = mimeType == Document.MIME_TYPE_DIR
var docId: Long? = null
actor.httpClient(parent.mountId).use { client ->
for (attempt in 0..MAX_NAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val parentUrl = parent.toHttpUrl(db)
val newLocation = parentUrl.newBuilder()
.addPathSegment(newName)
.build()
val doc = DavResource(client.okHttpClient, newLocation)
try {
runInterruptible(ioDispatcher) {
if (createDirectory)
doc.mkCol(null) {
// directory successfully created
}
else
doc.put("".toRequestBody(null), ifNoneMatch = true) {
// document successfully created
}
}
docId = documentDao.insertOrReplace(
WebDavDocument(
mountId = parent.mountId,
parentId = parent.id,
name = newName,
mimeType = mimeType.toMediaTypeOrNull(),
isDirectory = createDirectory
)
)
actor.notifyFolderChanged(parentDocumentId)
return@runBlocking docId.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(ignorePreconditionFailed = true)
}
}
}
null
}
override fun deleteDocument(documentId: String) = runBlocking {
logger.fine("WebDAV removeDocument $documentId")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
actor.httpClient(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.delete {
// successfully deleted
}
}
logger.fine("Successfully removed")
documentDao.delete(doc)
actor.notifyFolderChanged(doc.parentId)
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
}
}
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
if (doc.mountId != dstParent.mountId)
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
val newLocation = dstParent.toHttpUrl(db).newBuilder()
.addPathSegment(doc.name)
.build()
actor.httpClient(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully moved
}
}
documentDao.update(doc.copy(parentId = dstParent.id))
actor.notifyFolderChanged(sourceParentDocumentId)
actor.notifyFolderChanged(targetParentDocumentId)
} catch (e: HttpException) {
e.throwForDocumentProvider()
}
}
doc.id.toString()
}
override fun renameDocument(documentId: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV renameDocument $documentId $displayName")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
actor.httpClient(doc.mountId).use { client ->
for (attempt in 0..MAX_NAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val oldUrl = doc.toHttpUrl(db)
val newLocation = oldUrl.newBuilder()
.removePathSegment(oldUrl.pathSegments.lastIndex)
.addPathSegment(newName)
.build()
try {
val dav = DavResource(client.okHttpClient, oldUrl)
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully renamed
}
}
documentDao.update(doc.copy(name = newName))
actor.notifyFolderChanged(doc.parentId)
return@runBlocking doc.id.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(true)
}
}
}
null
}
private fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
val safeName = displayName.filterNot { it.isISOControl() }
if (appendNumber != 0) {
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
if (extension != null) {
val baseName = safeName.removeSuffix(".$extension")
return "${baseName}_$appendNumber.$extension"
} else
return "${safeName}_$appendNumber"
} else
return safeName
}
override fun renameDocument(documentId: String, displayName: String): String? =
entryPoint.renameDocumentOperation().invoke(documentId, displayName)
/*** read/write ***/
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
HeadResponse.fromUrl(client, url)
}
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?) =
entryPoint.openDocumentOperation().invoke(documentId, mode, signal)
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
logger.fine("WebDAV openDocument $documentId $mode $signal")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val url = doc.toHttpUrl(db)
val client = actor.httpClient(doc.mountId, logBody = false)
val modeFlags = ParcelFileDescriptor.parseMode(mode)
val readAccess = when (mode) {
"r" -> true
"w", "wt" -> false
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
}
val accessScope = CoroutineScope(SupervisorJob())
signal?.setOnCancelListener {
logger.fine("Cancelling WebDAV access to $url")
accessScope.cancel()
}
val fileInfo = accessScope.async {
headRequest(client, url)
}.await()
logger.fine("Received file info: $fileInfo")
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
return@runBlocking if (
Build.VERSION.SDK_INT >= 26 && // openProxyFileDescriptor exists since Android 8.0
readAccess && // WebDAV doesn't support random write access natively
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
fileInfo.supportsPartial == true // WebDAV server must support random access
) {
logger.fine("Creating RandomAccessCallback for $url")
val factory = globalEntryPoint.randomAccessCallbackWrapperFactory()
val accessor = factory.create(client, url, doc.mimeType, fileInfo, accessScope)
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
} else {
logger.fine("Creating StreamingFileDescriptor for $url")
val factory = globalEntryPoint.streamingFileDescriptorFactory()
val fd = factory.create(client, url, doc.mimeType, accessScope) { transferred ->
// called when transfer is finished
val now = System.currentTimeMillis()
if (!readAccess /* write access */) {
// write access, update file size
documentDao.update(doc.copy(size = transferred, lastModified = now))
}
actor.notifyFolderChanged(doc.parentId)
}
if (readAccess)
fd.download()
else
fd.upload()
}
}
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
if (connectivityManager.isActiveNetworkMetered)
// don't download the large images just to create a thumbnail on metered networks
return null
if (signal == null) {
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
return null
}
val accessScope = CoroutineScope(SupervisorJob())
signal.setOnCancelListener {
logger.fine("Cancelling thumbnail generation for $documentId")
accessScope.cancel()
}
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val docCacheKey = doc.cacheKey()
if (docCacheKey == null) {
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
return null
}
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
// create thumbnail
val job = accessScope.async {
withTimeout(THUMBNAIL_TIMEOUT_MS) {
actor.httpClient(doc.mountId, logBody = false).use { client ->
val url = doc.toHttpUrl(db)
val dav = DavResource(client.okHttpClient, url)
var result: ByteArray? = null
runInterruptible(ioDispatcher) {
dav.get("image/*", null) { response ->
response.body.byteStream().use { data ->
BitmapFactory.decodeStream(data)?.let { bitmap ->
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
val baos = ByteArrayOutputStream()
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
result = baos.toByteArray()
}
}
}
}
result
}
}
}
try {
runBlocking {
job.await()
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
null
}
}
if (thumbFile != null)
return AssetFileDescriptor(
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
0, thumbFile.length()
)
return null
}
/**
* Acts on behalf of [DavDocumentsProvider].
*
* Encapsulates functionality to make it easily testable without generating lots of
* DocumentProviders during the tests.
*
* By containing the actual implementation logic of [DavDocumentsProvider], it adds a layer of separation
* to make the methods of [DavDocumentsProvider] more easily testable.
* [DavDocumentsProvider]s methods should do nothing more, but to call [DavDocumentsActor]s methods.
*/
class DavDocumentsActor @AssistedInject constructor(
@Assisted private val cookieStores: MutableMap<Long, CookieJar>,
@Assisted private val credentialsStore: CredentialsStore,
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
@AssistedFactory
interface Factory {
fun create(cookieStore: MutableMap<Long, CookieJar>, credentialsStore: CredentialsStore): DavDocumentsActor
}
private val authority = context.getString(R.string.webdav_authority)
private val documentDao = db.webDavDocumentDao()
/**
* Finds children of given parent [WebDavDocument]. After querying, it
* updates existing children, adds new ones or removes deleted ones.
*
* There must never be more than one running instance per [parent]!
*
* @param parent folder to search for children
*/
internal suspend fun queryChildren(parent: WebDavDocument) {
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
val newChildrenList = hashMapOf<String, WebDavDocument>()
val parentUrl = parent.toHttpUrl(db)
httpClient(parent.mountId).use { client ->
val folder = DavCollection(client.okHttpClient, parentUrl)
try {
runInterruptible(ioDispatcher) {
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
logger.fine("$relation $response")
val resource: WebDavDocument =
when (relation) {
Response.HrefRelation.SELF -> // it's about the parent
parent
Response.HrefRelation.MEMBER -> // it's about a member
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
else -> {
// we didn't request this; log a warning and ignore it
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
return@propfind
}
}
val updatedResource = resource.copy(
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
?: resource.isDirectory,
displayName = response[DisplayName::class.java]?.displayName,
mimeType = response[GetContentType::class.java]?.type,
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
size = response[GetContentLength::class.java]?.contentLength,
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
)
if (resource == parent)
documentDao.update(updatedResource)
else {
documentDao.insertOrUpdate(updatedResource)
newChildrenList[resource.name] = updatedResource
}
// remove resource from known child nodes, because not found on server
oldChildren.remove(resource.name)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't query children", e)
}
}
// Delete child nodes which were not rediscovered (deleted serverside)
for ((_, oldChild) in oldChildren)
documentDao.delete(oldChild)
}
// helpers
/**
* Creates a HTTP client that can be used to access resources in the given mount.
*
* @param mountId ID of the mount to access
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
*/
internal fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient {
val builder = httpClientBuilder.get()
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
.setCookieStore(
cookieStores.getOrPut(mountId) { MemoryCookieStore() }
)
credentialsStore.getCredentials(mountId)?.let { credentials ->
builder.authenticate(host = null, getCredentials = { credentials })
}
return builder.build()
}
internal fun notifyFolderChanged(parentDocumentId: Long?) {
if (parentDocumentId != null)
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null)
}
internal fun notifyFolderChanged(parentDocumentId: String) {
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null)
}
}
private fun HttpException.throwForDocumentProvider(ignorePreconditionFailed: Boolean = false) {
when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED -> {
if (Build.VERSION.SDK_INT >= 26) {
// TODO edit mount
val intent = Intent(ourContext, WebdavMountsActivity::class.java)
throw AuthenticationRequiredException(
this,
TaskStackBuilder.create(ourContext)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
}
}
HttpURLConnection.HTTP_NOT_FOUND ->
throw FileNotFoundException()
HttpURLConnection.HTTP_PRECON_FAILED ->
if (ignorePreconditionFailed)
return
}
// re-throw
throw this
}
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?) =
entryPoint.openDocumentThumbnailOperation().invoke(documentId, sizeHint, signal)
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.MemoryCookieStore
import okhttp3.CookieJar
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Inject
import javax.inject.Provider
class DavHttpClientBuilder @Inject constructor(
private val credentialsStore: CredentialsStore,
private val httpClientBuilder: Provider<HttpClient.Builder>,
) {
/**
* Creates an HTTP client that can be used to access resources in the given mount.
*
* @param mountId ID of the mount to access
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
*/
fun build(mountId: Long, logBody: Boolean = true): HttpClient {
val cookieStore = cookieStores.getOrPut(mountId) {
MemoryCookieStore()
}
val builder = httpClientBuilder.get()
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
.setCookieStore(cookieStore)
credentialsStore.getCredentials(mountId)?.let { credentials ->
builder.authenticate(host = null, getCredentials = { credentials })
}
return builder.build()
}
companion object {
/** in-memory cookie stores (one per mount ID) that are available until the content
* provider (= process) is terminated */
private val cookieStores = mutableMapOf<Long, CookieJar>()
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
import android.app.AuthenticationRequiredException
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract.buildChildDocumentsUri
import android.provider.DocumentsContract.buildRootsUri
import android.webkit.MimeTypeMap
import androidx.core.app.TaskStackBuilder
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
import java.io.FileNotFoundException
import java.net.HttpURLConnection
object DocumentProviderUtils {
const val MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS = 5
internal fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
val safeName = displayName.filterNot { it.isISOControl() }
if (appendNumber != 0) {
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
if (extension != null) {
val baseName = safeName.removeSuffix(".$extension")
return "${baseName}_$appendNumber.$extension"
} else
return "${safeName}_$appendNumber"
} else
return safeName
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) {
if (parentDocumentId != null)
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId.toString()
),
null
)
}
internal fun notifyFolderChanged(context: Context, parentDocumentId: String) {
context.contentResolver.notifyChange(
buildChildDocumentsUri(
context.getString(R.string.webdav_authority),
parentDocumentId
),
null
)
}
internal fun notifyMountsChanged(context: Context) {
context.contentResolver.notifyChange(
buildRootsUri(context.getString(R.string.webdav_authority)),
null)
}
}
internal fun HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) {
when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED -> {
if (Build.VERSION.SDK_INT >= 26) {
val intent = Intent(context, WebdavMountsActivity::class.java)
throw AuthenticationRequiredException(
this,
TaskStackBuilder.create(context)
.addNextIntentWithParentStack(intent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
}
}
HttpURLConnection.HTTP_NOT_FOUND ->
throw FileNotFoundException()
HttpURLConnection.HTTP_PRECON_FAILED ->
if (ignorePreconditionFailed)
return
}
// re-throw
throw this
}

View File

@@ -5,20 +5,20 @@
package at.bitfire.davdroid.webdav
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelFileDescriptor
import android.os.ProxyFileDescriptorCallback
import android.os.storage.StorageManager
import android.system.ErrnoException
import android.system.OsConstants
import android.text.format.Formatter
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.util.DavUtils
import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
@@ -40,17 +40,17 @@ import okhttp3.MediaType
import java.io.InterruptedIOException
import java.net.HttpURLConnection
import java.util.logging.Logger
import javax.annotation.WillClose
@RequiresApi(26)
class RandomAccessCallback @AssistedInject constructor(
@Assisted val httpClient: HttpClient,
@Assisted val url: HttpUrl,
@Assisted val mimeType: MediaType?,
@Assisted @WillClose private val httpClient: HttpClient,
@Assisted private val url: HttpUrl,
@Assisted private val mimeType: MediaType?,
@Assisted headResponse: HeadResponse,
@Assisted private val externalScope: CoroutineScope,
@ApplicationContext val context: Context,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry
@ApplicationContext private val context: Context,
private val logger: Logger
): ProxyFileDescriptorCallback() {
companion object {
@@ -77,26 +77,34 @@ class RandomAccessCallback @AssistedInject constructor(
private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size")
private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified")
private val notificationManager = NotificationManagerCompat.from(context)
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentTitle(context.getString(R.string.webdav_notification_access))
.setContentText(dav.fileName())
.setSubText(Formatter.formatFileSize(context, fileSize))
.setSmallIcon(R.drawable.ic_storage_notify)
.setOngoing(true)
private val notificationTag = url.toString()
private val pageLoader = PageLoader(externalScope)
private val pageCache: LoadingCache<PageIdentifier, ByteArray> = CacheBuilder.newBuilder()
.maximumSize(10) // don't cache more than 10 entries (MAX_PAGE_SIZE each)
.softValues() // use SoftReference for the page contents so they will be garbage collected if memory is needed
.softValues() // use SoftReference for the page contents so they will be garbage-collected if memory is needed
.build(pageLoader) // fetch actual content using pageLoader
/** This thread will be used for I/O operations like [onRead]. Using the main looper would cause ANRs. */
private val ioThread = HandlerThread("WebDAV I/O").apply {
start()
}
private val pagingReader = PagingReader(fileSize, MAX_PAGE_SIZE, pageCache)
// file descriptor
/**
* Returns a random-access file descriptor that can be used in a DocumentsProvider.
*/
fun fileDescriptor(): ParcelFileDescriptor {
val storageManager = context.getSystemService<StorageManager>()!!
val ioHandler = Handler(ioThread.looper)
return storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, this, ioHandler)
}
// implementation
override fun onFsync() { /* not used */ }
override fun onGetSize(): Long = runBlockingFd("onGetFileSize") {
@@ -117,7 +125,10 @@ class RandomAccessCallback @AssistedInject constructor(
override fun onRelease() {
logger.fine("onRelease")
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
// free resources
ioThread.quitSafely()
httpClient.close()
}
@@ -185,16 +196,6 @@ class RandomAccessCallback @AssistedInject constructor(
val size = key.size
logger.fine("Loading page $url $offset/$size")
// update notification
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, tag = notificationTag) {
val progress =
if (fileSize == 0L) // avoid division by zero
100
else
(offset * 100 / fileSize).toInt()
notification.setProgress(100, progress, false).build()
}
val ifMatch: Headers =
documentState.eTag?.let { eTag ->
Headers.headersOf("If-Match", "\"$eTag\"")

View File

@@ -4,183 +4,85 @@
package at.bitfire.davdroid.webdav
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.ProxyFileDescriptorCallback
import android.system.ErrnoException
import android.system.OsConstants
import androidx.annotation.RequiresApi
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper.Companion.TIMEOUT_INTERVAL
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import okhttp3.HttpUrl
import okhttp3.MediaType
import ru.nsk.kstatemachine.event.Event
import ru.nsk.kstatemachine.state.State
import ru.nsk.kstatemachine.state.finalState
import ru.nsk.kstatemachine.state.initialState
import ru.nsk.kstatemachine.state.onEntry
import ru.nsk.kstatemachine.state.onExit
import ru.nsk.kstatemachine.state.onFinished
import ru.nsk.kstatemachine.state.state
import ru.nsk.kstatemachine.state.transitionOn
import ru.nsk.kstatemachine.statemachine.StateMachine
import ru.nsk.kstatemachine.statemachine.createStdLibStateMachine
import ru.nsk.kstatemachine.statemachine.processEventBlocking
import java.util.Timer
import java.util.TimerTask
import java.util.logging.Logger
import kotlin.concurrent.schedule
/**
* (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
* Use this wrapper to ensure that all memory is released as soon as [onRelease] is called.
*
* - (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
* the given callback is registered in `com.android.internal.os.AppFuseMount` (which adds it to
* a [Map]), but is not unregistered anymore. So it stays in the memory until the whole mount
* is unloaded. See https://issuetracker.google.com/issues/208788568
* is unloaded. See https://issuetracker.google.com/issues/208788568.
* - (2024/08/24) [Fixed in Android.](https://android.googlesource.com/platform/frameworks/base/+/e7dbf78143ba083af7a8ecadd839a9dbf6f01655%5E%21/#F0)
*
* Use this wrapper to
* **All fields of objects of this class must be set to `null` when [onRelease] is called!**
* Otherwise they will leak memory.
*
* - ensure that all memory is released as soon as [onRelease] is called,
* - provide timeout functionality: [RandomAccessCallback] will be closed when not
*
* used for more than [TIMEOUT_INTERVAL] ms and re-created when necessary.
*
* @param httpClient HTTP client [RandomAccessCallbackWrapper] is responsible to close it
* @param httpClient HTTP client ([RandomAccessCallbackWrapper] is responsible to close it)
*/
@RequiresApi(Build.VERSION_CODES.O)
@RequiresApi(26)
class RandomAccessCallbackWrapper @AssistedInject constructor(
@Assisted private val httpClient: HttpClient,
@Assisted private val url: HttpUrl,
@Assisted private val mimeType: MediaType?,
@Assisted private val headResponse: HeadResponse,
@Assisted private val externalScope: CoroutineScope,
private val logger: Logger,
private val callbackFactory: RandomAccessCallback.Factory
@Assisted httpClient: HttpClient,
@Assisted url: HttpUrl,
@Assisted mimeType: MediaType?,
@Assisted headResponse: HeadResponse,
@Assisted externalScope: CoroutineScope,
callbackFactory: RandomAccessCallback.Factory
): ProxyFileDescriptorCallback() {
companion object {
const val TIMEOUT_INTERVAL = 15000L
}
@AssistedFactory
interface Factory {
fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallbackWrapper
}
sealed class Events {
object Transfer : Event
object NowIdle : Event
object GoStandby : Event
object Close : Event
}
/* We don't use a sealed class for states here because the states would then be singletons, while we can have
multiple instances of the state machine (which require multiple instances of the states, too). */
private val machine = createStdLibStateMachine {
lateinit var activeIdleState: State
lateinit var activeTransferringState: State
lateinit var standbyState: State
lateinit var closedState: State
initialState("active") {
onEntry {
_callback = callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope)
}
onExit {
_callback?.onRelease()
_callback = null
}
// callback reference
transitionOn<Events.GoStandby> { targetState = { standbyState } }
transitionOn<Events.Close> { targetState = { closedState } }
/**
* This field is initialized with a strong reference to the callback. It is cleared when
* [onRelease] is called so that the garbage collector can remove the actual [RandomAccessCallback].
*/
private var callbackRef: RandomAccessCallback? =
callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope)
// active has two nested states: transferring (I/O running) and idle (starts timeout timer)
activeIdleState = initialState("idle") {
val timer: Timer = Timer(true)
var timeout: TimerTask? = null
onEntry {
timeout = timer.schedule(TIMEOUT_INTERVAL) {
machine.processEventBlocking(Events.GoStandby)
}
}
onExit {
timeout?.cancel()
timeout = null
}
onFinished {
timer.cancel()
}
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
}
activeTransferringState = state("transferring") {
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
}
}
standbyState = state("standby") {
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
transitionOn<Events.Close> { targetState = { closedState } }
}
closedState = finalState("closed")
onFinished {
shutdown()
}
logger = StateMachine.Logger { message ->
this@RandomAccessCallbackWrapper.logger.finer(message())
}
}
private val workerThread = HandlerThread(javaClass.simpleName).apply { start() }
val workerHandler: Handler = Handler(workerThread.looper)
private var _callback: RandomAccessCallback? = null
fun<T> requireCallback(block: (callback: RandomAccessCallback) -> T): T {
machine.processEventBlocking(Events.Transfer)
try {
return block(_callback ?: throw IllegalStateException())
} finally {
machine.processEventBlocking(Events.NowIdle)
}
}
private fun requireCallback(functionName: String): RandomAccessCallback =
callbackRef ?: throw ErrnoException(functionName, OsConstants.EBADF)
/// states ///
// non-interface delegates
@Synchronized
private fun shutdown() {
httpClient.close()
workerThread.quit()
}
fun fileDescriptor() =
requireCallback("fileDescriptor").fileDescriptor()
/// delegating implementation of ProxyFileDescriptorCallback ///
// delegating implementation of ProxyFileDescriptorCallback
@Synchronized
override fun onFsync() { /* not used */ }
@Synchronized
override fun onGetSize() =
requireCallback { it.onGetSize() }
requireCallback("onGetSize").onGetSize()
@Synchronized
override fun onRead(offset: Long, size: Int, data: ByteArray) =
requireCallback { it.onRead(offset, size, data) }
requireCallback("onRead").onRead(offset, size, data)
@Synchronized
override fun onWrite(offset: Long, size: Int, data: ByteArray) =
requireCallback { it.onWrite(offset, size, data) }
requireCallback("onWrite").onWrite(offset, size, data)
@Synchronized
override fun onRelease() {
machine.processEventBlocking(Events.Close)
requireCallback("onRelease").onRelease()
// remove reference to allow garbage collection
callbackRef = null
}
}

View File

@@ -4,23 +4,15 @@
package at.bitfire.davdroid.webdav
import android.content.Context
import android.os.ParcelFileDescriptor
import android.text.format.Formatter
import androidx.annotation.WorkerThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.util.DavUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -32,27 +24,21 @@ import okio.BufferedSink
import java.io.IOException
import java.util.logging.Level
import java.util.logging.Logger
import javax.annotation.WillClose
/**
* @param client HTTP client [StreamingFileDescriptor] is responsible to close it
* @param client HTTP client ([StreamingFileDescriptor] is responsible to close it)
*/
class StreamingFileDescriptor @AssistedInject constructor(
@Assisted private val client: HttpClient,
@Assisted @WillClose private val client: HttpClient,
@Assisted private val url: HttpUrl,
@Assisted private val mimeType: MediaType?,
@Assisted private val externalScope: CoroutineScope,
@Assisted private val finishedCallback: OnSuccessCallback,
@ApplicationContext private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry
private val logger: Logger
) {
companion object {
/** 1 MB transfer buffer */
private const val BUFFER_SIZE = 1024*1024
}
@AssistedFactory
interface Factory {
fun create(client: HttpClient, url: HttpUrl, mimeType: MediaType?, externalScope: CoroutineScope, finishedCallback: OnSuccessCallback): StreamingFileDescriptor
@@ -61,28 +47,21 @@ class StreamingFileDescriptor @AssistedInject constructor(
val dav = DavResource(client.okHttpClient, url)
var transferred: Long = 0
private val notificationManager = NotificationManagerCompat.from(context)
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentText(dav.fileName())
.setSmallIcon(R.drawable.ic_storage_notify)
.setOngoing(true)
val notificationTag = url.toString()
fun download() = doStreaming(false)
fun upload() = doStreaming(true)
private fun doStreaming(upload: Boolean): ParcelFileDescriptor {
val (readFd, writeFd) = ParcelFileDescriptor.createReliablePipe()
externalScope.launch(ioDispatcher) {
var success = false
externalScope.launch {
try {
if (upload)
uploadNow(readFd)
else
downloadNow(writeFd)
success = true
} catch (e: HttpException) {
logger.log(Level.WARNING, "HTTP error when opening remote file", e)
writeFd.closeWithError("${e.code} ${e.message}")
@@ -90,17 +69,15 @@ class StreamingFileDescriptor @AssistedInject constructor(
logger.log(Level.INFO, "Couldn't serve file (not necessarily an error)", e)
writeFd.closeWithError(e.message)
} finally {
// close pipe
try {
readFd.close()
writeFd.close()
} catch (_: IOException) {}
client.close()
finishedCallback.onFinished(transferred, success)
}
try {
readFd.close()
writeFd.close()
} catch (_: IOException) {}
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
finishedCallback.onSuccess(transferred)
}
return if (upload)
@@ -109,49 +86,20 @@ class StreamingFileDescriptor @AssistedInject constructor(
readFd
}
@WorkerThread
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible {
/**
* Downloads a WebDAV resource.
*
* @param writeFd destination file descriptor (could for instance represent a local file)
*/
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) {
dav.get(DavUtils.acceptAnything(preferred = mimeType), null) { response ->
response.body.use { body ->
if (response.isSuccessful) {
val length = body.contentLength()
notification.setContentTitle(context.getString(R.string.webdav_notification_download))
if (length == -1L)
// unknown file size, show notification now (no updates on progress)
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
notification
.setProgress(100, 0, true)
.build()
}
else
// known file size
notification.setSubText(Formatter.formatFileSize(context, length))
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { output ->
val buffer = ByteArray(BUFFER_SIZE)
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { destination ->
body.byteStream().use { source ->
// read first chunk
var bytes = source.read(buffer)
while (bytes != -1) {
// update notification (if file size is known)
if (length > 0)
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
val progress = (transferred*100/length).toInt()
notification
.setProgress(100, progress, false)
.build()
}
// write chunk
output.write(buffer, 0, bytes)
transferred += bytes
// read next chunk
bytes = source.read(buffer)
}
logger.finer("Downloaded $transferred byte(s) from $url")
transferred += source.copyTo(destination)
}
logger.finer("Downloaded $transferred byte(s) from $url")
}
} else
@@ -160,31 +108,18 @@ class StreamingFileDescriptor @AssistedInject constructor(
}
}
@WorkerThread
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible {
/**
* Uploads a WebDAV resource.
*
* @param readFd source file descriptor (could for instance represent a local file)
*/
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) {
val body = object: RequestBody() {
override fun contentType(): MediaType? = mimeType
override fun isOneShot() = true
override fun writeTo(sink: BufferedSink) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
notification
.setContentTitle(context.getString(R.string.webdav_notification_upload))
.build()
}
ParcelFileDescriptor.AutoCloseInputStream(readFd).use { input ->
val buffer = ByteArray(BUFFER_SIZE)
// read first chunk
var size = input.read(buffer)
while (size != -1) {
// write chunk
sink.write(buffer, 0, size)
transferred += size
// read next chunk
size = input.read(buffer)
}
transferred += input.copyTo(sink.outputStream())
logger.finer("Uploaded $transferred byte(s) to $url")
}
}
@@ -196,7 +131,7 @@ class StreamingFileDescriptor @AssistedInject constructor(
fun interface OnSuccessCallback {
fun onSuccess(transferred: Long)
fun onFinished(transferred: Long, success: Boolean)
}
}

View File

@@ -50,12 +50,13 @@ class WebDavMountRepository @Inject constructor(
displayName: String,
credentials: Credentials?
): Boolean {
if (!hasWebDav(url, credentials))
val webdavUrl = hasWebDav(url, credentials)
if (webdavUrl == null)
return false
// create in database
val mount = WebDavMount(
url = url,
url = webdavUrl,
name = displayName
)
val id = db.webDavMountDao().insert(mount)
@@ -65,7 +66,7 @@ class WebDavMountRepository @Inject constructor(
credentialsStore.setCredentials(id, credentials)
// notify content URI listeners
DavDocumentsProvider.notifyMountsChanged(context)
DocumentProviderUtils.notifyMountsChanged(context)
return true
}
@@ -78,7 +79,7 @@ class WebDavMountRepository @Inject constructor(
CredentialsStore(context).setCredentials(mount.id, null)
// notify content URI listeners
DavDocumentsProvider.notifyMountsChanged(context)
DocumentProviderUtils.notifyMountsChanged(context)
}
fun getAllFlow() = mountDao.getAllFlow()
@@ -110,11 +111,19 @@ class WebDavMountRepository @Inject constructor(
// helpers
/**
* Checks whether WebDAV is supported at given URL with given credentials
* and returns the resulting if following a few redirects.
*
* @param url The URL to check
* @param credentials The credentials to use for the request
* @return The URL at which WebDAV support was found
*/
@VisibleForTesting
internal suspend fun hasWebDav(
url: HttpUrl,
credentials: Credentials?
): Boolean = withContext(ioDispatcher) {
): HttpUrl? = withContext(ioDispatcher) {
val validVersions = arrayOf("1", "2", "3")
val builder = httpClientBuilder.get()
@@ -125,18 +134,18 @@ class WebDavMountRepository @Inject constructor(
getCredentials = { credentials }
)
var supported = false
var webdavUrl: HttpUrl? = null
builder.build().use { httpClient ->
val dav = DavResource(httpClient.okHttpClient, url)
runInterruptible {
dav.options { davCapabilities, _ ->
dav.options(followRedirects = true) { davCapabilities, response ->
if (davCapabilities.any { it in validVersions })
supported = true
webdavUrl = dav.location
}
}
}
supported
webdavUrl
}
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav
import dagger.hilt.DefineComponent
import dagger.hilt.components.SingletonComponent
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class WebdavScoped
@WebdavScoped
@DefineComponent(parent = SingletonComponent::class)
interface WebdavComponent
@DefineComponent.Builder
interface WebdavComponentBuilder {
fun build(): WebdavComponent
}

View File

@@ -11,7 +11,6 @@ import android.os.storage.StorageManager
import android.text.format.Formatter
import androidx.core.content.getSystemService
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.webdav.WebdavScoped
import com.google.common.hash.Hashing
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
@@ -21,7 +20,6 @@ import javax.inject.Inject
/**
* Simple disk cache for image thumbnails.
*/
@WebdavScoped
class ThumbnailCache @Inject constructor(
@ApplicationContext context: Context,
logger: Logger

View File

@@ -0,0 +1,77 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class CopyDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
val name = srcDoc.name
if (srcDoc.mountId != dstFolder.mountId)
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
httpClientBuilder.build(srcDoc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
.addPathSegment(name)
.build()
try {
runInterruptible(ioDispatcher) {
dav.copy(dstUrl, false) {
// successfully copied
}
}
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
val dstDocId = documentDao.insertOrReplace(
WebDavDocument(
mountId = dstFolder.mountId,
parentId = dstFolder.id,
name = name,
isDirectory = srcDoc.isDirectory,
displayName = srcDoc.displayName,
mimeType = srcDoc.mimeType,
size = srcDoc.size
)
).toString()
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
/* return */ dstDocId
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.provider.DocumentsContract.Document
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class CreateDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
val createDirectory = mimeType == Document.MIME_TYPE_DIR
var docId: Long?
httpClientBuilder.build(parent.mountId).use { client ->
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
val newName = displayNameToMemberName(displayName, attempt)
val parentUrl = parent.toHttpUrl(db)
val newLocation = parentUrl.newBuilder()
.addPathSegment(newName)
.build()
val doc = DavResource(client.okHttpClient, newLocation)
try {
runInterruptible(ioDispatcher) {
if (createDirectory)
doc.mkCol(null) {
// directory successfully created
}
else
doc.put(RequestBody.EMPTY, ifNoneMatch = true) {
// document successfully created
}
}
docId = documentDao.insertOrReplace(
WebDavDocument(
mountId = parent.mountId,
parentId = parent.id,
name = newName,
isDirectory = createDirectory,
mimeType = mimeType.toMediaTypeOrNull(),
eTag = null,
lastModified = null,
size = if (createDirectory) null else 0
)
)
DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId)
return@runBlocking docId.toString()
} catch (e: HttpException) {
e.throwForDocumentProvider(context, ignorePreconditionFailed = true)
}
}
}
null
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class DeleteDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String) = runBlocking {
logger.fine("WebDAV removeDocument $documentId")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
httpClientBuilder.build(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.delete {
// successfully deleted
}
}
logger.fine("Successfully removed")
documentDao.delete(doc)
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class IsChildDocumentOperation @Inject constructor(
db: AppDatabase,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(parentDocumentId: String, documentId: String): Boolean {
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
while (iter != null) {
val currentParentId = iter.parentId
if (currentParentId == parent.id)
return true
iter = if (currentParentId != null)
documentDao.get(currentParentId)
else
null
}
return false
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.throwForDocumentProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class MoveDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
if (doc.mountId != dstParent.mountId)
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
val newLocation = dstParent.toHttpUrl(db).newBuilder()
.addPathSegment(doc.name)
.build()
httpClientBuilder.build(doc.mountId).use { client ->
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
try {
runInterruptible(ioDispatcher) {
dav.move(newLocation, false) {
// successfully moved
}
}
documentDao.update(doc.copy(parentId = dstParent.id))
DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId)
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
} catch (e: HttpException) {
e.throwForDocumentProvider(context)
}
}
doc.id.toString()
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.os.Build
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentProviderUtils
import at.bitfire.davdroid.webdav.HeadResponse
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper
import at.bitfire.davdroid.webdav.StreamingFileDescriptor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class OpenDocumentOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val randomAccessCallbackWrapperFactory: RandomAccessCallbackWrapper.Factory,
private val streamingFileDescriptorFactory: StreamingFileDescriptor.Factory
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
logger.fine("WebDAV openDocument $documentId $mode $signal")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val url = doc.toHttpUrl(db)
val client = httpClientBuilder.build(doc.mountId, logBody = false)
val readOnlyMode = when (mode) {
"r" -> true
"w", "wt" -> false
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
}
val accessScope = CoroutineScope(SupervisorJob())
signal?.setOnCancelListener {
logger.fine("Cancelling WebDAV access to $url")
accessScope.cancel()
}
val fileInfo = accessScope.async {
headRequest(client, url)
}.await()
logger.fine("Received file info: $fileInfo")
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
return@runBlocking if (
androidSupportsRandomAccess &&
readOnlyMode && // WebDAV doesn't support random write access (natively)
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine when the document changes during access
fileInfo.supportsPartial == true // WebDAV server must advertise random access
) {
logger.fine("Creating RandomAccessCallback for $url")
val accessor = randomAccessCallbackWrapperFactory.create(client, url, doc.mimeType, fileInfo, accessScope)
accessor.fileDescriptor()
} else {
logger.fine("Creating StreamingFileDescriptor for $url")
val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred, success ->
// called when transfer is finished
if (!success)
return@create
val now = System.currentTimeMillis()
if (!readOnlyMode /* write access */) {
// write access, update file size
documentDao.update(doc.copy(size = transferred, lastModified = now))
}
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
}
if (readOnlyMode)
fd.download()
else
fd.upload()
}
}
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
HeadResponse.fromUrl(client, url)
}
companion object {
/** openProxyFileDescriptor (required for random access) exists since Android 8.0 */
val androidSupportsRandomAccess = Build.VERSION.SDK_INT >= 26
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Point
import android.media.ThumbnailUtils
import android.net.ConnectivityManager
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavResource
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withTimeout
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.use
class OpenDocumentThumbnailOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val thumbnailCache: ThumbnailCache
) {
private val documentDao = db.webDavDocumentDao()
operator fun invoke(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
// don't download the large images just to create a thumbnail on metered networks
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
if (connectivityManager.isActiveNetworkMetered)
return null
if (signal == null) {
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
return null
}
val accessScope = CoroutineScope(SupervisorJob())
signal.setOnCancelListener {
logger.fine("Cancelling thumbnail generation for $documentId")
accessScope.cancel()
}
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val docCacheKey = doc.cacheKey()
if (docCacheKey == null) {
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
return null
}
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
// create thumbnail
val job = accessScope.async {
withTimeout(THUMBNAIL_TIMEOUT_MS) {
httpClientBuilder.build(doc.mountId, logBody = false).use { client ->
val url = doc.toHttpUrl(db)
val dav = DavResource(client.okHttpClient, url)
var result: ByteArray? = null
runInterruptible(ioDispatcher) {
dav.get("image/*", null) { response ->
response.body.byteStream().use { data ->
BitmapFactory.decodeStream(data)?.let { bitmap ->
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
val baos = ByteArrayOutputStream()
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
result = baos.toByteArray()
}
}
}
}
result
}
}
}
try {
runBlocking {
job.await()
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
null
}
}
if (thumbFile != null)
return AssetFileDescriptor(
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
0, thumbFile.length()
)
return null
}
companion object {
const val THUMBNAIL_TIMEOUT_MS = 15000L
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.buildChildDocumentsUri
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GetContentLength
import at.bitfire.dav4jvm.property.webdav.GetContentType
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.dav4jvm.property.webdav.GetLastModified
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavDocumentDao
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
import at.bitfire.davdroid.webdav.DocumentSortByMapper
import at.bitfire.davdroid.webdav.DocumentsCursor
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import java.io.FileNotFoundException
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class QueryChildDocumentsOperation @Inject constructor(
@ApplicationContext private val context: Context,
private val db: AppDatabase,
private val documentSortByMapper: Lazy<DocumentSortByMapper>,
private val httpClientBuilder: DavHttpClientBuilder,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger
) {
private val authority = context.getString(R.string.webdav_authority)
private val documentDao = db.webDavDocumentDao()
private val backgroundScope = CoroutineScope(SupervisorJob())
operator fun invoke(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
synchronized(QueryChildDocumentsOperation::class.java) {
queryChildDocuments(parentDocumentId, projection, sortOrder)
}
private fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?
): DocumentsCursor {
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
val parentId = parentDocumentId.toLong()
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
val columns = projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED
)
// Register watcher
val result = DocumentsCursor(columns)
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
result.setNotificationUri(context.contentResolver, notificationUri)
// Dispatch worker querying for the children and keep track of it
val running = runningQueryChildren.getOrPut(parentId) {
backgroundScope.launch {
queryChildren(parent)
// Once the query is done, set query as finished (not running)
runningQueryChildren[parentId] = false
// .. and notify - effectively calling this method again
context.contentResolver.notifyChange(notificationUri, null)
}
true
}
if (running) // worker still running
result.loading = true
else // remove worker from list if done
runningQueryChildren.remove(parentId)
// Prepare SORT BY clause
val mapper = documentSortByMapper.get()
val sqlSortBy = if (sortOrder != null)
mapper.mapContentProviderToSql(sortOrder)
else
WebDavDocumentDao.DEFAULT_ORDER
// Regardless of whether the worker is done, return the children we already have
val children = documentDao.getChildren(parentId, sqlSortBy)
for (child in children) {
val bundle = child.toBundle(parent)
result.addRow(bundle)
}
return result
}
/**
* Finds children of given parent [WebDavDocument]. After querying, it
* updates existing children, adds new ones or removes deleted ones.
*
* There must never be more than one running instance per [parent]!
*
* @param parent folder to search for children
*/
internal suspend fun queryChildren(parent: WebDavDocument) {
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
val newChildrenList = hashMapOf<String, WebDavDocument>()
val parentUrl = parent.toHttpUrl(db)
httpClientBuilder.build(parent.mountId).use { client ->
val folder = DavCollection(client.okHttpClient, parentUrl)
try {
runInterruptible(ioDispatcher) {
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
logger.fine("$relation $response")
val resource: WebDavDocument =
when (relation) {
Response.HrefRelation.SELF -> // it's about the parent
parent
Response.HrefRelation.MEMBER -> // it's about a member
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
else -> {
// we didn't request this; log a warning and ignore it
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
return@propfind
}
}
val updatedResource = resource.copy(
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
?: resource.isDirectory,
displayName = response[DisplayName::class.java]?.displayName,
mimeType = response[GetContentType::class.java]?.type,
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.eTag,
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
size = response[GetContentLength::class.java]?.contentLength,
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
)
if (resource == parent)
documentDao.update(updatedResource)
else {
documentDao.insertOrUpdate(updatedResource)
newChildrenList[resource.name] = updatedResource
}
// remove resource from known child nodes, because not found on server
oldChildren.remove(resource.name)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't query children", e)
}
}
// Delete child nodes which were not rediscovered (deleted serverside)
for ((_, oldChild) in oldChildren)
documentDao.delete(oldChild)
}
companion object {
val DAV_FILE_FIELDS = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
GetETag.NAME,
GetContentType.NAME,
GetContentLength.NAME,
GetLastModified.NAME,
QuotaAvailableBytes.NAME,
QuotaUsedBytes.NAME,
)
/** List of currently active [queryChildDocuments] runners.
*
* Key: document ID (directory) for which children are listed.
* Value: whether the runner is still running (*true*) or has already finished (*false*).
*/
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.database.Cursor
import android.provider.DocumentsContract.Document
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.webdav.DocumentsCursor
import kotlinx.coroutines.runBlocking
import java.io.FileNotFoundException
import java.util.logging.Logger
import javax.inject.Inject
class QueryDocumentOperation @Inject constructor(
db: AppDatabase,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
private val mountDao = db.webDavMountDao()
operator fun invoke(documentId: String, projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
val parent = doc.parentId?.let { parentId ->
documentDao.get(parentId)
}
return DocumentsCursor(projection ?: arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_ICON,
Document.COLUMN_SUMMARY
)).apply {
val bundle = doc.toBundle(parent)
logger.fine("queryDocument($documentId) = $bundle")
// override display names of root documents
if (parent == null) {
val mount = runBlocking { mountDao.getById(doc.mountId) }
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
}
addRow(bundle)
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.webdav.operation
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.provider.DocumentsContract.Root
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.runBlocking
import java.util.logging.Logger
import javax.inject.Inject
class QueryRootsOperation @Inject constructor(
@ApplicationContext private val context: Context,
db: AppDatabase,
private val logger: Logger
) {
private val documentDao = db.webDavDocumentDao()
private val mountDao = db.webDavMountDao()
operator fun invoke(projection: Array<out String>?): Cursor {
logger.fine("WebDAV queryRoots")
val roots = MatrixCursor(projection ?: arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_FLAGS,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_SUMMARY
))
runBlocking {
for (mount in mountDao.getAll()) {
val rootDocument = documentDao.getOrCreateRoot(mount)
logger.info("Root ID: $rootDocument")
roots.newRow().apply {
add(Root.COLUMN_ROOT_ID, mount.id)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, context.getString(R.string.webdav_provider_root_title))
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
add(Root.COLUMN_SUMMARY, mount.name)
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
val quotaAvailable = rootDocument.quotaAvailable
if (quotaAvailable != null)
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
val quotaUsed = rootDocument.quotaUsed
if (quotaAvailable != null && quotaUsed != null)
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
}
}
}
return roots
}
}

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