Compare commits

...

161 Commits

Author SHA1 Message Date
Arnau Mora
46e8c4522b Updated name, package and color
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-10-25 17:47:23 +02:00
Sunik Kupfer
d00353ba9c Replace android sync framework result class with our own (#1094)
* Use our own SyncResult data class

* Minor comment changes

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-25 14:13:43 +02:00
Ricki Hirner
dc0d4f371a More compatible processing of multiget responses (#1099)
* Ignore multi-get responses without calendar/contact data

* Add comment
2024-10-25 12:44:54 +02:00
Arnau Mora
3d198f5454 LocalAddressBook: rename account to addressbookAccount (#1095)
* Upgraded vcard4android

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

* Replaced all usages of addressBookAccount

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

* Minor changes

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-22 14:07:53 +02:00
Ricki Hirner
1802740a2d Version bump to 4.4.3.2 2024-10-20 16:32:12 +02:00
Ricki Hirner
138e517d23 LocalAddressBook: move contacts when renaming the address book account (#1084)
* LocalAddressBook: move contacts when renaming the address book account

* Don't make contacts dirty when moving

* Move isDirty to tests because it's only required for tests

* We don't have to set the user-data twice

* Add test for groups
2024-10-20 16:31:31 +02:00
Ricki Hirner
166b2ac220 Bump version to 4.4.3.1 2024-10-18 16:53:28 +02:00
Ricki Hirner
450a418994 Don't crash when logging null parameter (#1081) 2024-10-18 16:51:26 +02:00
Ricki Hirner
d4e9e2a8f7 Reduce warnings, lint 2024-10-17 16:40:09 +02:00
Ricki Hirner
ecc59dda99 Update dependencies 2024-10-17 16:32:35 +02:00
Ricki Hirner
9c2afbab09 Fetch translations from Transifex 2024-10-17 16:14:04 +02:00
Ricki Hirner
cebf2d9dfd Version bump to 4.4.3 2024-10-17 16:10:18 +02:00
Sunik Kupfer
5f49c675c8 Try to adhere to google play guidelines for background location permission (bitfireAT/davx5#614)
* Always show "WiFi SSID card" in account settings when SSID restriction is active and adapt content according to whether all conditions are met or not

* Move explanation to top and add paragraph

* Remove unnecessary parenthesis

* Use two text composables and no spacer

* Fix preview
2024-10-16 11:11:44 +02:00
Ricki Hirner
62c46e123d Avoid very long log lines and resulting OOM (#1073)
* PlainTextFormatter: truncate log lines to ~10000 characters

* Update vcard4android which doesn't dump Contact photos anymore

* Add truncation test
2024-10-15 15:22:03 +02:00
Ricki Hirner
5f1215801d Update dependencies 2024-10-15 12:32:45 +02:00
Ricki Hirner
930977c44b Make collections in CollectionsList clickable again (#1075)
CollectionsList: use modifier again
2024-10-15 11:40:09 +02:00
Arnau Mora
a0d152a66f Fixed surface container color in dark theme (#1069)
* Fixed surface color in dark theme

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

* Account screen: higher contrast for collection cards

* Account screen: use normal instead of elevated cards

* Adapt colors of card lists

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-14 15:03:08 +02:00
Ricki Hirner
a8883427bc Fetch translations from Transifex 2024-10-10 18:25:36 +02:00
Ricki Hirner
7a8dbef80b Version bump to 4.4.3-rc.1 2024-10-10 18:23:05 +02:00
Ricki Hirner
4a40bb3d6f Syncer: make sure collections which are deleted are not synced (#1065)
* Syncer: make sure collections which are deleted are not synced

* Syncer: log when local collection is removed

* Update KDoc and tests

* Handle all CRUD work in updateCollections

* Update naming, KDoc, tests

* Minor changes (KDoc, naming)
2024-10-10 18:08:13 +02:00
Arnau Mora
c805e549ff [Push] Show notification on push notification (until sync is started) (#1043)
* Added sync pending notification

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

* Moved notify function to PushNotificationManager

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

* Added ongoing and only-alert-once

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

* Added notification hiding

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

* Got rid of `cancel`

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

* Fixed comments

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

* Added content intent and sub text

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

* Updated usages

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

* Review changes

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-10-10 15:50:53 +02:00
Sunik Kupfer
26a670c181 Fix old address book accounts not being deleted (#1039)
* Log warning instead of throwing exception when not possible to find account for address book account

* Run sync and accounts cleanup in migration

* Rename accounts in migration

* Run account settings migrations on background thread

* Revert "Run account settings migrations on background thread"

This reverts commit 6b578da4f1.

* Add tests for AccountsCleanupWorker

* Move companion object to end of class

* Don't use AccountRepository for address book accounts

* Update account user data in LocalAddressBook

* Minor changes (naming etc)

* Add log line when migrating

* Try to fix test error

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-09 13:28:54 +02:00
Arnau Mora
5b54c9dff0 MKCALENDAR: send VTIMEZONE in calendar-timezone (#1044)
* Upgrade dav4jvm

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

* separate CalendarTimezone and CalendarTimezoneId

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

* Fixed timezone name setting

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

* Fixed `VTIMEZONE` conversion

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

* Using text instead of CDATA

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

* Fixed spec

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

* Added comment

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

* Renamed `timezoneDef` to `timezoneId`

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

* Upgrade dav4jvm

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

* separate CalendarTimezone and CalendarTimezoneId

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

* Fixed timezone name setting

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

* Fixed `VTIMEZONE` conversion

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

* Using text instead of CDATA

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

* Fixed spec

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

* Added comment

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

* Renamed `timezoneDef` to `timezoneId`

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

* [CI] Update workflows to Java 21

* Set default value of the timezone state to null

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-09 00:12:34 +02:00
Ricki Hirner
1ca73b67a4 Add KDoc, rename AccountUtils to SystemAccountUtils (#1059) 2024-10-07 18:52:18 +02:00
Ricki Hirner
194c587476 Update Compose, Kotlin, gradle 2024-10-07 12:38:18 +02:00
Sunik Kupfer
1193027e5f Add account name to address book account name (#1050)
* Add account name to address book account name

* Add parenthesis for account name and a hashtag for the collection id

* Use the correct id

* Move DI entry point to where it is used
2024-10-07 12:26:53 +02:00
Ricki Hirner
7de7980860 Use SafeAndroidUriHandler to prevent crashes when no browser is installed (#1058)
* Use SafeAndroidUriHandler in AppTheme

* UiUtils: use DI for Logger

* SyncWorkerManager: use DI for Logger
2024-10-07 10:44:36 +02:00
Ricki Hirner
fc7f42c6fa Sync worker management: move logic out of companion object (#1056)
* Sync worker management: move logic from companion object to new class

* Fix tests

* Move re-sync inputs from [OneTimeSyncWorker] to [BaseSyncWorker] as they're processed there

* Remove useless Companion
2024-10-07 09:34:02 +02:00
Sunik Kupfer
196bfb3aea Don't use AccountSettings on main thread (#1049)
* Document that AccountSettings shouldn't be used in the main thread

* Throw exception when AccountSettings are used on the main thread

* Don't access AccountSettings on main thread

* Don't access AccountSettings on main thread

* Don't access AccountSettings on main thread
2024-10-03 15:36:42 +02:00
Sunik Kupfer
cb5798833d Ignore lint AppLinkUrlError (#1053) 2024-10-03 15:33:39 +02:00
Ricki Hirner
a1148613e9 [CI] Update workflows to Java 21 2024-10-02 13:08:11 +02:00
Ricki Hirner
12529fa9bd Update toolchain and AGP 2024-10-02 12:54:50 +02:00
Ricki Hirner
d743d19a3d AccountsScreen: better preview 2024-09-19 15:42:45 +02:00
Ricki Hirner
4dcee27e22 Version bump to 4.4.3-beta.1 2024-09-19 15:17:20 +02:00
Sunik Kupfer
b6ceaa7efc Remove concept of main accounts (#989)
* Acquire account settings via address book account

* Extract the code to find an address books main account to the account repository

* Use collection id as reference in address book account

* Remove obsolete baos

* Find main account directly from collection in SyncManager

* Require main account to get account settings

* Stop deleting address book accounts without a main account, since they may exist on their own now

* Require content provider and introduce static deleteByCollection method

* Update KDoc

* Show all address book accounts separately

* Drop mainAccount method

* [DI] Use AssistedInject for LocalAddressBook

* Renaming, remove "main account" concept

* Fix debug info

* AccountsCleanupWorker: Rename main account to account

* Further remove main accounts

* Reduce redundancy

* AccountSettings: check account type

* AccountSettingsMigrations: drop v5 -> v6 migration (not tested anyway)

* AccountRepository: directly delete accounts

* Remove obsolete workerAccount

* Get all address books, even if not sync enabled

* Delete orphan address book accounts

* Rename two more occurrences of main account concept

* AccountSettings: allow test accounts

* Syncer: rename methods for clarity, add KDoc

* Drop empty test class

* Make code more readable and add comment

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-09-19 15:06:45 +02:00
Ricki Hirner
5c6f712d32 Update AGP and dependencies 2024-09-19 14:02:34 +02:00
Arnau Mora
5180b99af2 Moved pull-to-refresh indicator below tabs (#1028)
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-16 15:48:48 +02:00
Arnau Mora
dcb7e315b9 ClickableText for URLs has been deprecated (#1024)
* Got rid of `UrlAnnotation`

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

* Deprecated and suggested a ReplaceWith

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

* Optimized imports

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

* Replaced usages of `ClickableTextWithLink`

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

* Removed `ClickableTextWithLink`

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

* Migrated `ClickableTextWithLink`

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

* Remove experimental text api annotations

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2024-09-16 15:02:17 +02:00
Arnau Mora
111481cd00 Added isLoading to Assistant (#1027)
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-16 14:35:36 +02:00
Ricki Hirner
4dc7df7c53 Reword login text (#1026)
- Change empty accounts text
- Add privacy note to Login screen
- Changed last button text
2024-09-16 14:34:08 +02:00
Arnau Mora
cf609288e1 Update Dependencies (#1017)
* Upgrade dependencies

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

* Upgrade dependencies

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

* Migrated pull to refresh

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

* Migrated `LocalMinimumInteractiveComponentEnforcement`

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

* Removed disabling of linting

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

* Optimize imports

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

* Increased indicator show time

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-14 13:22:43 +02:00
Ricki Hirner
0b9d4cd3b3 Unsubscribe push from unsynced collections (#1011)
* Unsubscribe push from unsynced collections

* Remove subscription from DB, too

* Subscription: catch HTTP errors
2024-09-10 12:07:04 +02:00
Arnau Mora
0581417bba Increase SDK level to 35 (#1003)
* Increase SDK level

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

* Fixed nullability issues

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

* Increase SDK level

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

* Fixed nullability issues

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

* Replaced `removeFirst()` by `removeAt(0)`

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

* Switched to null check instead of NPE catch

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

* Using orEmpty

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-10 11:18:40 +02:00
Ricki Hirner
f8fb016a27 [CI] Better test names 2024-09-06 11:29:15 +02:00
Ricki Hirner
8c3d1cdeae InitCalendarProviderRule: make multiple attempts to create a calendar (#1007) 2024-09-06 11:18:08 +02:00
Arnau Mora
4a4dc24cdf dark theme / black text basically unreadable on dark background (#986)
* Provided content color

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

* Adjusted bar color

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

* Got rid of theme changes

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

* Added wrapping scaffold

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

* Changed colors

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-06 10:07:30 +02:00
Arnau Mora
49a51ef384 Reproducible Builds (for IzzyOnDroid) (#995)
* Added `BUILD_DATE` environment variable

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

* Excluded `generated`

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

* Removed argument

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

* Using build time from git

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

* Removed unused import

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

* Got rid of build date

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

* Got rid of build date

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2024-09-06 09:48:29 +02:00
Arnau Mora
fc698040aa lint fails in AboutActivity (#1001)
* Disabled `CoroutineCreationDuringComposition` in lint

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

* Moved disable

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-09-04 17:46:44 +02:00
Ricki Hirner
6cbd71ab50 Update dependencies (including AGP) 2024-09-04 11:03:29 +02:00
Ricki Hirner
47f078dcd7 Version bump to 4.4.3-alpha.1 2024-08-21 12:23:54 +02:00
Ricki Hirner
be6ab8728c Update dependencies and Gradle 2024-08-21 12:18:51 +02:00
Ricki Hirner
2908bba298 [CI] Compile job: only compile sources, not tests 2024-08-21 12:01:22 +02:00
Sunik Kupfer
b962b68631 Use standard content provider instead of TaskProvider in TaskSyncer (#982)
* Update ical4android

* Use standard content provider in TaskSyncer

* Check version instead of acquiring TaskProvider

* Add sync result error

---------

Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2024-08-20 10:47:50 +02:00
Sunik Kupfer
fca7c09105 Tests for sync algorithm (#974)
* Prepare Syncer for tests

* Test sync honors preparation result

* Refactor sync algorithm into smaller testable methods

* Write weak tests for the individual methods

* Update KDoc

* Minor changes
 - update comments
 - update test method spacing
 - replace empty array method

---------

Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-08-19 13:42:22 +02:00
Sunik Kupfer
60c6aba2d2 Log stop reason when sync worker is cancelled (#984) 2024-08-17 16:06:21 +02:00
Ricki Hirner
70f6f2603e SyncAdapterServices: Use a coroutine scope to cancel waiting on framework request (#977)
* SyncAdapterServices: Use a coroutine scope to cancel waiting on framework request

* Added tests
2024-08-14 14:00:54 +02:00
Ricki Hirner
5d4c9c8d94 Don't overwrite calendar/task list color with default color (#971)
Calendars, task lists: always set color at creation, then overwrite only when available from server
2024-08-14 10:13:08 +02:00
Ricki Hirner
4378bee042 [CI] Skip compile job when not on main branch (#978) 2024-08-13 13:19:17 +02:00
Ricki Hirner
3776b50bbc Update dependencies, Kotlin, AGP 2024-08-12 14:19:00 +02:00
Sunik Kupfer
a9c7e1929f Fix sync not running directly after enabling a collection (#966)
Retrieve local sync collections only once and return them on creation
2024-08-12 11:55:35 +02:00
Ricki Hirner
318b9be77e AccountRepository: don't add onAccountsUpdated listener on main thread 2024-08-08 11:30:56 +02:00
Ricki Hirner
26cb845950 Reduce StrictMode annoyance 2024-08-08 10:40:25 +02:00
Sunik Kupfer
eae6d0c578 Fix coincidence naming of LocalCollection members (#957)
* Fix overlapping method name and use interface everywhere

* Fix overlapping property name

* Update logger usage

---------

Co-authored-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-08-08 10:18:21 +02:00
Ricki Hirner
6df0925e50 Version bump to 4.4.2 2024-08-06 13:31:14 +02:00
Ricki Hirner
f7ee1ea931 [UI] Show push support status in collection details (#961)
* Collection details: show WebPush support

* Collection view: show Push subscription time
2024-08-06 13:29:31 +02:00
Ricki Hirner
16731d3a5a Suppress dnsjava warning because of missing Context (#959) 2024-08-06 12:39:57 +02:00
Ricki Hirner
54e09acca3 Don't generate/let Gradle user cache grow in main branch 2024-08-06 11:26:46 +02:00
Ricki Hirner
46698a76b5 Fetch translations from Transifex 2024-08-05 17:41:06 +02:00
Ricki Hirner
e26a8519ff Version bump to 4.4.2-rc.1 2024-08-05 17:40:06 +02:00
Arnau Mora
410c70a47d Passing collection directly (#927)
* Passing `collection` directly

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

* Fixed imports

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

* Fix tests

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

* Log sync time using repository; no need for account/service check anymore

* SyncManagerTest: don't write SyncStats to DB

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-08-05 17:24:46 +02:00
Sunik Kupfer
bf1bdfc8ab Generalize syncer (#907)
* Use address book contacts content provider provided by syncer

* Close any acquired content provider after sync

* Acquire/Check task provider provider as preparation step

* Provide sync arguments at syncer creation

* Acquire ContentProviderClient in syncer implementations

* Generalize sync algorithm in Syncer

* Use contacts authority for address books

* Generalize sync algorithm in CalendarSyncer

* Generalize sync algorithm in SyncerTest

* Generalize sync algorithm in TaskSyncer

* Rename preparation method and add an after sync method

* Generalize sync algorithm in JtxSyncer

* Generalize sync algorithm in AddressBookSyncer

* Use repositories instead of DAOs

* Replace deprecated log statements

* Use generic type for collection types

* Infer authorities when possible and pass only task authorities along

* No need to close TaskProvider explicitly

* Use colors only where needed

* Use provider with auto closable

* Get sync collections in syncer implementations

* Delete syncer test

* Pass provider through methods instead of using lateinit property

* Reorder constructor arguments

* Remove trailing commas

* Remove obsolete undocumented conditional

* Reorder methods

* Reorder methods

* Abort sync when preparations fail

* Drop obsolete permission check

* Use generics for url and delete

* Use generic for update

* Use generic for syncCollection

* Rename create to createCollection for consistency

* Revert "Rename create to createCollection for consistency"

This reverts commit 0fee4fe7fcf3ec8ef965c9a2e0db991a1bbbbcf7.

* Revert "Use generic for syncCollection"

This reverts commit ae129fd17e06146a1e9f8631e3cdbd2dc0a4db06.

* Revert "Use generic for update"

This reverts commit 42dd665851ba83a75bb498b98bd56624e2b09647.

* Revert "Use generics for url and delete"

This reverts commit 7ae1425039656d4a9937628ca1799ce8c59ceebb.

* Move delete() to LocalCollection

* Move url to LocalCollection

* Fix local test collection

* Minor changes

* Minor changes
- make sync private
- use to autoclose provider

* Query for sync collections once only

* Add KDoc and update comments

* Make property a local variable

* Update KDoc

* Add back ose conditional

* Remove blank line at beginning of method

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-08-05 17:22:07 +02:00
Arnau Mora
5cbbfb39aa Update dav4jvm for better logging (#956)
* Upgrade dav4jvm

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

* Using `XmlReader`

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-08-05 16:42:24 +02:00
Ricki Hirner
62d5a21d05 Update ical4android, specify ical4j log level (#946)
* Update ical4android, specify ical4j log level (was previously in ical4android)

* Add "dependencies" label for auto-generated release notes
2024-07-28 16:54:01 +02:00
Ricki Hirner
2a7cf1ae17 Fix R8 rules 2024-07-28 14:34:55 +02:00
Ricki Hirner
1062eaa58a Version bump to 4.4.2-beta.1 2024-07-28 13:54:03 +02:00
Ricki Hirner
69bde87589 WebDAV mounts: fix quota view (#945)
* Refresh quota for newly created mounts

* WebDAV mounts screen: take quota from root document
2024-07-28 13:53:39 +02:00
Ricki Hirner
4111fe08d2 Drop userAgent build configuration variable 2024-07-28 12:36:59 +02:00
Ricki Hirner
19fb969040 Drop userAgent build configuration variable 2024-07-28 11:20:25 +02:00
Ricki Hirner
74a22cd24d Update dependencies 2024-07-28 11:14:53 +02:00
Ricki Hirner
4e264076d1 Amend PR template 2024-07-26 14:50:21 +02:00
Ricki Hirner
c3fe1b04e5 Backport minor changes from non-ose (#941)
Login: correctly combine input flows for account details UI state (backport bitfireAT/davx5#575)
2024-07-26 12:04:19 +02:00
Ricki Hirner
342314363b Simplify InitCalendarProviderRule (#940)
Simplify InitCalendarProviderRule
2024-07-25 22:56:07 +02:00
Ricki Hirner
d0358a9980 Use test account type (without sync adapters) for instrumented tests (bitfireAT/davx5#601)
* Use test account type (without sync adapters as side effects) for instrumented tests

* Provide standardized way to get test account; re-enable LocalAddressBookTest
2024-07-25 20:57:23 +02:00
Ricki Hirner
fbcf6996ad Don't access DB in UnifiedPushReceiver main thread 2024-07-24 17:43:58 +02:00
Ricki Hirner
cf994ee82e Move UrlUtils methods to DavUtils 2024-07-24 17:15:57 +02:00
Ricki Hirner
9880dd5158 Move TaskUtils to TasksAppManager (closes #937) 2024-07-24 16:39:30 +02:00
Ricki Hirner
8e7d289971 Tests: don't create account as long as Hilt is not ready (#939) 2024-07-24 15:13:34 +02:00
Ricki Hirner
d4c05b9282 NotificationRegistry: enforce creation of channels before they can be accessed 2024-07-24 15:13:06 +02:00
Ricki Hirner
768f462549 Use AccountRepository to get list of accounts (#938)
* Use AccountRepository to get list of accounts

* No AndroidEntryPoint in (Glance) widget
2024-07-24 14:50:48 +02:00
Ricki Hirner
cbd9a55c15 Replace some non-injected loggers 2024-07-24 09:53:31 +02:00
dependabot[bot]
4e496265e4 Bump dnsjava:dnsjava from 3.5.3 to 3.6.0 (#933)
Bumps [dnsjava:dnsjava](https://github.com/dnsjava/dnsjava) from 3.5.3 to 3.6.0.
- [Release notes](https://github.com/dnsjava/dnsjava/releases)
- [Changelog](https://github.com/dnsjava/dnsjava/blob/master/Changelog)
- [Commits](https://github.com/dnsjava/dnsjava/compare/v3.5.3...v3.6.0)

---
updated-dependencies:
- dependency-name: dnsjava:dnsjava
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-23 22:08:16 +02:00
Arnau Mora
73d0b63705 Debug info: Sync interval "0 min" when actually -1 (manual) (#928)
Added check for only manual sync

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-07-23 19:08:06 +02:00
Ricki Hirner
a361888d94 Version bump to 4.4.2-alpha.2 2024-07-23 01:11:39 +02:00
Ricki Hirner
907b38fd6a Better verbose log formatting 2024-07-22 00:18:52 +02:00
Ricki Hirner
bf0e169cf1 Remove obsolete Logger object (#930) 2024-07-21 23:32:45 +02:00
Ricki Hirner
fd2b3f0018 Refactor SettingsManager/SettingsProvider to use DI properly (bitfireAT/davx5#599)
* Refactor SettingsManager/SettingsProvider to use DI

* Fix tests

* Remove BaseDefaultsProvider

* Fix gplay tests
2024-07-20 21:47:15 +02:00
Ricki Hirner
6217582677 Debug builds: add StrictMode for ThreadPolicy, don't setup crash handler 2024-07-20 18:51:57 +02:00
Ricki Hirner
d03dc1f37d Remove unnecessary SyncComponent 2024-07-20 18:40:42 +02:00
Ricki Hirner
d33e4dcb23 Update dependencies 2024-07-20 18:35:31 +02:00
Ricki Hirner
f4c02d4ab6 [DI] Use @Inject lateinit var for abstract classes (#929)
* Use @Inject lateinit var for abstract classes (subclasses have @Inject constructor)

* Fix tests
2024-07-20 18:19:04 +02:00
Arnau Mora
fafa358dd8 DB: some missing indices (#890)
* Added index for `ownerId`

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

* Increased version number

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

* Increased version number

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

* Added version

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

* Added pushTopic as index

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

* Moved index

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

* Changed uniqueness

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

* Changed indexing method

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

* Moved `parentId` to independent index

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-07-20 17:35:39 +02:00
Ricki Hirner
d5d6592ae2 SyncWorker: use Provider for lazy injection (#925)
* SyncWorker: use Provider for lazy injection

* Use Provider to lazyily inject SyncAdapter
2024-07-19 18:09:20 +02:00
Ricki Hirner
ac48e65b1a Remove deprecated code / annotate as deprecated 2024-07-19 17:41:47 +02:00
Ricki Hirner
26f95db62a Re-factor Notifications and various Utils to DI (#924)
* [WIP] Use NotificationRegistry to post notifications

* Replace NotificationUtils by NotificationRegistry

* Re-factor SyncConditions; move tests to default location

* Describe notification channels
2024-07-19 17:10:29 +02:00
Ricki Hirner
5c4d697767 WebDAV cache: re-factor using Guava and Hilt (#921)
* Use per-resource Guava cache with weak keys and values as WebDAV page cache

* Replace ExtendedLruCache by Guava cache

* Use injected logger in DavDocumentsProvider

* Use DI for RandomAccessCallback, move Wrapper to outer class

* Fix tests

* Use Hilt for more classes

* Use Guava LoadingCache as page cache

* Fix tests

* Minor code change
2024-07-19 11:59:10 +02:00
Ricki Hirner
59a57fc40a Update dnsjava, use desugaring with nio, refactor DNS resolving (#917)
* Update dnsjava, use desugaring with nio, refactor DNS resolving

* KDoc

* Rewrite bestSRVRecord test

* Add pathsFromTXTRecords tests

* Add desugaring note

* Rename @AssistedFactory method to "create"

* testBestSRVRecord_MultipleRecords_Priority_Same: broaden range for distribution
2024-07-18 11:09:00 +02:00
Ricki Hirner
50c13e5b6d Install uncaught exception handler in a separate startup plugin (bitfireAT/davx5#597) 2024-07-18 10:21:31 +02:00
Ricki Hirner
3a38a06302 Version bump to 4.4.2-alpha.2 2024-07-17 12:41:50 +02:00
Ricki Hirner
c489002f5c Create interface for startup plugins (#915)
* Create StartupPlugin interface, implement with TasksAppWatcher (don't run during tests)

* KDoc
2024-07-16 14:45:40 +02:00
Ricki Hirner
7cbc9bd4f5 Sync: take URL from DB (Collection) instead of making a detour over other places 2024-07-16 12:14:10 +02:00
Ricki Hirner
1fd65a4d42 Use DI for AccountSettings and Android tests (#911)
* Use DI for AccountSettings

* AccountSettings: clarify different account variables

* Fix tests

* Inject @ApplicationContext instead of Application

* Add Hilt rule to various tests

* Fix tests

* Consequently use injected @ApplicationContext in Android tests; fix Hilt rules
2024-07-15 19:39:21 +02:00
Sunik Kupfer
b3cc24e4be Fix: Syncer synchronizes only once (#910)
* Use a separate HashMap to determine new remote collections

* Close provider after sync
2024-07-15 11:00:34 +02:00
Ricki Hirner
556741ae1e Get rid of Apache Commons (#901)
* Replace StringUtils

* Replace DateFormatUtils and NumberUtils

* Fix tests

* Replace ReflectionToStringBuilder

* Rewrite MemoryCookieStore to get rid of MultiMap

* Get rid of org.apache.commons.lang3.exception.ExceptionUtils

* Replace org.apache.commons.collections4.CollectionUtils

* Replace DigestUtils

* Replace ContextedException by SyncException

* Fix HttpClientTest

* SyncManager: remove obsolete code

* Fix StringUtilsTest

* DiskCache: add hit/miss logging

* DebugInfoModel: simplify PrintWriter generation
2024-07-14 10:22:00 +02:00
Ricki Hirner
b1bcf32535 Update AGP 2024-07-13 18:44:53 +02:00
Ricki Hirner
be43d360ba Refactor logging (#906)
* LogcatHandler: log source class as tag and not in the text, remove max length

* Separate LogManager and Logger usage

* Improve StringHandler for DavResourceFinder

* Tests, KDoc
2024-07-13 17:37:07 +02:00
Ricki Hirner
0adceb64ec Squashed commit of the following:
commit 60219aafc7
Author: Ricki Hirner <hirner@bitfire.at>
Date:   Thu Jul 11 23:59:54 2024 +0200

    Version bump to 4.4.1.1

commit 209fdf3e7c
Author: Ricki Hirner <hirner@bitfire.at>
Date:   Thu Jul 11 23:37:37 2024 +0200

    AccountSettings v16: re-enqueue periodic sync workers with correct class name (bitfireAT/davx5#593)
2024-07-12 12:30:19 +02:00
Ricki Hirner
5ebef30abb Tasks screen: adapt padding and recommendation (#898)
Tasks screen: adapt padding and text
2024-07-11 15:47:56 +02:00
Sunik Kupfer
3d65afbf8f Provide collection to SyncManager (#881)
* Provide collection to CalendarSyncManager

* Provide collection to ContactsSyncManager

* Provide collection to JtxSyncManager

* Provide collection to TasksSyncManager

* Provide collection to SyncManager

* Fix test

* Minor changes

* Add kdoc

* Minor changes

- added KDoc
- SyncManager can never handle address book authority (content
  authority for contacts is contacts authority)
- moved @UsesThreadContextClassLoader to respective sync manager

* Mock collection in test

* Add comments separating the sync process into separate steps

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-07-11 14:09:35 +02:00
Sunik Kupfer
51f01b215c Remove address books sync authority and content provider (#877)
* Drop address book provider

* Drop address book sync adapter service

* Replace address books authority with contacts authority

* Update kdoc

* Revert "Update kdoc"

This reverts commit dfb14d466f6c58d9422e59c770e0a5348a497b7d.

* Revert "Replace address books authority with contacts authority"

This reverts commit 0e15bf11b3235dfbc38696741100210fbe497dbd.

* Don't enable addressbook sync for main account

* Acquire content provider in Syncer

* Use contacts authority instead of address book authority when acquiring content provider

* Set default sync interval for address book authority again

* Minor re-ordering of lines

* Add comment, rename variable

* Move SyncAdapterServices out of adapter package

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-07-11 11:17:09 +02:00
Arnau Mora
c02bf942e4 Replaced "Create" by "Add" when creating accounts (#892)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2024-07-08 11:11:21 +02:00
Ricki Hirner
d7025d4e9e Use proper DI for Syncer implementations (#889) 2024-07-04 22:21:20 +02:00
Ricki Hirner
56f7b4bbc5 Replace Apache Commons by native calls/Guava (#883)
* Replace Commons IO by native calls/Guava

* Drop other Apache Commons

* Fix tests
2024-07-04 16:00:29 +02:00
Ricki Hirner
4a3ebc422f Version bump to 4.4.1 2024-07-02 17:03:37 +02:00
Ricki Hirner
4b18302ec7 Fix JtxSyncer entry point 2024-07-02 15:25:12 +02:00
Ricki Hirner
5dd7609524 Fix tests 2024-07-02 14:06:19 +02:00
Ricki Hirner
48855c7bb8 [DI] Use constructor injection for SyncManager sub-classes (#874) 2024-07-02 12:59:42 +02:00
Ricki Hirner
7a8761f703 Version bump to 4.4.1-rc.1 2024-07-01 21:58:21 +02:00
Ricki Hirner
1dd91a2848 Update dependencies 2024-07-01 21:55:09 +02:00
Ricki Hirner
90f1c015d2 Fix "force read-only address books" setting in UI (bitfireAT/davx5#587)
* AccountScreen: take "force read-only address books" setting into account

* Collection screen: take "force read-only address books" setting into account

* Add KDoc

* Small naming change
2024-07-01 21:50:15 +02:00
Ricki Hirner
726e20ed52 Show CalDAV/CardDAV tab when the respective service is present (#868)
Use existence of CalDAV/CardDAV service as condition for whether to show the respective tab
2024-07-01 12:28:35 +02:00
Ricki Hirner
7f750e22cb Split syncadapter package (bitfireAT/davx5#586)
* Split syncadapter package into:

- sync: actual collection sync code and everything else
- sync.account: related to Android accounts
- sync.framework: Android sync framework integration
- sync.groups: contact group strategies
- sync.worker: sync workers (WorkManager)

* Remove unused file, fix imports
2024-06-30 14:42:40 +02:00
Ricki Hirner
6b6573ddd2 [CI] Don't run lint/unit tests on release variant 2024-06-29 10:05:59 +02:00
Ricki Hirner
8849b363c7 Fetch translations from Transifex 2024-06-29 09:57:17 +02:00
Ricki Hirner
005d6b30c0 Version bump to 4.4.1-beta.1; update dependencies 2024-06-29 09:56:08 +02:00
Ricki Hirner
c3436fd23f Mark UnifiedPush setting as experimental 2024-06-27 19:54:28 +02:00
Ricki Hirner
5497e343c0 Add Github dependency graph 2024-06-27 18:46:14 +02:00
Ricki Hirner
479a2c363a Optimize build cache; use Gradle 8.8 2024-06-27 18:22:14 +02:00
Arnau Mora
18b1e5222e Fix GMD tests (#867)
* Run CI

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

* Changed GPU to swiftshader

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

* Added more options

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

* Cleaning managed devices

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

* Removed cleanup of GMD

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

* Cleaning cache

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

* Removed app clean

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

* Removal of cached system image

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

* Got rid of cached image removal

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

* Disabled build cache

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

* Disabled configuration cache

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

* Changed run command

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

* Downgrade AGP

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

* Restore to original

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

* Upgrade AGP again

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

* Add setupTimeoutMinutes=180 option

* Use large runner

* Update gradle, add maxConcurrentDevices=1

* Remove maxConcurrentDevices=1 again

* Use gradle 8.7 again, clean caches before

* Create "compile" task that caches dependencies etc.

* Don't use incremental build cache anymore

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-06-27 18:12:55 +02:00
Ricki Hirner
d4b4981e26 Version bump to 4.4.1-alpha.3 2024-06-25 22:22:09 +02:00
Ricki Hirner
e1f3785bc6 Add ProGuard rule to work around androidx-lifecycle 2.8.x + Compose 1.6 crash 2024-06-25 22:22:09 +02:00
Arnau Mora
e92f261faf Replace AppIntro by Compose Pager (#848)
* Migrated into to compose

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

* Got rid of AppIntro

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

* Imports cleanup

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

* Imports cleanup

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

* Removed padding

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

* When launching intro, going back closes the app

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

* Added content description

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

* Moved IntroActivity.Model to IntroModel

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

* Given fixed padding

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

* Moved intro composables together

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

* Do not create new task

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

* Minor changes

* Remove last XML styles that were required for AppIntro

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-06-25 15:12:25 +02:00
Ricki Hirner
ea035fa931 Version bump to 4.4.1-alpha.2 2024-06-25 14:52:51 +02:00
Ricki Hirner
8167e8e3cb Update dependencies 2024-06-25 14:49:57 +02:00
Ricki Hirner
bcc16e1ab6 Implement basic Push functionality (#856)
* Move PushRegistrationWorker to push package

* Add UP dependency

* [WIP] UnifiedPush basic implementation

* Handle endpoint unregistration

* [WIP] Parse push notification message

* Parse push message in PushMessageParser

* Sync only affected account on push message

* Only initiate sync when push message is about a syncable collection

* Push registration worker: log when there's no configured endpoint

* Handle invalid/non-XML push messages

* app settings: show UP endpoint
2024-06-25 14:07:29 +02:00
Ricki Hirner
28948485f6 Update AGP 2024-06-16 20:05:19 +02:00
Ricki Hirner
0985a99ed3 Update dependencies, remove unnecessary dependencies 2024-06-13 22:27:14 +02:00
Arnau Mora
1d7084b555 Add Push Subscription Management (#800)
* [wip] Create worker for push registration and call it from someplace in UI

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

* [wip] Subscription registration request

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

* Don't enqueue push registration worker from UI

* Enqueue PushRegistrationWorker on collection changes

* Fix tests

* Update dav4jvm; Use new post method

* Remove obsolete context

* Add get and deleteAll methods to serviceRepository and update usages

* requestPushRegistration: make endpoint an argument

* Update push subscription fields in DB on successful registration

* Don't create notification channels in test class

* Remove workmanager init and provide empty set of listeners in tests

* Require network connection to run PushRegistrationWorker

* Move module declaration to a separate TestModules interface

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-06-13 13:12:59 +02:00
Sunik Kupfer
64563bbd3a Observable collections repository (#829)
* Call collection updates and setters only from repository

* Make collection repository changes observable

* Add kdoc

* Add basic test

* Extract RefreshCollectionsWorker; move some HomeSetDao calls to DavHomeSetRepository

* Replace more HomeSetDao calls

* Remove duplicate copyright notice

* Drop weak reference for observers

* Rename method

* Remove test service after run

* Verify notifying works with mockk

* Rename test

* Use construction injection

* Remove unused SettingsManager

* Remove obsolete mockk rule

* Use runBlocking instead of runTest

* Change to observer linkedList to mutableSetOf, remove synchronized calls

* Change to hilt multibinding

* Remove some unnecessary lines; allow empty set by Hilt

* CollectionListRefresher: delete collections using repository

* deleteRemote: call callback too; adapt KDoc

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-06-12 12:43:56 +02:00
Ricki Hirner
0bdeffe70d [Github] Issue template: fix label of feature request 2024-06-12 11:39:26 +02:00
Ricki Hirner
9e0772a9dd Update AGP 2024-06-11 09:21:35 +02:00
Ricki Hirner
70e56df80c Move repository test package 2024-06-10 13:40:33 +02:00
Ricki Hirner
b4756666b6 Extract RefreshCollectionsWorker; move some (HomeSet)Dao calls to repository (#845)
* Extract RefreshCollectionsWorker; move some HomeSetDao calls to DavHomeSetRepository

* Replace more HomeSetDao calls

* Remove unused SettingsManager
2024-06-10 12:16:33 +02:00
Ricki Hirner
74304bfe17 Version bump to 4.4.1-alpha.1 2024-06-09 23:02:23 +02:00
Ricki Hirner
25daa57a6f Show CalDAV/CardDAV/Webcal tab when there's at least one item (#839)
Show CalDAV/CardDAV/Webcal tab when there's at least one time
2024-06-08 18:44:35 +02:00
Ricki Hirner
a3ccdc2a46 Define toolchain version; update dependencies (#834) 2024-06-08 18:09:45 +02:00
Arnau Mora
ffefd519b6 [Google] Allow login with custom domains again (#833)
Modified emailWithDomain in UiState

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-07 10:11:00 +02:00
Ricki Hirner
4823d6d671 [CI] Update gh-release action 2024-06-05 11:45:10 +02:00
Ricki Hirner
9b5d5c982b Version bump to 4.4.0.1 2024-05-31 08:49:25 +02:00
Ricki Hirner
a03c83450d App settings: mention that verbose logs can be viewed in debug info (#824) 2024-05-31 08:47:52 +02:00
Ricki Hirner
5d97161c9b Update dependencies (including ical4android, closes bitfireAT/davx5#551) 2024-05-31 08:11:32 +02:00
Ricki Hirner
671c17376a PreferenceRepository.observeAsFlow: emit initial value (#822) 2024-05-30 10:09:34 +02:00
265 changed files with 12056 additions and 7301 deletions

View File

@@ -1,6 +1,6 @@
name: Qualified Feature Request
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
labels: ["feature"]
labels: ["enhancement"]
body:
- type: checkboxes
attributes:

View File

@@ -1,4 +1,6 @@
Please delete this paragraph and other repeating text (like the examples) after reading and before submitting the PR.
The PR should be in _Draft_ state during development. As soon as it's finished, it should be marked as _Ready for review_ and a reviewer should be chosen.
See also: [Writing A Great Pull Request Description](https://www.pullrequest.com/blog/writing-a-great-pull-request-description/)

3
.github/release.yml vendored
View File

@@ -12,6 +12,9 @@ changelog:
- title: Refactoring
labels:
- refactoring
- title: Dependencies
labels:
- dependencies
- title: Other changes
labels:
- "*"

View File

@@ -32,19 +32,12 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true # gradle user home cache is generated by test jobs
- name: Use incremental build cache
uses: actions/cache/restore@v4
with:
key: incremental-build-tests
restore-keys: incremental-build-tests # restore cache from main branch
path: app/build
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3

View File

@@ -23,7 +23,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
- name: Prepare keystore
@@ -39,7 +39,7 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }}
- name: Create Github release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
prerelease: ${{ env.prerelease }}
files: app/build/outputs/apk/ose/release/*.apk

View File

@@ -8,58 +8,62 @@ concurrency:
group: test-dev-${{ github.ref }}
cancel-in-progress: true
env:
is_main_branch: ${{ github.ref == 'refs/heads/main-ose' }}
jobs:
compile:
name: Compile for build cache
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
with:
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
test:
name: Tests without emulator
needs: compile
if: ${{ always() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true # gradle user home cache is generated by test_on_emulator
cache-read-only: true
- name: Use incremental build cache
uses: actions/cache/restore@v4
with:
key: incremental-build-tests
restore-keys: incremental-build-tests # restore cache from main branch
path: app/build
- name: Run lint
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
- name: Run lint and unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:check
# generates the build caches because it uses more gradle dependencies
test_on_emulator:
name: Tests with emulator
needs: compile
if: ${{ always() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 21
- uses: gradle/actions/setup-gradle@v3
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
gradle-home-cache-cleanup: true # avoid ever-growing gradle user home cache
- name: Use incremental build cache
if: ${{ !env.is_main_branch }}
uses: actions/cache/restore@v4
with:
key: incremental-build-tests
restore-keys: incremental-build-tests # restore cache from main branch
path: |
.gradle/configuration-cache
app/build
cache-read-only: true
- name: Enable KVM group perms
run: |
@@ -74,11 +78,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 --no-daemon app:virtualCheck
- name: Create incremental build cache
if: ${{ env.is_main_branch }}
uses: actions/cache/save@v4
with:
key: incremental-build-tests-${{ github.run_id }}
path: app/build
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck

View File

@@ -13,32 +13,33 @@ plugins {
// Android configuration
android {
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404000004
versionName = "4.4"
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
versionCode = 404030200
versionName = "4.4.3.2"
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 34 // Android 14
targetSdk = 35 // Android 15
buildConfigField("String", "userAgent", "\"DAVx5\"")
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
testInstrumentationRunner = "at.bitfire.davdroid.CustomTestRunner"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
compileOptions {
// enable because ical4android requires desugaring
// required for
// - dnsjava 3.x: java.nio.file.Path
// - ical4android: time API
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
@@ -81,6 +82,9 @@ android {
signingConfig = signingConfigs.findByName("bitfire")
}
getByName("debug") {
applicationIdSuffix = ".debug"
}
}
lint {
@@ -115,6 +119,10 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
aboutLibraries {
excludeFields = arrayOf("generated")
}
configurations {
configureEach {
// exclude modules which are in conflict with system libraries
@@ -141,11 +149,7 @@ dependencies {
implementation(libs.androidx.activityCompose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser)
implementation(libs.androidx.cardView)
implementation(libs.androidx.concurrentFuture)
implementation(libs.androidx.constraintLayout)
implementation(libs.androidx.core)
implementation(libs.androidx.fragment)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -156,7 +160,6 @@ dependencies {
implementation(libs.androidx.preference)
implementation(libs.androidx.security)
implementation(libs.androidx.work.base)
implementation(libs.android.flexbox)
// Jetpack Compose
implementation(libs.compose.accompanist.permissions)
@@ -186,20 +189,16 @@ dependencies {
implementation(libs.bitfire.vcard4android)
// third-party libs
implementation(libs.appintro)
implementation(libs.commons.collections)
@Suppress("RedundantSuppression")
implementation(libs.commons.io)
implementation(libs.commons.lang)
implementation(libs.commons.text)
@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)
implementation(libs.openid.appauth)
implementation(libs.unifiedpush)
// for tests
androidTestImplementation(libs.androidx.arch.core.testing)
@@ -215,5 +214,6 @@ dependencies {
androidTestImplementation(libs.room.testing)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.okhttp.mockwebserver)
}
}

View File

@@ -54,6 +54,7 @@
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider # dnsjava
-dontwarn org.xmlpull.**
-dontwarn sun.net.spi.nameservice.NameService
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor

View File

@@ -0,0 +1,669 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "9a0eb47f27473eab254db568081a4585",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_ownerId_type",
"unique": false,
"columnNames": [
"ownerId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
},
{
"name": "index_collection_pushTopic_type",
"unique": false,
"columnNames": [
"pushTopic",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
},
{
"name": "index_webdav_document_parentId",
"unique": false,
"columnNames": [
"parentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a0eb47f27473eab254db568081a4585')"
]
}
}

View File

@@ -7,4 +7,21 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
</manifest>
<application>
<!-- test account type (without associated sync adapters) -->
<service
android:name="at.bitfire.davdroid.sync.account.TestAccountAuthenticator"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/test_account_authenticator"/>
</service>
</application>
</manifest>

View File

@@ -4,14 +4,14 @@
package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.testing.HiltTestApplication
class CustomTestRunner : AndroidJUnitRunner() {
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, name: String, context: Context) =
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View File

@@ -6,91 +6,118 @@ 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.annotation.RequiresApi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.log.Logger
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 org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.util.logging.Logger
/**
* JUnit ClassRule which initializes the AOSP CalendarProvider.
* Needed for some "flaky" tests which would otherwise only succeed on second run.
*
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
* if this comment) can find out on how to initialize the calendar provider so that the
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
* please let us know!
* 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.
*
* If you run tests manually, just make sure to ignore the first run after the calendar
* provider has been accessed the first time.
* 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(): TestRule {
class InitCalendarProviderRule private constructor(): ExternalResource() {
companion object {
fun getInstance() = RuleChain
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 apply(base: Statement, description: Description): Statement {
Logger.log.info("Initializing calendar provider before running ${description.displayName}")
return InitCalendarProviderStatement(base)
}
class InitCalendarProviderStatement(val base: Statement): Statement() {
override fun evaluate() {
override fun before() {
if (!isInitialized) {
logger.info("Initializing calendar provider")
if (Build.VERSION.SDK_INT < 31)
Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
initCalendarProvider()
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
base.evaluate()
}
private fun initCalendarProvider() {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val uri = AndroidCalendar.create(account, provider, ContentValues())
val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
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!!)
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
assertNotNull("Couldn't acquire calendar provider", client)
// 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()
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

@@ -4,9 +4,10 @@
package at.bitfire.davdroid
import androidx.test.platform.app.InstrumentationRegistry
import android.content.Context
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -21,6 +22,10 @@ class OkhttpClientTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
@@ -32,8 +37,7 @@ class OkhttpClientTest {
@Test
fun testIcloudWithSettings() {
val client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.build()
val client = HttpClient.Builder(context).build()
client.okHttpClient.newCall(Request.Builder()
.get()
.url("https://icloud.com")

View File

@@ -0,0 +1,38 @@
package at.bitfire.davdroid
import at.bitfire.davdroid.push.PushRegistrationWorker
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.startup.TasksAppWatcher
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dagger.multibindings.Multibinds
interface TestModules {
// remove PushRegistrationWorkerModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
)
abstract class TestPushRegistrationWorkerModule {
// provides empty set of listeners
@Multibinds
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
}
// remove TasksAppWatcherModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
)
abstract class TestTasksAppWatcherModuleModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>
}
}

View File

@@ -4,13 +4,14 @@
package at.bitfire.davdroid.db
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -33,26 +34,26 @@ class CollectionTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
fun setup() {
hiltRule.inject()
httpClient = HttpClient.Builder(context).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun shutDown() {
fun teardown() {
httpClient.close()
}

View File

@@ -21,10 +21,10 @@ class Android10ResolverTest {
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
fun testResolveA() {
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance(Inet4Address::class.java).first()
val www = InetAddress.getAllByName(FQDN_DAVX5).filterIsInstance<Inet4Address>().first()
val srvLookup = Lookup(FQDN_DAVX5, Type.A)
srvLookup.setResolver(Android10Resolver)
srvLookup.setResolver(Android10Resolver())
val resultGeneric = srvLookup.run()
assertEquals(1, resultGeneric.size)

View File

@@ -1,154 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkCapabilities.TRANSPORT_WIFI
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class ConnectionUtilsTest {
@get:Rule
val mockkRule = MockKRule(this)
private val connectivityManager = mockk<ConnectivityManager>()
private val network1 = mockk<Network>()
private val network2 = mockk<Network>()
private val capabilities = mockk<NetworkCapabilities>()
@Before
fun setUp() {
every { connectivityManager.allNetworks } returns arrayOf(network1, network2)
every { connectivityManager.getNetworkInfo(network1) } returns mockk()
every { connectivityManager.getNetworkInfo(network2) } returns mockk()
every { connectivityManager.getNetworkCapabilities(network1) } returns capabilities
every { connectivityManager.getNetworkCapabilities(network2) } returns capabilities
}
@Test
fun testWifiAvailable_capabilitiesNull() {
every { connectivityManager.getNetworkCapabilities(network1) } returns null
every { connectivityManager.getNetworkCapabilities(network2) } returns null
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable_wifi() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable_validated() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testWifiAvailable_wifiValidated() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertTrue(ConnectionUtils.wifiAvailable(connectivityManager))
}
@Test
fun testInternetAvailable_capabilitiesNull() {
every { connectivityManager.getNetworkCapabilities(network1) } returns null
every { connectivityManager.getNetworkCapabilities(network2) } returns null
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_Internet() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_Validated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_InternetValidated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_ignoreVpns() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, true))
}
@Test
fun testInternetAvailable_ignoreVpns_Notvpn() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() {
// The real case that failed in davx5-ose#395 is that the connection list contains (in this order)
// 1. a mobile network without INTERNET, but with VALIDATED
// 2. a WiFi network with INTERNET and VALIDATED
// The "return false" of hasINTERNET will trigger at the first connection, the
// "andThen true" will trigger for the second connection
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
// There is an internet connection if any(!) connection has both INTERNET and VALIDATED.
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotvpn() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.xbill.DNS.DClass
import org.xbill.DNS.Name
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import javax.inject.Inject
@HiltAndroidTest
class DnsRecordResolverTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var dnsRecordResolver: DnsRecordResolver
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun testBestSRVRecord_Empty() {
assertNull(dnsRecordResolver.bestSRVRecord(emptyArray()))
}
@Test
fun testBestSRVRecord_MultipleRecords_Priority_Different() {
val dns1010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
)
val dns2010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 20, 20, 8443, Name.fromString("dav2010.example.com.")
)
// lowest priority first
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns2010))
assertEquals(dns1010, result)
}
@Test
fun testBestSRVRecord_MultipleRecords_Priority_Same() {
val dns1010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
)
val dns1020 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.")
)
// entries are selected randomly (for load balancing)
// run 1000 times to get a good distribution
val counts = IntArray(2)
for (i in 0 until 1000) {
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns1020))
when (result) {
dns1010 -> counts[0]++
dns1020 -> counts[1]++
}
}
/* We had weights 10 and 20, so the distribution of 1000 tries should be roughly
weight 10 fraction 1/3 expected count 333 binomial distribution (p=1/3) with 99.99% in [275..393]
weight 20 fraction 2/3 expected count 667 binomial distribution (p=2/3) with 99.99% in [607..725]
*/
assertTrue(counts[0] in 275..393)
assertTrue(counts[1] in 607..725)
}
@Test
fun testBestSRVRecord_OneRecord() {
val dns1010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
)
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010))
assertEquals(dns1010, result)
}
@Test
fun testPathsFromTXTRecords_Empty() {
assertTrue(dnsRecordResolver.pathsFromTXTRecords(arrayOf()).isEmpty())
}
@Test
fun testPathsFromTXTRecords_OnePath() {
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("something=else", "path=/path1"))
)).toTypedArray()
assertArrayEquals(arrayOf("/path1"), result)
}
@Test
fun testPathsFromTXTRecords_TwoPaths() {
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("path=/path1", "something-else", "path=/path2"))
)).toTypedArray()
result.sort()
assertArrayEquals(arrayOf("/path1", "/path2"), result)
}
}

View File

@@ -4,8 +4,9 @@
package at.bitfire.davdroid.network
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -14,10 +15,12 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class HttpClientTest {
@@ -28,11 +31,15 @@ class HttpClientTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Before
fun setUp() {
hiltRule.inject()
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
httpClient = HttpClient.Builder(context).build()
server = MockWebServer()
server.start(30000)
@@ -70,7 +77,8 @@ class HttpClientTest {
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
val header = server.takeRequest().getHeader("Cookie")
assertTrue(header == "cookie1=1; cookie2=2" || header == "cookie2=2; cookie1=1")
server.enqueue(MockResponse()
.setResponseCode(200))

View File

@@ -0,0 +1,88 @@
package at.bitfire.davdroid.repository
import android.content.Context
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
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 DavCollectionRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var serviceRepository: DavServiceRepository
var service: Service? = null
@Before
fun setUp() {
hiltRule.inject()
service = createTestService(Service.TYPE_CARDDAV)!!
}
@After
fun cleanUp() {
db.close()
serviceRepository.deleteAll()
}
@Test
fun testOnChangeListener_setForceReadOnly() = runBlocking {
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
serviceId = service!!.id,
type = Collection.TYPE_ADDRESSBOOK,
url = "https://example.com".toHttpUrl(),
forceReadOnly = false,
)
)
val testObserver = mockk<DavCollectionRepository.OnChangeListener>(relaxed = true)
val collectionRepository = DavCollectionRepository(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository)
assert(db.collectionDao().get(collectionId)?.forceReadOnly == false)
verify(exactly = 0) {
testObserver.onCollectionsChanged()
}
collectionRepository.setForceReadOnly(collectionId, true)
assert(db.collectionDao().get(collectionId)?.forceReadOnly == true)
verify(exactly = 1) {
testObserver.onCollectionsChanged()
}
}
// Test helpers and dependencies
private fun createTestService(serviceType: String) : Service? {
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
val serviceId = serviceRepository.insertOrReplace(service)
return serviceRepository.get(serviceId)
}
}

View File

@@ -2,12 +2,13 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
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.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
@@ -15,67 +16,63 @@ import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class HomesetDaoTest {
class DavHomeSetRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
lateinit var repository: DavHomeSetRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@Before
fun setUp() {
hiltRule.inject()
}
@After
fun tearDown() {
db.close()
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val serviceId = createTestService()
val homeSetDao = db.homeSetDao()
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = homeSetDao.insertOrUpdateByUrl(entry1)
val insertId1 = repository.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.apply { id = 1L }, homeSetDao.getById(1L))
assertEquals(entry1.apply { id = 1L }, repository.getById(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = homeSetDao.insertOrUpdateByUrl(updatedEntry1)
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.apply { id = 1L }, homeSetDao.getById(1L))
assertEquals(updatedEntry1.apply { id = 1L }, repository.getById(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = homeSetDao.insertOrUpdateByUrl(entry2)
val insertId2 = repository.insertOrUpdateByUrl(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.apply { id = 2L }, homeSetDao.getById(2L))
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
}
@Test
fun testDelete() {
// should delete row with given primary key (id)
val serviceId = createTestService()
val homesetDao = db.homeSetDao()
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = homesetDao.insertOrUpdateByUrl(entry1)
val insertId1 = repository.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, homesetDao.getById(1L))
assertEquals(entry1, repository.getById(1L))
homesetDao.delete(entry1)
assertEquals(null, homesetDao.getById(1L))
repository.delete(entry1)
assertEquals(null, repository.getById(1L))
}
fun createTestService() : Long {
val serviceDao = db.serviceDao()
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
return serviceDao.insertOrReplace(service)
private fun createTestService() : Long {
val service = Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
return serviceRepository.insertOrReplace(service)
}
}

View File

@@ -4,15 +4,31 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import androidx.test.rule.GrantPermissionRule
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.LabeledProperty
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import ezvcard.property.Telephone
import java.util.LinkedList
import javax.inject.Inject
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
@@ -22,57 +38,114 @@ class LocalAddressBookTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
val mainAccountType = context.getString(R.string.account_type)
val mainAccount = Account("main", mainAccountType)
@Inject
@ApplicationContext
lateinit var context: Context
val addressBookAccountType = context.getString(R.string.account_type_address_book)
val addressBookAccount = Account("sub", addressBookAccountType)
lateinit var addressBook: LocalTestAddressBook
val accountManager = AccountManager.get(context)
@Before
fun setUp() {
hiltRule.inject()
// TODO DOES NOT WORK: the account immediately starts to sync, which creates the sync adapter services.
// The services however can't be created because Hilt is "not ready" (although it has been initialized in the line above).
// assertTrue(AccountUtils.createAccount(context, mainAccount, AccountSettings.initialUserData(null)))
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
LocalTestAddressBook.createAccount(context)
}
@After
fun cleanup() {
accountManager.removeAccount(addressBookAccount, null, null)
accountManager.removeAccount(mainAccount, null, null)
fun tearDown() {
// remove address book
addressBook.deleteCollection()
}
// TODO see above
/*@Test
fun testMainAccount_AddressBookAccount_WithMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle().apply {
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
}))
/**
* Tests whether contacts are moved (and not lost) when an address book is renamed.
*/
@Test
fun test_renameAccount_retainsContacts() {
// insert contact with data row
val uid = "12345"
val contact = Contact(
uid = uid,
displayName = "Test Contact",
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
)
val uri = LocalContact(addressBook, contact, null, null, 0).add()
val id = ContentUris.parseId(uri)
val localContact = addressBook.findContactById(id)
localContact.resetDirty()
assertFalse("Contact is dirty before moving", addressBook.isContactDirty(id))
// check mainAccount()
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
// check whether contact is still here (including data rows) and not dirty
val result = addressBook.findContactById(id)
assertFalse("Contact is dirty after moving", addressBook.isContactDirty(id))
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
}
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle()))
/**
* Tests whether groups are moved (and not lost) when an address book is renamed.
*/
@Test
fun test_renameAccount_retainsGroups() {
// insert group
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
val uri = localGroup.add()
val id = ContentUris.parseId(uri)
// check mainAccount(); should fail because there's no main account
LocalAddressBook.mainAccount(context, addressBookAccount)
}*/
// make sure it's not dirty
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_OtherAccount() {
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
val group = result.getContact()
assertEquals("Test Group", group.displayName)
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -15,19 +15,25 @@ 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.closeCompat
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
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.*
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
class LocalCalendarTest {
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
@@ -36,14 +42,14 @@ class LocalCalendarTest {
@BeforeClass
@JvmStatic
fun setUpProvider() {
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun closeProvider() {
fun tearDownClass() {
provider.closeCompat()
}
@@ -53,14 +59,14 @@ class LocalCalendarTest {
private lateinit var calendar: LocalCalendar
@Before
fun prepare() {
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun shutdown() {
calendar.delete()
fun tearDown() {
calendar.deleteCollection()
}

View File

@@ -21,11 +21,22 @@ 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 net.fortuna.ical4j.model.property.*
import org.junit.*
import org.junit.Assert.*
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.Test
import org.junit.rules.TestRule
import java.util.*
import java.util.UUID
class LocalEventTest {
@@ -35,34 +46,35 @@ class LocalEventTest {
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var provider: ContentProviderClient
private lateinit var calendar: LocalCalendar
@BeforeClass
@JvmStatic
fun connect() {
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
fun tearDownClass() {
provider.closeCompat()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var calendar: LocalCalendar
@Before
fun createCalendar() {
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun removeCalendar() {
calendar.delete()
calendar.deleteCollection()
}

View File

@@ -8,77 +8,79 @@ import android.Manifest
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.*
import org.junit.Assert.*
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
provider.close()
}
}
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"
}, null, null, 0
).apply {
add()
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@Before
fun clearContacts() {
fun setup() {
hiltRule.inject()
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
// clear contacts
addressBookGroupsAsCategories.clear()
addressBookGroupsAsVCards.clear()
}
@@ -264,4 +266,16 @@ class LocalGroupTest {
assertEquals("$newUid.vcf", fileName)
}
// helpers
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"
}, null, null, 0
).apply {
add()
}
}

View File

@@ -5,24 +5,38 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import org.junit.Assert.assertTrue
import java.io.FileNotFoundException
import java.util.logging.Logger
class LocalTestAddressBook(
context: Context,
provider: ContentProviderClient,
override val groupMethod: GroupMethod
): LocalAddressBook(context, ACCOUNT, provider) {
class LocalTestAddressBook @AssistedInject constructor(
@Assisted provider: ContentProviderClient,
@Assisted override val groupMethod: GroupMethod,
accountSettingsFactory: AccountSettings.Factory,
collectionRepository: DavCollectionRepository,
@ApplicationContext context: Context,
logger: Logger,
serviceRepository: DavServiceRepository
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
@AssistedFactory
interface Factory {
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
}
override var mainAccount: Account?
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
override var readOnly: Boolean
get() = false
set(_) = throw NotImplementedError()
@@ -35,4 +49,49 @@ class LocalTestAddressBook(
group.delete()
}
/**
* Returns the dirty flag of the given contact.
*
* @return true if the contact is dirty, false otherwise
*
* @throws FileNotFoundException if the contact can't be found
*/
fun isContactDirty(id: Long): Boolean {
val uri = ContentUris.withAppendedId(rawContactsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
/**
* Returns the dirty flag of the given contact group.
*
* @return true if the group is dirty, false otherwise
*
* @throws FileNotFoundException if the group can't be found
*/
fun isGroupDirty(id: Long): Boolean {
val uri = ContentUris.withAppendedId(groupsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
fun createAccount(context: Context) {
val am = AccountManager.get(context)
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
}
}
}

View File

@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
@@ -15,9 +16,19 @@ import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import org.junit.*
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
import org.junit.Assert.assertArrayEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class CachedGroupMembershipHandlerTest {
companion object {
@@ -27,30 +38,43 @@ class CachedGroupMembershipHandlerTest {
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBook: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
provider.close()
}
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership() {
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.content.ContentProviderClient
import android.content.Context
import android.net.Uri
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
@@ -14,38 +15,57 @@ import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import org.junit.*
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipBuilderTest {
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
provider.close()
}
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Before
fun inject() {
hiltRule.inject()
}
@@ -54,6 +74,7 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
assertEquals(1, result.size)
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
@@ -66,6 +87,7 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)

View File

@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
@@ -15,44 +16,67 @@ import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import org.junit.*
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
import org.junit.Assert
import org.junit.Assert.assertArrayEquals
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 javax.inject.Inject
@HiltAndroidTest
class GroupMembershipHandlerTest {
@JvmField
@Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
companion object {
private lateinit var provider: ContentProviderClient
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private var addressBookGroupsAsCategoriesGroup: Long = -1
private lateinit var provider: ContentProviderClient
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context: Context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
}
@Before
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@After
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership_GroupsAsCategories() {
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
@@ -66,6 +90,8 @@ class GroupMembershipHandlerTest {
@Test
fun testMembership_GroupsAsVCards() {
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {

View File

@@ -6,31 +6,23 @@ package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
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.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.NotificationUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.apache.commons.lang3.StringUtils
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -39,39 +31,11 @@ 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 RefreshCollectionsWorkerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Before
fun setUp() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
// Test dependencies
class CollectionListRefresherTest {
companion object {
private const val PATH_CALDAV = "/caldav"
@@ -86,45 +50,47 @@ class RefreshCollectionsWorkerTest {
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
@Inject
lateinit var settings: SettingsManager
var mockServer = MockWebServer()
lateinit var client: HttpClient
private val mockServer = MockWebServer()
private lateinit var client: HttpClient
@Before
fun mockServerSetup() {
fun setup() {
hiltRule.inject()
// Start mock web server
mockServer.dispatcher = TestDispatcher()
mockServer.dispatcher = TestDispatcher(logger)
mockServer.start()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
client = HttpClient.Builder(context).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun cleanUp() {
fun teardown() {
mockServer.shutdown()
db.close()
}
// Actual tests
/* Often fails for unknown reasons:
@Test
fun testRefreshCollections_enqueuesWorker() {
val service = createTestService(Service.TYPE_CALDAV)!!
val (workerName, enqueueOp) = RefreshCollectionsWorker.enqueue(context, service.id)
enqueueOp.result.get()
assertTrue(workScheduledOrRunningOrSuccessful(context, workerName))
}*/
@Test
fun testDiscoverHomesets() {
@@ -132,8 +98,7 @@ class RefreshCollectionsWorkerTest {
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.discoverHomesets(baseUrl)
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
// Check home sets have been saved to database
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
@@ -153,8 +118,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
@@ -191,8 +155,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
@@ -231,8 +194,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
@@ -273,9 +235,8 @@ class RefreshCollectionsWorkerTest {
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// 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)
@@ -304,8 +265,7 @@ class RefreshCollectionsWorkerTest {
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
@@ -338,8 +298,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got updated - with display name and description
assertEquals(
@@ -374,8 +333,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh - should delete collection
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
@@ -399,8 +357,7 @@ class RefreshCollectionsWorkerTest {
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
@@ -442,8 +399,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh principals
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
@@ -478,8 +434,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh principals
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
@@ -502,8 +457,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh principals - detecting it does not own collections
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
@@ -516,46 +470,48 @@ class RefreshCollectionsWorkerTest {
fun shouldPreselect_none() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
mockkObject(settings) {
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_all() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
mockkObject(settings) {
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
}
@Test
@@ -563,69 +519,72 @@ class RefreshCollectionsWorkerTest {
val service = createTestService(Service.TYPE_CARDDAV)!!
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_personal_notPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
mockkObject(settings) {
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
@Test
fun shouldPreselect_personal_isPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
mockkObject(settings) {
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("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
}
@Test
@@ -633,23 +592,24 @@ class RefreshCollectionsWorkerTest {
val service = createTestService(Service.TYPE_CARDDAV)!!
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
}
// Test helpers and dependencies
@@ -660,10 +620,13 @@ class RefreshCollectionsWorkerTest {
return db.serviceDao().get(serviceId)
}
class TestDispatcher: Dispatcher() {
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = StringUtils.removeEnd(request.path!!, "/")
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
@@ -741,8 +704,8 @@ class RefreshCollectionsWorkerTest {
"</multistatus>"
}
Logger.log.info("Queried: $path")
Logger.log.info("Response: $responseBody")
logger.info("Queried: $path")
logger.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
@@ -752,4 +715,4 @@ class RefreshCollectionsWorkerTest {
}
}
}
}

View File

@@ -4,17 +4,17 @@
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
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.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
@@ -32,22 +32,12 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class DavResourceFinderTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
companion object {
private const val PATH_NO_DAV = "/nodav"
private const val PATH_CALDAV = "/caldav"
@@ -59,21 +49,39 @@ class DavResourceFinderTest {
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
}
val server = MockWebServer()
@get:Rule
val hiltRule = HiltAndroidRule(this)
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@Inject
lateinit var resourceFinderFactory: DavResourceFinder.Factory
@Inject
lateinit var settingsManager: SettingsManager
private val server = MockWebServer()
private lateinit var finder: DavResourceFinder
private lateinit var client: HttpClient
@Before
fun initServerAndClient() {
server.dispatcher = TestDispatcher()
fun setup() {
hiltRule.inject()
server.dispatcher = TestDispatcher(logger)
server.start()
val baseURI = URI.create("/")
val credentials = Credentials("mock", "12345")
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, baseURI, credentials)
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
finder = resourceFinderFactory.create(baseURI, credentials)
client = HttpClient.Builder(context)
.addAuthentication(null, credentials)
.build()
@@ -81,7 +89,7 @@ class DavResourceFinderTest {
}
@After
fun stopServer() {
fun teardown() {
server.shutdown()
}
@@ -156,7 +164,9 @@ class DavResourceFinderTest {
// mock server
class TestDispatcher: Dispatcher() {
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (!checkAuth(request)) {
@@ -205,7 +215,7 @@ class DavResourceFinderTest {
else -> props = null
}
Logger.log.info("Sending props: $props")
logger.info("Sending props: $props")
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +

View File

@@ -1,140 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
/*@HiltAndroidTest
class AccountSettingsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
private val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
val fakeCredentials = Credentials("test", "test")
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
@Before
fun setUp() {
hiltRule.inject()
assertTrue(AccountUtils.createAccount(
context,
account,
AccountSettings.initialUserData(fakeCredentials)
))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
@RequiresApi(22)
fun removeAccount() {
AccountManager.get(context).removeAccountExplicitly(account)
}
@Test
fun testSyncIntervals() {
val settings = AccountSettings(context, account)
val presetIntervals =
context.resources.getStringArray(R.array.settings_sync_interval_seconds)
.map { it.toLong() }
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
for (interval in presetIntervals) {
assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval))
assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY))
}
}
@Test
fun testSyncIntervals_Syncable() {
val settings = AccountSettings(context, account)
val interval = 15*60L // 15 min
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
assertTrue(result)
}
@Test(expected = IllegalArgumentException::class)
fun testSyncIntervals_TooShort() {
val settings = AccountSettings(context, account)
val interval = 60L // 1 min is not supported by Android
settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
}
@Test
fun testSyncIntervals_activatesPeriodicSyncWorker() {
val settings = AccountSettings(context, account)
val interval = 15*60L
for (authority in authorities) {
ContentResolver.setIsSyncable(account, authority, 1)
assertTrue(settings.setSyncInterval(authority, interval))
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
assertEquals(interval, settings.getSyncInterval(authority))
}
}
@Test
fun testSyncIntervals_disablesPeriodicSyncWorker() {
val settings = AccountSettings(context, account)
val interval = AccountSettings.SYNC_INTERVAL_MANUALLY // -1
for (authority in authorities) {
ContentResolver.setIsSyncable(account, authority, 1)
assertTrue(settings.setSyncInterval(authority, interval))
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
assertEquals(AccountSettings.SYNC_INTERVAL_MANUALLY, settings.getSyncInterval(authority))
}
}
}*/

View File

@@ -2,12 +2,14 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalCollection
class LocalTestCollection: LocalCollection<LocalTestResource> {
class LocalTestCollection(
override val collectionUrl: String = "http://example.com/test/"
): LocalCollection<LocalTestResource> {
override val tag = "LocalTestCollection"
override val title = "Local Test Collection"
@@ -19,6 +21,8 @@ class LocalTestCollection: LocalCollection<LocalTestResource> {
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = true
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }

View File

@@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.resource.LocalResource

View File

@@ -0,0 +1,192 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
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.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.concurrent.Executors
import java.util.logging.Logger
import javax.inject.Inject
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
@get:Rule
val hiltRule = HiltAndroidRule(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)
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
unmockkAll()
}
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() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
runBlocking {
val sync = launch {
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
// simulate incoming cancellation from sync framework
syncAdapter.onSyncCanceled()
// wait for sync to finish (should happen immediately)
sync.join()
}
}
}
@Test
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function
// immediate timeout (instead of really waiting)
coEvery { withTimeout(any<Long>(), any<suspend CoroutineScope.() -> Unit>()) } throws CancellationException("Simulated timeout")
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
}
}
@Test
fun testSyncAdapter_onPerformSync_runsInTime() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker immediately returns with success
val success = mockk<WorkInfo>()
every { success.state } returns WorkInfo.State.SUCCEEDED
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow {
emit(listOf(success))
delay(60000) // keep the flow active
}
// should just run
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
}
}

View File

@@ -2,120 +2,102 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.content.SyncResult
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.dav4jvm.PropStat
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils.assertWithin
import at.bitfire.davdroid.db.Credentials
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.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.components.SingletonComponent
import io.mockk.every
import io.mockk.mockk
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.time.Instant
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidTest
class SyncManagerTest {
companion object {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account("SyncManagerTest", context.getString(R.string.account_type))
@BeforeClass
@JvmStatic
fun createAccount() {
assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"))))
}
@AfterClass
@JvmStatic
fun removeAccount() {
assertTrue(AccountManager.get(context).removeAccount(account, null, null).getResult(10, TimeUnit.SECONDS))
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()
}
@Module
@InstallIn(SingletonComponent::class)
object SyncManagerTestModule {
@Provides
fun davSyncStatsRepository(): DavSyncStatsRepository = mockk<DavSyncStatsRepository>(relaxed = true)
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncManagerFactory: TestSyncManager.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
val server = MockWebServer()
lateinit var account: Account
private val server = MockWebServer()
@Before
fun inject() {
fun setUp() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
account = TestAccountAuthenticator.create()
private fun syncManager(collection: LocalTestCollection, syncResult: SyncResult = SyncResult()) =
TestSyncManager(
context,
account,
arrayOf(),
"TestAuthority",
HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build(),
syncResult,
collection,
server
)
@Before
fun startServer() {
server.start()
}
@After
fun stopServer() {
fun tearDown() {
TestAccountAuthenticator.remove(account)
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()
server.close()
}
@@ -527,4 +509,25 @@ class SyncManagerTest {
assertTrue(collection.entries.isEmpty())
}
// helpers
private fun syncManager(
localCollection: LocalTestCollection,
syncResult: SyncResult = SyncResult(),
collection: Collection = mockk<Collection>() {
every { id } returns 1
every { url } returns server.url("/")
}
) = syncManagerFactory.create(
account,
accountSettingsFactory.create(account),
arrayOf(),
"TestAuthority",
HttpClient.Builder(context).build(),
syncResult,
localCollection,
collection
)
}

View File

@@ -0,0 +1,206 @@
/*
* 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.ContentProviderClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var testSyncer: TestSyncer.Factory
lateinit var account: Account
private lateinit var syncer: TestSyncer
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
@Test
fun testSync_prepare_fails() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns false
every { syncer.getSyncEnabledCollections() } returns emptyMap()
// Should stop the sync after prepare returns false
syncer.sync(provider)
verify(exactly = 1) { syncer.prepare(provider) }
verify(exactly = 0) { syncer.getSyncEnabledCollections() }
}
@Test
fun testSync_prepare_succeeds() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns true
every { syncer.getSyncEnabledCollections() } returns emptyMap()
// Should continue the sync after prepare returns true
syncer.sync(provider)
verify(exactly = 1) { syncer.prepare(provider) }
verify(exactly = 1) { syncer.getSyncEnabledCollections() }
}
@Test
fun testUpdateCollections_deletesCollection() {
val localCollection = mockk<LocalTestCollection>()
every { localCollection.collectionUrl } returns "http://delete.the/collection"
every { localCollection.deleteCollection() } returns true
every { localCollection.title } returns "Collection to be deleted locally"
// Should delete the localCollection if dbCollection (remote) does not exist
val localCollections = mutableListOf(localCollection)
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
verify(exactly = 1) { localCollection.deleteCollection() }
// Updated local collection list should be empty
assertTrue(result.isEmpty())
}
@Test
fun testUpdateCollections_updatesCollection() {
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
val dbCollections = mapOf("http://update.the/collection".toHttpUrl() to dbCollection)
every { dbCollection.url } returns "http://update.the/collection".toHttpUrl()
every { localCollection.collectionUrl } returns "http://update.the/collection"
every { localCollection.title } returns "The Local Collection"
// Should update the localCollection if it exists
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
// Updated local collection list should be same as input
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
}
@Test
fun testUpdateCollections_findsNewCollection() {
val dbCollection = mockk<Collection>()
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
val dbCollections = mapOf(dbCollection.url to dbCollection)
// Should return the new collection, because it was not updated
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
// Updated local collection list contain new entry
assertEquals(1, result.size)
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
}
@Test
fun testCreateLocalCollections() {
val provider = mockk<ContentProviderClient>()
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
every { syncer.create(provider, dbCollection) } returns localCollection
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
// Should return list of newly created local collections
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
assertEquals(listOf(localCollection), result)
}
@Test
fun testSyncCollectionContents() {
val provider = mockk<ContentProviderClient>()
val dbCollection1 = mockk<Collection>()
val dbCollection2 = mockk<Collection>()
val dbCollections = mapOf(
"http://newly.found/collection1".toHttpUrl() to dbCollection1,
"http://newly.found/collection2".toHttpUrl() to dbCollection2
)
val localCollection1 = mockk<LocalTestCollection>()
val localCollection2 = mockk<LocalTestCollection>()
val localCollections = listOf(localCollection1, localCollection2)
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
// Should call the collection content sync on both collections
syncer.syncCollectionContents(provider, localCollections, dbCollections)
verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) }
verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) }
}
// Test helpers
class TestSyncer @AssistedInject constructor(
@Assisted account: Account,
@Assisted extras: Array<String>,
@Assisted syncResult: SyncResult
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
@AssistedFactory
interface Factory {
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
}
override val authority: String
get() = ""
override val serviceType: String
get() = ""
override fun prepare(provider: ContentProviderClient): Boolean =
true
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
emptyList()
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
emptyList()
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
LocalTestCollection(remoteCollection.url.toString())
override fun syncCollection(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
remoteCollection: Collection
) {}
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
}
}

View File

@@ -2,41 +2,63 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import at.bitfire.dav4jvm.DavCollection
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.network.HttpClient
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import okhttp3.HttpUrl
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
class TestSyncManager(
context: Context,
account: Account,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
val mockWebServer: MockWebServer
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(context, account, AccountSettings(context, account), httpClient, extras, authority, syncResult, localCollection) {
class TestSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted accountSettings: AccountSettings,
@Assisted extras: Array<String>,
@Assisted authority: String,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted localCollection: LocalTestCollection,
@Assisted collection: Collection
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
account,
accountSettings,
httpClient,
extras,
authority,
syncResult,
localCollection,
collection
) {
@AssistedFactory
interface Factory {
fun create(
account: Account,
accountSettings: AccountSettings,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
collection: Collection
): TestSyncManager
}
override fun prepare(): Boolean {
collectionURL = mockWebServer.url("/")
davCollection = DavCollection(httpClient.okHttpClient, collectionURL)
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
return true
}

View File

@@ -2,14 +2,16 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.test.R
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
@@ -17,7 +19,6 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidTest
@@ -26,33 +27,40 @@ class AccountUtilsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
val testContext = InstrumentationRegistry.getInstrumentation().context
@Inject
lateinit var settingsManager: SettingsManager
val account = Account(
"AccountUtilsTest",
testContext.getString(R.string.account_type_test)
)
@Before
fun inject() {
fun setUp() {
hiltRule.inject()
}
val context by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
@Test
fun testCreateAccount() {
val userData = Bundle(2)
userData.putString("int", "1")
userData.putString("string", "abc/\"-")
val manager = AccountManager.get(context)
try {
assertTrue(AccountUtils.createAccount(context, account, userData))
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
// validate user data
val manager = AccountManager.get(context)
assertEquals("1", manager.getUserData(account, "int"))
assertEquals("abc/\"-", manager.getUserData(account, "string"))
} finally {
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
assertTrue(manager.removeAccountExplicitly(account))
}
}

View File

@@ -0,0 +1,152 @@
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountsCleanupWorkerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var settingsManager: SettingsManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var accountManager: AccountManager
lateinit var addressBookAccountType: String
lateinit var addressBookAccount: Account
lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
service = createTestService(Service.TYPE_CARDDAV)
// Prepare test account
accountManager = AccountManager.get(context)
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account(
"Fancy address book account",
addressBookAccountType
)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun tearDown() {
// Remove the account here in any case; Nice to have when the test fails
accountManager.removeAccountExplicitly(addressBookAccount)
}
@Test
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
// Create address book account without corresponding collection
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was deleted
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
}
@Test
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
// Create address book account _with_ corresponding collection and verify
val randomCollectionId = 12345L
val userData = Bundle(1).apply {
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
}
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
// Create the collection
val collectionDao = db.collectionDao()
collectionDao.insert(Collection(
randomCollectionId,
serviceId = service.id,
type = Collection.TYPE_ADDRESSBOOK,
url = "http://www.example.com/yay.php".toHttpUrl()
))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was _not_ deleted
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
}
// helpers
private fun createTestService(serviceType: String): Service {
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
val serviceId = db.serviceDao().insertOrReplace(service)
return db.serviceDao().get(serviceId)!!
}
}

View File

@@ -0,0 +1,96 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.sync.account
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.test.R
import org.junit.Assert.assertTrue
/**
* Handles the test account type, which has no sync adapters and side effects that run unintentionally.
*
* Usually used like this:
*
* ```
* lateinit var account: Account
*
* @Before
* fun setUp() {
* account = TestAccountAuthenticator.create()
*
* // You can now use the test account.
* }
*
* @After
* fun tearDown() {
* TestAccountAuthenticator.remove(account)
* }
* ```
*/
class TestAccountAuthenticator: Service() {
companion object {
val context by lazy { InstrumentationRegistry.getInstrumentation().context }
/**
* Creates a test account, usually in the `Before` setUp of a test.
*
* Remove it with [remove].
*/
fun create(): Account {
val accountType = context.getString(R.string.account_type_test)
val account = Account("Test Account", accountType)
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
return account
}
/**
* Removes a test account, usually in the `@After` tearDown of a test.
*/
fun remove(account: Account) {
val am = AccountManager.get(context)
am.removeAccountExplicitly(account)
}
}
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountAuthenticator = AccountAuthenticator(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?) = null
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}

View File

@@ -0,0 +1,103 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.test.R
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PeriodicSyncWorkerTest {
@Inject
@ApplicationContext
lateinit var context: Context
val testContext = InstrumentationRegistry.getInstrumentation().context
@Inject
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
@Test
fun doWork_cancelsItselfOnInvalidAccount() {
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))
// Run PeriodicSyncWorker as TestWorker
val inputData = workDataOf(
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)
// mock WorkManager to observe cancellation call
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
// run test worker, expect failure
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
syncWorkerFactory.create(appContext, workerParameters)
})
.build()
val result = runBlocking {
testWorker.doWork()
}
assertTrue(result is ListenableWorker.Result.Failure)
// verify that worker called WorkManager.cancelWorkById(<its ID>)
verify {
workManager.cancelWorkById(testWorker.id)
}
}
}

View File

@@ -0,0 +1,280 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkCapabilities.TRANSPORT_WIFI
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import androidx.core.content.getSystemService
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncConditions
import at.bitfire.davdroid.util.PermissionUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncConditionsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@MockK
lateinit var capabilities: NetworkCapabilities
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var factory: SyncConditions.Factory
@MockK
lateinit var network1: Network
@MockK
lateinit var network2: Network
private lateinit var accountSettings: AccountSettings
private lateinit var conditions: SyncConditions
private lateinit var connectivityManager: ConnectivityManager
@Before
fun setup() {
hiltRule.inject()
// prepare accountSettings with some necessary data
accountSettings = mockk<AccountSettings> {
every { account } returns Account("test", "test")
every { getIgnoreVpns() } returns false // default value
}
conditions = factory.create(accountSettings)
connectivityManager = context.getSystemService<ConnectivityManager>()!!.also { cm ->
mockkObject(cm)
every { cm.allNetworks } returns arrayOf(network1, network2)
every { cm.getNetworkInfo(network1) } returns mockk()
every { cm.getNetworkInfo(network2) } returns mockk()
every { cm.getNetworkCapabilities(network1) } returns capabilities
every { cm.getNetworkCapabilities(network2) } returns capabilities
}
}
@Test
fun testCorrectWifiSsid_CorrectWiFiSsid() {
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi")
mockkObject(PermissionUtils)
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
val wifiManager = context.getSystemService<WifiManager>()!!
mockkObject(wifiManager)
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
every { ssid } returns "ConnectedWiFi"
}
assertTrue(conditions.correctWifiSsid())
}
@Test
fun testCorrectWifiSsid_WrongWiFiSsid() {
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2")
mockkObject(PermissionUtils)
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
val wifiManager = context.getSystemService<WifiManager>()!!
mockkObject(wifiManager)
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
every { ssid } returns "ConnectedWiFi"
}
assertFalse(conditions.correctWifiSsid())
}
@Test
fun testInternetAvailable_capabilitiesNull() {
every { connectivityManager.getNetworkCapabilities(network1) } returns null
every { connectivityManager.getNetworkCapabilities(network2) } returns null
assertFalse(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_Internet() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_Validated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertFalse(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_InternetValidated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertTrue(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_ignoreVpns() {
every { accountSettings.getIgnoreVpns() } returns true
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false
assertFalse(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_ignoreVpns_NotVpn() {
every { accountSettings.getIgnoreVpns() } returns true
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true
assertTrue(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() {
// The real case that failed in davx5-ose#395 is that the connection list contains (in this order)
// 1. a mobile network without INTERNET, but with VALIDATED
// 2. a WiFi network with INTERNET and VALIDATED
// The "return false" of hasINTERNET will trigger at the first connection, the
// "andThen true" will trigger for the second connection
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
// There is an internet connection if any(!) connection has both INTERNET and VALIDATED.
assertTrue(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
assertTrue(conditions.internetAvailable())
}
@Test
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotVpn() {
every { accountSettings.getIgnoreVpns() } returns true
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true
assertTrue(conditions.internetAvailable())
}
@Test
fun testWifiAvailable_capabilitiesNull() {
every { connectivityManager.getNetworkCapabilities(network1) } returns null
every { connectivityManager.getNetworkCapabilities(network2) } returns null
assertFalse(conditions.wifiAvailable())
}
@Test
fun testWifiAvailable() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(conditions.wifiAvailable())
}
@Test
fun testWifiAvailable_wifi() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
assertFalse(conditions.wifiAvailable())
}
@Test
fun testWifiAvailable_validated() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertFalse(conditions.wifiAvailable())
}
@Test
fun testWifiAvailable_wifiValidated() {
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
assertTrue(conditions.wifiAvailable())
}
@Test
fun testWifiConditionsMet_withoutWifi() {
// "Sync only over Wi-Fi" is disabled
every { accountSettings.getSyncWifiOnly() } returns false
assertTrue(factory.create(accountSettings).wifiConditionsMet())
}
@Test
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
// "Sync only over Wi-Fi" is enabled
every { accountSettings.getSyncWifiOnly() } returns true
// Wi-Fi is available
mockkObject(conditions) {
// Wi-Fi is available
every { conditions.wifiAvailable() } returns true
// Wi-Fi SSID is correct
every { conditions.correctWifiSsid() } returns true
assertTrue(conditions.wifiConditionsMet())
}
}
@Test
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
// "Sync only over Wi-Fi" is enabled
every { accountSettings.getSyncWifiOnly() } returns true
mockkObject(conditions) {
// Wi-Fi is not available
every { conditions.wifiAvailable() } returns false
// Wi-Fi SSID is correct
every { conditions.correctWifiSsid() } returns true
assertFalse(conditions.wifiConditionsMet())
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncWorkerManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
// one-time sync workers
@Test
fun testEnqueueOneTime() {
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
assertEquals(workerName, returnedName)
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
// periodic sync workers
@Test
fun enablePeriodic() {
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disablePeriodic() {
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
}
}

View File

@@ -6,13 +6,12 @@ package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.CookieJar
@@ -31,33 +30,41 @@ import javax.inject.Inject
@HiltAndroidTest
class DavDocumentsProviderTest {
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
@ApplicationContext
lateinit var context: Context
@Inject lateinit var db: AppDatabase
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var logger: java.util.logging.Logger
@Before
fun setUp() {
hiltRule.inject()
}
private var mockServer = MockWebServer()
private lateinit var client: HttpClient
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@Before
fun mockServerSetup() {
// Start mock web server
mockServer.dispatcher = TestDispatcher()
mockServer.dispatcher = TestDispatcher(logger)
mockServer.start()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
client = HttpClient.Builder(context).build()
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
@@ -79,7 +86,7 @@ class DavDocumentsProviderTest {
val cookieStore = mutableMapOf<Long, CookieJar>()
// Query
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
// Assert new children were inserted into db
@@ -113,7 +120,7 @@ class DavDocumentsProviderTest {
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
// Query - should update the parent displayname and folder name
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
// Assert parent and children were updated in database
@@ -138,7 +145,7 @@ class DavDocumentsProviderTest {
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
// Assert folder got deleted
@@ -162,9 +169,9 @@ class DavDocumentsProviderTest {
assertEquals("parent2", parent2.name)
// Query - find children of two nodes simultaneously
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent1)
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent2)
// Assert the two folders names have changed
@@ -175,7 +182,9 @@ class DavDocumentsProviderTest {
// mock server
class TestDispatcher: Dispatcher() {
class TestDispatcher(
private val logger: java.util.logging.Logger
): Dispatcher() {
data class Resource(
val name: String,
@@ -230,8 +239,8 @@ class DavDocumentsProviderTest {
responses +
"</multistatus>"
Logger.log.info("Query path: $requestPath")
Logger.log.info("Response: $multistatus")
logger.info("Query path: $requestPath")
logger.info("Response: $multistatus")
return MockResponse()
.setResponseCode(207)
.setBody(multistatus)

View File

@@ -10,5 +10,6 @@
<resources>
<string name="app_name">Davx5Test</string>
<string name="account_type_test">at.bitfire.davdroid.test</string>
</resources>
</resources>

View File

@@ -1,3 +1,5 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="at.bitfire.davdroid.SyncManagerTest"
android:accountType="@string/account_type_test"
android:icon="@android:drawable/star_on"
android:smallIcon="@android:drawable/star_on"
android:label="Test Account" />

View File

@@ -1,150 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.ConnectionUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.util.PermissionUtils
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class BaseSyncWorkerTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext
private val accountManager = AccountManager.get(context)
private val account = Account("Test Account", context.getString(R.string.account_type))
private val fakeCredentials = Credentials("test", "test")
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun inject() = hiltRule.inject()
@Before
fun setUp() {
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@After
fun removeAccount() {
accountManager.removeAccountExplicitly(account)
}
@Test
fun testWifiConditionsMet_withoutWifi() {
val accountSettings = mockk<AccountSettings>()
every { accountSettings.getSyncWifiOnly() } returns false
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
val accountSettings = AccountSettings(context, account)
accountSettings.setSyncWiFiOnly(true)
mockkObject(ConnectionUtils)
every { ConnectionUtils.wifiAvailable(any()) } returns true
mockkObject(BaseSyncWorker.Companion)
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
val accountSettings = AccountSettings(context, account)
accountSettings.setSyncWiFiOnly(true)
mockkObject(ConnectionUtils)
every { ConnectionUtils.wifiAvailable(any()) } returns false
mockkObject(BaseSyncWorker.Companion)
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
assertFalse(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
}
@Test
fun testCorrectWifiSsid_CorrectWiFiSsid() {
val accountSettings = AccountSettings(context, account)
mockkObject(accountSettings)
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi")
mockkObject(PermissionUtils)
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
val wifiManager = context.getSystemService<WifiManager>()!!
mockkObject(wifiManager)
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
every { ssid } returns "ConnectedWiFi"
}
assertTrue(BaseSyncWorker.correctWifiSsid(context, accountSettings))
}
@Test
fun testCorrectWifiSsid_WrongWiFiSsid() {
val accountSettings = AccountSettings(context, account)
mockkObject(accountSettings)
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2")
mockkObject(PermissionUtils)
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
val wifiManager = context.getSystemService<WifiManager>()!!
mockkObject(wifiManager)
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
every { ssid } returns "ConnectedWiFi"
}
assertFalse(BaseSyncWorker.correctWifiSsid(context, accountSettings))
}
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
import android.content.Context
import android.provider.CalendarContract
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.syncadapter.SyncManagerTest.Companion.account
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert
import org.junit.Test
@HiltAndroidTest
class OneTimeSyncWorkerTest {
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun testEnqueue_enqueuesWorker() {
OneTimeSyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
Assert.assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
}

View File

@@ -1,148 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import dagger.assisted.AssistedFactory
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.AfterClass
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PeriodicSyncWorkerTest {
companion object {
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val accountManager = AccountManager.get(context)
private val account = Account("Test Account", context.getString(R.string.account_type))
private val fakeCredentials = Credentials("test", "test")
@BeforeClass
@JvmStatic
fun setUp() {
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
}
@AfterClass
@JvmStatic
fun removeAccount() {
accountManager.removeAccountExplicitly(account)
}
}
@AssistedFactory
interface PeriodicSyncWorkerFactory {
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Inject
lateinit var syncWorkerFactory: PeriodicSyncWorkerFactory
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun enable_enqueuesPeriodicWorker() {
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disable_removesPeriodicWorker() {
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY)
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
assertFalse(workScheduledOrRunning(context, workerName))
}
@Test
fun doWork_cancelsItselfOnInvalidAccount() {
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
// Run PeriodicSyncWorker as TestWorker
val inputData = workDataOf(
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)
// mock WorkManager to observe cancellation call
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
// run test worker, expect failure
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
syncWorkerFactory.create(appContext, workerParameters)
})
.build()
val result = runBlocking {
testWorker.doWork()
}
assertTrue(result is ListenableWorker.Result.Failure)
// verify that worker called WorkManager.cancelWorkById(<its ID>)
verify {
workManager.cancelWorkById(testWorker.id)
}
}
}

View File

@@ -1,73 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@HiltAndroidTest
class SyncerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext
/** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */
val mockAuthority = context.getString(R.string.webdav_authority)
val mockProvider = context.contentResolver!!.acquireContentProviderClient(mockAuthority)!!
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testOnPerformSync_runsSyncAndSetsClassLoader() {
val syncer = TestSyncer(context)
syncer.onPerformSync(account, arrayOf(), mockAuthority, mockProvider, SyncResult())
// check whether onPerformSync() actually calls sync()
assertEquals(1, syncer.syncCalled.get())
// check whether contextClassLoader is set
assertEquals(context.classLoader, Thread.currentThread().contextClassLoader)
}
class TestSyncer(context: Context) : Syncer(context) {
val syncCalled = AtomicInteger()
override fun sync(
account: Account,
extras: Array<String>,
authority: String,
httpClient: Lazy<HttpClient>,
provider: ContentProviderClient,
syncResult: SyncResult
) {
Thread.sleep(1000)
syncCalled.incrementAndGet()
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primaryColor">#E07C25</color>
<color name="primaryLightColor">#E5A371</color>
<color name="primaryDarkColor">#7C3E07</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
</resources>

View File

@@ -49,7 +49,7 @@
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:resizeableActivity="true"
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
@@ -130,7 +130,9 @@
<intent-filter>
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data
tools:ignore="AppLinkUrlError"
android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
</activity>
@@ -167,7 +169,7 @@
<!-- account type "DAVx⁵" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:name=".sync.account.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
@@ -177,7 +179,7 @@
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:name=".sync.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -188,7 +190,7 @@
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.JtxSyncAdapterService"
android:name=".sync.JtxSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -199,7 +201,7 @@
android:resource="@xml/sync_notes"/>
</service>
<service
android:name=".syncadapter.OpenTasksSyncAdapterService"
android:name=".sync.OpenTasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -210,7 +212,7 @@
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".syncadapter.TasksOrgSyncAdapterService"
android:name=".sync.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -233,7 +235,7 @@
<!-- account type "DAVx⁵ Address book" -->
<service
android:name=".syncadapter.AddressBookAuthenticatorService"
android:name=".sync.account.AddressBookAuthenticatorService"
android:exported="true"
tools:ignore="ExportedService"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
<intent-filter>
@@ -244,25 +246,8 @@
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator_address_book"/>
</service>
<provider
android:authorities="@string/address_books_authority"
android:exported="false"
android:label="@string/address_books_authority_title"
android:name=".syncadapter.AddressBookProvider" />
<service
android:name=".syncadapter.AddressBooksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_address_books"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:name=".sync.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -288,6 +273,16 @@
android:resource="@xml/debug_paths" />
</provider>
<!-- UnifiedPush receiver -->
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
</intent-filter>
</receiver>
<!-- Widgets -->
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
android:exported="true">

View File

@@ -0,0 +1,4 @@
# reduce verbose of some otherwise annoying ical4j messages
net.fortuna.ical4j.data.level = INFO
net.fortuna.ical4j.model.Recur.level = INFO

View File

@@ -5,81 +5,74 @@
package at.bitfire.davdroid
import android.app.Application
import android.os.StrictMode
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.log.LogManager
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.ui.UiUtils
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.system.exitProcess
@HiltAndroidApp
class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provider {
class App: Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var logger: Logger
/**
* Creates the [LogManager] singleton and thus initializes logging.
*/
@Inject
lateinit var logManager: LogManager
@Inject
lateinit var plugins: Set<@JvmSuppressWildcards StartupPlugin>
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
Logger.initialize(this)
if (BuildConfig.DEBUG)
// debug builds
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build())
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
// handle uncaught exceptions in non-debug standard flavor
Thread.setDefaultUncaughtExceptionHandler(this)
NotificationUtils.createChannels(this)
logger.fine("Logging using LogManager $logManager")
// set light/dark mode
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
// some current activity and causes an IllegalStateException in rare cases
// run startup plugins (sync)
for (plugin in plugins.sortedBy { it.priority() }) {
logger.fine("Running startup plugin: $plugin (onAppCreate)")
plugin.onAppCreate()
}
// don't block UI for some background checks
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Default) {
// clean up orphaned accounts in DB from time to time
AccountsCleanupWorker.enqueue(this@App)
// watch installed/removed tasks apps over whole app lifetime and update sync settings accordingly
TasksAppWatcher.watchInstalledTaskApps(this@App, this)
AccountsCleanupWorker.enable(this@App)
// create/update app shortcuts
UiUtils.updateShortcuts(this@App)
// run startup plugins (async)
for (plugin in plugins.sortedBy { it.priorityAsync() }) {
logger.fine("Running startup plugin: $plugin (onAppCreateAsync)")
plugin.onAppCreateAsync()
}
}
}
override fun uncaughtException(t: Thread, e: Throwable) {
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
val intent = DebugInfoActivity.IntentBuilder(this)
.withCause(e)
.newTask()
.build()
startActivity(intent)
exitProcess(1)
}
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.Context
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.util.packageChangedFlow
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Watches whether a tasks app has been installed or uninstalled and updates
* the selected tasks app and task sync settings accordingly.
*/
object TasksAppWatcher {
fun watchInstalledTaskApps(context: Context, externalScope: CoroutineScope) {
externalScope.launch(Dispatchers.Default) {
packageChangedFlow(context).collect {
onPackageChanged(context)
}
}
}
private fun onPackageChanged(context: Context) {
val currentProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or package (un)installed; current tasks provider = $currentProvider")
if (currentProvider == null) {
// Iterate through all supported providers and select one, if available.
var providerSelected = false
for (provider in TaskProvider.ProviderName.entries) {
val available = context.packageManager.resolveContentProvider(provider.authority, 0) != null
if (available) {
Logger.log.info("Selecting new tasks provider: $provider")
TaskUtils.selectProvider(context, provider)
providerSelected = true
break
}
}
if (!providerSelected)
// no provider available (anymore), also clear setting and sync
TaskUtils.selectProvider(context, null)
}
}
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid
import org.apache.commons.lang3.StringUtils
import java.util.Collections
class TextTable(
@@ -41,7 +40,7 @@ class TextTable(
// first line
sb.append("\n")
for (colIdx in headers.indices)
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┐' else '┬')
sb.append('\n')
@@ -49,14 +48,14 @@ class TextTable(
sb.append('│')
for (colIdx in headers.indices)
sb .append(' ')
.append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1))
.append(headers[colIdx].padEnd(colWidths[colIdx] + 1))
.append('│')
sb.append('\n')
// separator between header and body
sb.append('├')
for (colIdx in headers.indices) {
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┤' else '┼')
}
sb.append('\n')
@@ -65,7 +64,7 @@ class TextTable(
for (line in lines) {
for (colIdx in headers.indices)
sb .append("")
.append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1))
.append(line[colIdx].padEnd(colWidths[colIdx] + 1))
sb.append("\n")
}

View File

@@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration
import androidx.room.Database
@@ -24,17 +23,16 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.Writer
import java.util.logging.Logger
import javax.inject.Singleton
@Suppress("ClassName")
@@ -46,11 +44,12 @@ import javax.inject.Singleton
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 13, autoMigrations = [
], exportSchema = true, version = 14, autoMigrations = [
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
AutoMigration(from = 12, to = 13)
AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@@ -60,29 +59,32 @@ abstract class AppDatabase: RoomDatabase() {
object AppDatabaseModule {
@Provides
@Singleton
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
fun appDatabase(
@ApplicationContext context: Context,
notificationRegistry: NotificationRegistry
): AppDatabase =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(*migrations)
.addAutoMigrationSpec(AutoMigration11_12(context))
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
val nm = NotificationManagerCompat.from(context)
val launcherIntent = Intent(context, AccountsActivity::class.java)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
.setContentText(context.getString(R.string.database_destructive_migration_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setAutoCancel(true)
.build()
nm.notifyIfPossible(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify)
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
.setContentText(context.getString(R.string.database_destructive_migration_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setAutoCancel(true)
.build()
}
// remove all accounts because they're unfortunately useless without database
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
am.removeAccount(account, null, null)
am.removeAccountExplicitly(account)
}
})
.build()
@@ -94,7 +96,7 @@ abstract class AppDatabase: RoomDatabase() {
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
Logger.log.info("Database update to v12, refreshing services to get display names of owners")
Logger.getGlobal().info("Database update to v12, refreshing services to get display names of owners")
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
@@ -215,7 +217,7 @@ abstract class AppDatabase: RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
// We don't have access to the context in a Room migration now, so
// we will just drop those settings from old DAVx5 versions.
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
Logger.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
try {

View File

@@ -22,10 +22,10 @@ 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.ResourceType
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.trimToNull
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
@Entity(tableName = "collection",
foreignKeys = [
@@ -36,6 +36,8 @@ import org.apache.commons.lang3.StringUtils
indices = [
Index("serviceId","type"),
Index("homeSetId","type"),
Index("ownerId","type"),
Index("pushTopic","type"),
Index("url")
]
)
@@ -70,11 +72,28 @@ data class Collection(
*/
var url: HttpUrl,
/**
* Whether we have the permission to change contents of the collection on the server.
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
*/
var privWriteContent: Boolean = true,
/**
* Whether we have the permission to delete the collection on the server
*/
var privUnbind: Boolean = true,
/**
* Whether the user has manually set the "force read-only" flag.
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
*/
var forceReadOnly: Boolean = false,
/**
* Human-readable name of the collection
*/
var displayName: String? = null,
/**
* Human-readable description of the collection
*/
var description: String? = null,
// CalDAV only
@@ -142,7 +161,7 @@ data class Collection(
privUnbind = privilegeSet.mayUnbind
}
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
val displayName = dav[DisplayName::class.java]?.displayName.trimToNull()
var description: String? = null
var color: Int? = null

View File

@@ -32,6 +32,9 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
fun getSyncableByPushTopic(topic: String): Collection?
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
suspend fun anyOfType(serviceId: Long, type: String): Boolean
@@ -62,6 +65,16 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncTaskLists(serviceId: Long): List<Collection>
/**
* Get a list of collections that are both sync enabled and push capable (supportsWebPush and
* pushTopic is available).
*/
@Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL")
suspend fun getPushCapableSyncCollections(): List<Collection>
@Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync")
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@@ -74,6 +87,9 @@ interface CollectionDao {
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
suspend fun updateSync(id: Long, sync: Boolean)
@@ -94,23 +110,6 @@ interface CollectionDao {
localCollection.id
} ?: insert(collection)
/**
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
* [Collection.forceReadOnly]), but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
// remember locally set flags
getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
newCollection.sync = oldCollection.sync
newCollection.forceReadOnly = oldCollection.forceReadOnly
}
// commit to database
insertOrUpdateByUrl(newCollection)
}
@Delete
fun delete(collection: Collection)

View File

@@ -8,7 +8,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import okhttp3.HttpUrl
@Entity(tableName = "homeset",

View File

@@ -8,7 +8,6 @@ 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
@@ -36,20 +35,6 @@ interface HomeSetDao {
@Update
fun update(homeset: HomeSet)
/**
* 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 insertOrUpdateByUrl(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,8 +12,8 @@ import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.util.trimToNull
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
@Entity(tableName = "principal",
foreignKeys = [
@@ -47,9 +47,7 @@ data class Principal(
return null
// Try getting the display name of the principal
val displayName: String? = StringUtils.trimToNull(
dav[DisplayName::class.java]?.displayName
)
val displayName: String? = dav[DisplayName::class.java]?.displayName.trimToNull()
// Create and return principal - even without it's display name
return Principal(

View File

@@ -14,11 +14,11 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
import at.bitfire.davdroid.webdav.DocumentState
import java.io.FileNotFoundException
import java.time.Instant
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import java.io.FileNotFoundException
import java.time.Instant
@Entity(
tableName = "webdav_document",
@@ -27,7 +27,8 @@ import java.time.Instant
ForeignKey(entity = WebDavDocument::class, parentColumns = ["id"], childColumns = ["parentId"], onDelete = ForeignKey.CASCADE)
],
indices = [
Index("mountId", "parentId", "name", unique = true)
Index("mountId", "parentId", "name", unique = true),
Index("parentId")
]
)
data class WebDavDocument(

View File

@@ -8,7 +8,6 @@ import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
@@ -32,8 +31,12 @@ interface WebDavMountDao {
// complex queries
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
@Transaction
fun getAllWithRootDocumentFlow(): Flow<List<WebDavMountWithRootDocument>>
/**
* Gets a list of mounts with the quotas of their root document, if available.
*/
@Query("SELECT webdav_mount.*, quotaAvailable, quotaUsed FROM webdav_mount " +
"LEFT JOIN webdav_document ON (webdav_mount.id=webdav_document.mountId AND webdav_document.parentId IS NULL) " +
"ORDER BY webdav_mount.name, webdav_mount.url")
fun getAllWithQuotaFlow(): Flow<List<WebDavMountWithQuota>>
}

View File

@@ -5,18 +5,14 @@
package at.bitfire.davdroid.db
import androidx.room.Embedded
import androidx.room.Relation
/**
* A [WebDavMount] with an optional root document (that contains information like quota).
*/
data class WebDavMountWithRootDocument(
data class WebDavMountWithQuota(
@Embedded
val mount: WebDavMount,
@Relation(
parentColumn = "id",
entityColumn = "mountId"
)
val rootDocument: WebDavDocument?
val quotaAvailable: Long? = null,
val quotaUsed: Long? = null
)

View File

@@ -0,0 +1,172 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Process
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.Closeable
import java.io.File
import java.util.Date
import java.util.logging.FileHandler
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
import java.util.logging.Logger
import javax.inject.Inject
/**
* Logging handler that logs to a debug log file.
*
* Shows a permanent notification as long as it's active (until [close] is called).
*
* Only one [LogFileHandler] should be active at once, because the notification is shared.
*/
class LogFileHandler @Inject constructor(
@ApplicationContext val context: Context,
private val logger: Logger,
private val notificationRegistry: NotificationRegistry
): Handler(), Closeable {
companion object {
private const val DEBUG_INFO_DIRECTORY = "debug"
/**
* Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored.
* Must match the contents of `res/xml/debug.paths.xml`.
*
* @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully.
*/
fun debugDir(context: Context): File? {
val dir = File(context.filesDir, DEBUG_INFO_DIRECTORY)
if (dir.exists() && dir.isDirectory)
return dir
if (dir.mkdir())
return dir
return null
}
/**
* The file (in [debugDir]) where verbose logs are stored.
*
* @return The file where verbose logs are stored, or `null` if there's no [debugDir].
*/
fun getDebugLogFile(context: Context): File? {
val logDir = debugDir(context) ?: return null
return File(logDir, "davx5-log.txt")
}
}
private var fileHandler: FileHandler? = null
private val notificationManager = NotificationManagerCompat.from(context)
private val logFile = getDebugLogFile(context)
init {
if (logFile != null) {
if (logFile.createNewFile())
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
// actual logging is handled by a FileHandler
fileHandler = FileHandler(logFile.toString(), true).apply {
formatter = PlainTextFormatter.DEFAULT
}
showNotification()
} else {
logger.severe("Couldn't create log file in app-private directory $DEBUG_INFO_DIRECTORY/.")
level = Level.OFF
}
}
@Synchronized
override fun publish(record: LogRecord) {
fileHandler?.publish(record)
}
@Synchronized
override fun flush() {
fileHandler?.flush()
}
@Synchronized
override fun close() {
fileHandler?.close()
fileHandler = null
// remove all files in debug info directory, may also contain zip files from debug info activity etc.
logFile?.parentFile?.deleteRecursively()
removeNotification()
}
// notifications
private fun showNotification() {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_VERBOSE_LOGGING) {
val builder = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_DEBUG)
builder.setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.app_settings_logging))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(
context.getString(
R.string.logging_notification_text, context.getString(
R.string.app_name
)
)
)
.setOngoing(true)
// add action to view/share the logs
val shareIntent = DebugInfoActivity.IntentBuilder(context)
.newTask()
.share()
val pendingShare =
PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_share,
context.getString(R.string.logging_notification_view_share),
pendingShare
).build()
)
// add action to disable verbose logging
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingPref =
PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_settings,
context.getString(R.string.logging_notification_disable),
pendingPref
).build()
)
builder.build()
}
}
private fun removeNotification() {
notificationManager.cancel(NotificationRegistry.NOTIFY_VERBOSE_LOGGING)
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.content.Context
import android.util.Log
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.repository.PreferenceRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* Handles logging configuration and which loggers are active at a moment.
* To initialize, just make sure that the [LogManager] singleton is created.
*
* Configures the root logger like this:
*
* - Always logs to logcat.
* - Watches the "log to file" preference and activates or deactivates file logging accordingly.
* - If "log to file" is enabled, log level is set to [Level.ALL].
* - Otherwise, log level is set to [Level.INFO].
*
* Preferred ways to get a [Logger] are:
*
* - `@Inject` [Logger] for a general-purpose logger when injection is possible
* - `Logger.getGlobal()` for a general-purpose logger
* - `Logger.getLogger(javaClass.name)` for a specific logger that can be customized
*
* When using the global logger, the class name of the logging calls will still be logged, so there's
* no need to always get a separate logger for each class (only if the class wants to customize it).
*/
@Singleton
class LogManager @Inject constructor(
@ApplicationContext private val context: Context,
private val logFileHandler: Provider<LogFileHandler>,
private val logger: Logger,
private val prefs: PreferenceRepository
) : AutoCloseable {
private val scope = CoroutineScope(Dispatchers.Default)
init {
// observe preference changes
scope.launch {
prefs.logToFileFlow().collect {
reloadConfig()
}
}
reloadConfig()
}
override fun close() {
scope.cancel()
}
@Synchronized
fun reloadConfig() {
val logToFile = prefs.logToFile()
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(logger.name, Log.DEBUG)
logger.info("Verbose logging = $logVerbose; log to file = $logToFile")
// reset existing loggers and initialize from assets/logging.properties
context.assets.open("logging.properties").use {
val javaLogManager = java.util.logging.LogManager.getLogManager()
javaLogManager.readConfiguration(it)
}
// 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())
// log to file, if requested
if (logToFile)
rootLogger.addHandler(logFileHandler.get())
}
}

View File

@@ -4,43 +4,49 @@
package at.bitfire.davdroid.log
import android.os.Build
import android.util.Log
import org.apache.commons.lang3.math.NumberUtils
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
object LogcatHandler: Handler() {
private const val MAX_LINE_LENGTH = 3000
/**
* Logging handler that logs to Android logcat.
*/
internal class LogcatHandler: Handler() {
init {
formatter = PlainTextFormatter.LOGCAT
level = Level.ALL
}
override fun publish(r: LogRecord) {
val text = formatter.format(r)
val level = r.level.intValue()
val text = formatter.format(r)
val end = text.length
var pos = 0
while (pos < end) {
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
when {
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
else -> Log.v(r.loggerName, line)
}
pos += MAX_LINE_LENGTH
// 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,157 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import android.app.Application
import android.app.PendingIntent
import android.content.Intent
import android.content.SharedPreferences
import android.os.Process
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import java.io.File
import java.io.IOException
import java.util.Date
import java.util.logging.FileHandler
import java.util.logging.Level
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
private const val LOGGER_NAME = "davx5"
const val LOG_TO_FILE = "log_to_file"
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger(LOGGER_NAME)
private lateinit var context: Application
private lateinit var preferences: SharedPreferences
fun initialize(app: Application) {
context = app
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener(this)
reinitialize()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
if (key == LOG_TO_FILE) {
log.info("Logging settings changed; re-initializing logger")
reinitialize()
}
}
@Synchronized
private fun reinitialize() {
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(log.name, Log.DEBUG)
log.info("Verbose logging: $logVerbose; to file: $logToFile")
// set logging level according to preferences
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
// reset all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { handler ->
rootLogger.removeHandler(handler)
if (handler is FileHandler) // gracefully close previous verbose-logging FileHandlers
handler.close()
}
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val logFile = getDebugLogFile() ?: return log.warning("Log file could not be retrieved.")
if (logFile.createNewFile())
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
try {
val fileHandler = FileHandler(logFile.toString(), true).apply {
formatter = PlainTextFormatter.DEFAULT
}
rootLogger.addHandler(fileHandler)
log.info("Now logging to file: $logFile")
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.app_settings_logging))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(context.getString(R.string.logging_notification_text, context.getString(R.string.app_name)))
.setOngoing(true)
val shareIntent = DebugInfoActivity.IntentBuilder(context)
.newTask()
.share()
val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(
R.drawable.ic_share,
context.getString(R.string.logging_notification_view_share),
pendingShare
).build())
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingPref = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(
R.drawable.ic_settings,
context.getString(R.string.logging_notification_disable),
pendingPref
).build())
nm.notifyIfPossible(NotificationUtils.NOTIFY_VERBOSE_LOGGING, builder.build())
} catch(e: IOException) {
log.log(Level.SEVERE, "Couldn't create log file", e)
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
}
} else {
// verbose logging is disabled -> cancel notification and remove old logs
nm.cancel(NotificationUtils.NOTIFY_VERBOSE_LOGGING)
debugDir()?.deleteRecursively()
}
}
/**
* Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored.
* Must match the contents of `res/xml/debug.paths.xml`.
*
* @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully.
*/
fun debugDir(): File? {
val dir = File(context.filesDir, "debug")
if (dir.exists() && dir.isDirectory)
return dir
if (dir.mkdir())
return dir
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
return null
}
/**
* The file (in [debugDir]) where verbose logs are stored.
*
* @return The file where verbose logs are stored, or `null` if there's no [debugDir].
*/
fun getDebugLogFile(): File? {
val logDir = debugDir() ?: return null
return File(logDir, "davx5-log.txt")
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.logging.Logger
@Module
@InstallIn(SingletonComponent::class)
class LoggerModule {
@Provides
fun globalLogger(): Logger = Logger.getGlobal()
}

View File

@@ -4,55 +4,108 @@
package at.bitfire.davdroid.log
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
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 constructor(
private val logcat: Boolean
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 {
val LOGCAT = PlainTextFormatter(true)
val DEFAULT = PlainTextFormatter(false)
const val MAX_MESSAGE_LENGTH = 20000
/**
* 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 (!logcat)
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss", Locale.ROOT))
if (withTime)
builder .append(timeFormat.format(Date(r.millis)))
.append(" ").append(r.threadID).append(" ")
val className = shortClassName(r.sourceClassName)
if (className != r.loggerName)
builder.append("[").append(className).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(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
builder.append(truncate(r.message))
r.thrown?.let {
builder .append("\nEXCEPTION ")
.append(ExceptionUtils.getStackTrace(it))
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).append(" = ").append(param)
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 (!logcat)
builder.append("\n")
if (lineSeparator != null)
builder.append(lineSeparator)
return builder.toString()
}
private fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
.replace(Regex("\\$.*$"), "")
private fun truncate(s: String) =
Ascii.truncate(s, MAX_LENGTH, "[…]")
}
}

View File

@@ -4,10 +4,22 @@
package at.bitfire.davdroid.log
import com.google.common.base.Ascii
import java.util.logging.Handler
import java.util.logging.LogRecord
class StringHandler: Handler() {
/**
* Handler that writes log messages to a string buffer.
*
* @param maxSize Maximum size of the buffer. If the buffer exceeds this size, it will be truncated.
*/
class StringHandler(
private val maxSize: Int
): Handler() {
companion object {
const val TRUNCATION_MARKER = "[...]"
}
val builder = StringBuilder()
@@ -16,7 +28,24 @@ class StringHandler: Handler() {
}
override fun publish(record: LogRecord) {
builder.append(formatter.format(record))
var text = formatter.format(record)
val currentSize = builder.length
val sizeLeft = maxSize - currentSize
when {
// Append the text if there is enough space
sizeLeft > text.length ->
builder.append(text)
// Truncate the text if there is not enough space
sizeLeft > TRUNCATION_MARKER.length -> {
text = Ascii.truncate(text, maxSize - currentSize, TRUNCATION_MARKER)
builder.append(text)
}
// Do nothing if the buffer is already full
}
}
override fun flush() {}
@@ -24,4 +53,4 @@ class StringHandler: Handler() {
override fun toString() = builder.toString()
}
}

View File

@@ -11,22 +11,23 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.runBlocking
import org.xbill.DNS.EDNSOption
import org.xbill.DNS.Message
import org.xbill.DNS.Resolver
import org.xbill.DNS.ResolverListener
import org.xbill.DNS.TSIG
import java.io.IOException
import java.time.Duration
/**
* dnsjava Resolver that uses Android's [DnsResolver] API, which is available since Android 10.
* dnsjava [Resolver] that uses Android's [DnsResolver] API, which can resolve raw queries and
* is available since Android 10.
*/
@RequiresApi(Build.VERSION_CODES.Q)
object Android10Resolver: Resolver {
class Android10Resolver : Resolver {
private val executor = Dispatchers.IO.asExecutor()
private val resolver = DnsResolver.getInstance()
override fun send(query: Message): Message = runBlocking {
val future = CompletableDeferred<Message>()
@@ -44,10 +45,6 @@ object Android10Resolver: Resolver {
future.await()
}
override fun sendAsync(query: Message, listener: ResolverListener) =
// currently not used by dnsjava, so no need to implement it
throw NotImplementedError()
override fun setPort(port: Int) {
// not applicable
@@ -61,11 +58,7 @@ object Android10Resolver: Resolver {
// not applicable
}
override fun setEDNS(level: Int) {
// not applicable
}
override fun setEDNS(level: Int, payloadSize: Int, flags: Int, options: MutableList<Any?>?) {
override fun setEDNS(version: Int, payloadSize: Int, flags: Int, options: MutableList<EDNSOption>?) {
// not applicable
}
@@ -73,11 +66,7 @@ object Android10Resolver: Resolver {
// not applicable
}
override fun setTimeout(secs: Int, msecs: Int) {
// not applicable
}
override fun setTimeout(secs: Int) {
override fun setTimeout(timeout: Duration?) {
// not applicable
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.network
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
@@ -14,6 +13,7 @@ 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.
@@ -24,6 +24,9 @@ class BearerAuthInterceptor(
companion object {
val logger: Logger
get() = Logger.getGlobal()
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
return runBlocking {
val accessTokenFuture = CompletableDeferred<String>()
@@ -37,7 +40,7 @@ class BearerAuthInterceptor(
accessTokenFuture.complete(accessToken)
}
else {
Logger.log.log(Level.WARNING, "Couldn't obtain access token", ex)
logger.log(Level.WARNING, "Couldn't obtain access token", ex)
accessTokenFuture.cancel()
}
}
@@ -54,7 +57,7 @@ class BearerAuthInterceptor(
}
override fun intercept(chain: Interceptor.Chain): Response {
Logger.log.finer("Authenticating request with access token")
logger.finer("Authenticating request with access token")
val rq = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()

View File

@@ -1,73 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import at.bitfire.davdroid.log.Logger
import java.util.logging.Level
object ConnectionUtils {
/**
* Checks whether we are connected to validated WiFi
*/
internal fun wifiAvailable(connectivityManager: ConnectivityManager): Boolean {
connectivityManager.allNetworks.forEach { network ->
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
return true
}
}
return false
}
/**
* Checks whether we are connected to the Internet.
*
* On API 26+ devices, if a VPN is used, WorkManager might start the SyncWorker without an
* Internet connection (because [NetworkCapabilities.NET_CAPABILITY_VALIDATED] is always set for VPN connections).
* To prevent the start without internet access, we don't check for VPN connections by default
* (by using [NetworkCapabilities.NET_CAPABILITY_NOT_VPN]).
*
* However in special occasions (when syncing over a VPN without validated Internet on the
* underlying connection) we do not want to exclude VPNs.
*
* @param ignoreVpns *true* filters VPN connections in the Internet check; *false* allows them as valid connection
* @return whether we are connected to the Internet
*/
internal fun internetAvailable(connectivityManager: ConnectivityManager, ignoreVpns: Boolean): Boolean {
return connectivityManager.allNetworks.any { network ->
val capabilities = connectivityManager.getNetworkCapabilities(network)
Logger.log.log(Level.FINE, "Looking for validated Internet over this connection.",
arrayOf(connectivityManager.getNetworkInfo(network), capabilities))
if (capabilities != null) {
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
Logger.log.fine("Missing network capability: INTERNET")
return@any false
}
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
Logger.log.fine("Missing network capability: VALIDATED")
return@any false
}
if (ignoreVpns)
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
Logger.log.fine("Missing network capability: NOT_VPN")
return@any false
}
Logger.log.fine("This connection can be used.")
/* return@any */ true
} else
// no network capabilities available, we can't use this connection
/* return@any */ false
}
}
}

View File

@@ -0,0 +1,158 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Lookup
import org.xbill.DNS.Record
import org.xbill.DNS.Resolver
import org.xbill.DNS.ResolverConfig
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.TXTRecord
import java.net.InetAddress
import java.util.LinkedList
import java.util.TreeMap
import java.util.logging.Logger
import javax.inject.Inject
/**
* Allows to resolve SRV/TXT records. Chooses the correct resolver, DNS servers etc.
*/
class DnsRecordResolver @Inject constructor(
@ApplicationContext val context: Context,
private val logger: Logger
) {
// resolving
/**
* Fallback DNS server that will be used when other DNS are not known or working.
* `9.9.9.9` belongs to Cloudflare who promise good privacy.
*/
private val DNS_FALLBACK = InetAddress.getByAddress(byteArrayOf(9,9,9,9))
private val resolver by lazy { chooseResolver() }
init {
// empty initialization for dnsjava because we set the servers for each request
ResolverConfig.setConfigProviders(listOf())
}
/**
* Creates a matching Resolver, depending on the Android version:
*
* Android 10+: Android10Resolver, which uses the raw DNS resolver that comes with Android
* Android <10: ExtendedResolver, which uses the known DNS servers to resolve DNS queries
*/
private fun chooseResolver(): Resolver =
if (Build.VERSION.SDK_INT >= 29) {
/* Since Android 10, there's a native DnsResolver API that allows to send SRV queries without
knowing which DNS servers have to be used. DNS over TLS is now also supported. */
logger.fine("Using Android 10+ DnsResolver")
Android10Resolver()
} else {
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
The current version of dnsjava relies on these properties to find the default name servers,
so we have to add the servers explicitly (fortunately, there's an Android API to
get the DNS servers of the network connections). */
val dnsServers = LinkedList<InetAddress>()
val connectivity = context.getSystemService<ConnectivityManager>()!!
@Suppress("DEPRECATION")
connectivity.allNetworks.forEach { network ->
val active = connectivity.getNetworkInfo(network)?.isConnected == true
connectivity.getLinkProperties(network)?.let { link ->
if (active)
// active connection, insert at top of list
dnsServers.addAll(0, link.dnsServers)
else
// inactive connection, insert at end of list
dnsServers.addAll(link.dnsServers)
}
}
// fallback: add Quad9 DNS in case that no other DNS works
dnsServers.add(DNS_FALLBACK)
val uniqueDnsServers = LinkedHashSet<InetAddress>(dnsServers)
val simpleResolvers = uniqueDnsServers.map { dns ->
logger.fine("Adding DNS server ${dns.hostAddress}")
SimpleResolver(dns)
}
// combine SimpleResolvers which query one DNS server each to an ExtendedResolver
ExtendedResolver(simpleResolvers.toTypedArray())
}
fun resolve(query: String, type: Int): Array<out Record> {
val lookup = Lookup(query, type)
lookup.setResolver(resolver)
return lookup.run().orEmpty()
}
// record selection
fun bestSRVRecord(records: Array<out Record>): SRVRecord? {
val srvRecords = records.filterIsInstance<SRVRecord>()
if (srvRecords.size <= 1)
return srvRecords.firstOrNull()
/* RFC 2782
Priority
The priority of this target host. A client MUST attempt to
contact the target host with the lowest-numbered priority it can
reach; target hosts with the same priority SHOULD be tried in an
order defined by the weight field. [...]
Weight
A server selection mechanism. The weight field specifies a
relative weight for entries with the same priority. [...]
To select a target to be contacted next, arrange all SRV RRs
(that have not been ordered yet) in any order, except that all
those with weight 0 are placed at the beginning of the list.
Compute the sum of the weights of those RRs, and with each RR
associate the running sum in the selected order. Then choose a
uniform random number between 0 and the sum computed
(inclusive), and select the RR whose running sum value is the
first in the selected order which is greater than or equal to
the random number selected. The target host specified in the
selected SRV RR is the next one to be contacted by the client.
*/
// Select records which have the minimum priority
val minPriority = srvRecords.minOfOrNull { it.priority }
val usableRecords = srvRecords.filter { it.priority == minPriority }
.sortedBy { it.weight != 0 } // and put those with weight 0 first
val map = TreeMap<Int, SRVRecord>()
var runningWeight = 0
for (record in usableRecords) {
val weight = record.weight
runningWeight += weight
map[runningWeight] = record
}
val selector = (0..runningWeight).random()
return map.ceilingEntry(selector)!!.value
}
fun pathsFromTXTRecords(records: Array<out Record>): List<String> {
val paths = LinkedList<String>()
records.filterIsInstance<TXTRecord>().forEach { txt ->
for (segment in txt.strings as List<String>)
if (segment.startsWith("path="))
paths.add(segment.substring(5))
}
return paths
}
}

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.network
import android.net.Uri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -20,10 +19,14 @@ import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import java.net.URI
import java.util.logging.Logger
class GoogleLogin(
val authService: AuthorizationService
) {
private val logger: Logger = Logger.getGlobal()
companion object {
@@ -72,7 +75,7 @@ class GoogleLogin(
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
logger.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success, save authState (= refresh token)

View File

@@ -12,7 +12,6 @@ import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
@@ -23,6 +22,21 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
import kotlinx.coroutines.flow.MutableStateFlow
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
@@ -37,22 +51,6 @@ import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
class HttpClient @AssistedInject constructor(
@Assisted val okHttpClient: OkHttpClient,
@@ -108,7 +106,7 @@ class HttpClient @AssistedInject constructor(
class Builder(
val context: Context,
accountSettings: AccountSettings? = null,
val logger: java.util.logging.Logger? = Logger.log,
val logger: Logger = Logger.getGlobal(),
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
) {
@@ -116,7 +114,7 @@ class HttpClient @AssistedInject constructor(
@InstallIn(SingletonComponent::class)
interface HttpClientBuilderEntryPoint {
fun authorizationService(): AuthorizationService
fun httpClientFactory(): HttpClient.Factory
fun httpClientFactory(): Factory
fun settingsManager(): SettingsManager
}
@@ -140,7 +138,7 @@ class HttpClient @AssistedInject constructor(
init {
// add network logging, if requested
if (logger != null && logger.isLoggable(Level.FINEST)) {
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
loggingInterceptor.level = loggerLevel
orig.addNetworkInterceptor(loggingInterceptor)
@@ -167,10 +165,10 @@ class HttpClient @AssistedInject constructor(
else -> throw IllegalArgumentException("Invalid proxy type")
}
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy setting", proxy)
logger.log(Level.INFO, "Using proxy setting", proxy)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
customCertManager {
@@ -239,7 +237,7 @@ class HttpClient @AssistedInject constructor(
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
Logger.log.fine("Using disk cache: $cacheDir")
logger.fine("Using disk cache: $cacheDir")
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
break
}
@@ -319,14 +317,11 @@ class HttpClient @AssistedInject constructor(
object UserAgentInterceptor: Interceptor {
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {
Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests")
Logger.getGlobal().info("Will set User-Agent: $userAgent")
}
override fun intercept(chain: Interceptor.Chain): Response {
@@ -340,4 +335,4 @@ class HttpClient @AssistedInject constructor(
}
}
}

View File

@@ -4,31 +4,54 @@
package at.bitfire.davdroid.network
import androidx.annotation.VisibleForTesting
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import org.apache.commons.collections4.keyvalue.MultiKey
import org.apache.commons.collections4.map.HashedMap
import org.apache.commons.collections4.map.MultiKeyMap
import java.util.LinkedList
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
class MemoryCookieStore: CookieJar {
class MemoryCookieStore : CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
data class StorageKey(
val domain: String,
val path: String,
val name: String
)
private val storage = mutableMapOf<StorageKey, Cookie>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
/* [RFC 6265 5.3 Storage Model]
11. If the cookie store contains a cookie with the same name,
domain, and path as the newly created cookie:
1. Let old-cookie be the existing cookie with the same name,
domain, and path as the newly created cookie. (Notice that
this algorithm maintains the invariant that there is at most
one such cookie.)
2. If the newly created cookie was received from a "non-HTTP"
API and the old-cookie's http-only-flag is set, abort these
steps and ignore the newly created cookie entirely.
3. Update the creation-time of the newly created cookie to
match the creation-time of the old-cookie.
4. Remove the old-cookie from the cookie store.
*/
synchronized(storage) {
for (cookie in cookies)
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
storage.putAll(cookies.map {
StorageKey(
domain = it.domain,
path = it.path,
name = it.name
) to it
})
}
}
@@ -36,10 +59,9 @@ class MemoryCookieStore: CookieJar {
val cookies = LinkedList<Cookie>()
synchronized(storage) {
val iter = storage.mapIterator()
val iter = storage.iterator()
while (iter.hasNext()) {
iter.next()
val cookie = iter.value
val (_, cookie) = iter.next()
// remove expired cookies
if (cookie.expiresAt <= System.currentTimeMillis()) {
@@ -47,7 +69,7 @@ class MemoryCookieStore: CookieJar {
continue
}
// add applicable cookies
// add applicable cookies to result
if (cookie.matches(url))
cookies += cookie
}
@@ -56,4 +78,4 @@ class MemoryCookieStore: CookieJar {
return cookies
}
}
}

View File

@@ -9,6 +9,7 @@ import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
@@ -20,7 +21,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.lang3.StringUtils
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
@@ -101,7 +101,7 @@ class NextcloudLoginFlow(
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = StringUtils.appendIfMissing(json.getString("server"), "/")
val serverUrl = json.getString("server").withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),

View File

@@ -0,0 +1,43 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import at.bitfire.dav4jvm.XmlReader
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.push.PushMessage
import org.xmlpull.v1.XmlPullParserException
import java.io.StringReader
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class PushMessageParser @Inject constructor(
private val logger: Logger
) {
/**
* Parses a WebDAV-Push message and returns the `topic` that the message is about.
*
* @return topic of the modified collection, or `null` if the topic couldn't be determined
*/
operator fun invoke(message: String): String? {
var topic: String? = null
val parser = XmlUtils.newPullParser()
try {
parser.setInput(StringReader(message))
XmlReader(parser).processTag(PushMessage.NAME) {
val pushMessage = PushMessage.Factory.create(parser)
topic = pushMessage.topic
}
} catch (e: XmlPullParserException) {
logger.log(Level.WARNING, "Couldn't parse push message", e)
}
return topic
}
}

View File

@@ -0,0 +1,65 @@
package at.bitfire.davdroid.push
import android.accounts.Account
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.ui.account.AccountActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class PushNotificationManager @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationRegistry: NotificationRegistry
) {
/**
* Generates the notification ID for a push notification.
*/
private fun notificationId(account: Account, authority: String): Int {
return account.name.hashCode() + account.type.hashCode() + authority.hashCode()
}
/**
* Sends a notification to inform the user that a push notification has been received, the
* sync has been scheduled, but it still has not run.
*/
fun notify(account: Account, authority: String) {
notificationRegistry.notifyIfPossible(notificationId(account, authority)) {
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_sync)
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
.setContentText(context.getString(R.string.sync_notification_pending_push_message))
.setSubText(account.name)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(
PendingIntent.getActivity(
context,
0,
Intent(context, AccountActivity::class.java).apply {
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
.build()
}
}
/**
* Once the sync has been started, the notification is no longer needed and can be dismissed.
* It's safe to call this method even if the notification has not been shown.
*/
fun dismiss(account: Account, authority: String) {
NotificationManagerCompat.from(context)
.cancel(notificationId(account, authority))
}
}

View File

@@ -0,0 +1,217 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.accounts.Account
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.push.NS_WEBDAV_PUSH
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.settings.AccountSettings
import dagger.Binds
import dagger.Module
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.StringWriter
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
/**
* Worker that registers push for all collections that support it.
* To be run as soon as a collection that supports push is changed (selected for sync status
* changes, or collection is created, deleted, etc).
*
* TODO Should run periodically, too (to refresh registrations that are about to expire).
* Not required for a first demonstration version.
*/
@Suppress("unused")
@HiltWorker
class PushRegistrationWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParameters: WorkerParameters,
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
private val logger: Logger,
private val preferenceRepository: PreferenceRepository,
private val serviceRepository: DavServiceRepository
) : CoroutineWorker(context, workerParameters) {
companion object {
private const val UNIQUE_WORK_NAME = "push-registration"
/**
* Enqueues a push registration worker with a minimum delay of 5 seconds.
*/
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
.build()
val workRequest = OneTimeWorkRequestBuilder<PushRegistrationWorker>()
.setInitialDelay(5, TimeUnit.SECONDS)
.setConstraints(constraints)
.build()
Logger.getGlobal().info("Enqueueing push registration worker")
WorkManager.getInstance(context)
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest)
}
}
override suspend fun doWork(): Result {
logger.info("Running push registration worker")
registerSyncable()
unregisterNotSyncable()
return Result.success()
}
private suspend fun registerPushSubscription(collection: Collection, account: Account, endpoint: String) {
val settings = accountSettingsFactory.create(account)
runInterruptible {
HttpClient.Builder(applicationContext, settings)
.setForeground(true)
.build()
.use { client ->
val httpClient = client.okHttpClient
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-register")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "subscription")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) {
text(endpoint)
}
}
}
}
serializer.endDocument()
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
response.header("Location")?.let { subscriptionUrl ->
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
}
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
}
}
}
private suspend fun registerSyncable() {
val endpoint = preferenceRepository.unifiedPushEndpoint()
// register push subscription for syncable collections
if (endpoint != null)
for (collection in collectionRepository.getPushCapableAndSyncable()) {
logger.info("Registering push for ${collection.url}")
serviceRepository.get(collection.serviceId)?.let { service ->
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
try {
registerPushSubscription(collection, account, endpoint)
} catch (e: DavException) {
// catch possible per-collection exception so that all collections can be processed
logger.log(Level.WARNING, "Couldn't register push for ${collection.url}", e)
}
}
}
else
logger.info("No UnifiedPush endpoint configured")
}
private suspend fun unregisterPushSubscription(collection: Collection, account: Account, url: HttpUrl) {
val settings = accountSettingsFactory.create(account)
runInterruptible {
HttpClient.Builder(applicationContext, settings)
.setForeground(true)
.build()
.use { client ->
val httpClient = client.okHttpClient
try {
DavResource(httpClient, url).delete {
// deleted
}
} catch (e: DavException) {
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
}
// remove registration URL from DB in any case
collectionRepository.updatePushSubscription(collection.id, null)
}
}
}
private suspend fun unregisterNotSyncable() {
for (collection in collectionRepository.getPushRegisteredAndNotSyncable()) {
logger.info("Unregistering push for ${collection.url}")
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
serviceRepository.get(collection.serviceId)?.let { service ->
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
unregisterPushSubscription(collection, account, url)
}
}
}
}
/**
* Listener that enqueues a push registration worker when the collection list changes.
*/
class CollectionsListener @Inject constructor(
@ApplicationContext val context: Context
): DavCollectionRepository.OnChangeListener {
override fun onCollectionsChanged() = enqueue(context)
}
/**
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
*/
@Module
@InstallIn(SingletonComponent::class)
interface PushRegistrationWorkerModule {
@Binds
@IntoSet
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.AndroidEntryPoint
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
@AndroidEntryPoint
class UnifiedPushReceiver: MessagingReceiver() {
@Inject
lateinit var accountRepository: AccountRepository
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var preferenceRepository: PreferenceRepository
@Inject
lateinit var parsePushMessage: PushMessageParser
@Inject
lateinit var syncWorkerManager: SyncWorkerManager
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
// remember new endpoint
preferenceRepository.unifiedPushEndpoint(endpoint)
// register new endpoint at CalDAV/CardDAV servers
PushRegistrationWorker.enqueue(context)
}
override fun onUnregistered(context: Context, instance: String) {
// reset known endpoint
preferenceRepository.unifiedPushEndpoint(null)
}
override fun onMessage(context: Context, message: ByteArray, instance: String) {
CoroutineScope(Dispatchers.Default).launch {
val messageXml = message.toString(Charsets.UTF_8)
logger.log(Level.INFO, "Received push message", messageXml)
// parse push notification
val topic = parsePushMessage(messageXml)
// sync affected collection
if (topic != null) {
logger.info("Got push notification for topic $topic")
// Sync all authorities of account that the collection belongs to
// Later: only sync affected collection and authorities
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
val account = accountRepository.fromName(service.accountName)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
}
}
} else {
logger.warning("Got push message without topic, syncing all accounts")
for (account in accountRepository.getAll())
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
}
}
}
}

View File

@@ -4,23 +4,17 @@
package at.bitfire.davdroid.repository
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Application
import android.content.ContentResolver
import android.content.pm.PackageManager
import android.content.Context
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.servicedetection.DavResourceFinder
@@ -28,17 +22,20 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
/**
@@ -48,17 +45,22 @@ import javax.inject.Inject
* [at.bitfire.davdroid.resource.LocalAddressBook].
*/
class AccountRepository @Inject constructor(
val context: Application,
val db: AppDatabase,
val settingsManager: SettingsManager,
val serviceRepository: DavServiceRepository
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
private val logger: Logger,
private val settingsManager: SettingsManager,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
) {
private val accountType = context.getString(R.string.account_type)
private val accountManager = AccountManager.get(context)
/**
* Creates a new main account with discovered services and enables periodic syncs with
* Creates a new account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param accountName name of the account
@@ -69,19 +71,19 @@ class AccountRepository @Inject constructor(
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = account(accountName)
val account = fromName(accountName)
// create Android account
val userData = AccountSettings.initialUserData(credentials)
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
logger.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = AccountSettings(context, account)
val accountSettings = accountSettingsFactory.create(account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
@@ -90,68 +92,64 @@ class AccountRepository @Inject constructor(
// insert CardDAV service
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// initial CardDAV account settings
// initial CardDAV account settings and sync intervals
accountSettings.setGroupMethod(groupMethod)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
}
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
val taskProvider = tasksAppManager.get().currentProvider()
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
logger.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
logger.info("No tasks provider found. Did not enable tasks sync.")
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
logger.log(Level.SEVERE, "Couldn't access account settings", e)
return null
}
return account
}
suspend fun delete(accountName: String): Boolean {
// remove account
val future = accountManager.removeAccount(account(accountName), null, null, null)
val account = fromName(accountName)
// remove account directly (bypassing the authenticator, which is our own)
return try {
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
future.result
}
accountManager.removeAccountExplicitly(account)
// delete address book accounts
LocalAddressBook.deleteByAccount(context, accountName)
// delete address books (= address book accounts)
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
collectionRepository.getByService(service.id).forEach { collection ->
LocalAddressBook.deleteByCollection(context, collection.id)
}
}
// delete from database
serviceRepository.deleteByAccount(accountName)
true
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't remove account $accountName", e)
logger.log(Level.WARNING, "Couldn't remove account $accountName", e)
false
}
}
@@ -162,7 +160,10 @@ class AccountRepository @Inject constructor(
else
accountManager
.getAccountsByType(accountType)
.contains(Account(accountName, accountType))
.any { it.name == accountName }
fun fromName(accountName: String) =
Account(accountName, accountType)
fun getAll(): Array<Account> = accountManager.getAccountsByType(accountType)
@@ -170,7 +171,9 @@ class AccountRepository @Inject constructor(
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
accountManager.addOnAccountsUpdatedListener(listener, null, true)
withContext(Dispatchers.Default) { // causes disk I/O
accountManager.addOnAccountsUpdatedListener(listener, null, true)
}
awaitClose {
accountManager.removeOnAccountsUpdatedListener(listener)
@@ -191,20 +194,20 @@ class AccountRepository @Inject constructor(
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String) {
val oldAccount = account(oldName)
val newAccount = account(newName)
val oldAccount = fromName(oldName)
val newAccount = fromName(newName)
// check whether new account name already exists
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
throw IllegalArgumentException("Account with name \"$newName\" already exists")
// remember sync intervals
val oldSettings = AccountSettings(context, oldAccount)
val oldSettings = accountSettingsFactory.create(oldAccount)
val authorities = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
)
val tasksProvider = TaskUtils.currentProvider(context)
val tasksProvider = tasksAppManager.get().currentProvider()
tasksProvider?.authority?.let { authorities.add(it) }
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
@@ -235,27 +238,12 @@ class AccountRepository @Inject constructor(
// disable periodic syncs for old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, oldAccount, authority)
syncWorkerManager.disablePeriodic(oldAccount, authority)
}
// update account name references in database
serviceRepository.renameAccount(oldName, newName)
// update main account of address book accounts
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED)
try {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, provider)
if (oldAccount == addressBook.mainAccount)
addressBook.mainAccount = Account(newName, oldAccount.type)
}
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
// Couldn't update address book accounts, but this is not a fatal error (will be fixed at next sync)
}
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again at next sync)
@@ -263,12 +251,12 @@ class AccountRepository @Inject constructor(
try {
LocalTaskList.onRenameAccount(context, oldAccount.name, newName)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e)
logger.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e)
// Couldn't update task lists, but this is not a fatal error (will be fixed at next sync)
}
// restore sync intervals
val newSettings = AccountSettings(context, newAccount)
val newSettings = accountSettingsFactory.create(newAccount)
for ((authority, interval) in syncIntervals) {
if (interval == null)
ContentResolver.setIsSyncable(newAccount, authority, 0)
@@ -286,23 +274,19 @@ class AccountRepository @Inject constructor(
// helpers
private fun account(accountName: String) = Account(accountName, accountType)
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = db.serviceDao().insertOrReplace(service)
val serviceId = serviceRepository.insertOrReplace(service)
// insert home sets
val homeSetDao = db.homeSetDao()
for (homeSet in info.homeSets)
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
homeSetRepository.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
val collectionDao = db.collectionDao()
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionDao.insertOrUpdateByUrl(collection)
collectionRepository.insertOrUpdateByUrl(collection)
}
return serviceId

View File

@@ -5,13 +5,14 @@
package at.bitfire.davdroid.repository
import android.accounts.Account
import android.app.Application
import android.content.Context
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
@@ -28,26 +29,44 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.ical4android.util.DateUtils
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
import java.io.StringWriter
import java.util.Collections
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import net.fortuna.ical4j.model.Calendar
import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.ComponentList
import net.fortuna.ical4j.model.component.VTimeZone
import okhttp3.HttpUrl
import java.io.StringWriter
import java.util.UUID
import javax.inject.Inject
/**
* Repository for managing collections.
*
* Implements an observer pattern that can be used to listen for changes of collections.
*/
class DavCollectionRepository @Inject constructor(
val context: Application,
db: AppDatabase
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
db: AppDatabase,
defaultListeners: Set<@JvmSuppressWildcards OnChangeListener>,
private val serviceRepository: DavServiceRepository
) {
private val serviceDao = db.serviceDao()
private val listeners = Collections.synchronizedSet(defaultListeners.toMutableSet())
private val dao = db.collectionDao()
suspend fun anyWebcal(serviceId: Long) =
dao.anyOfType(serviceId, Collection.TYPE_WEBCAL)
/**
* Creates address book collection on server and locally
*/
suspend fun createAddressBook(
account: Account,
homeSet: HomeSet,
@@ -77,13 +96,18 @@ class DavCollectionRepository @Inject constructor(
serviceId = homeSet.serviceId,
homeSetId = homeSet.id,
url = url,
type = Collection.TYPE_ADDRESSBOOK, //if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR,
type = Collection.TYPE_ADDRESSBOOK,
displayName = displayName,
description = description
)
dao.insertAsync(collection)
notifyOnChangeListeners()
}
/**
* Create calendar collection on server and locally
*/
suspend fun createCalendar(
account: Account,
homeSet: HomeSet,
@@ -111,7 +135,7 @@ class DavCollectionRepository @Inject constructor(
displayName = displayName,
description = description,
color = color,
timezoneDef = timeZoneId,
timezoneId = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
@@ -127,7 +151,7 @@ class DavCollectionRepository @Inject constructor(
displayName = displayName,
description = description,
color = color,
timezone = timeZoneId?.let { getVTimeZone(it) },
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
@@ -137,42 +161,118 @@ class DavCollectionRepository @Inject constructor(
// Trigger service detection (because the collection may actually have other properties than the ones we have inserted).
// Some servers are known to change the supported components (VEVENT, …) after creation.
RefreshCollectionsWorker.enqueue(context, homeSet.serviceId)
notifyOnChangeListeners()
}
/** Deletes the given collection from the server and the database. */
suspend fun delete(collection: Collection) {
val service = serviceDao.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
suspend fun deleteRemote(collection: Collection) {
val service = serviceRepository.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, context.getString(R.string.account_type))
HttpClient.Builder(context, AccountSettings(context, account))
HttpClient.Builder(context, accountSettingsFactory.create(account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
runInterruptible {
DavResource(httpClient.okHttpClient, collection.url).delete() {
// success, otherwise an exception would have been thrown
dao.delete(collection)
// success, otherwise an exception would have been thrown → delete locally, too
delete(collection)
}
}
}
}
}
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
fun get(id: Long) = dao.get(id)
fun getFlow(id: Long) = dao.getFlow(id)
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
dao.updateForceReadOnly(id, forceReadOnly)
fun getByService(serviceId: Long) = dao.getByService(serviceId)
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
fun getByServiceAndSync(serviceId: Long) = dao.getByServiceAndSync(serviceId)
fun getSyncCalendars(serviceId: Long) = dao.getSyncCalendars(serviceId)
fun getSyncJtxCollections(serviceId: Long) = dao.getSyncJtxCollections(serviceId)
fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId)
/** Returns all collections that are both selected for synchronization and push-capable. */
suspend fun getPushCapableAndSyncable(): List<Collection> =
dao.getPushCapableSyncCollections()
suspend fun getPushRegisteredAndNotSyncable(): List<Collection> =
dao.getPushRegisteredAndNotSyncable()
/**
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
* [Collection.forceReadOnly]), but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
// remember locally set flags
dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
newCollection.sync = oldCollection.sync
newCollection.forceReadOnly = oldCollection.forceReadOnly
}
// commit to database
insertOrUpdateByUrl(newCollection)
}
/**
* Creates or updates the existing collection if it exists (URL)
*/
fun insertOrUpdateByUrl(collection: Collection) {
dao.insertOrUpdateByUrl(collection)
notifyOnChangeListeners()
}
fun pageByServiceAndType(serviceId: Long, type: String) =
dao.pageByServiceAndType(serviceId, type)
fun pagePersonalByServiceAndType(serviceId: Long, type: String) =
dao.pagePersonalByServiceAndType(serviceId, type)
/**
* Sets the flag for whether read-only should be enforced on the local collection
*/
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
dao.updateForceReadOnly(id, forceReadOnly)
notifyOnChangeListeners()
}
/**
* Whether or not the local collection should be synced with the server
*/
suspend fun setSync(id: Long, forceReadOnly: Boolean) {
dao.updateSync(id, forceReadOnly)
notifyOnChangeListeners()
}
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
dao.updatePushSubscription(id, subscriptionUrl)
}
/**
* Deletes the collection locally
*/
fun delete(collection: Collection) {
dao.delete(collection)
notifyOnChangeListeners()
}
// helpers
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
HttpClient.Builder(context, AccountSettings(context, account))
HttpClient.Builder(context, accountSettingsFactory.create(account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
@@ -193,7 +293,7 @@ class DavCollectionRepository @Inject constructor(
displayName: String?,
description: String?,
color: Int? = null,
timezoneDef: String? = null,
timezoneId: String? = null,
supportsVEVENT: Boolean = true,
supportsVTODO: Boolean = true,
supportsVJOURNAL: Boolean = true
@@ -249,9 +349,17 @@ class DavCollectionRepository @Inject constructor(
text(DavUtils.ARGBtoCalDAVColor(it))
}
}
timezoneDef?.let {
insertTag(CalendarTimezone.NAME) {
cdsect(it)
timezoneId?.let { id ->
insertTag(CalendarTimezoneId.NAME) {
text(id)
}
getVTimeZone(id)?.let { vTimezone ->
insertTag(CalendarTimezone.NAME) {
text(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(ComponentList(listOf(vTimezone))).toString()
)
}
}
}
@@ -285,7 +393,34 @@ class DavCollectionRepository @Inject constructor(
return writer.toString()
}
private fun getVTimeZone(tzId: String): String? =
DateUtils.ical4jTimeZone(tzId)?.toString()
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
/*** OBSERVERS ***/
/**
* Notifies registered listeners about changes in the collections.
*/
private fun notifyOnChangeListeners() = synchronized(listeners) {
listeners.forEach { listener ->
listener.onCollectionsChanged()
}
}
fun interface OnChangeListener {
/**
* Will be called when collections have changed. Will run in the coroutine context/thread
* of the data-modifying method. For instance, if [delete] is called, [onCollectionsChanged]
* will be called in the context/thread that called [delete].
*/
fun onCollectionsChanged()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DavCollectionRepositoryModule {
// Provides empty set of listeners
@Multibinds abstract fun defaultOnChangeListeners(): Set<OnChangeListener>
}
}

View File

@@ -5,7 +5,9 @@
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
import javax.inject.Inject
@@ -18,7 +20,31 @@ class DavHomeSetRepository @Inject constructor(
fun getAddressBookHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV)
fun getBindableByServiceFlow(serviceId: Long) = dao.getBindableByServiceFlow(serviceId)
fun getById(id: Long) = dao.getById(id)
fun getByService(serviceId: Long) = dao.getByService(serviceId)
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 insertOrUpdateByUrl(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 delete(homeSet: HomeSet) = dao.delete(homeSet)
}

View File

@@ -14,9 +14,13 @@ class DavServiceRepository @Inject constructor(
private val dao = db.serviceDao()
suspend fun deleteByAccount(accountName: String) {
dao.deleteByAccount(accountName)
}
// Read
fun get(id: Long): Service? = dao.get(id)
fun getByAccountAndType(name: String, serviceType: String): Service? =
dao.getByAccountAndType(name, serviceType)
fun getCalDavServiceFlow(accountName: String) =
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CALDAV)
@@ -24,8 +28,21 @@ class DavServiceRepository @Inject constructor(
fun getCardDavServiceFlow(accountName: String) =
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CARDDAV)
suspend fun renameAccount(oldName: String, newName: String) {
// Create & update
fun insertOrReplace(service: Service) =
dao.insertOrReplace(service)
suspend fun renameAccount(oldName: String, newName: String) =
dao.renameAccount(oldName, newName)
}
// Delete
fun deleteAll() = dao.deleteAll()
suspend fun deleteByAccount(accountName: String) =
dao.deleteByAccount(accountName)
}

View File

@@ -4,18 +4,21 @@
package at.bitfire.davdroid.repository
import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.db.SyncStats
import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.Collator
import java.util.logging.Logger
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.text.Collator
import javax.inject.Inject
class DavSyncStatsRepository @Inject constructor(
val context: Application,
db: AppDatabase
@ApplicationContext val context: Context,
db: AppDatabase,
private val logger: Logger
) {
private val dao = db.syncStatsDao()
@@ -37,6 +40,15 @@ class DavSyncStatsRepository @Inject constructor(
}
}
fun logSyncTime(collectionId: Long, authority: String, lastSync: Long = System.currentTimeMillis()) {
dao.insertOrReplace(SyncStats(
id = 0,
collectionId = collectionId,
authority = authority,
lastSync = lastSync
))
}
/**
* Tries to find the application name for given authority. Returns the authority if not
@@ -50,9 +62,14 @@ class DavSyncStatsRepository @Inject constructor(
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
packageManager.getApplicationLabel(appInfo).toString()
if (appInfo != null) {
packageManager.getApplicationLabel(appInfo).toString()
} else {
logger.warning("Package name ($packageName) not found for authority: $authority")
authority
}
} catch (e: PackageManager.NameNotFoundException) {
Logger.log.warning("Application name not found for authority: $authority")
logger.warning("Application name not found for authority: $authority")
authority
}
}

View File

@@ -7,7 +7,6 @@ package at.bitfire.davdroid.repository
import android.app.Application
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -22,26 +21,55 @@ class PreferenceRepository @Inject constructor(
context: Application
) {
companion object {
const val LOG_TO_FILE = "log_to_file"
const val UNIFIED_PUSH_ENDPOINT = "unified_push_endpoint"
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
/**
* Updates the "log to file" (verbose logging") setting.
* Updates the "log to file" (verbose logging") preference.
*/
fun logToFile(logToFile: Boolean) {
preferences
.edit()
.putBoolean(Logger.LOG_TO_FILE, logToFile)
.putBoolean(LOG_TO_FILE, logToFile)
.apply()
}
/**
* Gets the "log to file" (verbose logging) setting as a live value.
* Gets the "log to file" (verbose logging) preference.
*/
fun logToFile(): Boolean =
preferences.getBoolean(LOG_TO_FILE, false)
/**
* Gets the "log to file" (verbose logging) preference as a live value.
*/
fun logToFileFlow(): Flow<Boolean> = observeAsFlow(Logger.LOG_TO_FILE) {
preferences.getBoolean(Logger.LOG_TO_FILE, false)
fun logToFileFlow(): Flow<Boolean> = observeAsFlow(LOG_TO_FILE) {
logToFile()
}
fun unifiedPushEndpoint() =
preferences.getString(UNIFIED_PUSH_ENDPOINT, null)
fun unifiedPushEndpointFlow() = observeAsFlow(UNIFIED_PUSH_ENDPOINT) {
unifiedPushEndpoint()
}
fun unifiedPushEndpoint(endpoint: String?) {
preferences
.edit()
.putString(UNIFIED_PUSH_ENDPOINT, endpoint)
.apply()
}
// helpers
private fun<T> observeAsFlow(keyToObserve: String, getValue: () -> T): Flow<T> =
callbackFlow {
val listener = OnSharedPreferenceChangeListener { _, key ->
@@ -51,6 +79,9 @@ class PreferenceRepository @Inject constructor(
}
preferences.registerOnSharedPreferenceChangeListener(listener)
// Emit the initial value
trySend(getValue())
awaitClose {
preferences.unregisterOnSharedPreferenceChangeListener(listener)
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Principal
import javax.inject.Inject
class PrincipalRepository @Inject constructor(
db: AppDatabase
) {
private val dao = db.principalDao()
fun get(id: Long): Principal = dao.get(id)
}

View File

@@ -17,147 +17,67 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.util.Base64
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
import at.bitfire.vcard4android.Constants
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.GroupMethod
import java.io.ByteArrayOutputStream
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
/**
* A local address book. Requires an own Android account, because Android manages contacts per
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book. These accounts are bound to a
* DAVx5 main account.
* address book" account for every CardDAV address book.
*
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
* the new name will only be available in [addressBookAccount], so usually that one should be used.
*
* @param provider Content provider needed to access and modify the address book
*/
open class LocalAddressBook(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
companion object {
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
const val USER_DATA_URL = "url"
const val USER_DATA_READ_ONLY = "read_only"
/**
* Creates a local address book.
*
* @param context app context to resolve string resources
* @param provider contacts provider client
* @param mainAccount main account this address book (account) belongs to
* @param info collection where to take the name and settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
*/
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
val userData = initialUserData(mainAccount, info.url.toString())
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
if (!AccountUtils.createAccount(context, account, userData))
throw IllegalStateException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, account, provider)
addressBook.updateSyncFrameworkSettings()
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
return addressBook
}
fun deleteByAccount(context: Context, accountName: String) {
val mainAccount = Account(accountName, context.getString(R.string.account_type))
findAll(context, null, mainAccount).forEach {
it.delete()
}
}
/**
* Finds and returns all the local address books belonging to a given main account
*
* @param mainAccount the main account to use
* @return list of [mainAccount]'s address books
*/
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account) = AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter {
try {
it.mainAccount == mainAccount
} catch(e: IllegalArgumentException) {
false
}
}
.toList()
fun accountName(mainAccount: Account, info: Collection): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
info.url.lastSegment
else
it
})
sb.append(" (${mainAccount.name} $hash)")
return sb.toString()
}
fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
bundle.putString(USER_DATA_URL, url)
return bundle
}
/**
* Finds and returns the main account of the given address book's account (sub-account)
*
* @param account the address book account to find the main account for
*
* @return the associated main account, `null` if none can be found (e.g. when main account has been deleted)
*
* @throws IllegalArgumentException when [account] is not an address book account
*/
fun mainAccount(context: Context, account: Account): Account? =
if (account.type == context.getString(R.string.account_type_address_book)) {
val manager = AccountManager.get(context)
val accountName = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val accountType = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (accountName != null && accountType != null)
Account(accountName, accountType)
else
null
} else
throw IllegalArgumentException("$account is not an address book account")
@OpenForTesting
open class LocalAddressBook @AssistedInject constructor(
@Assisted _addressBookAccount: Account,
@Assisted provider: ContentProviderClient,
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext val context: Context,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
@AssistedFactory
interface Factory {
fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook
}
override val tag: String
get() = "contacts-${account.name}"
override val title = account.name!!
override val tag: String
get() = "contacts-${addressBookAccount.name}"
override val title
get() = addressBookAccount.name
/**
* Whether contact groups ([LocalGroup]) are included in query results
@@ -167,48 +87,31 @@ open class LocalAddressBook(
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
open val groupMethod: GroupMethod by lazy {
val accountSettings = AccountSettings(context, requireMainAccount())
val manager = AccountManager.get(context)
val account = manager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
}
if (account == null)
throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account")
val accountSettings = accountSettingsFactory.create(account)
accountSettings.getGroupMethod()
}
val includeGroups
private val includeGroups
get() = groupMethod == GroupMethod.GROUP_VCARDS
private var _mainAccount: Account? = null
/**
* The associated main account which this address book's accounts belong to.
*
* @throws IllegalArgumentException when [account] is not an address book account or when no main account is assigned
*/
open var mainAccount: Account?
get() {
_mainAccount?.let { return it }
val result = mainAccount(context, account)
_mainAccount = result
return result
}
set(newMainAccount) {
if (newMainAccount == null)
throw IllegalArgumentException("Main account must not be null")
AccountManager.get(context).let { accountManager ->
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
}
_mainAccount = newMainAccount
}
fun requireMainAccount(): Account =
mainAccount ?: throw IllegalArgumentException("No main account assigned to address book $account")
var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
@Deprecated("Local collection should be identified by ID, not by URL")
override var collectionUrl: String
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_URL, url)
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
@@ -248,48 +151,104 @@ open class LocalAddressBook(
* Updates the address book settings.
*
* @param info collection where to take the settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
* @param forceReadOnly `true`: set the address book to "force read-only";
* `false`: determine read-only flag from [info];
* `null`: don't change the existing value
*/
fun update(info: Collection, forceReadOnly: Boolean) {
val newAccountName = accountName(requireMainAccount(), info)
fun update(info: Collection, forceReadOnly: Boolean? = null) {
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
val accountManager = AccountManager.get(context)
if (account.name != newAccountName) {
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, null, null)
account = future.result
}
// Update the account name
val newAccountName = accountName(context, info)
if (addressBookAccount.name != newAccountName)
// rename, move contacts/groups and update [AndroidAddressBook.]account
renameAccount(newAccountName)
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
// Update the account user data
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
// update address book itself
readOnly = nowReadOnly
// Update force read only
if (forceReadOnly != null) {
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
logger.info("Address book now read-only = $nowReadOnly, updating contacts")
// update raw contacts
val rawContactValues = ContentValues(1)
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update address book itself
readOnly = nowReadOnly
// update data rows
val dataValues = ContentValues(1)
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update raw contacts
val rawContactValues = ContentValues(1)
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update group rows
val groupValues = ContentValues(1)
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
// update data rows
val dataValues = ContentValues(1)
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update group rows
val groupValues = ContentValues(1)
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
}
}
// make sure it will still be synchronized when contacts are updated
updateSyncFrameworkSettings()
}
fun delete() {
/**
* Renames an address book account and moves the contacts and groups (without making them dirty).
* Does not keep user data of the old account, so these have to be set again.
*
* On success, [addressBookAccount] will be updated to the new account name.
*
* _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never
* a moment when both accounts are available.
*
* @param newName the new account name (account type is taken from [addressBookAccount])
*
* @return whether the account was renamed successfully
*/
@VisibleForTesting
internal fun renameAccount(newName: String): Boolean {
val oldAccount = addressBookAccount
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
// create new account
val newAccount = Account(newName, oldAccount.type)
if (!SystemAccountUtils.createAccount(context, newAccount, Bundle()))
return false
// move contacts and groups to new account
val batch = BatchOperation(provider!!)
batch.enqueue(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
.newUpdate(rawContactsSyncUri())
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
)
batch.commit()
// update AndroidAddressBook.account
addressBookAccount = newAccount
// delete old account
val accountManager = AccountManager.get(context)
accountManager.removeAccount(account, null, null, null)
accountManager.removeAccountExplicitly(oldAccount)
return true
}
override fun deleteCollection(): Boolean {
val accountManager = AccountManager.get(context)
return accountManager.removeAccountExplicitly(addressBookAccount)
}
@@ -303,15 +262,15 @@ open class LocalAddressBook(
*/
fun updateSyncFrameworkSettings() {
// Enable sync-ability
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
// Enable content trigger
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
if (!ContentResolver.getSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY, true)
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
for (periodicSync in ContentResolver.getPeriodicSyncs(addressBookAccount, ContactsContract.AUTHORITY))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
}
@@ -403,10 +362,10 @@ open class LocalAddressBook(
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
@@ -444,9 +403,134 @@ open class LocalAddressBook(
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
Logger.log.log(Level.FINE, "Deleting group", group)
logger.log(Level.FINE, "Deleting group", group)
group.delete()
}
}
companion object {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface LocalAddressBookCompanionEntryPoint {
fun localAddressBookFactory(): Factory
fun serviceRepository(): DavServiceRepository
fun logger(): Logger
}
const val USER_DATA_URL = "url"
const val USER_DATA_COLLECTION_ID = "collection_id"
const val USER_DATA_READ_ONLY = "read_only"
// create/query/delete
/**
* Creates a new local address book.
*
* @param context app context to resolve string resources
* @param provider contacts provider client
* @param info collection where to take the name and settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
*/
fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val logger = entryPoint.logger()
val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book))
val userData = initialUserData(info.url.toString(), info.id.toString())
logger.log(Level.INFO, "Creating local address book $account", userData)
if (!SystemAccountUtils.createAccount(context, account, userData))
throw IllegalStateException("Couldn't create address book account")
val factory = entryPoint.localAddressBookFactory()
val addressBook = factory.create(account, provider)
addressBook.updateSyncFrameworkSettings()
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
return addressBook
}
/**
* Finds a [LocalAddressBook] based on its corresponding collection.
*
* @param id collection ID to look for
*
* @return The [LocalAddressBook] for the given collection or *null* if not found
*/
fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? {
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val factory = entryPoint.localAddressBookFactory()
val accountManager = AccountManager.get(context)
return accountManager
.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { account ->
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
.map { account -> factory.create(account, provider) }
.firstOrNull()
}
/**
* Deletes a [LocalAddressBook] based on its corresponding database collection.
*
* @param id collection ID to look for
*/
fun deleteByCollection(context: Context, id: Long) {
val accountManager = AccountManager.get(context)
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
if (addressBookAccount != null)
accountManager.removeAccountExplicitly(addressBookAccount)
}
// helpers
/**
* Creates a name for the address book account from its corresponding db collection info.
*
* The address book account name contains
* - the collection display name or last URL path segment
* - the actual account name
* - the collection ID, to make it unique.
*
* @param info The corresponding collection
*/
fun accountName(context: Context, info: Collection): String {
// Name the address book after given collection display name, otherwise use last URL path segment
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
info.url.lastSegment
else
it
})
// Add the actual account name to the address book account name
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val serviceRepository = entryPoint.serviceRepository()
serviceRepository.get(info.serviceId)?.let { service ->
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
sb.append(" #${info.id}")
return sb.toString()
}
private fun initialUserData(url: String, collectionId: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_COLLECTION_ID, collectionId)
bundle.putString(USER_DATA_URL, url)
return bundle
}
}
}

View File

@@ -14,8 +14,7 @@ import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
@@ -23,19 +22,32 @@ import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
/**
* Application-specific subclass of [AndroidCalendar] for local calendars.
*
* [Calendars.NAME] is used to store the calendar URL.
*/
class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
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()
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollectionInfo(info, withColor = true)
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name)
@@ -57,8 +69,8 @@ class LocalCalendar private constructor(
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
@@ -74,7 +86,7 @@ class LocalCalendar private constructor(
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
}
} catch(e: IllegalArgumentException) {
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
logger.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
@@ -86,6 +98,9 @@ class LocalCalendar private constructor(
}
override val collectionUrl: String?
get() = name
override val tag: String
get() = "events-${account.name}-$id"
@@ -96,6 +111,8 @@ class LocalCalendar private constructor(
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
override fun deleteCollection(): Boolean = delete()
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
@@ -116,11 +133,11 @@ class LocalCalendar private constructor(
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
@@ -145,7 +162,7 @@ class LocalCalendar private constructor(
event.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
dirty += localEvent
}
@@ -194,14 +211,14 @@ class LocalCalendar private constructor(
fun processDirtyExceptions() {
// process deleted exceptions
Logger.log.info("Processing 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.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
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)
@@ -231,14 +248,14 @@ class LocalCalendar private constructor(
}
// process dirty exceptions
Logger.log.info("Processing 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.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
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)
@@ -278,7 +295,7 @@ class LocalCalendar private constructor(
// delete event if there are no instances
if (numEventInstances == 0) {
Logger.log.info("Marking event #$eventID without instances as deleted")
logger.info("Marking event #$eventID without instances as deleted")
LocalEvent.markAsDeleted(provider, account, eventID)
}
}

View File

@@ -9,9 +9,13 @@ import at.bitfire.davdroid.db.SyncState
interface LocalCollection<out T: LocalResource<*>> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
/** A tag that uniquely identifies the collection (DAVx5-wide) */
val tag: String
/** Address of the remote collection */
@Deprecated("Local collection should be identified by ID, not by URL")
val collectionUrl: String?
/** collection title (used for user notifications etc.) **/
val title: String
@@ -23,6 +27,13 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
val readOnly: Boolean
/**
* Deletes the local collection.
*
* @return true if the collection was deleted, false otherwise
*/
fun deleteCollection(): Boolean
/**
* Finds local resources of this collection which have been marked as *deleted* by the user
* or an app acting on their behalf.

View File

@@ -11,7 +11,6 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler
@@ -26,21 +25,25 @@ import at.bitfire.vcard4android.Contact
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.UUID
import java.util.logging.Logger
class LocalContact: AndroidContact, LocalAddress {
override val addressBook: LocalAddressBook
get() = super.addressBook as LocalAddressBook
companion object {
init {
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
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
}
private val logger: Logger = Logger.getGlobal()
override val addressBook: LocalAddressBook
get() = super.addressBook as LocalAddressBook
internal val cachedGroupMemberships = HashSet<Long>()
internal val groupMemberships = HashSet<Long>()
@@ -102,7 +105,7 @@ class LocalContact: AndroidContact, LocalAddress {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
logger.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
@@ -148,7 +151,7 @@ class LocalContact: AndroidContact, LocalAddress {
// groupMemberships is filled by getContact()
val dataHash = getContact().hashCode()
val groupHash = groupMemberships.hashCode()
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
logger.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
return dataHash xor groupHash
}
@@ -157,7 +160,7 @@ class LocalContact: AndroidContact, LocalAddress {
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
val hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
logger.fine("Storing contact hash = $hashCode")
if (batch == null) {
val values = ContentValues(1)

View File

@@ -25,7 +25,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
companion object {
init {
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
ICalendar.prodId = ProdId("DAVx5/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
}
const val COLUMN_ETAG = Events.SYNC_DATA1

View File

@@ -14,7 +14,7 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
@@ -22,13 +22,16 @@ import at.bitfire.vcard4android.AndroidGroupFactory
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import org.apache.commons.lang3.StringUtils
import java.util.LinkedList
import java.util.UUID
import java.util.logging.Logger
class LocalGroup: AndroidGroup, LocalAddress {
companion object {
private val logger: Logger
get() = Logger.getGlobal()
const val COLUMN_FLAGS = Groups.SYNC4
@@ -44,7 +47,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
* @param addressBook address book to take groups from
*/
fun applyPendingMemberships(addressBook: LocalAddressBook) {
Logger.log.info("Assigning memberships of contact groups")
logger.info("Assigning memberships of contact groups")
addressBook.allGroups { group ->
val groupId = group.id!!
@@ -59,7 +62,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
val uid = addressBook.getContactUidFromId(currentMemberId) ?: continue
if (!pendingMemberUids.contains(uid)) {
Logger.log.fine("$currentMemberId removed from group $groupId; removing group membership")
logger.fine("$currentMemberId removed from group $groupId; removing group membership")
val currentMember = addressBook.findContactById(currentMemberId)
currentMember.removeGroupMemberships(batch)
@@ -76,11 +79,11 @@ class LocalGroup: AndroidGroup, LocalAddress {
for (missingMemberUid in pendingMemberUids) {
val missingMember = addressBook.findContactByUid(missingMemberUid)
if (missingMember == null) {
Logger.log.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring")
logger.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring")
continue
}
Logger.log.fine("Assigning member $missingMember to group $groupId")
logger.fine("Assigning member $missingMember to group $groupId")
missingMember.addToGroup(batch, groupId)
// Android 7 hack
@@ -134,7 +137,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
var uid: String? = null
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = StringUtils.trimToNull(cursor.getString(0))
uid = cursor.getString(0).trimToNull()
}
if (uid == null) {

View File

@@ -7,17 +7,17 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.net.Uri
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxCollectionFactory
import at.bitfire.ical4android.JtxICalObject
import at.techbee.jtx.JtxContract
import java.util.logging.Level
import java.util.logging.Logger
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
@@ -25,9 +25,14 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
companion object {
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) {
val values = valuesFromCollection(info, account, owner, true)
create(account, client, values)
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri {
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollection(info, account, owner, withColor = true)
return create(account, client, values)
}
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
@@ -40,10 +45,11 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
if (owner != null)
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
else Logger.log.log(Level.SEVERE, "No collection owner given. Will create jtx collection without owner")
else
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
if (withColor)
put(JtxContract.JtxCollection.COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (withColor && info.color != null)
put(JtxContract.JtxCollection.COLOR, info.color)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
@@ -56,16 +62,20 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = delete()
override val tag: String
get() = "jtx-${account.name}-$id"
override val collectionUrl: String?
get() = url
override val title: String
get() = displayname ?: id.toString()
override var lastSyncState: SyncState?
get() = SyncState.fromString(syncstate)
set(value) { syncstate = value.toString() }
fun updateCollection(info: Collection, owner: Principal?, withColor: Boolean) {
val values = valuesFromCollection(info, account, owner, withColor)
fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) {
val values = valuesFromCollection(info, account, owner, updateColor)
update(values)
}

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