Compare commits

..

113 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
Ricki Hirner
71f3558b4b Fetch translations from Transifex 2025-07-21 10:50:15 +02:00
Ricki Hirner
22d933096f Bump version to 4.5.2 2025-07-21 10:24:22 +02:00
Arnau Mora
666b707854 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>
2025-07-21 10:03:19 +02:00
Ricki Hirner
39f6b82926 Move Insert/update to DAO (#1587)
* Move homeset insert/update logic from repository to DAO; add thread-safety test

* Rename insertOrUpdateByUrlRememberSync
2025-07-21 09:28:32 +02:00
Sunik Kupfer
b02fd23f0a Bump version to 4.5.2-beta.1 2025-07-17 11:26:40 +02:00
Ricki Hirner
ca56380c29 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
2025-07-17 11:06:04 +02:00
Ricki Hirner
ba9eb1446b Move SyncState to resource package because it's not in the database (#1585) 2025-07-16 12:06:34 +02:00
Ricki Hirner
055599c74f Update cert4android to get 16 kB page size support over Conscrypt 2.5.3 (#1581) 2025-07-15 09:50:39 +02:00
Ricki Hirner
62db3da579 Update Android Gradle Plugin to 8.11.1 and AboutLibraries to 12.2.4 2025-07-14 10:58:22 +02:00
Ricki Hirner
76d8d5acbf Sync davx5 / davx5-ose repos (#1575)
* Add custom certs UI build config field and use it in HttpClient; sync CollectionDao

* Backport max accounts setting
2025-07-10 16:49:07 +02:00
Ricki Hirner
dd294a4b03 Move external URIs from Constants to ExternalUris object (#1574)
* [WIP] Move external URIs from Constants to ExternalUris object

* Update external URIs to use class names or screen name for stat params
2025-07-10 16:14:09 +02:00
Sunik Kupfer
0efe6a7b9b Bump version to 4.5.2-alpha.1
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-07-10 11:49:25 +02:00
Ricki Hirner
405b7abb39 [synctools] Use EventReader / EventWriter (#1573)
* Update synctools. Use Writer instead of ByteArrayOutputStream

* Update synctools
2025-07-10 10:57:45 +02:00
Ricki Hirner
4e2640ca01 Synctools: AndroidEvent companion object moved (#1572)
* Update synctools

* Refactor LocalCalendar to use Hilt

* Update synctools
2025-07-09 16:35:27 +02:00
Sunik Kupfer
904c8ba29b [Sync framework] Disable contacts content change triggered syncs if sync interval set to manual only (#1569)
* Fix lint error

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

* Show manual sync interval setting in UI

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

* Disable contacts content change triggered syncs if set to manual; update kdoc

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

* Update comments and kdoc

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

* Automatically close provider

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

* Explicitly handle special case

* Rename updateAutomaticSync to updateSyncFrameworkSetting; adjust comments

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-09 11:13:54 +02:00
Michael Biebl
62dc73c2a0 Use system defined line separator (#1562)
This fixes the test suite on Windows.
Windows uses CR LF, in contrast to Linux which uses LF. So do not
hard-code the value.
2025-07-07 17:42:24 +02:00
Ricki Hirner
58344099f7 AndroidCalendar refactoring (#1560)
* [WIP] Refactor calendar sync manager to use synctools library

* [WIP] Update synctools

* [WIP] Tests

* Remove test logger module and update calendar color methods

* Fix migrations

* Update libs.versions.toml
2025-07-03 22:12:21 +02:00
Ricki Hirner
b62c7eff0b Update to synctools version that uses explicit parsing / splitting (#1554)
* Update to synctools version that uses explicit parsing / splitting

* Update synctools
2025-07-02 15:33:13 +02:00
Ricki Hirner
12cedd4010 Fetch translations from Transifex 2025-07-02 14:30:57 +02:00
Ricki Hirner
3a0221c749 Update version to 4.5.1 2025-07-02 14:30:07 +02:00
Ricki Hirner
f78e7868e8 Revert "Update to synctools version that uses explicit parsing / splitting (#1554)"
This reverts commit 5dbaedfa60.

Reason: we don't want it in 4.5.1 yet
2025-07-02 14:28:57 +02:00
Ricki Hirner
5dbaedfa60 Update to synctools version that uses explicit parsing / splitting (#1554)
* Update to synctools version that uses explicit parsing / splitting

* Update synctools
2025-07-01 21:52:20 +02:00
Ricki Hirner
6187f92efd Bump version to 4.5.1-rc.2 2025-06-29 16:33:51 +02:00
Ricki Hirner
82ccf6a2f9 Update workflows to use build cache; don't reduce error level for configuration cache problems anymore 2025-06-29 16:23:56 +02:00
Ricki Hirner
0f9c5027d4 Fetch translations from Transifex 2025-06-29 15:42:40 +02:00
Ricki Hirner
7b76df3e70 Bump version to 4.5.1-rc.1 2025-06-29 15:41:36 +02:00
Ricki Hirner
80cfe1013d [CI] Don't generate a new discussion thread for every release
Updates will be posted in major release thread manually
2025-06-29 15:28:23 +02:00
Ricki Hirner
3e3c346019 Update dav4jvm 2025-06-29 14:58:12 +02:00
Ricki Hirner
1773dff8a4 Update synctools for R8 rules 2025-06-27 21:54:14 +02:00
Ricki Hirner
604b0aab98 Bump version to 4.5.1-alpha.1 2025-06-27 17:44:04 +02:00
Ricki Hirner
35cffa603b LocalCalendar: don't subclass AndroidCalendar (#1552)
* [WIP] Refactor LocalEvent to delegate to AndroidEvent

* Move tests

* Use test rules from synctools

* Add null check for content provider client in JtxSyncManagerTest

* Update dependencies, move OkhttpClientTest

* Refactor LocalCalendar to wrap AndroidCalendar

* Update bitfire-synctools to 1a613d5d3c
2025-06-27 17:43:14 +02:00
Ricki Hirner
89c3eacd36 LocalEvent: don't subclass AndroidEvent (#1551)
* [WIP] Refactor LocalEvent to delegate to AndroidEvent

* Move tests

* Use test rules from synctools

* Add null check for content provider client in JtxSyncManagerTest

* Update dependencies, move OkhttpClientTest
2025-06-27 17:06:41 +02:00
Ricki Hirner
4246ed65ac OAuth: Synchronize access token generation (#1547)
* OAuthInterceptor: synchronize refreshing of access token

* Remove sensitive logging

* [WIP] Logging

* [Google] Support custom client ID on re-authorization

* Statically synchronize acquisition of access token

* [WIP] OAuth: use callbacks for reading/writing AuthState

* Fix DavResourceFinderTest

* Move Credentials class to settings package; KDoc

* Simplify reauthorization
2025-06-27 11:24:02 +02:00
Ricki Hirner
789e7f3045 Set iCalendar PRODID in Constants and use it in respective classes (#1550)
* Set iCalendar PRODID in Constants and use it in respective classes

* Update bitfire-synctools
2025-06-26 15:38:37 +02:00
Michael Biebl
66f99f7362 Update README.md after switch to synctools (#1549) 2025-06-26 12:50:27 +02:00
Sunik Kupfer
90b04ddbdc [Google] Remove warning box on login screen (#1548)
* Remove the google login warning card

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

* Remove unused uri handler variable

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-26 12:47:53 +02:00
Ricki Hirner
a7f8ea8a48 Don't subclass AndroidEvent / AndroidCalendar populate / build methods anymore (#1544)
* Fix tests

* Update synctools; use AndroidCalendar SyncState

* Update synctools; move companion objects to end of class declarations
2025-06-25 14:17:58 +02:00
Sunik Kupfer
42cd8d8631 Update sync progress bar when pending in SAF (#1445)
* Provide flow to check if sync of account and authority is pending in sync framework

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

* Show if sync is pending in sync framework

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

* Show if sync is pending in sync framework

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

* Fix kdoc

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

* Cancel any pending SAF syncs on sync request

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

* Cancel sync adapter sync only after having enqueued our worker sync

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

* Improve accuracy by also checking isSyncActive

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

* Remove log statements

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

* Only query pending state

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

* Cancel sync adapter sync only on android 14 and 15

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

* Cancel sync adapter sync with authority

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

* Cancel sync adapter sync by using sync request instead of canceling directly

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

* Include android 16

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

* Include all versions after Android 14

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>

* Revert "Add test which documents wrong pending sync check behaviour"

This reverts commit 8c538149ff2cb032d6355232c1736e103dcc9a18.

* Drop Android 14+ always pending sync work around

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

* Differentiate better between enqueued and pending syncs

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

* Refactor sync pending check to use flow for address book accounts

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

* Always return false for sync pending state check on Android 14+

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

* Refactor sync pending check to use flow for address book accounts

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

* Add comments

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

* Update comment

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

* Shorten variable name

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

* Update comments and variable name

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

* Remvoe obsolete call and add argument names as comments

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

* Remove sync active check from listener

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-25 13:54:44 +02:00
Ricki Hirner
a26847cf10 Update AGP, Kotlin, dependencies 2025-06-24 23:00:19 +02:00
Ricki Hirner
0e6c26aec6 Update synctools and use unified BatchOperation (#1543)
* Update synctools and use unified BatchOperation

* Fix tests
2025-06-24 16:26:40 +02:00
Arnau Mora
2204027993 Improve IME integration on forms (#1504)
* Fix focus accessibility issues

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

* Got rid of focus options. Improved IME integration

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

* Remove custom focus requesters

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

* Move url text to top of fields

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

* Add focus requester again

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

* Moved text

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-24 11:36:07 +02:00
Arnau Mora
e7189d66b0 Link to Managed DAVx5 in navigation drawer (bitfireAT/davx5#633)
Added managed drawer entry
2025-06-24 09:43:48 +02:00
Ricki Hirner
c517647819 Update dependencies 2025-06-24 09:40:21 +02:00
221 changed files with 6485 additions and 5235 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
@@ -50,7 +50,7 @@ jobs:
# uses: github/codeql-action/autobuild@v2
- name: Build
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

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
@@ -30,8 +30,8 @@ jobs:
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
- name: Build signed package
# Make sure that caches are disabled to generate reproducible release builds
run: ./gradlew --no-build-cache --no-configuration-cache --no-daemon app:assembleRelease
# Use build cache to speed up building of build variants, but clean caches from previous tests before
run: ./gradlew --build-cache --configuration-cache --no-daemon app:clean app:assembleRelease
env:
ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }}
@@ -45,4 +45,3 @@ jobs:
files: app/build/outputs/apk/ose/release/*.apk
fail_on_unmatched_files: true
generate_release_notes: true
discussion_category_name: Announcements

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
@@ -27,7 +27,7 @@ jobs:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph: generate-and-submit # submit Github Dependency Graph info
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
test:
needs: compile
@@ -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
@@ -46,9 +46,9 @@ jobs:
cache-read-only: true
- name: Run lint
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
test_on_emulator:
needs: compile
@@ -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
@@ -79,4 +79,4 @@ jobs:
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
- name: Run device tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck
run: ./gradlew --build-cache --configuration-cache app:virtualCheck

View File

@@ -26,8 +26,7 @@ Parts of DAVx⁵ have been outsourced into these libraries:
* [cert4android](https://github.com/bitfireAT/cert4android) custom certificate management
* [dav4jvm](https://github.com/bitfireAT/dav4jvm) WebDAV/CalDav/CardDAV framework
* [ical4android](https://github.com/bitfireAT/ical4android) iCalendar processing and Calendar Provider access
* [vcard4android](https://github.com/bitfireAT/vcard4android) vCard processing and Contacts Provider access
* [synctools](https://github.com/bitfireAT/synctools) iCalendar/vCard/Tasks processing and content provider access
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
or [purchasing it](https://www.davx5.com/download).**

View File

@@ -19,14 +19,16 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 405000000
versionName = "4.5"
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
buildConfigField("boolean", "customCertsUI", "true")
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
@@ -176,14 +178,16 @@ dependencies {
exclude(group="junit")
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
}
implementation(libs.bitfire.synctools)
implementation(libs.bitfire.synctools) {
exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code
exclude(group = "junit")
}
// third-party libs
@Suppress("RedundantSuppression")
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,37 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import kotlin.reflect.KClass
/**
* Use this custom rule to ignore exceptions thrown by another rule.
*
* @param innerRule The rule to wrap.
* @param exceptionsToIgnore The exceptions to ignore.
*/
class CatchExceptionsRule(
private val innerRule: TestRule,
private vararg val exceptionsToIgnore: KClass<out Throwable>
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
try {
innerRule.apply(base, description).evaluate()
} catch (e: Throwable) {
val shouldIgnore = exceptionsToIgnore.any { it.isInstance(e) }
if (shouldIgnore)
base.evaluate()
else
throw e
}
}
}
}
}

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 Dav4jvm {
@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,8 +10,11 @@ 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
import java.util.logging.Level
import java.util.logging.Logger
@Suppress("unused")
class HiltTestRunner : AndroidJUnitRunner() {
@@ -22,13 +25,16 @@ class HiltTestRunner : AndroidJUnitRunner() {
override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments)
// set root logger to adb Logcat
val rootLogger = Logger.getLogger("")
rootLogger.level = Level.ALL
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
// MockK requirements
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

@@ -1,123 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import org.junit.Assert.assertNotNull
import org.junit.rules.ExternalResource
import org.junit.rules.RuleChain
import java.util.logging.Logger
/**
* JUnit ClassRule which initializes the AOSP CalendarProvider.
*
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
* maybe by some wrongly synchronized database initialization. So things like querying the instances
* fails in this case.
*
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
* is used the very first time (especially in CI tests / a fresh emulator).
*
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
*/
class InitCalendarProviderRule private constructor(): ExternalResource() {
companion object {
private var isInitialized = false
private val logger = Logger.getLogger(InitCalendarProviderRule::javaClass.name)
fun getInstance(): RuleChain = RuleChain
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
.around(InitCalendarProviderRule())
}
override fun before() {
if (!isInitialized) {
logger.info("Initializing calendar provider")
if (Build.VERSION.SDK_INT < 31)
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
assertNotNull("Couldn't acquire calendar provider", client)
client!!.use {
initCalendarProvider(client)
isInitialized = true
}
}
}
private fun initCalendarProvider(provider: ContentProviderClient) {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
var calendarOrNull: LocalCalendar? = null
for (i in 0..50) {
calendarOrNull = createAndVerifyCalendar(account, provider)
if (calendarOrNull != null)
break
else
Thread.sleep(100)
}
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
try {
// single event init
val normalEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
normalLocalEvent.add()
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
// recurring event init
val recurringEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
}
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
localRecurringEvent.add()
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
} finally {
calendar.delete()
}
}
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
val uri = AndroidCalendar.create(account, provider, ContentValues())
return try {
AndroidCalendar.findByID(
account,
provider,
LocalCalendar.Factory,
ContentUris.parseId(uri)
)
} catch (e: Exception) {
logger.warning("Couldn't find calendar after creation: $e")
null
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class HomeSetDaoTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
lateinit var dao: HomeSetDao
var serviceId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
dao = db.homeSetDao()
serviceId = db.serviceDao().insertOrReplace(
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
)
}
@After
fun tearDown() {
db.serviceDao().deleteAll()
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.copy(id = 1L), dao.getById(1))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = dao.insertOrUpdateByUrlBlocking(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.copy(id = 1L), dao.getById(1))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = dao.insertOrUpdateByUrlBlocking(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.copy(id = 2L), dao.getById(2))
}
@Test
fun testInsertOrUpdate_TransactionSafe() {
runBlocking(Dispatchers.IO) {
for (i in 0..9999)
launch {
dao.insertOrUpdateByUrlBlocking(
HomeSet(
id = 0,
serviceId = serviceId,
url = "https://example.com/".toHttpUrl(),
personal = true
)
)
}
}
assertEquals(1, dao.getByService(serviceId).size)
}
@Test
fun testDelete() {
// should delete row with given primary key (id)
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = dao.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, dao.getById(1L))
dao.delete(entry1)
assertEquals(null, dao.getById(1L))
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import androidx.sqlite.SQLiteException
import at.bitfire.davdroid.sync.SyncDataType
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncStatsDaoTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
var collectionId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
val serviceId = db.serviceDao().insertOrReplace(Service(
id = 0,
accountName = "test@example.com",
type = Service.TYPE_CALDAV
))
collectionId = db.collectionDao().insert(Collection(
id = 0,
serviceId = serviceId,
type = Collection.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
))
}
@After
fun tearDown() {
db.serviceDao().deleteAll()
}
@Test
fun testInsertOrReplace_ExistingForeignKey() = runTest {
val dao = db.syncStatsDao()
dao.insertOrReplace(
SyncStats(
id = 0,
collectionId = collectionId,
dataType = SyncDataType.CONTACTS.toString(),
lastSync = System.currentTimeMillis()
)
)
}
@Test(expected = SQLiteException::class)
fun testInsertOrReplace_MissingForeignKey() = runTest {
val dao = db.syncStatsDao()
dao.insertOrReplace(
SyncStats(
id = 0,
collectionId = 12345,
dataType = SyncDataType.CONTACTS.toString(),
lastSync = System.currentTimeMillis()
)
)
}
}

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

@@ -1,33 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.log.LogcatHandler
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Singleton
/**
* Module that provides verbose logging for tests.
*/
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [LoggerModule::class]
)
@Module
class TestLoggerModule {
@Provides
@Singleton
fun logger(): Logger = Logger.getGlobal().apply {
level = Level.ALL
addHandler(LogcatHandler())
}
}

View File

@@ -19,7 +19,7 @@ class Android10ResolverTest {
val FQDN_DAVX5 = "www.davx5.com"
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q, maxSdkVersion = 34)
fun testResolveA() {
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance<Inet4Address>().first()

View File

@@ -2,9 +2,9 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
package at.bitfire.davdroid.network
import at.bitfire.davdroid.network.HttpClient
import androidx.test.filters.SdkSuppress
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -29,13 +29,16 @@ class OkhttpClientTest {
@Test
@SdkSuppress(maxSdkVersion = 34)
fun testIcloudWithSettings() {
httpClientBuilder.build().use { client ->
client.okHttpClient
.newCall(Request.Builder()
.get()
.url("https://icloud.com")
.build())
.newCall(
Request.Builder()
.get()
.url("https://icloud.com")
.build()
)
.execute()
}
}

View File

@@ -1,74 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class DavHomeSetRepositoryTest {
@Inject
lateinit var repository: DavHomeSetRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@get:Rule
var hiltRule = HiltAndroidRule(this)
var serviceId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
serviceId = serviceRepository.insertOrReplaceBlocking(
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
)
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.copy(id = 1L), repository.getByIdBlocking(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = repository.insertOrUpdateByUrlBlocking(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.copy(id = 1L), repository.getByIdBlocking(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = repository.insertOrUpdateByUrlBlocking(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.copy(id = 2L), repository.getByIdBlocking(2L))
}
@Test
fun testDeleteBlocking() {
// should delete row with given primary key (id)
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, repository.getByIdBlocking(1L))
repository.deleteBlocking(entry1)
assertEquals(null, repository.getByIdBlocking(1L))
}
}

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,65 +8,68 @@ 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.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
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
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import javax.inject.Inject
@HiltAndroidTest
class LocalCalendarTest {
companion object {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
@get:Rule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.initialize()
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
@Inject
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
@Before
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
androidCalendar = provider.createAndGetCalendar(ContentValues())
calendar = localCalendarFactory.create(androidCalendar)
}
@After
fun tearDown() {
calendar.delete()
androidCalendar.delete()
client.closeCompat()
}
@@ -96,12 +99,18 @@ class LocalCalendarTest {
status = Status.VEVENT_CANCELLED
})
}
val localEvent = LocalEvent(calendar, 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
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -109,7 +118,7 @@ class LocalCalendarTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -126,26 +135,102 @@ class LocalCalendarTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
val localEvent = LocalEvent(calendar, 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
provider.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
provider.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

@@ -4,292 +4,65 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
import android.provider.CalendarContract.Events
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import androidx.test.rule.GrantPermissionRule
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.parameter.Value
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import java.util.UUID
import javax.inject.Inject
@HiltAndroidTest
class LocalEventTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
@Inject
lateinit var localCalendarFactory: LocalCalendar.Factory
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var client: ContentProviderClient
private lateinit var calendar: LocalCalendar
@Before
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
hiltRule.inject()
val context = InstrumentationRegistry.getInstrumentation().targetContext
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val provider = AndroidCalendarProvider(account, client)
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
}
@After
fun removeCalendar() {
calendar.delete()
}
@Test
fun testNumDirectInstances_SingleInstance() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_Recurring() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_Recurring_Endless() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event without end"
rRules.add(RRule("FREQ=DAILY"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_Recurring_LateEnd() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 53 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
else
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_Recurring_ManyInstances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 2 years"
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!)
// Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct),
// but we are satisfied with either result for now
assertTrue(number == 365*2 || number == 365*2+1)
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumDirectInstances_RecurringWithExdate() {
val event = Event().apply {
dtStart = DtStart(Date("20220120T010203Z"))
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME)))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumDirectInstances_RecurringWithExceptions() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T130203Z")
summary = "Exception on 3rd day"
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220124T010203Z")
dtStart = DtStart("20220122T160203Z")
summary = "Exception on 5th day"
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
localEvent.add()
assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_SingleInstance() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_Recurring() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 5 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_Recurring_Endless() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with infinite instances"
rRules.add(RRule("FREQ=YEARLY"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_Recurring_LateEnd() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!))
else
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
// flaky, needs InitCalendarProviderRule
fun testNumInstances_Recurring_ManyInstances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over two years"
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
assertEquals(
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
365*2 // Android <10: does not include UNTIL (incorrect!)
else
365*2 + 1, // Android ≥10: includes UNTIL (correct)
LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testNumInstances_RecurringWithExceptions() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 6 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=6"))
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220122T010203Z")
dtStart = DtStart("20220122T130203Z")
summary = "Exception on 3rd day"
})
exceptions.add(Event().apply {
recurrenceId = RecurrenceId("20220124T010203Z")
dtStart = DtStart("20220122T160203Z")
summary = "Exception on 5th day"
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
localEvent.add()
calendar.findById(localEvent.id!!)
assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!))
}
@Test
fun testMarkEventAsDeleted() {
// Create event
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "A fine event"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
localEvent.add()
// Delete event
LocalEvent.markAsDeleted(provider, account, localEvent.id!!)
// Get the status of whether the event is deleted
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.DELETED),
null,
null, null
)!!.use { cursor ->
cursor.moveToFirst()
assertEquals(1, cursor.getInt(0))
}
fun tearDown() {
calendar.androidCalendar.delete()
client.closeCompat()
}
@@ -300,8 +73,15 @@ class LocalEventTest {
dtStart = DtStart("20220120T010203Z")
summary = "Event without uid"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
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()
@@ -311,7 +91,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage should be the same as file name
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -328,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(calendar, event, null, null, null, 0)
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()
@@ -338,7 +124,7 @@ class LocalEventTest {
assertEquals(event.uid, fileName)
// UID in calendar storage should still be set, too
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -355,8 +141,14 @@ class LocalEventTest {
summary = "Event with funny uid"
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
}
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
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()
@@ -366,7 +158,7 @@ class LocalEventTest {
UUID.fromString(fileName)
// UID in calendar storage shouldn't have been changed
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
arrayOf(Events.UID_2445), null, null, null
)!!.use { cursor ->
@@ -407,12 +199,18 @@ class LocalEventTest {
status = Status.VEVENT_CANCELLED
})
}
val localEvent = LocalEvent(calendar, 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
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -420,7 +218,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is now marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -436,12 +234,18 @@ class LocalEventTest {
summary = "Event with 3 instances"
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
}
val localEvent = LocalEvent(calendar, 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
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
put(Events.DIRTY, 1)
}, null, null)
@@ -449,7 +253,7 @@ class LocalEventTest {
calendar.deleteDirtyEventsWithoutInstances()
// verify that event is not marked as deleted
provider.query(
client.query(
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
arrayOf(Events.DELETED), null, null, null
)!!.use { cursor ->
@@ -458,28 +262,4 @@ class LocalEventTest {
}
}
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
}

View File

@@ -14,7 +14,7 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -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
@@ -115,7 +116,7 @@ class LocalGroupTest {
val group = newGroup(ab)
// add contact1 to group
val batch = BatchOperation(ab.provider!!)
val batch = ContactsBatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
@@ -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(
@@ -223,7 +224,7 @@ class LocalGroupTest {
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val batch = BatchOperation(ab.provider!!)
val batch = ContactsBatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()

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

@@ -8,9 +8,9 @@ import android.security.NetworkSecurityPolicy
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.Credentials
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
@@ -72,7 +72,7 @@ class DavResourceFinderTest {
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
client = httpClientBuilder
.authenticate(host = null, credentials = credentials)
.authenticate(host = null, getCredentials = { credentials })
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)

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

@@ -127,16 +127,16 @@ class AccountSettingsMigration20Test {
Calendars.NAME to url,
Calendars.SYNC_EVENTS to 1
)
)!!
)!!.asSyncAdapter(account)
try {
migration.migrateCalendars(account, calDavServiceId = 1)
migration.migrateCalendars(account, 1)
provider.query(uri.asSyncAdapter(account), arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
provider.query(uri, arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
cursor.moveToNext()
assertEquals(collectionId, cursor.getLongOrNull(0))
}
} finally {
provider.delete(uri.asSyncAdapter(account), null, null)
provider.delete(uri, null, null)
}
}
}

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

@@ -4,10 +4,9 @@
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.CatchExceptionsRule
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
@@ -18,6 +17,7 @@ import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.synctools.test.GrantPermissionOrSkipRule
import at.techbee.jtx.JtxContract
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
@@ -25,6 +25,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeNotNull
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
@@ -60,12 +61,9 @@ class JtxSyncManagerTest {
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = CatchExceptionsRule(
GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX),
SecurityException::class
)
val permissionRule = GrantPermissionOrSkipRule(TaskProvider.PERMISSIONS_JTX.toSet())
private val account = TestAccount.create()
lateinit var account: Account
private lateinit var provider: ContentProviderClient
private lateinit var syncManager: JtxSyncManager
@@ -79,7 +77,11 @@ class JtxSyncManagerTest {
assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX))
// Acquire the jtx content provider
provider = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)!!
val providerOrNull = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
assumeNotNull(providerOrNull)
provider = providerOrNull!!
account = TestAccount.create()
// Create dummy dependencies
val service = Service(0, account.name, Service.TYPE_CALDAV, null)
@@ -106,9 +108,12 @@ class JtxSyncManagerTest {
if (this::localJtxCollection.isInitialized)
localJtxCollectionStore.delete(localJtxCollection)
serviceRepository.deleteAllBlocking()
if (this::provider.isInitialized)
provider.closeCompat()
TestAccount.remove(account)
if (this::account.isInitialized)
TestAccount.remove(account)
}

View File

@@ -4,8 +4,8 @@
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.SyncState
class LocalTestCollection(
override val dbCollectionId: Long = 0L

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

@@ -15,9 +15,9 @@ import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.assertWithin
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext

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

@@ -10,10 +10,10 @@ import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.di.SyncDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.SyncState
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory

View File

@@ -4,7 +4,7 @@
package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.Credentials
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals

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,8 +14,8 @@ 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.CookieJar
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -30,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)
@@ -60,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()
@@ -76,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<Long, CookieJar>(),
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",
@@ -129,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<Long, CookieJar>(),
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<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
operation.queryChildren(rootDocument)
// Assert folder got deleted
assertEquals(null, db.webDavDocumentDao().get(folderId))
@@ -168,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<Long, CookieJar>(),
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)
@@ -215,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>",
@@ -225,7 +199,7 @@ class DavDocumentsProviderTest {
),
Resource("Library",
"<resourcetype><collection/></resourcetype>" +
"<displayname>Library</displayname>"
"<displayname>Library</displayname>"
)
),
@@ -244,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()
@@ -265,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,8 +3,9 @@
*/
package at.bitfire.davdroid
import android.net.Uri
import androidx.core.net.toUri
import at.bitfire.synctools.icalendar.ical4jVersion
import ezvcard.Ezvcard
import net.fortuna.ical4j.model.property.ProdId
/**
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
@@ -13,48 +14,10 @@ object Constants {
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
val HOMEPAGE_URL = "https://www.davx5.com".toUri()
const val HOMEPAGE_PATH_FAQ = "faq"
const val HOMEPAGE_PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected"
const val HOMEPAGE_PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission"
const val HOMEPAGE_PATH_OPEN_SOURCE = "donate"
const val HOMEPAGE_PATH_PRIVACY = "privacy"
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
val MANUAL_URL = "https://manual.davx5.com".toUri()
// product IDs for iCalendar/vCard
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
const val MANUAL_PATH_INTRODUCTION = "introduction.html"
const val MANUAL_FRAGMENT_AUTHENTICATION_METHODS = "authentication-methods"
const val MANUAL_PATH_SETTINGS = "settings.html"
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
const val MANUAL_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
const val MANUAL_PATH_WEBDAV_PUSH = "webdav_push.html"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
val FEDIVERSE_HANDLE = "@davx5app@fosstodon.org"
val FEDIVERSE_URL = "https://fosstodon.org/@davx5app".toUri()
/**
* Appends query parameters for anonymized usage statistics (app ID, version).
* Can be used by the called Website to get an idea of which versions etc. are currently used.
*
* @param context optional info about from where the URL was opened (like a specific Activity)
*/
fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder {
appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
if (context != null)
appendQueryParameter("pk_kwd", context)
return this
}
val iCalProdId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/$ical4jVersion")
const val vCardProdId = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/${Ezvcard.VERSION}"
}

View File

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

View File

@@ -38,6 +38,10 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
suspend fun getSyncableByPushTopic(topic: String): Collection?
@Suppress("unused") // for build variant
@Query("SELECT * FROM collection WHERE sync")
fun getSyncCollections(): List<Collection>
@Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1")
suspend fun getFirstVapidKey(serviceId: Long): String?

View File

@@ -14,7 +14,7 @@ class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
url?.toString()
@TypeConverter
fun mediaTypeToString(mediaType: MediaType?) =

View File

@@ -8,6 +8,7 @@ import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@@ -35,6 +36,24 @@ interface HomeSetDao {
@Update
fun update(homeset: HomeSet)
/**
* If a homeset with the given service ID and URL already exists, it is updated with the other fields.
* Otherwise, a new homeset is inserted.
*
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @param homeSet home set to insert/update
*
* @return ID of the row that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
getByUrl(homeSet.serviceId, homeSet.url.toString())?.let { existingHomeset ->
update(homeSet.copy(id = existingHomeset.id))
existingHomeset.id
} ?: insert(homeSet)
@Delete
fun delete(homeset: HomeSet)

View File

@@ -12,9 +12,11 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.LogFileHandler.Companion.debugDir
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.synctools.log.PlainTextFormatter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.Closeable
import java.io.File

View File

@@ -8,6 +8,7 @@ import android.content.Context
import android.util.Log
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.synctools.log.LogcatHandler
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -79,7 +80,7 @@ class LogManager @Inject constructor(
// root logger: set default log level and always log to logcat
val rootLogger = Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
rootLogger.addHandler(LogcatHandler())
rootLogger.addHandler(LogcatHandler(BuildConfig.APPLICATION_ID))
// log to file, if requested
if (logToFile)

View File

@@ -1,52 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.os.Build
import android.util.Log
import at.bitfire.davdroid.BuildConfig
import com.google.common.base.Ascii
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
/**
* Logging handler that logs to Android logcat.
*/
internal class LogcatHandler: Handler() {
init {
formatter = PlainTextFormatter.LOGCAT
}
override fun publish(r: LogRecord) {
val level = r.level.intValue()
val text = formatter.format(r)
// get class name that calls the logger (or fall back to package name)
val className = if (r.sourceClassName != null)
PlainTextFormatter.shortClassName(r.sourceClassName)
else
BuildConfig.APPLICATION_ID
// truncate class name to 23 characters on Android <8, see Log documentation
val tag = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
Ascii.truncate(className, 23, "")
else
className
when {
level >= Level.SEVERE.intValue() -> Log.e(tag, text, r.thrown)
level >= Level.WARNING.intValue() -> Log.w(tag, text, r.thrown)
level >= Level.CONFIG.intValue() -> Log.i(tag, text, r.thrown)
level >= Level.FINER.intValue() -> Log.d(tag, text, r.thrown)
else -> Log.v(tag, text, r.thrown)
}
}
override fun flush() {}
override fun close() {}
}

View File

@@ -1,111 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import com.google.common.base.Ascii
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.logging.Formatter
import java.util.logging.LogRecord
class PlainTextFormatter(
private val withTime: Boolean,
private val withSource: Boolean,
private val padSource: Int = 30,
private val withException: Boolean,
private val lineSeparator: String?
): Formatter() {
companion object {
/**
* Formatter intended for logcat output.
*/
val LOGCAT = PlainTextFormatter(
withTime = false,
withSource = false,
withException = false,
lineSeparator = null
)
/**
* Formatter intended for file output.
*/
val DEFAULT = PlainTextFormatter(
withTime = true,
withSource = true,
withException = true,
lineSeparator = System.lineSeparator()
)
/**
* Maximum length of a log line (estimate).
*/
const val MAX_LENGTH = 10000
fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), ".")
.replace(Regex("\\$.*$"), "")
private fun stackTrace(ex: Throwable): String {
val writer = StringWriter()
ex.printStackTrace(PrintWriter(writer))
return writer.toString()
}
}
private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
override fun format(r: LogRecord): String {
val builder = StringBuilder()
if (withTime)
builder .append(timeFormat.format(Date(r.millis)))
.append(" ").append(r.threadID).append(" ")
if (withSource && r.sourceClassName != null) {
val className = shortClassName(r.sourceClassName)
if (className != r.loggerName) {
val classNameColumn = "[$className] ".padEnd(padSource)
builder.append(classNameColumn)
}
}
builder.append(truncate(r.message))
if (withException && r.thrown != null) {
val indentedStackTrace = stackTrace(r.thrown)
.replace("\n", "\n\t")
.removeSuffix("\t")
builder.append("\n\tEXCEPTION ").append(indentedStackTrace)
}
r.parameters?.let {
for ((idx, param) in it.withIndex()) {
builder.append("\n\tPARAMETER #").append(idx + 1).append(" = ")
val valStr = if (param == null)
"(null)"
else
truncate(param.toString())
builder.append(valStr)
}
}
if (lineSeparator != null)
builder.append(lineSeparator)
return builder.toString()
}
private fun truncate(s: String) =
Ascii.truncate(s, MAX_LENGTH, "[…]")
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.log
import at.bitfire.synctools.log.PlainTextFormatter
import com.google.common.base.Ascii
import java.util.logging.Handler
import java.util.logging.LogRecord

View File

@@ -1,72 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
import okhttp3.Interceptor
import okhttp3.Response
import java.util.logging.Level
import java.util.logging.Logger
/**
* Sends an OAuth Bearer token authorization as described in RFC 6750.
*/
class BearerAuthInterceptor(
private val accessToken: String
): Interceptor {
companion object {
val logger: Logger
get() = Logger.getGlobal()
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
return runBlocking {
val accessTokenFuture = CompletableDeferred<String>()
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
if (accessToken != null) {
// persist updated AuthState
callback?.onUpdate(authState)
// emit access token
accessTokenFuture.complete(accessToken)
}
else {
logger.log(Level.WARNING, "Couldn't obtain access token", ex)
accessTokenFuture.cancel()
}
}
// return value
try {
BearerAuthInterceptor(accessTokenFuture.await())
} catch (ignored: CancellationException) {
null
}
}
}
}
override fun intercept(chain: Interceptor.Chain): Response {
logger.finer("Authenticating request with access token")
val rq = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(rq)
}
fun interface AuthStateUpdateCallback {
fun onUpdate(authState: AuthState)
}
}

View File

@@ -10,9 +10,10 @@ import androidx.annotation.WorkerThread
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.ForegroundTracker
@@ -21,7 +22,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import okhttp3.Authenticator
import okhttp3.Cache
import okhttp3.ConnectionSpec
@@ -39,17 +39,14 @@ import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
class HttpClient(
val okHttpClient: OkHttpClient,
private val authorizationService: AuthorizationService? = null
val okHttpClient: OkHttpClient
): AutoCloseable {
override fun close() {
authorizationService?.dispose()
okHttpClient.cache?.close()
}
@@ -66,11 +63,11 @@ class HttpClient(
*/
class Builder @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val authorizationServiceProvider: Provider<AuthorizationService>,
@ApplicationContext private val context: Context,
defaultLogger: Logger,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val keyManagerFactory: ClientCertKeyManager.Factory,
private val oAuthInterceptorFactory: OAuthInterceptor.Factory,
private val settingsManager: SettingsManager
) {
@@ -97,14 +94,22 @@ class HttpClient(
private var authenticationInterceptor: Interceptor? = null
private var authenticator: Authenticator? = null
private var authorizationService: AuthorizationService? = null
private var certificateAlias: String? = null
fun authenticate(host: String?, credentials: Credentials, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): Builder {
val credentials = getCredentials()
if (credentials.authState != null) {
// OAuth
val authService = authorizationServiceProvider.get()
authenticationInterceptor = BearerAuthInterceptor.fromAuthState(authService, credentials.authState, authStateCallback)
authorizationService = authService
authenticationInterceptor = oAuthInterceptorFactory.create(
readAuthState = {
// We don't use the "credentials" object from above because it may contain an outdated access token
// when readAuthState is called. Instead, we fetch the up-to-date auth-state.
getCredentials().authState
},
writeAuthState = { authState ->
updateAuthState?.invoke(authState)
}
)
} else if (credentials.username != null && credentials.password != null) {
// basic/digest auth
@@ -164,9 +169,11 @@ class HttpClient(
val accountSettings = accountSettingsFactory.create(account)
authenticate(
host = onlyHost,
credentials = accountSettings.credentials(),
authStateCallback = { authState: AuthState ->
accountSettings.credentials(Credentials(authState = authState))
getCredentials = {
accountSettings.credentials()
},
updateAuthState = { authState ->
accountSettings.updateAuthState(authState)
}
)
return this
@@ -231,10 +238,7 @@ class HttpClient(
okBuilder.addNetworkInterceptor(loggingInterceptor)
}
return HttpClient(
okHttpClient = okBuilder.build(),
authorizationService = authorizationService
)
return HttpClient(okBuilder.build())
}
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
@@ -263,7 +267,7 @@ class HttpClient(
val certManager = CustomCertManager(
context = context,
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
appInForeground = if (/* davx5-ose */ true)
appInForeground = if (BuildConfig.customCertsUI)
ForegroundTracker.inForeground // interactive mode
else
null // non-interactive mode

View File

@@ -6,7 +6,7 @@ package at.bitfire.davdroid.network
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
@@ -125,7 +125,7 @@ class NextcloudLoginFlow @Inject constructor(
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body?.use { body ->
response.body.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
@@ -133,8 +133,6 @@ class NextcloudLoginFlow @Inject constructor(
// decode JSON
return@withContext JSONObject(body.string())
}
throw DavException("Invalid Login Flow response (no body)")
}
}

View File

@@ -9,19 +9,14 @@ import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.OAuthIntegration.redirectUri
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.TokenResponse
import java.util.Locale
/**
* Integration with OpenID AppAuth (Android)
@@ -38,42 +33,20 @@ object OAuthIntegration {
* @param authService authorization service
* @param authResponse response from the server (coming over the Intent from the browser / [AuthorizationContract])
*/
suspend fun authenticate(authService: AuthorizationService, authResponse: AuthorizationResponse): Credentials {
suspend fun authenticate(authService: AuthorizationService, authResponse: AuthorizationResponse): AuthState {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val credentials = CompletableDeferred<Credentials>()
val authStateFuture = CompletableDeferred<AuthState>()
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
credentials.complete(Credentials(authState = authState))
} else if (refreshTokenException != null)
credentials.completeExceptionally(refreshTokenException)
}
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
authStateFuture.complete(authState)
} else if (refreshTokenException != null)
authStateFuture.completeExceptionally(refreshTokenException)
}
return credentials.await()
}
/**
* Creates a new authorization request from a known configuration. Typically used to re-authorize
* from a given configuration.
*
* @param authConfig current authorization config that shall be replaced
* @return authorization request, or `null` if the current config doesn't contain a known provider
*/
fun newAuthorizeRequest(authConfig: AuthorizationServiceConfiguration): AuthorizationRequest? {
val authHost = authConfig.authorizationEndpoint.host.toString()
val locale = Locale.getDefault().toLanguageTag()
// If more OAuth providers become added, this should be rewritten so that all providers
// are checked automatically.
return when {
authHost.contains("fastmail.com") -> OAuthFastmail.signIn(null, locale)
authHost.contains("google.com") -> OAuthGoogle.signIn(null, null, locale)
else -> return null
}
return authStateFuture.await()
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import at.bitfire.davdroid.BuildConfig
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
/**
* Sends an OAuth Bearer token authorization as described in RFC 6750.
*
* @param readAuthState callback that fetches an up-to-date authorization state
* @param writeAuthState callback that persists a new authorization state
*/
class OAuthInterceptor @AssistedInject constructor(
@Assisted private val readAuthState: () -> AuthState?,
@Assisted private val writeAuthState: (AuthState) -> Unit,
private val authServiceProvider: Provider<AuthorizationService>,
private val logger: Logger
): Interceptor {
@AssistedFactory
interface Factory {
fun create(readAuthState: () -> AuthState?, writeAuthState: (AuthState) -> Unit): OAuthInterceptor
}
override fun intercept(chain: Interceptor.Chain): Response {
val rq = chain.request().newBuilder()
/** Syntax for the "Authorization" header [RFC 6750 2.1]:
*
* b64token = 1*( ALPHA / DIGIT /
* "-" / "." / "_" / "~" / "+" / "/" ) *"="
* credentials = "Bearer" 1*SP b64token
*/
val accessToken = provideAccessToken()
if (accessToken != null)
rq.header("Authorization", "Bearer $accessToken")
else
logger.severe("No access token available, won't authenticate")
return chain.proceed(rq.build())
}
/**
* Provides a fresh access token for authorization. Uses the current one if it's still valid,
* or requests a new one if necessary.
*
* This method is synchronized / thread-safe so that it can be called for multiple HTTP requests at the same time.
*
* @return access token or `null` if no valid access token is available (usually because of an error during refresh)
*/
fun provideAccessToken(): String? = synchronized(javaClass) {
// if possible, use cached access token
val authState = readAuthState() ?: return null
if (authState.isAuthorized && authState.accessToken != null && !authState.needsTokenRefresh) {
if (BuildConfig.DEBUG) // log sensitive information (refresh/access token) only in debug builds
logger.log(Level.FINEST, "Using cached AuthState", authState.jsonSerializeString())
return authState.accessToken
}
// request fresh access token
logger.fine("Requesting fresh access token")
val accessTokenFuture = CompletableFuture<String>()
val authService = authServiceProvider.get()
try {
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
// appauth internally fetches the new token over HttpURLConnection in an AsyncTask
if (BuildConfig.DEBUG)
logger.log(Level.FINEST, "Got new AuthState", authState.jsonSerializeString())
// persist updated AuthState
writeAuthState(authState)
if (ex != null)
accessTokenFuture.completeExceptionally(ex)
else if (accessToken != null)
accessTokenFuture.complete(accessToken)
}
accessTokenFuture.join()
} catch (e: CompletionException) {
logger.log(Level.SEVERE, "Couldn't obtain access token", e.cause)
null
} finally {
authService.dispose()
}
}
}

View File

@@ -19,6 +19,12 @@ import java.net.URL
@InstallIn(SingletonComponent::class)
object OAuthModule {
/**
* Make sure to call [AuthorizationService.dispose] when obtaining an instance.
*
* Creating an instance is expensive (involves CustomTabsManager), so don't create an
* instance if not necessary (use Provider/Lazy).
*/
@Provides
fun authorizationService(@ApplicationContext context: Context): AuthorizationService =
AuthorizationService(context,

View File

@@ -9,7 +9,6 @@ import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
@@ -18,6 +17,7 @@ import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager

View File

@@ -23,6 +23,7 @@ import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
@@ -32,8 +33,6 @@ import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.util.DateUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runInterruptible
@@ -42,6 +41,7 @@ import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.ComponentList
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.PropertyList
import net.fortuna.ical4j.model.TimeZoneRegistryFactory
import net.fortuna.ical4j.model.component.VTimeZone
import net.fortuna.ical4j.model.property.Version
import okhttp3.HttpUrl
@@ -228,7 +228,7 @@ class DavCollectionRepository @Inject constructor(
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
fun insertOrUpdateByUrlRememberSync(newCollection: Collection) {
db.runInTransaction {
// remember locally set flags
val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())
@@ -375,8 +375,8 @@ class DavCollectionRepository @Inject constructor(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(
PropertyList<Property>().apply {
add(ICalendar.prodId)
add(Version.VERSION_2_0)
add(Constants.iCalProdId)
},
ComponentList(
listOf(vTimezone)
@@ -417,6 +417,9 @@ class DavCollectionRepository @Inject constructor(
return writer.toString()
}
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
private fun getVTimeZone(tzId: String): VTimeZone? {
val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()
return tzRegistry.getTimeZone(tzId)?.vTimeZone
}
}

View File

@@ -5,7 +5,6 @@
package at.bitfire.davdroid.repository
import android.accounts.Account
import androidx.room.Transaction
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
@@ -29,21 +28,8 @@ class DavHomeSetRepository @Inject constructor(
fun getCalendarHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"
* which will create a new row with incremented ID and thus breaks entity relationships!
*
* @return ID of the row, that has been inserted or updated. -1 If the insert fails due to other reasons.
*/
@Transaction
fun insertOrUpdateByUrlBlocking(homeset: HomeSet): Long =
dao.getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
dao.update(homeset.copy(id = existingHomeset.id))
existingHomeset.id
} ?: dao.insert(homeset)
fun insertOrUpdateByUrlBlocking(homeSet: HomeSet): Long =
dao.insertOrUpdateByUrlBlocking(homeSet)
fun deleteBlocking(homeSet: HomeSet) = dao.delete(homeSet)

View File

@@ -17,19 +17,20 @@ import android.provider.ContactsContract.RawContacts
import androidx.annotation.OpenForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ONLY
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.SyncDataType
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
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -198,17 +199,17 @@ open class LocalAddressBook @AssistedInject constructor(
return false
// move contacts and groups to new account
val batch = BatchOperation(provider!!)
batch.enqueue(BatchOperation.CpoBuilder
val batch = ContactsBatchOperation(provider!!)
batch += BatchOperation.CpoBuilder
.newUpdate(groupsSyncUri())
.withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(Groups.ACCOUNT_NAME, newAccount.name)
)
batch.enqueue(BatchOperation.CpoBuilder
.withValue(Groups.ACCOUNT_TYPE, newAccount.type)
batch += BatchOperation.CpoBuilder
.newUpdate(rawContactsSyncUri())
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
)
.withValue(RawContacts.ACCOUNT_TYPE, newAccount.type)
batch.commit()
// update AndroidAddressBook.account
@@ -222,15 +223,18 @@ open class LocalAddressBook @AssistedInject constructor(
/**
* Makes contacts of this address book available to be synced and activates synchronization upon
* contact data changes.
* Enables or disables sync on content changes for the address book account based on the current sync
* interval account setting.
*/
fun updateSyncFrameworkSettings() {
// Enable sync-ability of contacts
syncFramework.enableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
val accountSettings = accountSettingsFactory.create(account)
val syncInterval = accountSettings.getSyncInterval(SyncDataType.CONTACTS)
// Changes in contact data should trigger syncs
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
// Enable/Disable content triggered syncs for the address book account.
if (syncInterval != null)
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
else
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
}

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.ContentProviderClient
import android.content.Context
import android.provider.ContactsContract
@@ -23,6 +24,9 @@ import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.DavUtils.lastSegment
import com.google.common.base.CharMatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@@ -74,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")
@@ -115,17 +125,10 @@ class LocalAddressBookStore @Inject constructor(
return addressBookAccount
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
return accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == account.name &&
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == account.type
}
.map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
getAddressBookAccounts(account).map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
@@ -155,7 +158,7 @@ class LocalAddressBookStore @Inject constructor(
localCollection.readOnly = nowReadOnly
}
// make sure it will still be synchronized when contacts are updated
// Update automatic synchronization
localCollection.updateSyncFrameworkSettings()
}
@@ -197,6 +200,45 @@ class LocalAddressBookStore @Inject constructor(
accountManager.removeAccountExplicitly(addressBookAccount)
}
/**
* Returns all address book accounts that belong to the given account.
*
* @param account Account which has the address books.
* @return List of address book accounts.
*/
fun getAddressBookAccounts(account: Account): List<Account> =
AccountManager.get(context).let { accountManager ->
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
account.name == accountManager.getUserData(
addressBookAccount,
LocalAddressBook.USER_DATA_ACCOUNT_NAME
) && account.type == accountManager.getUserData(
addressBookAccount,
LocalAddressBook.USER_DATA_ACCOUNT_TYPE
)
}
}
/**
* Returns all address book accounts that belong to the given account in a flow.
*
* @param account Account which has the address books.
* @return List of address book accounts as flow.
*/
fun getAddressBookAccountsFlow(account: Account): Flow<List<Account>> = callbackFlow {
val accountManager = AccountManager.get(context)
val listener = OnAccountsUpdateListener { accounts ->
trySend(getAddressBookAccounts(account))
}
accountManager.addOnAccountsUpdatedListener(
/* listener = */ listener,
/* handler = */ null,
/* updateImmediately = */ true
)
awaitClose { accountManager.removeOnAccountsUpdatedListener(listener) }
}
companion object {

View File

@@ -4,20 +4,22 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
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
/**
@@ -25,55 +27,62 @@ import java.util.logging.Logger
*
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
private val logger: Logger
get() = Logger.getGlobal()
class LocalCalendar @AssistedInject constructor(
@Assisted internal val androidCalendar: AndroidCalendar,
private val logger: Logger
) : LocalCollection<LocalEvent> {
@AssistedFactory
interface Factory {
fun create(calendar: AndroidCalendar): LocalCalendar
}
// properties
override val dbCollectionId: Long?
get() = syncId?.toLongOrNull()
get() = androidCalendar.syncId?.toLongOrNull()
override val tag: String
get() = "events-${account.name}-$id"
get() = "events-${androidCalendar.account.name}-${androidCalendar.id}"
override val title: String
get() = displayName ?: id.toString()
get() = androidCalendar.displayName ?: androidCalendar.id.toString()
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
get() = androidCalendar.accessLevel <= Calendars.CAL_ACCESS_READ
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
get() = androidCalendar.readSyncState()?.let {
SyncState.fromString(it)
}
set(state) {
val values = contentValuesOf(COLUMN_SYNC_STATE to state.toString())
provider.update(calendarSyncURI(), values, null, null)
androidCalendar.writeSyncState(state.toString())
}
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
override fun populate(info: ContentValues) {
super.populate(info)
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
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() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
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>()
@@ -83,129 +92,119 @@ class LocalCalendar private 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 (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
try {
val event = requireNotNull(localEvent.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) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
LocalEvent(recurringCalendar, it)
}
override fun markNotDirty(flags: Int): Int {
val values = contentValuesOf(LocalEvent.COLUMN_FLAGS to flags)
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
}
override fun markNotDirty(flags: Int) =
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 {
var deleted = 0
// list all non-dirty events with the given flags and delete every row + its exceptions
provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
val batch = BatchOperation(provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(Events.CONTENT_URI.asSyncAdapter(account))
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
}
deleted = batch.commit()
val batch = CalendarBatchOperation(androidCalendar.client)
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
// `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.getAsLong(Events._ID)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch += BatchOperation.CpoBuilder
.newDelete(androidCalendar.eventsUri)
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
}
return deleted
return batch.commit()
}
override fun forgetETags() {
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.toString()))
androidCalendar.updateEventRows(
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
)
}
fun processDirtyExceptions() {
// process deleted exceptions
logger.info("Processing deleted exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val batch = BatchOperation(provider)
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
// get original event's SEQUENCE
provider.query(
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
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)
// re-schedule original event and set it to DIRTY
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1))
}
}
val batch = CalendarBatchOperation(androidCalendar.client)
// completely remove deleted exception
batch.enqueue(BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account)))
batch.commit()
}
// enqueue: increase sequence of main event
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
batch += BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
// completely remove deleted exception
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
batch.commit()
}
// process dirty exceptions
logger.info("Processing dirty exceptions")
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
androidCalendar.iterateEventRows(
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(androidCalendar.id.toString())
) { values ->
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val batch = BatchOperation(provider)
// original event to DIRTY
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
.withValue(Events.DIRTY, 1))
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0))
batch.commit()
}
val id = values.getAsLong(Events._ID) // can't be null (by definition)
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
val batch = CalendarBatchOperation(androidCalendar.client)
// enqueue: set original event to DIRTY
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(originalID))
.withValue(Events.DIRTY, 1)
// enqueue: increase exception SEQUENCE and set DIRTY to 0
batch += BatchOperation.CpoBuilder
.newUpdate(androidCalendar.eventUri(id))
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
batch.commit()
}
}
@@ -215,33 +214,23 @@ class LocalCalendar private constructor(
* @return number of affected events
*/
fun deleteDirtyEventsWithoutInstances() {
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
// Iterate dirty main events without exceptions
androidCalendar.iterateEventRows(
arrayOf(Events._ID),
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events)
null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val eventID = cursor.getLong(0)
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
null
) { values ->
val eventId = values.getAsLong(Events._ID)
// get number of instances
val numEventInstances = LocalEvent.numInstances(provider, account, eventID)
// get number of instances
val numEventInstances = androidCalendar.numInstances(eventId)
// delete event if there are no instances
if (numEventInstances == 0) {
logger.info("Marking event #$eventID without instances as deleted")
LocalEvent.markAsDeleted(provider, account, eventID)
}
// delete event if there are no instances
if (numEventInstances == 0) {
logger.fine("Marking event #$eventId without instances as deleted")
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
}
}
}
object Factory: AndroidCalendarFactory<LocalCalendar> {
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
LocalCalendar(account, provider, id)
}
}

View File

@@ -6,11 +6,13 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Attendees
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import android.provider.CalendarContract.Reminders
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
@@ -18,10 +20,9 @@ import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
@@ -30,6 +31,7 @@ import javax.inject.Inject
class LocalCalendarStore @Inject constructor(
@ApplicationContext private val context: Context,
private val accountSettingsFactory: AccountSettings.Factory,
private val localCalendarFactory: LocalCalendar.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
): LocalDataStore<LocalCalendar> {
@@ -37,10 +39,16 @@ 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(provider: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
@@ -69,26 +77,49 @@ class LocalCalendarStore @Inject constructor(
}
logger.log(Level.INFO, "Adding local calendar", values)
val uri = AndroidCalendar.create(account, provider, values)
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
val provider = AndroidCalendarProvider(account, client)
return localCalendarFactory.create(provider.createAndGetCalendar(values))
}
override fun getAll(account: Account, provider: ContentProviderClient) =
AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${Calendars.SYNC_EVENTS}!=0", null)
override fun getAll(account: Account, client: ContentProviderClient) =
AndroidCalendarProvider(account, client)
.findCalendars("${Calendars.SYNC_EVENTS}!=0", null)
.map { localCalendarFactory.create(it) }
override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.account)
override fun update(client: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.androidCalendar.account)
val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())
logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values)
localCollection.update(values)
val androidCalendar = localCollection.androidCalendar
val provider = AndroidCalendarProvider(androidCalendar.account, client)
provider.updateCalendar(androidCalendar.id, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars._SYNC_ID, info.id)
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
val values = contentValuesOf(
Calendars._SYNC_ID to info.id,
Calendars.CALENDAR_DISPLAY_NAME to
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName,
Calendars.ALLOWED_AVAILABILITY to arrayOf(
Events.AVAILABILITY_BUSY,
Events.AVAILABILITY_FREE
).joinToString(",") { it.toString() },
Calendars.ALLOWED_ATTENDEE_TYPES to arrayOf(
Attendees.TYPE_NONE,
Attendees.TYPE_OPTIONAL,
Attendees.TYPE_REQUIRED,
Attendees.TYPE_RESOURCE
).joinToString(",") { it.toString() },
Calendars.ALLOWED_REMINDERS to arrayOf(
Reminders.METHOD_DEFAULT,
Reminders.METHOD_ALERT,
Reminders.METHOD_EMAIL
).joinToString(",") { it.toString() },
)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
@@ -104,9 +135,6 @@ class LocalCalendarStore @Inject constructor(
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
}
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}
@@ -120,7 +148,7 @@ class LocalCalendarStore @Inject constructor(
override fun delete(localCollection: LocalCalendar) {
logger.log(Level.INFO, "Deleting local calendar", localCollection)
localCollection.delete()
localCollection.androidCalendar.delete()
}
}
}

View File

@@ -4,8 +4,6 @@
package at.bitfire.davdroid.resource
import at.bitfire.davdroid.db.SyncState
interface LocalCollection<out T: LocalResource<*>> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
@@ -50,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]])
*
@@ -62,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)
@@ -78,4 +73,4 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
fun forgetETags()
}
}

View File

@@ -10,31 +10,26 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidContactFactory
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.Optional
import java.util.UUID
import kotlin.jvm.optionals.getOrNull
class LocalContact: AndroidContact, LocalAddress {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//DAVx5/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
}
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
@@ -45,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
@@ -95,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)
@@ -110,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)
@@ -132,31 +130,39 @@ class LocalContact: AndroidContact, LocalAddress {
this.flags = flags
}
override fun deleteLocal() {
delete()
}
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID))
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
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
groupMemberships += groupID
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
)
batch += BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
cachedGroupMemberships += groupID
}
fun removeGroupMemberships(batch: BatchOperation) {
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(dataSyncURI())
.withSelection(
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
))
batch += BatchOperation.CpoBuilder
.newDelete(dataSyncURI())
.withSelection(
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
)
groupMemberships.clear()
cachedGroupMemberships.clear()
}

View File

@@ -25,41 +25,44 @@ 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
*/
fun create(provider: ContentProviderClient, fromCollection: Collection): T?
fun create(client: ContentProviderClient, fromCollection: Collection): T?
/**
* Returns all local collections of the data store, including those which don't have a corresponding remote
* [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
*/
fun getAll(account: Account, provider: ContentProviderClient): List<T>
fun getAll(account: Account, client: ContentProviderClient): List<T>
/**
* 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
*/
fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection)
fun update(client: ContentProviderClient, localCollection: T, fromCollection: Collection)
/**
* Deletes the local collection.

View File

@@ -4,195 +4,109 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.resource.LocalEvent.Companion.numInstances
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.AndroidEventFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.ical4jVersion
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import net.fortuna.ical4j.model.property.ProdId
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: AndroidEvent, LocalResource<Event> {
class LocalEvent(
val recurringCalendar: AndroidRecurringCalendar,
val androidEvent: AndroidEvent2
) : LocalResource<Event> {
companion object {
init {
ICalendar.prodId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/" + ical4jVersion)
}
override val id: Long
get() = androidEvent.id
const val COLUMN_ETAG = Events.SYNC_DATA1
const val COLUMN_FLAGS = Events.SYNC_DATA2
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
override val fileName: String?
get() = androidEvent.syncId
/**
* Marks the event as deleted
* @param eventID
*/
fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) {
provider.update(
ContentUris.withAppendedId(
Events.CONTENT_URI,
eventID
).asSyncAdapter(account),
contentValuesOf(Events.DELETED to 1),
null, null
)
}
override val eTag: String?
get() = androidEvent.eTag
override val scheduleTag: String?
get() = androidEvent.scheduleTag
override val flags: Int
get() = androidEvent.flags
/**
* Finds the amount of direct instances this event has (without exceptions); used by [numInstances]
* to find the number of instances of exceptions.
*
* The number of returned instances may vary with the Android version.
*
* @return number of direct event instances (not counting instances of exceptions); *null* if
* the number can't be determined or if the event has no last date (recurring event without last instance)
*/
fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
// query event to get first and last instance
var first: Long? = null
var last: Long? = null
provider.query(
ContentUris.withAppendedId(
Events.CONTENT_URI,
eventID
),
arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null
)?.use { cursor ->
cursor.moveToNext()
if (!cursor.isNull(0))
first = cursor.getLong(0)
if (!cursor.isNull(1))
last = cursor.getLong(1)
}
// if this event doesn't have a last occurence, it's endless and always has instances
if (first == null || last == null)
return null
/* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances
on the fly and it doesn't accept those values. So we use the first/last actual occurence
of the event (calculated by Android). */
val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account)
.buildUpon()
.appendPath(first.toString()) // begin timestamp
.appendPath(last.toString()) // end timestamp
.build()
var numInstances = 0
provider.query(
instancesUri, null,
"${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()),
null
)?.use { cursor ->
numInstances += cursor.count
}
return numInstances
}
/**
* Finds the total number of instances this event has (including instances of exceptions)
*
* The number of returned instances may vary with the Android version.
*
* @return number of direct event instances (not counting instances of exceptions); *null* if
* the number can't be determined or if the event has no last date (recurring event without last instance)
*/
fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
// num instances of the main event
var numInstances = numDirectInstances(provider, account, eventID) ?: return null
// add the number of instances of every main event's exception
provider.query(
Events.CONTENT_URI,
arrayOf(Events._ID),
"${Events.ORIGINAL_ID}=?", // get exception events of the main event
arrayOf("$eventID"), null
)?.use { exceptionsEventCursor ->
while (exceptionsEventCursor.moveToNext()) {
val exceptionEventID = exceptionsEventCursor.getLong(0)
val exceptionInstances = numDirectInstances(provider, account, exceptionEventID)
if (exceptionInstances == null)
// number of instances of exception can't be determined; so the total number of instances is also unclear
return null
numInstances += exceptionInstances
}
}
return numInstances
}
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)
}
override var fileName: String? = null
private set
override var eTag: String? = null
override var scheduleTag: String? = null
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 }
override var flags: Int = 0
private set
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
val event = legacyCalendar.getEvent(androidEvent.id)
?: throw LocalStorageException("Event ${androidEvent.id} not found")
var weAreOrganizer = false
private set
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
this.flags = flags
_event = event
return event
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
/**
* 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
}
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
val event = requireNotNull(event)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
super.populateEvent(row, groupScheduled)
}
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
super.buildEvent(recurrence, builder)
/**
* 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
))
}
@@ -204,25 +118,25 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
*/
override fun prepareForUpload(): String {
// make sure that UID is set
val uid: String = 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)
calendar.provider.update(eventSyncURI(), values, null, null)
androidEvent.update(values)
// update this event
event?.uid = newUid
// update in cached event data object
getCachedEvent().uid = newUid
newUid
}
val uidIsGoodFilename = uid.all { char ->
// see RFC 2396 2.2
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?'
'-','_','.','!','~','*','\'','(',')' // allow unreserved characters
char.isLetterOrDigit() || arrayOf( // allow letters and digits
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
).contains(char)
}
return if (uidIsGoodFilename)
@@ -231,39 +145,31 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
"${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(COLUMN_ETAG, eTag)
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
values.put(Events.DIRTY, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
val values = contentValuesOf(
Events.DIRTY to 0,
AndroidEvent2.COLUMN_ETAG to eTag,
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
)
if (fileName.isPresent)
values.put(Events._SYNC_ID, fileName.get())
androidEvent.update(values)
}
override fun updateFlags(flags: Int) {
val values = contentValuesOf(COLUMN_FLAGS to flags)
calendar.provider.update(eventSyncURI(), values, null, null)
androidEvent.update(contentValuesOf(
AndroidEvent2.COLUMN_FLAGS to flags
))
}
this.flags = flags
override fun deleteLocal() {
recurringCalendar.deleteEventAndExceptions(id)
}
override fun resetDeleted() {
val values = contentValuesOf(Events.DELETED to 0)
calendar.provider.update(eventSyncURI(), values, null, null)
androidEvent.update(contentValuesOf(
Events.DELETED to 0
))
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
}
}

View File

@@ -14,15 +14,18 @@ import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.AndroidGroupFactory
import at.bitfire.vcard4android.BatchOperation
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
@@ -53,7 +56,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
addressBook.allGroups { group ->
val groupId = group.id!!
val pendingMemberUids = group.pendingMemberships.toMutableSet()
val batch = BatchOperation(addressBook.provider!!)
val batch = ContactsBatchOperation(addressBook.provider!!)
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
@@ -109,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
@@ -157,40 +160,40 @@ 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
val batch = BatchOperation(addressBook.provider!!)
val batch = ContactsBatchOperation(addressBook.provider!!)
// delete old cached group memberships
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
))
batch += BatchOperation.CpoBuilder
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
)
// insert updated cached group memberships
for (member in getMembers())
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id))
batch += BatchOperation.CpoBuilder
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
batch.commit()
}
@@ -199,19 +202,23 @@ class LocalGroup: AndroidGroup, LocalAddress {
* Marks all members of the current group as dirty.
*/
fun markMembersDirty() {
val batch = BatchOperation(addressBook.provider!!)
val batch = ContactsBatchOperation(addressBook.provider!!)
for (member in getMembers())
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1))
batch += BatchOperation.CpoBuilder
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
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) {
@@ -221,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

@@ -6,7 +6,6 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxCollectionFactory
import at.bitfire.ical4android.JtxICalObject

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

@@ -6,12 +6,13 @@ package at.bitfire.davdroid.resource
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.Task
import at.bitfire.synctools.storage.BatchOperation
import 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

@@ -8,7 +8,6 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.SyncState
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
@@ -110,7 +109,7 @@ class LocalTaskList private constructor(
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}

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

@@ -2,21 +2,21 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
package at.bitfire.davdroid.resource
import at.bitfire.dav4jvm.property.webdav.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String,
val type: Type,
val value: String,
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
) {
companion object {

View File

@@ -7,7 +7,7 @@ package at.bitfire.davdroid.resource.contactrow
import android.net.Uri
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.contactrow.DataRowBuilder

View File

@@ -5,7 +5,7 @@
package at.bitfire.davdroid.resource.contactrow
import android.net.Uri
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.contactrow.DataRowBuilder
import java.util.LinkedList

View File

@@ -9,7 +9,8 @@ import android.os.Build
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalContact.Companion.COLUMN_HASHCODE
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.synctools.storage.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -129,12 +130,12 @@ class Android7DirtyVerifier @Inject constructor(
addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null)
}
override fun updateHashCode(contact: LocalContact, batch: BatchOperation) {
override fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation) {
val hashCode = contactDataHashCode(contact)
batch.enqueue(BatchOperation.CpoBuilder
batch += BatchOperation.CpoBuilder
.newUpdate(contact.rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode))
.withValue(COLUMN_HASHCODE, hashCode)
}

View File

@@ -7,7 +7,7 @@ package at.bitfire.davdroid.resource.workaround
import android.content.ContentValues
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.synctools.storage.ContactsBatchOperation
/**
* Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8,
@@ -49,6 +49,6 @@ interface ContactDirtyVerifier {
/**
Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data in a content provider batch operation.
*/
fun updateHashCode(contact: LocalContact, batch: BatchOperation)
fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation)
}

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.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.HrefListProperty
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.insertOrUpdateByUrlAndRememberFlags(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.insertOrUpdateByUrlAndRememberFlags(
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.insertOrUpdateByUrlAndRememberFlags(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

@@ -21,16 +21,16 @@ import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.common.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.network.DnsRecordResolver
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Credentials
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -88,7 +88,7 @@ class DavResourceFinder @AssistedInject constructor(
if (credentials != null)
authenticate(
host = null,
credentials = credentials
getCredentials = { credentials }
)
}
.build()

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

@@ -11,7 +11,6 @@ import android.os.Looper
import androidx.annotation.WorkerThread
import androidx.core.os.bundleOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings.Companion.CREDENTIALS_LOCK
import at.bitfire.davdroid.settings.AccountSettings.Companion.CREDENTIALS_LOCK_AT_LOGIN_AND_SETTINGS
import at.bitfire.davdroid.settings.migration.AccountSettingsMigration
@@ -125,7 +124,13 @@ class AccountSettings @AssistedInject constructor(
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
// OAuth
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString())
credentials.authState?.let { authState ->
updateAuthState(authState)
}
}
fun updateAuthState(authState: AuthState) {
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, authState.jsonSerializeString())
}
/**
@@ -172,7 +177,7 @@ class AccountSettings @AssistedInject constructor(
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
}
val newValue = if (seconds == null) SYNC_INTERVAL_MANUALLY else seconds
val newValue = seconds ?: SYNC_INTERVAL_MANUALLY
accountManager.setAndVerifyUserData(account, key, newValue.toString())
automaticSyncManager.updateAutomaticSync(account, dataType)
@@ -349,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

@@ -2,16 +2,26 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
package at.bitfire.davdroid.settings
import net.openid.appauth.AuthState
/**
* Represents credentials that are used to authenticate against a CalDAV/CardDAV/WebDAV server.
*
* Note: [authState] can change from request to request, so make sure that you have an up-to-date
* copy when using it.
*/
data class Credentials(
/** username for Basic / Digest auth */
val username: String? = null,
/** password for Basic / Digest auth */
val password: CharArray? = null,
/** alias of an client certificate that is present on the system */
val certificateAlias: String? = null,
/** OAuth authorization state */
val authState: AuthState? = null
) {
@@ -26,7 +36,7 @@ data class Credentials(
if (certificateAlias != null)
s += "certificateAlias=$certificateAlias"
if (authState != null)
if (authState != null) // contains sensitive information (refresh token, access token)
s += "authState=${authState.jsonSerializeString()}"
return "Credentials(" + s.joinToString(", ") + ")"

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.settings
import androidx.appcompat.app.AppCompatDelegate
import at.bitfire.davdroid.settings.Settings.PRESELECT_COLLECTIONS_EXCLUDED
object Settings {
@@ -60,5 +61,9 @@ object Settings {
/** whether all address books are forced to be read-only */
const val FORCE_READ_ONLY_ADDRESSBOOKS = "force_read_only_addressbooks"
/** max. number of accounts */
const val MAX_ACCOUNTS = "max_accounts"
}
}

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

@@ -8,10 +8,11 @@ import android.accounts.Account
import android.content.Context
import android.content.pm.PackageManager
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Reminders
import androidx.core.content.ContextCompat
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Binds
@@ -45,8 +46,14 @@ class AccountSettingsMigration10 @Inject constructor(
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
provider.update(
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
AndroidCalendar.calendarBaseValues, null, null)
Calendars.CONTENT_URI.asSyncAdapter(account),
contentValuesOf(
Calendars.ALLOWED_REMINDERS to arrayOf(
Reminders.METHOD_DEFAULT,
Reminders.METHOD_ALERT,
Reminders.METHOD_EMAIL
).joinToString(",") { it.toString() }
), null, null)
}
}

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

@@ -7,7 +7,7 @@ package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract
import androidx.annotation.OpenForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.Service
@@ -18,6 +18,7 @@ import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.ical4android.JtxCollection
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
import at.techbee.jtx.JtxContract
import dagger.Binds
import dagger.Module
@@ -64,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
@@ -80,35 +76,25 @@ class AccountSettingsMigration20 @Inject constructor(
@OpenForTesting
internal fun migrateCalendars(account: Account, calDavServiceId: Long) {
try {
calendarStore.acquireContentProvider()
} catch (_: SecurityException) {
// no contacts permission
null
}?.use { provider ->
for (calendar in calendarStore.getAll(account, provider))
provider.query(calendar.calendarSyncURI(), arrayOf(Calendars.NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
cursor.getString(0)?.let { url ->
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
calendar.update(contentValuesOf(
Calendars._SYNC_ID to collection.id
))
}
}
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()) {
val url = calendar.name ?: continue
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
calendar.update(contentValuesOf(
CalendarContract.Calendars._SYNC_ID to collection.id
))
}
}
}
}
@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
}
}

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