Compare commits

...

151 Commits

Author SHA1 Message Date
Arnau Mora
e9fb031d0a Upgrade dependencies
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-04 19:55:31 +02:00
Arnau Mora
d1c3548ccc Upgrade dependencies
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-04 19:54:46 +02:00
Arnau Mora
762095c7ce Merge branch 'main-ose' into nav3-migration
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
#	app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt
2025-06-04 19:53:43 +02:00
Ricki Hirner
fa50fe4c30 [CI] Run tests on API level 35 2025-06-04 16:14:00 +02:00
Arnau Mora
ba4d3b2fd1 Increase SDK to 36 (#1513)
* Upgrade to SDK 36

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

* Enable on back invoked callback

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

* Remove `enableOnBackInvokedCallback`

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-06-04 15:42:57 +02:00
Sunik Kupfer
0fed85fdc3 Add app password hint under password field (#1507)
* Add app password hint under password field

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

* Change text to use prefer

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

* Append path encoded

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

* Update app password help URL and text

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-06-04 15:42:40 +02:00
Sunik Kupfer
6fbaea9487 [SyncManager]s Remove authority (#1491)
* Update sync stats to store sync data type instead of authority

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

* Use a real authority in the tests

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

* Replace authority with syncDataType in sync managers

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

* Minor changes
- import index
- edit comments

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

* Use lowercase localised strings for datatypes

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

* Pass sync data type extra as string

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

* Remove unknown datatype

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

* Move datatype name strings to collection screen section

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

* Update string usages

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

* Update test

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

* Add any type annotations to arrays

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-04 14:44:12 +02:00
Arnau Mora
fc2bc8aa47 Pass state to modal drawer for automatic back handler (#1495)
* Pass state to modal drawer for automatic back handler

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

* Fix deprecation: Use toUri instead of Uri.parse

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2025-06-02 17:12:41 +02:00
Ricki Hirner
0321e4ab8f [Lint] Convert URIs to strings using toUri() (#1506) 2025-06-02 12:00:13 +02:00
Ricki Hirner
711543c5f1 Credentials / dav4jvm: store passwords as CharArray (#1483)
* Credentials / dav4jvm: store passwords as CharArray

* Fix tests
2025-05-30 17:37:14 +02:00
Ricki Hirner
5c485834e9 Update Gradle wrapper and Android Gradle Plugin versions 2025-05-30 17:36:10 +02:00
Ricki Hirner
f349f1fec8 Bump version to 4.4.11 2025-05-30 16:28:23 +02:00
Ricki Hirner
e6413506cb Fetch translations from Transifex 2025-05-30 16:26:53 +02:00
Arnau Mora
d9b36a0e34 Fix predictive back for drawer
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-27 15:21:23 +02:00
Arnau Mora
514623c0f2 Use ComponentActivity instead of AppCompatActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-27 15:14:07 +02:00
Arnau Mora
9978850594 Moved nav back stack holding to viewmodel
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 20:07:50 +02:00
Arnau Mora
e1f5b2e3c1 Upgrade Activity Compose
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 20:06:49 +02:00
Arnau Mora
ad0cdb5c0c Use SDK 36
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:43:30 +02:00
Arnau Mora
de9d58bc20 Migrated AccountsActivity to MainActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:36:11 +02:00
Arnau Mora
a6238a4131 Enable back invoked callback
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:35:27 +02:00
Arnau Mora
bbc7fbfa1e Added missing plugin
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:35:11 +02:00
Arnau Mora
3ba4dfb157 Upgrade Kotlin and KSP
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:35:01 +02:00
Arnau Mora
4544cd9b5c Fixed snapshot dependencies repository
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:34:05 +02:00
Arnau Mora
24026edad0 Added dependencies
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-05-26 19:07:13 +02:00
Michael Biebl
d4b5039297 Use lowercase GroupIDs as a workaround for jitpack.io issues (#1489)
Related discussion at

https://github.com/bitfireAT/davx5-ose/discussions/1236

and

https://github.com/jitpack/jitpack.io/issues/3295#issuecomment-2329134019

For example trying to access:
https://jitpack.io/com/github/bitfireAT/dav4jvm/b87d772e44/ I get:

File not found. Build ok

In comparison under
https://jitpack.io/com/github/bitfireat/dav4jvm/b87d772e44/ I get:

build.log
dav4jvm-b87d772e44-sources.jar
dav4jvm-b87d772e44.jar
dav4jvm-b87d772e44.module
dav4jvm-b87d772e44.pom
dav4jvm-b87d772e44.pom.md5
dav4jvm-b87d772e44.pom.sha1
2025-05-26 13:53:21 +02:00
Ricki Hirner
979f2257de Bump version to 4.4.11-rc.2 2025-05-22 19:43:03 +02:00
Ricki Hirner
3efb8d5c62 TaskSyncer: accept nullable ResyncType (#1487)
Fix TaskSyncer constructor to accept nullable ResyncType
2025-05-22 19:42:34 +02:00
Ricki Hirner
ec657519a9 Bump version to 4.4.11-rc.1 2025-05-22 17:33:24 +02:00
Sunik Kupfer
a835557b35 Handle when AccountActivity is started without account (#1481)
* Pass new account as non-nullable

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

* Move companion object

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

* Handle missing account in intent by logging and redirecting to accounts overview

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

* Fix deprecation

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

* Also check account exists

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

* Simplify toast message

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

* Add account name to toast message

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

* Don't check for invalid accounts in AccountActivity; handle InvalidAccountException in model

* Minor changes

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-22 17:22:31 +02:00
Arnau Mora
19f86670bf Implemented sort order for DAV documents (#1434)
* Implemented sort order

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

* Fixed column name

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

* Implemented sort order

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

* Fixed column name

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

* Improved issues

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

* Add test for WebDavDocumentDao.getChildren for ORDER BY

* Converted getChildren into a raw query

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

* Fix formatting of SQL query in WebDavDocumentDao

* Refactoring

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

* Drop comment

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

* Changed default sort to show directories first

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

* Fixed tests

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

* Switched to query constructor instead of in-place

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

* Changed log method

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

* Add DocumentSortByMapper to handle orderBy mapping for WebDavDocument queries

* Rename DavDocumentsProviderTest to DocumentSortByMapperTest and update test method name

* Refactor sorting and mapping

* Add "order by name" as last criterion

* Remove default sort order constant from DocumentSortByMapper and use WebDavDocumentDao.DEFAULT_ORDER instead

* Adapt comments

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-22 09:48:12 +02:00
Ricki Hirner
f74d14e2a2 Always append one-time syncs (#1482)
* Refactor sync worker manager to always append one-time syncs (but only once)

* Adapt KDoc
2025-05-21 15:57:10 +02:00
Ricki Hirner
57ef059099 [CI] Also run tests on push to main branch 2025-05-21 11:59:12 +02:00
Ricki Hirner
f157a819b7 Bump version to 4.4.11-beta.1 2025-05-21 11:55:51 +02:00
Ricki Hirner
9bbc4c096d Update Compose BOM version to 2025.05.01 2025-05-21 11:55:51 +02:00
Sunik Kupfer
b306219015 [Push] Append sync when already syncing (#1480)
* [WIP] Add delay to sync worker and append push syncs

* Enqueue sync work with append policy for push notifications

* Remove delay

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

* Also check for enqueued work

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-21 11:54:18 +02:00
Ricki Hirner
469c30b511 [CI] Run tests on pull requests instead of git pushes (#1479)
* [CI] Run tests on pull requests instead of git pushes

* Add comment to PR trigger in test-dev.yml

* Update concurrency group for development tests
2025-05-21 11:30:24 +02:00
Sunik Kupfer
05f6c7ab0b Reset subscription URLs on distributor change (#1467)
* Move distributor set/get to PushRegistrationManager

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

* Handle unsubscription after manual distributor change before resubscribing

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

* Minor changes

* Update distributor check to use method of the own class

* Use mutex and add KDoc

* UnifiedPush registration: add service type to "message for distributor"

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
2025-05-19 16:42:40 +02:00
Ricki Hirner
eeb94d4039 [Sync] Replace extras Bundle by explicit arguments (#1475)
* Remove extras parameter from sync managers

* Refactor sync worker parameters and improve documentation

* Adapt tests

* Rename resyncType to resync in syncers, adapt KDoc

* Remove legacy sync result comment
2025-05-19 15:37:50 +02:00
Ricki Hirner
7bf9172bdc SyncManager: directly wrap HTTP calls with runInterruptible (#1472)
* SyncManager: Handle CancellationException in sync process

* Wrap remote resource operations with runInterruptible; provide suspending SyncException wrappers

* Update more sync methods to use runInterruptible only for the HTTP request itself

* Make downloadRemote methods suspendable and use runInterruptible for better concurrency handling

* Update code style

* Fix tests

* Refactor SyncException to use runBlocking for wrapping resources
2025-05-19 12:54:51 +02:00
Arnau Mora
dec5be5690 Display FCM instead of DAVx⁵ in distributor settings (#1468)
* Display FCM instead of DAVx⁵ in distributor settings

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

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

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-19 10:49:19 +02:00
Ricki Hirner
d8e8129d7b Bump version to 4.4.11-alpha.1 2025-05-14 13:49:05 +02:00
Ricki Hirner
c9fb7dc7a2 Update dependency 2025-05-14 13:46:47 +02:00
Arnau Mora
ededcb98e1 Added widget with just a sync icon (#1340)
* Renamed existing widget

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

* Added sync icon widget

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

* Added labels for widgets

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

* Added widget preview

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

* Removed max width restriction

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

* Changed widget description

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-14 08:50:50 +02:00
Arnau Mora
fc10a315d5 Added warning about credentials in debug info (#1457)
* Added warning about credentials in debug info

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

* Updated warning message

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

* Changed descriptive icon

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

* Replace "alert" by "notice"

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-13 15:45:52 +02:00
Ricki Hirner
cfeb6b3974 Make SyncManager suspending (first part) (#1451)
* Introduce coroutine dispatcher for sync operations

* Make `logSyncTimeBlocking` and `insertOrReplace` suspend functions

* Suspend first bunch of SyncManager methods; move sync dispatcher usage to SyncManager

* [WIP] Fix tests

TODO: extract test framework changes to separate PR

* Remove mainDispatcher from SyncManagerTest

* Remove explicit coroutineScope naming
2025-05-12 15:52:54 +02:00
Ricki Hirner
a15902e586 [DI] Make backing field of TestDispatcher + scheduler private 2025-05-10 09:17:22 +02:00
Ricki Hirner
47afddbd08 Simplify running suspending tests (#1452)
* Move LoggerModule to di package and add TestLoggerModule

* Remove main dispatcher injection and use runTest directly

* Add verbose logging module for tests

* Add test for UnifiedPushService.onUnregistered
2025-05-09 14:55:30 +02:00
Ricki Hirner
5f647b7403 Move GetDelayUntil tests to dav4jvm 2025-05-09 12:18:16 +02:00
Ricki Hirner
d460e4ca7b Update dav4jvm 2025-05-09 11:50:45 +02:00
Ricki Hirner
827a1b954f Update version code and name and increase max build metaspace size to 1GB 2025-05-09 10:59:34 +02:00
Ricki Hirner
54bcda1bb4 Update Android Gradle Plugin and Compose BOM versions 2025-05-08 13:27:13 +02:00
Ricki Hirner
7003c5f730 Bump version to 4.4.10-rc.1 2025-05-07 18:03:28 +02:00
Ricki Hirner
4ccf99ce23 Fetch translations from Transifex 2025-05-07 18:03:03 +02:00
Ricki Hirner
3fca4d60f1 Update dependencies 2025-05-07 18:02:57 +02:00
Ricki Hirner
2099f47d22 Use ical4android that removes ClassLoader checks; refactor SyncDispatcher (#1446)
* Use ical4android that removes ClassLoader checks; refactor SyncDispatcher

* [Tests] Add SyncDispatcher provider for tests

* Add sync dispatcher with fixed thread pool

* Replace fixed thread pool context with thread pool executor (as we had it previously, but without setting the contextClassLoader)

* Replace sync dispatcher with I/O dispatcher with limited parallelism
2025-05-07 17:57:46 +02:00
Sunik Kupfer
c12a723a52 Send Push-Dont-Notify header (#1444)
* Update dav4jvm

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

* Add push-dont-notify header to requests when push subscription has not expired

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

* Fix mocks in TestSyncManager

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

* Use relaxed mockk for collection

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

* Use lazy val for pushDontNotifyHeader

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

* Use null instead of empty map for pushDontNotifyHeader

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

* Get push subscription state as calculated collection property

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

* Add tests for active push subscription status in collection

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

* Rename test methods

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

* Return active subscription or null

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

* Use empty map instead of null for pushDontNotifyHeader

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

* Fix test

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

* Always send push-dont-notify header if subscription exists

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

* Fix copyright

* Send Push-Dont-Notify URL as quoted string

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-07 17:22:54 +02:00
Arnau Mora
eb0b75a9a7 [Push] Better title/description for Google FCM in UI (#1424)
* Added FCM indicator

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

* Changed text

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

* Added content description

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

* Add link to manual for WebDAV push in app settings

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-05-02 12:06:23 +02:00
Arnau Mora
94ca9cd871 Fixed deprecations with menu anchors (#1428)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-30 11:58:01 +02:00
Ricki Hirner
9f697f06be Repositories: apply new naming scheme (#1423)
* DavHomeSetRepository, DavServiceRepository: apply new naming scheme for (non-)suspending calls

* AccountRepository: apply new naming scheme for (non-)suspending calls

* Update repository methods to new naming scheme
2025-04-30 10:58:00 +02:00
Ricki Hirner
ba7f95aad5 Clean up app/build.gradle (#1421)
* Remove packaging resources exclusion and configurations

* Update mikepenz-aboutLibraries to 12.1.0-rc03 and configure resource merging for LICENSE files
2025-04-27 11:10:20 +02:00
Ricki Hirner
993fffaa15 Fix UnifiedPushService tests (#1420) 2025-04-27 10:21:17 +02:00
Ricki Hirner
4b7f7ed45e Fetch translations from Transifex 2025-04-27 10:03:44 +02:00
Ricki Hirner
7e80607a34 Bump version to 4.4.10-beta.1 2025-04-27 10:01:41 +02:00
Ricki Hirner
d0389f13fc Move Hilt modules to .di package (bitfireAT/davx5#665)
Move Hilt module definitions to di package
2025-04-26 23:21:27 +02:00
Sunik Kupfer
993d0f66ec [Push] Update subscriptions when collection is (un)selected for sync (#1407)
* Update subscriptions when collection is (un)selected for sync

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

* Remove collections change listener and its hilt module

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

* Remove observer pattern to listen for collection changes

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

* Update push subscriptions when collection is (un)selected for sync

* Update collection selection listener to use lazy initialization

* Update push subscriptions and sync when collection is (un)selected for sync

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

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

* Move CollectionSelectedUseCase to account package

* Add test for CollectionSelectedUseCase

* Inject application scope instead of using a factory

* Update tests to run on our main dispatcher

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-26 22:13:40 +02:00
Arnau Mora
4c9ad959dd Upgrade about libs to 12.1.0-rc02 (#1416)
* Upgrade about libs to 12.1.0

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

* Update AboutLibraries version to 12.1.0-rc02

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-26 14:43:51 +02:00
Ricki Hirner
044a28138a [Push] Don't block main thread in UnifiedPushService (#1412)
* Refactor UnifiedPushService to use CoroutineScope so that it doesn't block on main thread

* Add KDoc why the scope is not cancelled
2025-04-25 10:36:02 +02:00
Ricki Hirner
e8ec98c257 Fix HttpClient.Builder.followRedirect 2025-04-25 10:30:53 +02:00
Ricki Hirner
fe0c1e67e7 Drop LiveData (#1414)
* Remove LiveData dependencies
2025-04-25 09:59:08 +02:00
Ricki Hirner
7261a8137d [IDE] Code style, width 2025-04-25 09:05:43 +02:00
Ricki Hirner
b1493f3f6a Bump version to 4.4.10-alpha.2 2025-04-24 20:08:30 +02:00
Ricki Hirner
d679dc5e97 [Push] Fix various subscription problems (#1411)
* Handle non-existing account gracefully when (un)subscribing collections

* Unsubscribe from all (subscribed) collections when no push distributor is selected
2025-04-24 20:03:39 +02:00
Ricki Hirner
b0f7196f2b Fix non-suspending DB access on main thread in accountRepository.delete() (#1409)
Make more database queries suspendable and run them in a blocking context
2025-04-24 19:02:22 +02:00
Ricki Hirner
77a6e5c5ab Bump version to 4.4.10-alpha.1 2025-04-24 13:12:38 +02:00
Ricki Hirner
845d979046 Update dependency versions 2025-04-24 13:12:14 +02:00
Arnau Mora
f62509ed80 [Push] Support UnifiedPush Connector 3.x, VAPID, Encryption, Google FCM (#1325)
* Upgrade UnifiedPush Connector to 3.0.4

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

* Updated overrides

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

* Added storing keys and auths

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

* Excluded tink

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

* Fixed deprecations and calls

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

* Integrate UnifiedPush 3.x connector and FCM distributor

* Integrate UnifiedPush connector 3.x, use VAPID and message encryption

* [WIP] Refactor push registration logic and remove deprecated methods

* [WIP] Remove PushRegistrationWorkerManager and refactor PushRegistrationManager

* Remove unused service repository dependency and update worker to suspend

* Add suspend modifier to DAO methods and repository methods

* Add runBlocking to getByService call in CollectionListRefresherTest

* Add documentation for UnifiedPushService and PushRegistrationManager

* Add fallback for push messages without topic

* [WIP] Add UnifiedPushService test with workaround for PushService binder

* Update UnifiedPush library version and clean up test code

* Refactor push message handling, synchronization and coroutines

* Add coroutine dispatchers for push registration and unregistration

* Add async support for push subscription updates

* Refactor unsubscribe logic into reusable method

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-24 13:09:51 +02:00
Ricki Hirner
e79c362f46 [WebDAV] Refactor coroutine usage (#1405)
* Inject coroutine scopes for cancellation and I/O

* Inject coroutine scopes for better cancellation and lifecycle management

* Inject coroutine scopes for WebDAV operations

* Fix tests

* More correct coroutine contexts
2025-04-24 12:12:17 +02:00
Arnau Mora
5c35741226 Update AboutLibraries to 12.0.0 (#1406)
* Upgrade aboutlibs to 12.0.0

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

* Migrated code

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

* Apply AboutLibraries after other gradle plugins

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-24 10:33:59 +02:00
Ricki Hirner
b90b8ce6a2 Update AGP to 8.9.2 2025-04-23 17:30:29 +02:00
Sunik Kupfer
321aeedd8f Sync after sync flag or forceReadOnly flag of a collection are changed (#1383)
* Perform delayed sync on a collection UI change

- sync state
- force-read-only state

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

* Use withContext to access DB on background thread

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

* Remove return comment

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

* Extract delay value to constant

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

* Use accountRepository to create account from name

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-04-23 17:25:12 +02:00
Arnau Mora
09f68a237b NPE when trying to log in without specifying an email (#1401)
* Added check for null baseUri

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

* Rollback

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

* Added check for IME next

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-22 10:44:21 +02:00
Ricki Hirner
93d715bb99 Test coroutines with runTest instead of runBlocking (#1399)
Add kotlinx-coroutines-test dependency and replace runBlocking with runTest in tests
2025-04-22 10:44:08 +02:00
Arnau Mora
04fe8e1aca Hide "keep permissions" in API 29 and below (#1400)
* Hide "keep permissions" for API 29 and below

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

* Fix deprecation

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-22 09:35:12 +02:00
Ricki Hirner
1f02f3cc27 Use R8 full mode (#1397)
* Use R8 full mode

* Add dav4jvm dependency (which includes an XmlPullParser) for testing
2025-04-20 12:53:17 +02:00
Ricki Hirner
a0acd4e929 Use ProGuard rules from libraries (#1387)
Update library versions and optimize ProGuard rules (most of them are now in the libraries consumer rules)
2025-04-20 12:46:46 +02:00
Ricki Hirner
3901e6ebe4 Update translation fetching script to use relative paths 2025-04-20 10:30:11 +02:00
Ricki Hirner
f229226521 Bump version to 4.4.9 2025-04-20 10:26:36 +02:00
Ricki Hirner
6644e4acd7 Fix 4.4.9 release compile error (#1386)
Update ical4android version and force Apache Commons dependencies
2025-04-17 16:39:23 +02:00
Ricki Hirner
d46f8056a5 Fetch translations from Transifex 2025-04-17 12:55:02 +02:00
Ricki Hirner
c3731ace88 Version bump to 4.4.9-rc.1 2025-04-17 12:53:21 +02:00
Ricki Hirner
038c2df524 Update dependencies 2025-04-17 12:52:20 +02:00
Arnau Mora
400318b390 Fixed paddings on horizontal mode (#1353)
* Fixed paddings

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

* Added padding to all screens

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

* Fixed edge to edge on accounts screen

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

* Fixed e2e issues

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

* Undo edge-to-edge

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

* Rollback

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

* Fixed paddings

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

* Fixed padding consumption

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

* Got rid of ime padding

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

* Fixed issues with paddings

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

* Fix UI elements partially obscured in landscape mode

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-04-17 12:47:59 +02:00
Arnau Mora
0d2e5a1f07 Avoid NPE when missing DTSTART for recurring events (#1336)
* Added handling of null dtstart

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

* Added JtxSyncManagerTest

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

* Test recurrence id without dtstart does not cause NPE

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

* Extract syncmanager creation from try-catch

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

* Add tests

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

* Assert RRULE remains in main vtodo

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

* Skip tests when jtx board not installed

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

* Correct annotation

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

* Simplify null checks

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

* Extract recurid definition

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

* Update ical4android

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

* Find recurrence instance without dtstart

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

* Rename method for clarity and update kdoc

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

* Use new method in test too

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

* Fix lint warnings

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

* Use a custom rule to wrap and ignore security exception if jtxboard is not installed

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

* Use existing permission utils

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

* Rename capture to catch exceptions rule

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

* Format code

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2025-04-16 14:49:44 +02:00
Ricki Hirner
9835cd0d53 Refactor SharedPreferences edits to use Kotlin extension function (#1382) 2025-04-15 11:46:17 +02:00
Bernhard Stockmann
f6d8efcd26 Update Screenshots (#1380)
now reflects latest UI
2025-04-14 13:32:27 +02:00
Ricki Hirner
955de83b35 Add VAPID support to Collection entity (#1376) 2025-04-13 11:41:30 +02:00
Ricki Hirner
29a09f2038 Update dependencies, including dav4jvm (#1374)
* Update dependencies

* Remove unused import and simplify topic extraction

* Fix room issue
2025-04-13 10:26:34 +02:00
Ricki Hirner
fff332f31f Enable dependabot for Github Actions (#1363) 2025-04-01 17:28:24 +02:00
Ricki Hirner
b4d4a2fddd Update Android Gradle Plugin and Compose BOM versions 2025-04-01 10:40:56 +02:00
Arnau Mora
566a539a85 Debug Info does not contain actual error (#1362)
Fixed typo on intent extra

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-04-01 10:20:03 +02:00
Arnau Mora
e588ada891 Original string for webdav_add_mount_empty_more_info broken (#1354)
Got rid of extra `</string>` tag inside of CDATA

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-03-23 11:36:16 +01:00
Sunik Kupfer
0c89e3ba3b Version bump to 4.4.8 2025-03-13 12:44:38 +01:00
Sunik Kupfer
7b0e134c20 Fetch translations from Transifex 2025-03-13 12:40:43 +01:00
Ricki Hirner
8f7c285cb7 Move InvalidAccountException to account package (#1342)
Move InvalidAccountException
2025-03-13 11:21:44 +01:00
Arnau Mora
a2cddfc012 Release 4.4.8-rc.1 (bitfireAT/davx5#656)
* Increase version code and modify version name

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-03-13 11:21:44 +01:00
Sunik Kupfer
54eaecc6b5 Backport of bitfireAT/davx5#655: honor build config decision on whether users are allowed to accept custom certificates
* Honor customCertsUI build config value on whether users are allowed to accept custom certificates

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

* Add kdoc

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

* Update comment

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

* Extract config evaluation; Update kdoc

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

---------

Signed-off-by: Sunik Kupfer <kupfer@bitfire.at>
2025-03-11 12:37:55 +01:00
Sunik Kupfer
0012dec482 Track app foreground state via AppTheme using separate object (#1335)
* Track app in foreground state via AppTheme using separate object

* Make inForeground read only; add kdoc

* Remove type
2025-03-05 11:13:41 +01:00
Ricki Hirner
ced6abea3f Update AGP and dependencies 2025-03-05 10:41:35 +01:00
Sunik Kupfer
4a82baeaea Remove special chars in address book account names (#1334)
* Remove special chars from address book account names

* Add a test

* Update kdoc

* Only remove iso control and SQL problematic chars
2025-03-05 09:51:39 +01:00
Sunik Kupfer
f41b4fd59d Show a notification at sync when ContentProvider inaccessible (#1278)
* Extract notification handling to separate class

* Notify user at sync if content provider is missing

* Dismiss only content provider specific notification

* Remove title from notification text body

* Move sync warning strings into their own block

* Add KDoc, duplicate method for clarity

* Show message in notification for disabled tasks apps

* Pass authority through method calls

* Shorten method names

* Don't show content provider error notification when missing permission

* Rename methods and remove obsolete var

* Add spacing in content provider missing warning

* Improve kdoc

* Remove obsolete tasks provider error messages

* Syntactic sugar

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-03-04 13:36:29 +01:00
Ricki Hirner
f6bd4b0fc2 Update issue templates to support issue types and allow blank issues (for internal use) 2025-03-01 12:47:36 +01:00
Ricki Hirner
50879b6a0c Update AGP, Kotlin, dependencies 2025-02-27 13:14:47 +01:00
Arnau Mora
6be42d4ec3 Added timestamp to debug info (#1323)
* Added timestamp to debug info

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

* Added timestamp to `DebugInfoActivity.IntentBuilder`

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

* Show local time and UTC of timestamp

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-25 20:00:22 +01:00
Sunik Kupfer
a56d42d9a5 Provide test address books via DI (#1310)
* Add LocalTestAddressBookStore.kt

* Update tests acquiring local test address books

* Remove unused methods from LocalTestAddressBook

* Extract dirty check methods to the single test class using them

* Remove unused read only flag

* Drop obsolete context

* Reusables as properties

* Rename LocalTestAddressBookStore to LocalTestAddressBookProvider

* Minor changes

* Remove test address book after provision finishes and don't remove all before

* Fix more tests by removing address book accounts after run, not before

* Wrap provision method call in try-finally

* Rename provide methods anonymous function param for clarity

* Extract account recreation to variable

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-25 17:27:45 +01:00
Ricki Hirner
e34952bca9 Update dependencies 2025-02-19 12:58:36 +01:00
Arnau Mora
9d293a00e7 Added confirmation dialog for "Distrust System Certificates" (#1307)
* Added confirmation dialog for dsc

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

* Automatic dialog dismiss

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

* Added preview, and changed texts

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

* Updated dialog

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-02-15 23:04:32 +01:00
Arnau Mora
1ee41f8027 Use TaskStackBuilder instead of PendingIntent.getActivity (#1293)
* Use `TaskStackBuilder` instead of `PendingIntent.getActivity`

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

* Replaced usages for addNextIntentWithParentStack

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

* Remove unused import

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-13 18:53:10 +01:00
Sunik Kupfer
d3c1dbb5da DnsRecordResolverTest: Use seeded random number generator for deterministic test result (#1306)
* Use seeded random number generator for a 100% deterministic test result

* Fix typo

* Rename variable

* Mock random numbers and assert corresponding record is found
2025-02-12 17:50:50 +01:00
Sunik Kupfer
cd554d885b Skip login type selection when logging in via intent (#1267)
* Move companion object to the end of class

* Skip login type selection when logging in via intent

* Skip login type page if not default login type

* Add test for implicit email intent

* Fix test

* Update KDoc

* Refactor URI handling in LoginActivity and StandardLoginTypesProvider

* Skip login type page if intent is clear, but don't skip when using defaultLoginType

* Log unclear intents

* Use data class instead of pair

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-02-11 10:58:15 +01:00
Ricki Hirner
d9b4149d41 Refactor: replace deprecated getShowOnlyPersonalPair method and simplify removeAccount logic (#1304) 2025-02-07 11:55:16 +01:00
Arnau Mora
4af6165094 Backport login_credentials_lock for password change in account settings (bitfireAT/davx5#643)
---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-02-07 11:41:47 +01:00
Ricki Hirner
fb2023762d Add syncActive flag to control SyncAdapter behavior during tests (#1302) 2025-02-07 11:26:10 +01:00
Ricki Hirner
946c450036 SyncAdapter: Hilt error handling (#1299)
* SyncAdapter: Hilt error handling

* HiltTestRunner: enforce Android P requirement for MockK

* Update comment
2025-02-06 17:29:14 +01:00
Ricki Hirner
969d92d037 Bump version to 4.4.7 2025-02-02 18:30:35 +01:00
Ricki Hirner
6998f009c4 Fetch translations from Transifex 2025-02-02 14:57:15 +01:00
Ricki Hirner
cba1f01bdb Version bump to 4.4.7-rc.1 2025-02-02 14:56:29 +01:00
Ricki Hirner
5f80c8e779 Add migration for Syncer URL → ID change (#1285)
* Add AccountSettingsMigration20 to handle collection ID migration for syncer

* Add success path tests for address books and calendars

* Increase CURRENT_VERSION, fix task list store
2025-02-02 14:55:39 +01:00
Ricki Hirner
5ece438b3f Open-source intro page: don't select a "dont show for... months" option by default (#1287)
Refactor RadioButtons component to support initial selection and update parameter names for clarity
2025-02-02 14:46:50 +01:00
Ricki Hirner
acd4e41f8b Update cert4android to avoid Conscrypt crash in custom certificate handling (#1290) 2025-02-02 14:35:36 +01:00
Ricki Hirner
ae08093906 Syncer SecurityException: disable permission notification for now (#1284) 2025-02-02 14:28:07 +01:00
Ricki Hirner
dd456b41f1 Version bump to 4.4.7-beta.1 2025-01-31 22:52:19 +01:00
Ricki Hirner
30283f36a4 Update dependencies 2025-01-31 22:51:53 +01:00
Ricki Hirner
4858dd9229 Move authority and ContentProvider creation to LocalDataStore (#1272)
* Move authority and content provider acquire() to LocalDataStore implementations

* Fix tests

* Update LocalCalendarStore.kt

Reroder LocalCalendarStore delete
2025-01-30 12:42:11 +01:00
Sunik Kupfer
b910ba25ae Use ID to match DB collections with content provider collections (#1274)
* Update ical4android

* Match DB collections with content provider collections via ID

* Minor renaming and KDoc

* Move string constant to companion object

* Update KDoc

* Use getOrDefault to be more explicit

* Remove exception throw on missing collection ID

* Rewrite LocalAddressBookStoreTest

* Minor changes
- remove unused param
- make companion methods internal

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-29 16:47:51 +01:00
Arnau Mora
2a542210ca MKCALENDAR does not send valid calendar-timezone (#1251)
Fixed missing calendar properties

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-28 12:39:24 +01:00
Sunik Kupfer
eef85f1f7a Provide three options for donation reminder time (#1256)
* Provide three options for donation reminder time

* Move radio buttons to top card; Change strings

* Use padding instead of height for radio elements
2025-01-27 17:50:12 +01:00
Ricki Hirner
4f2d4e3a49 Further unmockk after tests 2025-01-27 16:47:31 +01:00
Ricki Hirner
feccb76ce8 MockK: use MockkRule if possible (#1269)
Unmockk all
2025-01-27 16:24:44 +01:00
Ricki Hirner
835689a4a6 Use DI for HttpClient.Builder (#1250)
* [WIP] Use Hilt for HttpClient.Builder

* Inject Provider<HttpClientBuilder> when necessary

* Proxy support

* [WIP] Tests

* Fix tests

* Minor changes, enable cache support again

* Update HttpClient to use modern TLS connection specification (disable TLS 1.0 and 1.1)

* Use real CredentialsStore
2025-01-27 13:44:33 +01:00
Ricki Hirner
50cbac147e [CI] Use if: !cancelled() instead of if: always() (#1266)
To make jobs cancellable,
see https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#always
2025-01-27 13:02:38 +01:00
Ricki Hirner
d1dd2f016e Fetch translations from Transifex 2025-01-25 12:54:34 +01:00
Ricki Hirner
bd5e26a9a9 Version bump to 4.4.6 2025-01-24 17:55:54 +01:00
Sunik Kupfer
2d686bee01 Show a warning if calendar or contacts storage is deactivated or missing (#1243)
* Add AppTheme to previews

* Show warning when contacts or calendar system apps are missing or disabled

* Change android icon to database missing icon

* Remove duplication

* Use packageChangedFlow to observer live changes

* Send user to settings app when deactivated and manual when missing

* Find whether content provider app is available by authority

* [WIP] Minor changes

* Open "Manage apps" instead of manual

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-24 17:52:17 +01:00
Arnau Mora
2438f1a8d4 Replaced LocalContext casting with LocalActivity (#1261)
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-22 21:17:00 +01:00
Ricki Hirner
331f8d5743 Bump version to 4.4.6-rc.1 2025-01-22 15:53:40 +01:00
Ricki Hirner
7e43524ff5 Update dependencies 2025-01-22 15:53:00 +01:00
Arnau Mora
708d94b69b Debug info: print all workers (#1200)
* Added printing of debug info for AccountsCleanupWorker

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

* Improved docs

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

* Fixed issues

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

* Cleanup

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

* Move debug info generation into separate class

* Minor changes

---------

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>
2025-01-22 15:27:28 +01:00
Arnau Mora
8b3c36f702 Use DB StringDefs also in DAOs and methods where they are used (#1252)
* Added annotations to daos

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

* Added missing annotations on methods

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

* Missing annotation

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

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-22 11:35:37 +01:00
Sunik Kupfer
ca2d57cf61 Change warning when Internet is not available (#1255) 2025-01-22 11:33:58 +01:00
Sunik Kupfer
1cc9b4bdd1 Fix show only personal setting not updating the view immediately (#1238)
* Split pair of show only personal settings

* Create and consume showOnlyPersonal settings separately

* Fix showOnlyPersonal flow state change not triggering re-emission

* Combine if statements

* Minor refactoring (lift out "if")

* Create separate reload methods

* Reload on model creation

* Use viewmodel scope

* Move init after relevant method declarations

* Add kdoc

* Remove deprecated getShowOnlyPersonalPair

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-20 13:45:50 +01:00
Sunik Kupfer
226583f19e Don't remove port from URI of nextcloud login intent (#1242)
* Test loginFromIntent

* Fix given URI

* Add test

* Use URI authority directly

* Add test for implicit intents

* Add URI port only if not -1

* Modify URI using Java URI

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-16 14:34:51 +01:00
274 changed files with 9456 additions and 4795 deletions

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: DAVx⁵ Community Support
url: https://github.com/bitfireAT/davx5-ose/discussions

View File

@@ -1,5 +1,6 @@
name: Qualified Bug Report
description: "[Developers only] For qualified bug reports. (Use Discussions if unsure.)"
description: "For qualified bug reports. (Use Discussions if unsure.)"
type: bug
labels: ["bug"]
body:
- type: checkboxes

View File

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

11
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
version: 2
updates:
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
# Workflow files stored in the default location of `.github/workflows`
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "[CI] "

View File

@@ -2,7 +2,8 @@ name: Development tests
on:
push:
branches:
- '*'
- 'main-ose'
pull_request:
concurrency:
group: test-dev-${{ github.ref }}
@@ -30,7 +31,7 @@ jobs:
test:
needs: compile
if: ${{ always() }} # even if compile didn't run (because not on main branch)
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
@@ -51,7 +52,7 @@ jobs:
test_on_emulator:
needs: compile
if: ${{ always() }} # even if compile didn't run (because not on main branch)
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:

9
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<option name="RIGHT_MARGIN" value="180" />
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -1,30 +1,32 @@
/***************************************************************************************************
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
*/
plugins {
alias(libs.plugins.mikepenz.aboutLibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries)
}
// Android configuration
android {
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404060001
versionName = "4.4.6-beta.1"
versionCode = 404110004
versionName = "4.4.11"
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 35 // Android 15
targetSdk = 36 // Android 16
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
@@ -85,26 +87,27 @@ android {
}
lint {
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos", "NullSafeMutableLiveData")
}
packaging {
resources {
excludes += arrayOf("META-INF/*.md")
}
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
}
androidResources {
generateLocaleConfig = true
}
packaging {
resources {
// multiple (test) dependencies have LICENSE files at same location
merges += arrayOf("META-INF/LICENSE*")
}
}
@Suppress("UnstableApiUsage")
testOptions {
managedDevices {
localDevices {
create("virtual") {
device = "Pixel 3"
apiLevel = 34
apiLevel = 35
systemImageSource = "aosp-atd"
}
}
@@ -117,20 +120,10 @@ ksp {
}
aboutLibraries {
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
excludeFields = arrayOf("generated")
}
configurations {
configureEach {
// exclude modules which are in conflict with system libraries
exclude(module="commons-logging")
exclude(group="org.json", module="json")
// Groovy requires SDK 26+, and it's not required, so exclude it
exclude(group="org.codehaus.groovy")
}
}
dependencies {
// core
implementation(libs.kotlin.stdlib)
@@ -152,6 +145,9 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.base)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.paging)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.preference)
@@ -162,8 +158,8 @@ dependencies {
implementation(libs.compose.accompanist.permissions)
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
implementation(libs.compose.material3.navigation3)
implementation(libs.compose.materialIconsExtended)
implementation(libs.compose.runtime.livedata)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.toolingPreview)
@@ -181,10 +177,15 @@ dependencies {
implementation(libs.bitfire.cert4android)
implementation(libs.bitfire.dav4jvm) {
exclude(group="junit")
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
}
implementation(libs.bitfire.ical4android)
implementation(libs.bitfire.vcard4android)
// Serialization (for navigation)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
// third-party libs
@Suppress("RedundantSuppression")
implementation(libs.dnsjava)
@@ -195,7 +196,17 @@ dependencies {
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)
implementation(libs.openid.appauth)
implementation(libs.unifiedpush)
implementation(libs.unifiedpush) {
// UnifiedPush connector seems to be using a workaround by importing this library.
// Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
// See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59
exclude(group = "com.google.crypto.tink", module = "tink")
}
implementation(libs.unifiedpush.fcm)
// force some versions for compatibility with our minSdk level (see version catalog for details)
implementation(libs.commons.codec)
implementation(libs.commons.lang)
// for tests
androidTestImplementation(libs.androidx.arch.core.testing)
@@ -206,10 +217,12 @@ dependencies {
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.okhttp.mockwebserver)
androidTestImplementation(libs.room.testing)
testImplementation(libs.bitfire.dav4jvm)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.okhttp.mockwebserver)

View File

@@ -8,53 +8,19 @@
-dontobfuscate
-printusage build/reports/r8-usage.txt
# ez-vcard: keep all vCard properties/parameters (used via reflection)
-keep class ezvcard.io.scribe.** { *; }
-keep class ezvcard.property.** { *; }
-keep class ezvcard.parameter.** { *; }
# ical4j: keep all iCalendar properties/parameters (used via reflection)
-keep class net.fortuna.ical4j.** { *; }
# XmlPullParser
# keep rules
-keep class at.bitfire.** { *; } # all DAVx5 code is required
-keep class org.xmlpull.** { *; }
# DAVx + libs
-keep class at.bitfire.** { *; } # all DAVx code is required
# AGP 8.2 and 8.3 seem to remove this class, but ezvcard.io uses it. See https://github.com/bitfireAT/davx5/issues/499
-keep class javax.xml.namespace.QName { *; }
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Additional rules which are now required since missing classes can't be ignored in R8 anymore.
# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning]
-dontwarn com.android.org.conscrypt.SSLParametersImpl
-dontwarn com.github.erosb.jsonsKema.** # ical4j
-dontwarn com.google.errorprone.annotations.**
-dontwarn com.sun.jna.** # dnsjava
-dontwarn groovy.**
-dontwarn java.beans.Transient
-dontwarn javax.cache.** # ical4j
-dontwarn javax.naming.NamingException # dnsjava
-dontwarn javax.naming.directory.** # dnsjava
-dontwarn junit.textui.TestRunner
-dontwarn lombok.** # dnsjava
-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.codehaus.groovy.**
-dontwarn org.joda.**
-dontwarn org.jparsec.** # ical4j
-dontwarn org.json.*
-dontwarn org.jsoup.**
-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.**
# dnsjava
-dontwarn com.sun.jna.**
-dontwarn lombok.**
-dontwarn javax.naming.NamingException
-dontwarn javax.naming.directory.**
-dontwarn sun.net.spi.nameservice.NameService
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
-dontwarn org.xbill.DNS.spi.DnsjavaInetAddressResolverProvider

View File

@@ -0,0 +1,648 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "cd15d368408570cc2e57252816869de2",
"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"
}
],
"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`)"
}
]
},
{
"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"
}
],
"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, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `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"
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"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"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "timezoneId",
"columnName": "timezoneId",
"affinity": "TEXT"
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT"
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT"
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushVapidKey",
"columnName": "pushVapidKey",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscriptionExpires",
"columnName": "pushSubscriptionExpires",
"affinity": "INTEGER"
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER"
}
],
"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"
}
],
"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"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT"
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT"
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER"
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER"
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER"
}
],
"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"
]
}
}
],
"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, 'cd15d368408570cc2e57252816869de2')"
]
}
}

View File

@@ -0,0 +1,648 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "6a0f7e1553e1f621ae7913ea14370fd0",
"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"
}
],
"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`)"
}
]
},
{
"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"
}
],
"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, `timezoneId` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushVapidKey` TEXT, `pushSubscription` TEXT, `pushSubscriptionExpires` INTEGER, `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"
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"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"
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER"
},
{
"fieldPath": "timezoneId",
"columnName": "timezoneId",
"affinity": "TEXT"
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER"
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT"
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT"
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushVapidKey",
"columnName": "pushVapidKey",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT"
},
{
"fieldPath": "pushSubscriptionExpires",
"columnName": "pushSubscriptionExpires",
"affinity": "INTEGER"
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER"
}
],
"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"
}
],
"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, `dataType` 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": "dataType",
"columnName": "dataType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_dataType",
"unique": true,
"columnNames": [
"collectionId",
"dataType"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_dataType` ON `${TABLE_NAME}` (`collectionId`, `dataType`)"
}
],
"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"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT"
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT"
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER"
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER"
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER"
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER"
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER"
}
],
"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"
]
}
}
],
"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, '6a0f7e1553e1f621ae7913ea14370fd0')"
]
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import kotlin.reflect.KClass
/**
* Use this custom rule to ignore exceptions thrown by another rule.
*
* @param innerRule The rule to wrap.
* @param exceptionsToIgnore The exceptions to ignore.
*/
class CatchExceptionsRule(
private val innerRule: TestRule,
private vararg val exceptionsToIgnore: KClass<out Throwable>
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
try {
innerRule.apply(base, description).evaluate()
} catch (e: Throwable) {
val shouldIgnore = exceptionsToIgnore.any { it.isInstance(e) }
if (shouldIgnore)
base.evaluate()
else
throw e
}
}
}
}
}

View File

@@ -6,12 +6,31 @@ package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.Bundle
import androidx.test.runner.AndroidJUnitRunner
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
import at.bitfire.davdroid.sync.SyncAdapterService
import dagger.hilt.android.testing.HiltTestApplication
@Suppress("unused")
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, name: String, context: Context): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments)
// MockK requirements
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
// disable sync adapters
SyncAdapterService.syncActive.set(false)
// set main dispatcher for tests (especially runTest)
TestCoroutineDispatchersModule.initMainDispatcher()
}
}

View File

@@ -4,10 +4,7 @@
package at.bitfire.davdroid
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
@@ -19,16 +16,12 @@ import javax.inject.Inject
@HiltAndroidTest
class OkhttpClientTest {
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
@@ -37,12 +30,14 @@ class OkhttpClientTest {
@Test
fun testIcloudWithSettings() {
val client = HttpClient.Builder(context).build()
client.okHttpClient.newCall(Request.Builder()
httpClientBuilder.build().use { client ->
client.okHttpClient
.newCall(Request.Builder()
.get()
.url("https://icloud.com")
.build())
.execute()
}
}
}

View File

@@ -4,14 +4,11 @@
package at.bitfire.davdroid.db
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.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
@@ -31,16 +28,12 @@ import javax.inject.Inject
@HiltAndroidTest
class CollectionTest {
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var settingsManager: SettingsManager
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@@ -48,7 +41,7 @@ class CollectionTest {
fun setup() {
hiltRule.inject()
httpClient = HttpClient.Builder(context).build()
httpClient = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@@ -211,4 +204,4 @@ class CollectionTest {
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class WebDavDocumentDaoTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var logger: Logger
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testGetChildren() = runTest {
val mountDao = db.webDavMountDao()
val dao = db.webDavDocumentDao()
val mount = WebDavMount(id = 1, name = "Test", url = "https://example.com/".toHttpUrl())
db.webDavMountDao().insert(mount)
val root = WebDavDocument(
id = 1,
mountId = mount.id,
parentId = null,
name = "Root Document"
)
dao.insertOrReplace(root)
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 1", displayName = "DisplayName 2"))
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 2", displayName = "DisplayName 1"))
dao.insertOrReplace(WebDavDocument(id = 0, mountId = mount.id, parentId = root.id, name = "Name 3", displayName = "Directory 1", isDirectory = true))
try {
dao.getChildren(root.id, orderBy = "name DESC").let { result ->
logger.log(Level.INFO, "getChildren single sort Result", result)
assertEquals(listOf(
"Name 3",
"Name 2",
"Name 1"
), result.map { it.name })
}
dao.getChildren(root.id, orderBy = "isDirectory DESC, name ASC").let { result ->
logger.log(Level.INFO, "getChildren multiple sort Result", result)
assertEquals(listOf(
"Name 3",
"Name 1",
"Name 2"
), result.map { it.name })
}
} finally {
mountDao.deleteAsync(mount)
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Test
@HiltAndroidTest
class AutoMigration18Test : DatabaseMigrationTest(toVersion = 18) {
@Test
fun testMigration_AllAuthorities() = testMigration(
prepare = { db ->
// Insert service and collection to respect relation constraints
db.execSQL("INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)", arrayOf<Any?>(1, "test", 1))
listOf(1L, 2L, 3L).forEach { id ->
db.execSQL(
"INSERT INTO collection (id, serviceId, url, type, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(id, 1, "https://example.com/$id", 1, 1, 1, 0, 1)
)
}
// Insert some syncstats with authorities and lastSync times
val syncstats = listOf(
Entry(1, 1, "com.android.contacts", 1000),
Entry(2, 1, "com.android.calendar", 1000),
Entry(3, 1, "org.dmfs.tasks", 1000),
Entry(4, 1, "org.tasks.opentasks", 2000),
Entry(5, 1, "at.techbee.jtx.provider", 3000), // highest lastSync for collection 1
Entry(6, 1, "unknown.authority", 1000), // ignored
Entry(7, 2, "org.dmfs.tasks", 1000),
Entry(8, 2, "org.tasks.opentasks", 2000), // highest lastSync for collection 2
Entry(9, 3, "org.tasks.opentasks", 1000),
)
syncstats.forEach { (id, collectionId, authority, lastSync) ->
db.execSQL(
"INSERT INTO syncstats (id, collectionId, authority, lastSync) VALUES (?, ?, ?, ?)",
arrayOf<Any?>(id, collectionId, authority, lastSync)
)
}
},
validate = { db ->
db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor ->
val found = mutableListOf<Entry>()
db.query("SELECT id, collectionId, dataType FROM syncstats ORDER BY id").use { cursor ->
val idIdx = cursor.getColumnIndex("id")
val colIdx = cursor.getColumnIndex("collectionId")
val typeIdx = cursor.getColumnIndex("dataType")
while (cursor.moveToNext())
found.add(
Entry(cursor.getInt(idIdx), cursor.getLong(colIdx), cursor.getString(typeIdx))
)
}
// Expect one TASKS row per collection (collections 1, 2, 3)
assertEquals(
listOf(
Entry(1, 1, "CONTACTS"),
Entry(2, 1, "EVENTS"),
Entry(5, 1, "TASKS"), // highest lastSync TASK for collection 1 is JTX Board
Entry(8, 2, "TASKS"), // highest lastSync TASK for collection 2
Entry(9, 3, "TASKS"), // only TASK for collection 3
), found
)
}
}
)
data class Entry(
val id: Int,
val collectionId: Long,
val dataType: String? = null,
val lastSync: Long? = null
)
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule.standardTestDispatcher
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.setMain
/**
* Provides test dispatchers to be injected instead of the normal ones.
*
* The [standardTestDispatcher] is set as main dispatcher in [at.bitfire.davdroid.HiltTestRunner],
* so that tests can just use [kotlinx.coroutines.test.runTest] without providing [standardTestDispatcher].
*/
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [CoroutineDispatchersModule::class]
)
object TestCoroutineDispatchersModule {
private val standardTestDispatcher = StandardTestDispatcher()
@Provides
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = standardTestDispatcher
@Provides
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = standardTestDispatcher
@Provides
@MainDispatcher
fun mainDispatcher(): CoroutineDispatcher = standardTestDispatcher
@Provides
@SyncDispatcher
fun syncDispatcher(): CoroutineDispatcher = standardTestDispatcher
/**
* Sets the [standardTestDispatcher] as [Dispatchers.Main] so that test dispatchers
* created in the future use the same scheduler. See [StandardTestDispatcher] docs
* for more information.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun initMainDispatcher() {
Dispatchers.setMain(standardTestDispatcher)
}
}

View File

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

View File

@@ -2,10 +2,8 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
package at.bitfire.davdroid.di
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.startup.TasksAppWatcher
import dagger.Module
@@ -13,25 +11,13 @@ import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dagger.multibindings.Multibinds
// remove PushRegistrationWorkerModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PushRegistrationWorkerManager.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 {
abstract class TestTasksAppWatcherModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>

View File

@@ -6,6 +6,8 @@ package at.bitfire.davdroid.network
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -18,6 +20,7 @@ import org.xbill.DNS.Name
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import javax.inject.Inject
import kotlin.random.Random
@HiltAndroidTest
class DnsRecordResolverTest {
@@ -65,25 +68,22 @@ class DnsRecordResolverTest {
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.")
)
val dns1030 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 30, 8443, Name.fromString("dav1030.example.com.")
)
val records = arrayOf(dns1010, dns1020, dns1030)
// 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]++
val randomNumberGenerator = mockk<Random>()
for (i in 0..60) {
every { randomNumberGenerator.nextInt(0, 61) } returns i
val expected = when (i) {
in 0..10 -> dns1010
in 11..30 -> dns1020
else -> dns1030
}
assertEquals(expected, dnsRecordResolver.bestSRVRecord(records, randomNumberGenerator))
}
/* 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

View File

@@ -4,9 +4,7 @@
package at.bitfire.davdroid.network
import android.content.Context
import android.security.NetworkSecurityPolicy
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -25,21 +23,20 @@ import javax.inject.Inject
@HiltAndroidTest
class HttpClientTest {
lateinit var server: MockWebServer
lateinit var httpClient: HttpClient
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
lateinit var httpClientBuilder: HttpClient.Builder
lateinit var httpClient: HttpClient
lateinit var server: MockWebServer
@Before
fun setUp() {
hiltRule.inject()
httpClient = HttpClient.Builder(context).build()
httpClient = httpClientBuilder.build()
server = MockWebServer()
server.start(30000)

View File

@@ -0,0 +1,46 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PushMessageHandlerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var handler: PushMessageHandler
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testParse_InvalidXml() {
Assert.assertNull(handler.parse("Non-XML content"))
}
@Test
fun testParse_WithXmlDeclAndTopic() {
val topic = handler.parse(
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>" +
"<P:push-message xmlns:D=\"DAV:\" xmlns:P=\"https://bitfire.at/webdav-push\">" +
" <P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>" +
"</P:push-message>"
)
Assert.assertEquals("O7M1nQ7cKkKTKsoS_j6Z3w", topic)
}
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.test.rule.ServiceTestRule
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class UnifiedPushServiceTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@get:Rule
val serviceTestRule = ServiceTestRule()
@Inject
@ApplicationContext
lateinit var context: Context
@RelaxedMockK
@BindValue
lateinit var pushRegistrationManager: PushRegistrationManager
lateinit var binder: IBinder
lateinit var unifiedPushService: UnifiedPushService
@Before
fun setUp() {
hiltRule.inject()
binder = serviceTestRule.bindService(Intent(context, UnifiedPushService::class.java))!!
unifiedPushService = (binder as PushService.PushBinder).getService() as UnifiedPushService
}
@Test
fun testOnNewEndpoint() = runTest {
val endpoint = mockk<PushEndpoint> {
every { url } returns "https://example.com/12"
}
unifiedPushService.onNewEndpoint(endpoint, "12")
advanceUntilIdle()
coVerify {
pushRegistrationManager.processSubscription(12, endpoint)
}
confirmVerified(pushRegistrationManager)
}
@Test
fun testOnRegistrationFailed() = runTest {
unifiedPushService.onRegistrationFailed(FailedReason.INTERNAL_ERROR, "34")
advanceUntilIdle()
coVerify {
pushRegistrationManager.removeSubscription(34)
}
confirmVerified(pushRegistrationManager)
}
@Test
fun testOnUnregistered() = runTest {
unifiedPushService.onUnregistered("45")
advanceUntilIdle()
coVerify {
pushRegistrationManager.removeSubscription(45)
}
confirmVerified(pushRegistrationManager)
}
}

View File

@@ -1,103 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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.Lazy
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,
object : Lazy<Set<DavCollectionRepository.OnChangeListener>> {
override fun get(): Set<DavCollectionRepository.OnChangeListener> {
return 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

@@ -18,61 +18,57 @@ import javax.inject.Inject
@HiltAndroidTest
class DavHomeSetRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var repository: DavHomeSetRepository
@Inject
lateinit var serviceRepository: DavServiceRepository
@get:Rule
var hiltRule = HiltAndroidRule(this)
var serviceId: Long = 0
@Before
fun setUp() {
hiltRule.inject()
serviceId = serviceRepository.insertOrReplaceBlocking(
Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
)
}
@Test
fun testInsertOrUpdate() {
// should insert new row or update (upsert) existing row - without changing its key!
val serviceId = createTestService()
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrl(entry1)
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.copy(id = 1L), repository.getById(1L))
assertEquals(entry1.copy(id = 1L), repository.getByIdBlocking(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
val updateId1 = repository.insertOrUpdateByUrlBlocking(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.copy(id = 1L), repository.getById(1L))
assertEquals(updatedEntry1.copy(id = 1L), repository.getByIdBlocking(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = repository.insertOrUpdateByUrl(entry2)
val insertId2 = repository.insertOrUpdateByUrlBlocking(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.copy(id = 2L), repository.getById(2L))
assertEquals(entry2.copy(id = 2L), repository.getByIdBlocking(2L))
}
@Test
fun testDelete() {
fun testDeleteBlocking() {
// should delete row with given primary key (id)
val serviceId = createTestService()
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrl(entry1)
val insertId1 = repository.insertOrUpdateByUrlBlocking(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1, repository.getById(1L))
assertEquals(entry1, repository.getByIdBlocking(1L))
repository.delete(entry1)
assertEquals(null, repository.getById(1L))
}
private fun createTestService() : Long {
val service = Service(id=0, accountName="test", type= Service.TYPE_CALDAV, principal = null)
return serviceRepository.insertOrReplace(service)
repository.deleteBlocking(entry1)
assertEquals(null, repository.getByIdBlocking(1L))
}
}

View File

@@ -9,26 +9,18 @@ import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
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.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.just
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
@@ -37,81 +29,81 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookStoreTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var localAddressBookStore: LocalAddressBookStore
@RelaxedMockK
lateinit var provider: ContentProviderClient
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
@SpyK
lateinit var context: Context
@get:Rule
val mockkRule = MockKRule(this)
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
val addressBookAccount by lazy { Account("MrRobert@example.com", context.getString(R.string.account_type_address_book)) }
val provider = mockk<ContentProviderClient>(relaxed = true)
val addressBook: LocalAddressBook = mockk(relaxed = true) {
every { account } answers { this@LocalAddressBookStoreTest.account }
every { updateSyncFrameworkSettings() } just runs
every { addressBookAccount } answers { this@LocalAddressBookStoreTest.addressBookAccount }
every { settings } returns LocalAddressBookStore.contactsProviderSettings
}
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
@RelaxedMockK
lateinit var collectionRepository: DavCollectionRepository
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
val localAddressBookFactory = mockk<LocalAddressBook.Factory> {
every { create(any(), any(), provider) } returns addressBook
}
@Inject
@SpyK
lateinit var logger: Logger
@RelaxedMockK
lateinit var settingsManager: SettingsManager
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
val serviceRepository = mockk<DavServiceRepository>(relaxed = true) {
every { get(any<Long>()) } returns null
every { get(200) } returns mockk<Service> {
every { accountName } returns "MrRobert@example.com"
}
}
@InjectMockKs
@SpyK
lateinit var localAddressBookStore: LocalAddressBookStore
lateinit var addressBookAccountType: String
lateinit var addressBookAccount: Account
lateinit var account: Account
lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
// initialize global mocks
MockKAnnotations.init(this)
addressBookAccountType = context.getString(R.string.account_type_address_book)
account = TestAccount.create()
service = Service(
id = 200,
accountName = account.name,
type = Service.Companion.TYPE_CARDDAV,
principal = null
)
db.serviceDao().insertOrReplace(service)
addressBookAccount = Account(
"MrRobert@example.com",
addressBookAccountType
)
}
@After
fun tearDown() {
unmockkAll()
TestAccount.remove(account)
removeAddressBooks()
}
@Test
fun test_accountName_removesSpecialChars() {
// Should remove iso control characters and `, ", ',
val collection = mockk<Collection> {
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns "手 M's_\"F-e\"\\(´д`)/;æøå% äöü #42"
every { serviceId } returns service.id
}
assertEquals("手 Ms_F-e\\(´д)/;æøå% äöü #42 (Test Account) #1", localAddressBookStore.accountName(collection))
}
@Test
fun test_accountName_missingService() {
val collection = mockk<Collection> {
every { id } returns 42
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns 404
every { serviceId } returns 404 // missing service
}
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
}
@@ -122,19 +114,19 @@ class LocalAddressBookStoreTest {
every { id } returns 42
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns 200
every { serviceId } returns service.id
}
val accountName = localAddressBookStore.accountName(collection)
assertEquals("funnyfriends (MrRobert@example.com) #42", accountName)
assertEquals("funnyfriends (${account.name}) #42", accountName)
}
@Test
fun test_accountName_missingDisplayNameAndService() {
val collection = mockk<Collection>(relaxed = true) {
val collection = mockk<Collection> {
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns 404 // missing service
every { serviceId } returns 404 // missing service
}
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
}
@@ -143,45 +135,42 @@ class LocalAddressBookStoreTest {
@Test
fun test_create_createAccountReturnsNull() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns 200
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
}
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns null
mockkObject(localAddressBookStore)
every { localAddressBookStore.createAddressBookAccount(any(), any(), any()) } returns null
assertEquals(null, localAddressBookStore.create(provider, collection))
}
@Test
fun test_create_createAccountReturnsAccount() {
fun test_create_ReadOnly() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns 200
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { readOnly() } returns true
}
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns addressBookAccount
every { addressBook.readOnly } returns true
val addrBook = localAddressBookStore.create(provider, collection)!!
verify(exactly = 1) { addressBook.updateSyncFrameworkSettings() }
assertEquals(addressBookAccount, addrBook.addressBookAccount)
assertEquals(LocalAddressBookStore.contactsProviderSettings, addrBook.settings)
assertEquals(true, addrBook.readOnly)
every { addressBook.readOnly } returns false
val addrBook2 = localAddressBookStore.create(provider, collection)!!
assertEquals(false, addrBook2.readOnly)
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
assertTrue(addrBook.readOnly)
}
@Test
fun test_createAccount_succeeds() {
mockkObject(SystemAccountUtils)
every { SystemAccountUtils.createAccount(any(), any(), any()) } returns true
val result: Account = localAddressBookStore.createAddressBookAccount(
account, "MrRobert@example.com", 42, "https://example.com/addressbook/funnyfriends"
)!!
verify(exactly = 1) { SystemAccountUtils.createAccount(any(), any(), any()) }
assertEquals("MrRobert@example.com", result.name)
assertEquals(context.getString(R.string.account_type_address_book), result.type)
fun test_create_ReadWrite() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { readOnly() } returns false
}
val addrBook = localAddressBookStore.create(provider, collection)!!
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
assertFalse(addrBook.readOnly)
}
@@ -223,4 +212,14 @@ class LocalAddressBookStoreTest {
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
}
// helpers
private fun removeAddressBooks() {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(addressBookAccountType).forEach {
accountManager.removeAccountExplicitly(it)
}
}
}

View File

@@ -18,7 +18,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import ezvcard.property.Telephone
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -28,6 +27,7 @@ import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.io.FileNotFoundException
import java.util.LinkedList
import javax.inject.Inject
@@ -37,24 +37,17 @@ class LocalAddressBookTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
lateinit var addressBook: LocalTestAddressBook
@Before
fun setUp() {
hiltRule.inject()
addressBook = LocalTestAddressBook.create(context, account, provider)
}
@After
fun tearDown() {
// remove address book
addressBook.remove()
}
@@ -63,32 +56,34 @@ class LocalAddressBookTest {
*/
@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))
localTestAddressBookProvider.provide(account, provider) { addressBook ->
// 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", isContactDirty(addressBook, id))
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(newName, addressBook.addressBookAccount.name)
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(newName, addressBook.addressBookAccount.name)
// 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))
// check whether contact is still here (including data rows) and not dirty
val result = addressBook.findContactById(id)
assertFalse("Contact is dirty after moving", isContactDirty(addressBook, id))
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
}
}
/**
@@ -96,26 +91,63 @@ class LocalAddressBookTest {
*/
@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)
localTestAddressBookProvider.provide(account, provider) { addressBook ->
// insert group
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
val uri = localGroup.add()
val id = ContentUris.parseId(uri)
// make sure it's not dirty
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
// make sure it's not dirty
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
// rename address book
val newName = "New Name"
assertTrue(addressBook.renameAccount(newName))
assertEquals(newName, addressBook.addressBookAccount.name)
// rename address book
val newName = "New Name"
assertTrue(addressBook.renameAccount(newName))
assertEquals(newName, addressBook.addressBookAccount.name)
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
assertFalse("Group is dirty after moving", isGroupDirty(addressBook, id))
val group = result.getContact()
assertEquals("Test Group", group.displayName)
val group = result.getContact()
assertEquals("Test Group", group.displayName)
}
}
// helpers
/**
* 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(adddressBook: LocalAddressBook, id: Long): Boolean {
val uri = ContentUris.withAppendedId(adddressBook.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(adddressBook: LocalAddressBook, id: Long): Boolean {
val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id)
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
if (cursor.moveToFirst())
return cursor.getInt(0) != 0
}
throw FileNotFoundException()
}

View File

@@ -21,7 +21,6 @@ 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.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -41,216 +40,217 @@ class LocalGroupTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@Before
fun setup() {
hiltRule.inject()
addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
// clear contacts
addressBookGroupsAsCategories.clear()
addressBookGroupsAsVCards.clear()
}
@After
fun tearDown() {
addressBookGroupsAsCategories.remove()
addressBookGroupsAsVCards.remove()
}
@Test
fun testApplyPendingMemberships_addPendingMembership() {
val ab = addressBookGroupsAsVCards
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val group = newGroup(ab)
// set pending membership of contact1
ab.provider!!.update(
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
ContentValues().apply {
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
},
null, null
)
val group = newGroup(ab)
// set pending membership of contact1
ab.provider!!.update(
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
ContentValues().apply {
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
},
null, null
)
// pending membership -> contact1 should be added to group
LocalGroup.applyPendingMemberships(ab)
// pending membership -> contact1 should be added to group
LocalGroup.applyPendingMemberships(ab)
// check group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
// check group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
assertFalse(cursor.moveToNext())
}
}
}
@Test
fun testApplyPendingMemberships_removeMembership() {
val ab = addressBookGroupsAsVCards
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val group = newGroup(ab)
val group = newGroup(ab)
// add contact1 to group
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
// add contact1 to group
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
// no pending memberships -> membership should be removed
LocalGroup.applyPendingMemberships(ab)
// no pending memberships -> membership should be removed
LocalGroup.applyPendingMemberships(ab)
// check group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
// check group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=?",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
}
}
@Test
fun testClearDirty_addCachedGroupMembership() {
val ab = addressBookGroupsAsCategories
val group = newGroup(ab)
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val contact1 =
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
// insert group membership, but no cached group membership
ab.provider!!.insert(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
put(GroupMembership.GROUP_ROW_ID, group.id)
// insert group membership, but no cached group membership
ab.provider!!.insert(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
put(GroupMembership.GROUP_ROW_ID, group.id)
}
)
group.clearDirty(null, null)
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
)
group.clearDirty(null, null)
// check cached group membership
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertTrue(cursor.moveToNext())
assertEquals(group.id, cursor.getLong(0))
assertEquals(contact1.id, cursor.getLong(1))
assertFalse(cursor.moveToNext())
}
}
@Test
fun testClearDirty_removeCachedGroupMembership() {
val ab = addressBookGroupsAsCategories
val group = newGroup(ab)
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
// insert cached group membership, but no group membership
ab.provider!!.insert(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
put(CachedGroupMembership.GROUP_ID, group.id)
// insert cached group membership, but no group membership
ab.provider!!.insert(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
put(CachedGroupMembership.GROUP_ID, group.id)
}
)
group.clearDirty(null, null)
// cached group membership should be gone
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
}
)
group.clearDirty(null, null)
// cached group membership should be gone
ab.provider!!.query(
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
null
)!!.use { cursor ->
assertFalse(cursor.moveToNext())
}
}
@Test
fun testMarkMembersDirty() {
val ab = addressBookGroupsAsCategories
val group = newGroup(ab)
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val contact1 =
LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
contact1.add()
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
val batch = BatchOperation(ab.provider!!)
contact1.addToGroup(batch, group.id!!)
batch.commit()
assertEquals(0, ab.findDirty().size)
group.markMembersDirty()
assertEquals(contact1.id, ab.findDirty().first().id)
assertEquals(0, ab.findDirty().size)
group.markMembersDirty()
assertEquals(contact1.id, ab.findDirty().first().id)
}
}
@Test
fun testPrepareForUpload() {
val group = newGroup()
assertNull(group.getContact().uid)
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { ab ->
val group = newGroup(ab)
assertNull(group.getContact().uid)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
}
}
// helpers
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
private fun newGroup(addressBook: LocalAddressBook): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"

View File

@@ -5,12 +5,8 @@
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.R
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
@@ -19,16 +15,13 @@ import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.junit.Assert.assertTrue
import java.io.FileNotFoundException
import java.util.Optional
import java.util.concurrent.atomic.AtomicInteger
import java.util.logging.Logger
/**
* A local address book that provides an easy way to set the group method in tests.
*/
class LocalTestAddressBook @AssistedInject constructor(
@Assisted account: Account,
@Assisted("addressBook") addressBookAccount: Account,
@@ -36,7 +29,7 @@ class LocalTestAddressBook @AssistedInject constructor(
@Assisted override val groupMethod: GroupMethod,
accountSettingsFactory: AccountSettings.Factory,
collectionRepository: DavCollectionRepository,
@ApplicationContext private val context: Context,
@ApplicationContext context: Context,
logger: Logger,
serviceRepository: DavServiceRepository,
syncFramework: SyncFrameworkIntegration
@@ -55,93 +48,12 @@ class LocalTestAddressBook @AssistedInject constructor(
@AssistedFactory
interface Factory {
fun create(account: Account, @Assisted("addressBook") addressBookAccount: Account, provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
}
override var readOnly: Boolean
get() = false
set(_) = throw NotImplementedError()
fun clear() {
for (contact in queryContacts(null, null))
contact.delete()
for (group in queryGroups(null, null))
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()
}
fun remove() {
val accountManager = AccountManager.get(context)
assertTrue(accountManager.removeAccountExplicitly(addressBookAccount))
}
companion object {
@dagger.hilt.EntryPoint
@InstallIn(SingletonComponent::class)
interface EntryPoint {
fun localTestAddressBookFactory(): Factory
}
val counter = AtomicInteger()
/**
* Creates a [at.bitfire.davdroid.resource.LocalTestAddressBook].
*
* Make sure to delete it with [at.bitfire.davdroid.resource.LocalTestAddressBook.remove] or [removeAll] after use.
*/
fun create(context: Context, account: Account, provider: ContentProviderClient, groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS): LocalTestAddressBook {
// create new address book account
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", context.getString(R.string.account_type_address_book))
val accountManager = AccountManager.get(context)
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
// return address book with this account
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(context)
val factory = entryPoint.localTestAddressBookFactory()
return factory.create(account, addressBookAccount, provider, groupMethod)
}
fun removeAll(context: Context) {
val accountManager = AccountManager.get(context)
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
accountManager.removeAccountExplicitly(account)
}
fun create(
account: Account,
@Assisted("addressBook") addressBookAccount: Account,
provider: ContentProviderClient,
groupMethod: GroupMethod
): LocalTestAddressBook
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import org.junit.Assert.assertTrue
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
/**
* Provides [LocalTestAddressBook]s in tests.
*/
class LocalTestAddressBookProvider @Inject constructor(
@ApplicationContext context: Context,
private val localTestAddressBookFactory: LocalTestAddressBook.Factory
) {
/**
* Counter for creating unique address book names.
*/
val counter = AtomicInteger()
val accountManager = AccountManager.get(context)
val accountType = context.getString(R.string.account_type_address_book)
/**
* Creates and provides a new temporary [LocalTestAddressBook] for the given [account] and
* removes it again.
*
* @param account The DAVx5 account to use for the address book
* @param provider Content provider needed to access and modify the address book
* @param groupMethod The group method the address book should use
* @param block Function to execute with the temporary available address book
*/
fun provide(
account: Account,
provider: ContentProviderClient,
groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS,
block: (LocalTestAddressBook) -> Unit
) {
// create new address book account
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", accountType)
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
val addressBook = localTestAddressBookFactory.create(account, addressBookAccount, provider, groupMethod)
// Empty the address book (Needed by LocalGroupTest)
for (contact in addressBook.queryContacts(null, null))
contact.delete()
for (group in addressBook.queryGroups(null, null))
group.delete()
try {
// provide address book
block(addressBook)
} finally {
// recreate account of provided address book, since the account might have been renamed
val renamedAccount = Account(addressBook.addressBookAccount.name, addressBook.addressBookAccount.type)
// remove address book account / address book
assertTrue(accountManager.removeAccountExplicitly(renamedAccount))
}
}
}

View File

@@ -13,7 +13,7 @@ import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -36,6 +36,9 @@ class CachedGroupMembershipHandlerTest {
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -49,8 +52,7 @@ class CachedGroupMembershipHandlerTest {
@Test
fun testMembership() {
val addressBook = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
try {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBook ->
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
@@ -58,8 +60,6 @@ class CachedGroupMembershipHandlerTest {
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
}, contact)
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
} finally {
addressBook.remove()
}
}

View File

@@ -13,7 +13,7 @@ import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -34,6 +34,9 @@ class GroupMembershipBuilderTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
@@ -51,15 +54,12 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
try {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
assertEquals(1, result.size)
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
}
} finally {
addressBookGroupsAsCategories.remove()
}
}
@@ -68,14 +68,11 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
try {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
}
} finally {
addressBookGroupsAsVCards.remove()
}
}

View File

@@ -13,7 +13,7 @@ import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
@@ -37,6 +37,9 @@ class GroupMembershipHandlerTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
var hiltRule = HiltAndroidRule(this)
@@ -50,8 +53,7 @@ class GroupMembershipHandlerTest {
@Test
fun testMembership_GroupsAsCategories() {
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
try {
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
val contact = Contact()
@@ -62,16 +64,13 @@ class GroupMembershipHandlerTest {
}, contact)
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
} finally {
addressBookGroupsAsCategories.remove()
}
}
@Test
fun testMembership_GroupsAsVCards() {
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
try {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
@@ -80,8 +79,6 @@ class GroupMembershipHandlerTest {
}, contact)
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
assertTrue(contact.categories.isEmpty())
} finally {
addressBookGroupsAsVCards.remove()
}
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
@@ -14,11 +13,13 @@ import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -37,50 +38,62 @@ import javax.inject.Inject
@HiltAndroidTest
class CollectionListRefresherTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
@Inject
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
@get:Rule
var hiltRule = HiltAndroidRule(this)
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
private val mockServer = MockWebServer()
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun setup() {
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer.dispatcher = TestDispatcher(logger)
mockServer.start()
client = HttpClient.Builder(context).build()
mockServer = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
// build HTTP client
client = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
// insert test service
val serviceId = db.serviceDao().insertOrReplace(
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
)
service = db.serviceDao().get(serviceId)!!
}
@After
fun teardown() {
fun tearDown() {
client.close()
mockServer.shutdown()
db.close()
}
@Test
fun testDiscoverHomesets() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
@@ -109,9 +122,7 @@ class CollectionListRefresherTest {
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
// save homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
@@ -138,8 +149,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -175,8 +184,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -216,8 +223,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save homeset in DB - which is empty (zero address books) on the serverside
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
@@ -244,8 +249,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save a homeset in DB
val homesetId = db.homeSetDao().insert(
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
@@ -283,8 +286,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -318,8 +319,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomelessCollections_deletesInaccessibleCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB - it is also inaccessible
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -341,8 +340,6 @@ class CollectionListRefresherTest {
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -375,8 +372,6 @@ class CollectionListRefresherTest {
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
@@ -410,8 +405,6 @@ class CollectionListRefresherTest {
@Test
fun refreshPrincipals_updatesPrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
@@ -445,8 +438,6 @@ class CollectionListRefresherTest {
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without collections in DB
db.principalDao().insert(
Principal(
@@ -468,159 +459,161 @@ class CollectionListRefresherTest {
@Test
fun shouldPreselect_none() {
val service = createTestService(Service.TYPE_CARDDAV)!!
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_PERSONAL"))
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
val service = createTestService(Service.TYPE_CARDDAV)!!
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_PERSONAL"))
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all_blacklisted() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
val url = mockServer.url("/addressbook-homeset/addressbook/")
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
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_PERSONAL"))
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
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_PERSONAL"))
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
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_PERSONAL"))
val collection = Collection(
0,
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
val refresher = refresherFactory.create(service, client.okHttpClient)
assertTrue(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonalButBlacklisted() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
mockkObject(settings) {
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
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_PERSONAL"))
val collection = Collection(
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
// Test helpers and dependencies
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)
}
companion object {
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
@@ -633,6 +626,7 @@ class CollectionListRefresherTest {
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher(
@@ -751,4 +745,5 @@ class CollectionListRefresherTest {
}
}
}
}

View File

@@ -4,17 +4,13 @@
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
@@ -53,8 +49,7 @@ class DavResourceFinderTest {
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@@ -62,40 +57,37 @@ class DavResourceFinderTest {
@Inject
lateinit var resourceFinderFactory: DavResourceFinder.Factory
@Inject
lateinit var settingsManager: SettingsManager
private val server = MockWebServer()
private lateinit var finder: DavResourceFinder
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var finder: DavResourceFinder
@Before
fun setup() {
fun setUp() {
hiltRule.inject()
server.dispatcher = TestDispatcher(logger)
server.start()
server = MockWebServer().apply {
dispatcher = TestDispatcher(logger)
start()
}
val credentials = Credentials(username = "mock", password = "12345".toCharArray())
client = httpClientBuilder
.authenticate(host = null, credentials = credentials)
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
val baseURI = URI.create("/")
val credentials = Credentials("mock", "12345")
finder = resourceFinderFactory.create(baseURI, credentials)
client = HttpClient.Builder(context)
.addAuthentication(null, credentials)
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun teardown() {
fun tearDown() {
client.close()
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()

View File

@@ -10,7 +10,7 @@ import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toSet
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -59,7 +59,7 @@ class SettingsManagerTest {
@Test
fun test_observerFlow_initialValue() = runBlocking {
fun test_observerFlow_initialValue() = runTest {
var counter = 0
val live = settingsManager.observerFlow {
if (counter++ == 0)
@@ -71,7 +71,7 @@ class SettingsManagerTest {
}
@Test
fun test_observerFlow_updatedValue() = runBlocking {
fun test_observerFlow_updatedValue() = runTest {
var counter = 0
val live = settingsManager.observerFlow {
when (counter++) {

View File

@@ -53,6 +53,7 @@ class AccountSettingsMigration17Test {
@Test
fun testMigrate_OldAddressBook_CollectionInDB() {
val localAddressBookUserDataUrl = "url"
TestAccount.provide(version = 16) { account ->
val accountManager = AccountManager.get(context)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
@@ -63,7 +64,7 @@ class AccountSettingsMigration17Test {
// address book has account + URL
val url = "https://example.com/address-book"
accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name)
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_URL, url)
accountManager.setAndVerifyUserData(addressBookAccount, localAddressBookUserDataUrl, url)
// and is known in database
db.serviceDao().insertOrReplace(
@@ -86,7 +87,7 @@ class AccountSettingsMigration17Test {
// migration renames address book, update account
addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter {
accountManager.getUserData(it, LocalAddressBook.USER_DATA_URL) == url
accountManager.getUserData(it, localAddressBookUserDataUrl) == url
}.first()
assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name)

View File

@@ -15,11 +15,8 @@ import at.bitfire.davdroid.resource.LocalAddressBook
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -34,29 +31,27 @@ class AccountSettingsMigration18Test {
@Inject @ApplicationContext
lateinit var context: Context
@MockK
@Inject
lateinit var db: AppDatabase
@InjectMockKs
@Inject
lateinit var migration: AccountSettingsMigration18
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
hiltRule.inject()
MockKAnnotations.init(this)
}
@Test
fun testMigrate_AddressBook_InvalidCollection() {
every { db.serviceDao() } returns mockk {
every { getByAccountAndType(any(), any()) } returns null
}
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
@@ -75,10 +70,6 @@ class AccountSettingsMigration18Test {
@Test
fun testMigrate_AddressBook_NoCollection() {
every { db.serviceDao() } returns mockk {
every { getByAccountAndType(any(), any()) } returns null
}
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
@@ -99,22 +90,18 @@ class AccountSettingsMigration18Test {
fun testMigrate_AddressBook_ValidCollection() {
val account = Account("test", "test")
every { db.serviceDao() } returns mockk {
every { getByAccountAndType(any(), any()) } returns Service(
id = 10,
accountName = account.name,
type = Service.TYPE_CARDDAV,
principal = null
)
}
every { db.collectionDao() } returns mockk {
every { getByService(10) } returns listOf(Collection(
id = 100,
serviceId = 10,
url = "http://example.com".toHttpUrl(),
type = Collection.TYPE_ADDRESSBOOK
))
}
db.serviceDao().insertOrReplace(Service(
id = 10,
accountName = account.name,
type = Service.TYPE_CARDDAV,
principal = null
))
db.collectionDao().insertOrUpdateByUrl(Collection(
id = 100,
serviceId = 10,
url = "http://example.com".toHttpUrl(),
type = Collection.TYPE_ADDRESSBOOK
))
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)

View File

@@ -13,16 +13,13 @@ import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.sync.AutomaticSyncManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -32,13 +29,13 @@ import javax.inject.Inject
class AccountSettingsMigration19Test {
@Inject @ApplicationContext
@SpyK
lateinit var context: Context
@MockK(relaxed = true)
@BindValue
@RelaxedMockK
lateinit var automaticSyncManager: AutomaticSyncManager
@InjectMockKs
@Inject
lateinit var migration: AccountSettingsMigration19
@Inject
@@ -47,6 +44,9 @@ class AccountSettingsMigration19Test {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
@@ -58,13 +58,6 @@ class AccountSettingsMigration19Test {
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
MockKAnnotations.init(this)
}
@After
fun tearDown() {
unmockkAll()
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import androidx.core.content.contentValuesOf
import androidx.core.database.getLongOrNull
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.resource.LocalTestAddressBookProvider
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration20Test {
@Inject
lateinit var calendarStore: LocalCalendarStore
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var migration: AccountSettingsMigration20
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@get:Rule
val permissionsRule = GrantPermissionRule.grant(
Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR
)
val accountManager by lazy { AccountManager.get(context) }
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testMigrateAddressBooks_UrlMatchesCollection() {
// set up legacy address-book with URL, but without collection ID
val account = Account("test", "test")
val url = "https://example.com/"
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null))
val collectionId = db.collectionDao().insert(Collection(
serviceId = 1,
type = Collection.Companion.TYPE_ADDRESSBOOK,
url = url.toHttpUrl()
))
localTestAddressBookProvider.provide(account, mockk(relaxed = true), GroupMethod.GROUP_VCARDS) { addressBook ->
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, AccountSettingsMigration20.ADDRESS_BOOK_USER_DATA_URL, url)
accountManager.setAndVerifyUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, null)
migration.migrateAddressBooks(account, cardDavServiceId = 1)
assertEquals(
collectionId,
accountManager.getUserData(addressBook.addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID).toLongOrNull()
)
}
}
@Test
fun testMigrateCalendars_UrlMatchesCollection() {
// set up legacy calendar with URL, but without collection ID
val account = Account("test", CalendarContract.ACCOUNT_TYPE_LOCAL)
val url = "https://example.com/"
db.serviceDao().insertOrReplace(Service(id = 1, accountName = account.name, type = Service.TYPE_CALDAV, principal = null))
val collectionId = db.collectionDao().insert(
Collection(
serviceId = 1,
type = Collection.Companion.TYPE_CALENDAR,
url = url.toHttpUrl()
)
)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!.use { provider ->
val uri = provider.insert(
Calendars.CONTENT_URI.asSyncAdapter(account),
contentValuesOf(
Calendars.ACCOUNT_NAME to account.name,
Calendars.ACCOUNT_TYPE to account.type,
Calendars.CALENDAR_DISPLAY_NAME to "Test",
Calendars.NAME to url,
Calendars.SYNC_EVENTS to 1
)
)!!
try {
migration.migrateCalendars(account, calDavServiceId = 1)
provider.query(uri.asSyncAdapter(account), arrayOf(Calendars._SYNC_ID), null, null, null)!!.use { cursor ->
cursor.moveToNext()
assertEquals(collectionId, cursor.getLongOrNull(0))
}
} finally {
provider.delete(uri.asSyncAdapter(account), null, null)
}
}
}
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.content.ContentProviderClient
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.CatchExceptionsRule
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalJtxCollection
import at.bitfire.davdroid.resource.LocalJtxCollectionStore
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.techbee.jtx.JtxContract
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.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.StringReader
import javax.inject.Inject
/**
* Ensure you have jtxBoard installed on the emulator, before running these tests. Otherwise they
* will be skipped.
*/
@HiltAndroidTest
class JtxSyncManagerTest {
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var localJtxCollectionStore: LocalJtxCollectionStore
@Inject
lateinit var jtxSyncManagerFactory: JtxSyncManager.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = CatchExceptionsRule(
GrantPermissionRule.grant(*TaskProvider.PERMISSIONS_JTX),
SecurityException::class
)
private val account = TestAccount.create()
private lateinit var provider: ContentProviderClient
private lateinit var syncManager: JtxSyncManager
private lateinit var localJtxCollection: LocalJtxCollection
@Before
fun setUp() {
hiltRule.inject()
// Check jtxBoard permissions were granted (+jtxBoard is installed); skip test otherwise
assumeTrue(PermissionUtils.havePermissions(context, TaskProvider.PERMISSIONS_JTX))
// Acquire the jtx content provider
provider = context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)!!
// Create dummy dependencies
val service = Service(0, account.name, Service.TYPE_CALDAV, null)
val serviceId = serviceRepository.insertOrReplaceBlocking(service)
val dbCollection = Collection(
0,
serviceId,
type = Collection.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
)
localJtxCollection = localJtxCollectionStore.create(provider, dbCollection)!!
syncManager = jtxSyncManagerFactory.jtxSyncManager(
account = account,
httpClient = httpClientBuilder.build(),
syncResult = SyncResult(),
localCollection = localJtxCollection,
collection = dbCollection,
resync = null
)
}
@After
fun tearDown() {
if (this::localJtxCollection.isInitialized)
localJtxCollectionStore.delete(localJtxCollection)
serviceRepository.deleteAllBlocking()
if (this::provider.isInitialized)
provider.closeCompat()
TestAccount.remove(account)
}
@Test
fun testProcessICalObject_addsVtodo() {
val calendar = "BEGIN:VCALENDAR\n" +
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Main VTODO)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"END:VTODO\n" +
"END:VCALENDAR"
// Should create "demo-calendar"
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))
// Verify main VTODO is created
val localJtxIcalObject = localJtxCollection.findByName("demo-calendar")!!
assertEquals("47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f", localJtxIcalObject.uid)
assertEquals("abc123", localJtxIcalObject.eTag)
assertEquals("Test Task (Main VTODO)", localJtxIcalObject.summary)
}
@Test
fun testProcessICalObject_addsRecurringVtodo_withoutDtStart() {
// Valid calendar example (See bitfireAT/davx5-ose#1265)
// Note: We don't support starting a recurrence from DUE (RFC 5545 leaves it open to interpretation)
val calendar = "BEGIN:VCALENDAR\n" +
"PRODID:-Vivaldi Calendar V1.0//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Exception)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"DUE;TZID=America/New_York:20250228T130000\n" +
"RECURRENCE-ID;TZID=America/New_York:20250228T130000\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"END:VTODO\n" +
"BEGIN:VTODO\n" +
"SUMMARY:Test Task (Main VTODO)\n" +
"DTSTAMP;VALUE=DATE-TIME:20250228T032800Z\n" +
"DUE;TZID=America/New_York:20250228T130000\n" + // Due date will NOT be assumed as start for recurrence
"SEQUENCE:1\n" +
"UID:47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f\n" +
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20250505T235959Z\n" +
"END:VTODO\n" +
"END:VCALENDAR"
// Create and store calendar
syncManager.processICalObject("demo-calendar", "abc123", StringReader(calendar))
// Verify main VTODO was created with RRULE present
val mainVtodo = localJtxCollection.findByName("demo-calendar")!!
assertEquals("Test Task (Main VTODO)", mainVtodo.summary)
assertEquals("FREQ=WEEKLY;UNTIL=20250505T235959Z;INTERVAL=1;BYDAY=FR", mainVtodo.rrule)
// Verify the RRULE exception instance was created with correct recurrence-id timezone
val vtodoException = localJtxCollection.findRecurInstance(
uid = "47a23c66-8c1a-4b44-bbe8-ebf33f8cf80f",
recurid = "20250228T130000"
)!!
assertEquals("Test Task (Exception)", vtodoException.summary)
assertEquals("America/New_York", vtodoException.recuridTimezone)
}
}

View File

@@ -8,7 +8,7 @@ import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalCollection
class LocalTestCollection(
override val collectionUrl: String = "http://example.com/test/"
override val dbCollectionId: Long = 0L
): LocalCollection<LocalTestResource> {
override val tag = "LocalTestCollection"

View File

@@ -24,16 +24,16 @@ import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.junit4.MockKRule
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.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
@@ -55,8 +55,7 @@ class SyncAdapterServicesTest {
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
@Inject
@@ -74,6 +73,9 @@ class SyncAdapterServicesTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
@get:Rule
val timeoutRule: Timeout = Timeout.seconds(5)
@@ -90,7 +92,6 @@ class SyncAdapterServicesTest {
@After
fun tearDown() {
TestAccount.remove(account)
unmockkAll()
}
@@ -109,7 +110,7 @@ class SyncAdapterServicesTest {
@Test
fun testSyncAdapter_onPerformSync_cancellation() {
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
@@ -121,17 +122,15 @@ class SyncAdapterServicesTest {
// 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()
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()
}
}

View File

@@ -20,15 +20,15 @@ import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.components.SingletonComponent
import io.mockk.every
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import okhttp3.mockwebserver.MockResponse
@@ -46,41 +46,45 @@ import javax.inject.Inject
@HiltAndroidTest
class SyncManagerTest {
@Module
@InstallIn(SingletonComponent::class)
object SyncManagerTestModule {
@Provides
fun davSyncStatsRepository(): DavSyncStatsRepository = mockk<DavSyncStatsRepository>(relaxed = true)
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var syncManagerFactory: TestSyncManager.Factory
@BindValue
@RelaxedMockK
lateinit var syncStatsRepository: DavSyncStatsRepository
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
private val server = MockWebServer()
private lateinit var account: Account
private lateinit var server: MockWebServer
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
server.start()
server = MockWebServer().apply {
start()
}
}
@After
@@ -94,30 +98,6 @@ class SyncManagerTest {
}
@Test
fun testGetDelayUntil_defaultOnNull() {
val now = Instant.now()
val delayUntil = SyncManager.getDelayUntil(null).epochSecond
val default = now.plusSeconds(SyncManager.DELAY_UNTIL_DEFAULT).epochSecond
assertWithin(default, delayUntil, 5)
}
@Test
fun testGetDelayUntil_reducesToMax() {
val now = Instant.now()
val delayUntil = SyncManager.getDelayUntil(now.plusSeconds(10*24*60*60)).epochSecond
val max = now.plusSeconds(SyncManager.DELAY_UNTIL_MAX).epochSecond
assertWithin(max, delayUntil, 5)
}
@Test
fun testGetDelayUntil_increasesToMin() {
val delayUntil = SyncManager.getDelayUntil(Instant.EPOCH).epochSecond
val min = Instant.now().plusSeconds(SyncManager.DELAY_UNTIL_MIN).epochSecond
assertWithin(min, delayUntil, 5)
}
private fun queryCapabilitiesResponse(cTag: String? = null): MockResponse {
val body = StringBuilder()
body.append(
@@ -142,8 +122,9 @@ class SyncManagerTest {
.setBody(body.toString())
}
@Test
fun testPerformSync_503RetryAfter_DelaySeconds() {
fun testPerformSync_503RetryAfter_DelaySeconds() = runTest {
server.enqueue(MockResponse()
.setResponseCode(503)
.setHeader("Retry-After", "60")) // 60 seconds
@@ -160,7 +141,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_FirstSync_Empty() {
fun testPerformSync_FirstSync_Empty() = runTest {
val collection = LocalTestCollection() /* no last known ctag */
server.enqueue(queryCapabilitiesResponse())
@@ -175,7 +156,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_UploadNewMember_ETagOnPut() {
fun testPerformSync_UploadNewMember_ETagOnPut() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
@@ -218,7 +199,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_UploadModifiedMember_ETagOnPut() {
fun testPerformSync_UploadModifiedMember_ETagOnPut() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
@@ -265,7 +246,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_UploadModifiedMember_NoETagOnPut() {
fun testPerformSync_UploadModifiedMember_NoETagOnPut() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
@@ -310,7 +291,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() {
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
@@ -356,7 +337,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_NoopOnMemberWithSameETag() {
fun testPerformSync_NoopOnMemberWithSameETag() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
entries += LocalTestResource().apply {
@@ -393,7 +374,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_DownloadNewMember() {
fun testPerformSync_DownloadNewMember() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
}
@@ -427,7 +408,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_DownloadUpdatedMember() {
fun testPerformSync_DownloadUpdatedMember() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
@@ -465,7 +446,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_RemoveVanishedMember() {
fun testPerformSync_RemoveVanishedMember() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
@@ -485,7 +466,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_CTagDidntChange() {
fun testPerformSync_CTagDidntChange() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
}
@@ -507,16 +488,13 @@ class SyncManagerTest {
private fun syncManager(
localCollection: LocalTestCollection,
syncResult: SyncResult = SyncResult(),
collection: Collection = mockk<Collection>() {
collection: Collection = mockk<Collection>(relaxed = true) {
every { id } returns 1
every { url } returns server.url("/")
}
) = syncManagerFactory.create(
account,
accountSettingsFactory.create(account),
arrayOf(),
"TestAuthority",
HttpClient.Builder(context).build(),
httpClientBuilder.build(),
syncResult,
localCollection,
collection

View File

@@ -17,7 +17,6 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -38,7 +37,7 @@ class SyncerTest {
@SpyK
@InjectMockKs
var syncer = TestSyncer(mockk(relaxed = true), emptyArray(), SyncResult(), dataStore)
var syncer = TestSyncer(mockk(relaxed = true), null, SyncResult(), dataStore)
@Test
@@ -66,9 +65,10 @@ class SyncerTest {
@Test
fun testUpdateCollections_deletesCollection() {
val localCollection = mockk<LocalTestCollection>()
every { localCollection.collectionUrl } returns "http://delete.the/collection"
every { localCollection.title } returns "Collection to be deleted locally"
val localCollection = mockk<LocalTestCollection> {
every { dbCollectionId } returns 0L
every { title } returns "Collection to be deleted locally"
}
// Should delete the localCollection if dbCollection (remote) does not exist
val localCollections = mutableListOf(localCollection)
@@ -81,12 +81,14 @@ class SyncerTest {
@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"
val localCollection = mockk<LocalTestCollection> {
every { dbCollectionId } returns 0L
every { title } returns "The Local Collection"
}
val dbCollection = mockk<Collection> {
every { id } returns 0L
}
val dbCollections = mapOf(0L to dbCollection)
// Should update the localCollection if it exists
val result = syncer.updateCollections(provider, listOf(localCollection), dbCollections)
@@ -99,13 +101,13 @@ class SyncerTest {
@Test
fun testUpdateCollections_findsNewCollection() {
val dbCollection = mockk<Collection> {
every { url } returns "http://newly.found/collection".toHttpUrl()
every { id } returns 0L
}
val localCollections = listOf(mockk<LocalTestCollection> {
every { collectionUrl } returns "http://newly.found/collection"
every { dbCollectionId } returns 0L
})
val dbCollections = listOf(dbCollection)
val dbCollectionsMap = mapOf(dbCollection.url to dbCollection)
val dbCollectionsMap = mapOf(dbCollection.id to dbCollection)
every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections
// Should return the new collection, because it was not updated
@@ -113,7 +115,7 @@ class SyncerTest {
// Updated local collection list contain new entry
assertEquals(1, result.size)
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
assertEquals(dbCollection.id, result[0].dbCollectionId)
}
@@ -134,14 +136,14 @@ class SyncerTest {
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
0L to dbCollection1,
1L to dbCollection2
)
val localCollection1 = mockk<LocalTestCollection>()
val localCollection2 = mockk<LocalTestCollection>()
val localCollection1 = mockk<LocalTestCollection> { every { dbCollectionId } returns 0L }
val localCollection2 = mockk<LocalTestCollection> { every { dbCollectionId } returns 1L }
val localCollections = listOf(localCollection1, localCollection2)
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
every { localCollection1.dbCollectionId } returns 0L
every { localCollection2.dbCollectionId } returns 1L
every { syncer.syncCollection(provider, any(), any()) } just runs
// Should call the collection content sync on both collections
@@ -153,19 +155,16 @@ class SyncerTest {
// Test helpers
class TestSyncer (
class TestSyncer(
account: Account,
extras: Array<String>,
resyncType: ResyncType?,
syncResult: SyncResult,
theDataStore: LocalTestStore
) : Syncer<LocalTestStore, LocalTestCollection>(account, extras, syncResult) {
) : Syncer<LocalTestStore, LocalTestCollection>(account, resyncType, syncResult) {
override val dataStore: LocalTestStore =
theDataStore
override val authority: String
get() = throw NotImplementedError()
override val serviceType: String
get() = throw NotImplementedError()
@@ -187,6 +186,13 @@ class SyncerTest {
class LocalTestStore : LocalDataStore<LocalTestCollection> {
override val authority: String
get() = throw NotImplementedError()
override fun acquireContentProvider(): ContentProviderClient? {
throw NotImplementedError()
}
override fun create(
provider: ContentProviderClient,
fromCollection: Collection

View File

@@ -11,13 +11,14 @@ import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.di.SyncDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import okhttp3.HttpUrl
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
@@ -25,31 +26,26 @@ import org.junit.Assert.assertEquals
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
@Assisted collection: Collection,
@SyncDispatcher syncDispatcher: CoroutineDispatcher
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
account,
accountSettings,
httpClient,
extras,
authority,
SyncDataType.EVENTS,
syncResult,
localCollection,
collection
collection,
resync = null,
syncDispatcher
) {
@AssistedFactory
interface Factory {
fun create(
account: Account,
accountSettings: AccountSettings,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
@@ -63,7 +59,7 @@ class TestSyncManager @AssistedInject constructor(
}
var didQueryCapabilities = false
override fun queryCapabilities(): SyncState? {
override suspend fun queryCapabilities(): SyncState? {
if (didQueryCapabilities)
throw IllegalStateException("queryCapabilities() must not be called twice")
didQueryCapabilities = true
@@ -89,7 +85,7 @@ class TestSyncManager @AssistedInject constructor(
var listAllRemoteResult = emptyList<Pair<Response, Response.HrefRelation>>()
var didListAllRemote = false
override fun listAllRemote(callback: MultiResponseCallback) {
override suspend fun listAllRemote(callback: MultiResponseCallback) {
if (didListAllRemote)
throw IllegalStateException("listAllRemote() must not be called twice")
didListAllRemote = true
@@ -99,7 +95,7 @@ class TestSyncManager @AssistedInject constructor(
var assertDownloadRemote = emptyMap<HttpUrl, String>()
var didDownloadRemote = false
override fun downloadRemote(bunch: List<HttpUrl>) {
override suspend fun downloadRemote(bunch: List<HttpUrl>) {
didDownloadRemote = true
assertEquals(assertDownloadRemote.keys.toList(), bunch)

View File

@@ -15,7 +15,6 @@ import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
@@ -66,9 +65,6 @@ class AccountsCleanupWorkerTest {
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account("Fancy address book account", addressBookAccountType)
// Make sure there are no address books
LocalTestAddressBook.removeAll(context)
}
@After

View File

@@ -19,9 +19,10 @@ import at.bitfire.davdroid.sync.account.TestAccount
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -41,6 +42,9 @@ class PeriodicSyncWorkerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
lateinit var account: Account
@Before
@@ -58,7 +62,7 @@ class PeriodicSyncWorkerTest {
@Test
fun doWork_cancelsItselfOnInvalidAccount() {
fun doWork_cancelsItselfOnInvalidAccount() = runTest {
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
// Run PeriodicSyncWorker as TestWorker
@@ -68,7 +72,7 @@ class PeriodicSyncWorkerTest {
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)
// mock WorkManager to observe cancellation call
// observe WorkManager cancellation call
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
@@ -79,9 +83,7 @@ class PeriodicSyncWorkerTest {
syncWorkerFactory.create(appContext, workerParameters)
})
.build()
val result = runBlocking {
testWorker.doWork()
}
val result = testWorker.doWork()
assertTrue(result is ListenableWorker.Result.Failure)
// verify that worker called WorkManager.cancelWorkById(<its ID>)

View File

@@ -0,0 +1,94 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.push.PushRegistrationManager
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coVerify
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class CollectionSelectedUseCaseTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
val collection = Collection(
id = 2,
serviceId = 1,
type = Collection.Companion.TYPE_CALENDAR,
url = "https://example.com".toHttpUrl()
)
@Inject
lateinit var collectionRepository: DavCollectionRepository
val service = Service(
id = 1,
type = Service.Companion.TYPE_CALDAV,
accountName = "test@example.com"
)
@BindValue
@RelaxedMockK
lateinit var pushRegistrationManager: PushRegistrationManager
@Inject
lateinit var serviceRepository: DavServiceRepository
@BindValue
@RelaxedMockK
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var useCase: CollectionSelectedUseCase
@Before
fun setUp() {
hiltRule.inject()
serviceRepository.insertOrReplaceBlocking(service)
collectionRepository.insertOrUpdateByUrl(collection)
}
@After
fun tearDown() {
serviceRepository.deleteAllBlocking()
}
@Test
fun testHandleWithDelay() = runTest {
useCase.handleWithDelay(collectionId = collection.id)
advanceUntilIdle()
coVerify {
syncWorkerManager.enqueueOneTimeAllAuthorities(any())
pushRegistrationManager.update(service.id)
}
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import android.net.Uri
import org.junit.Assert.assertEquals
import org.junit.Test
class LoginActivityTest {
@Test
fun loginInfoFromIntent() {
val intent = Intent().apply {
data = Uri.parse("https://example.com/nextcloud")
putExtra(LoginActivity.EXTRA_USERNAME, "user")
putExtra(LoginActivity.EXTRA_PASSWORD, "password")
}
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com/nextcloud", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_withPort() {
val intent = Intent().apply {
data = Uri.parse("https://example.com:444/nextcloud")
putExtra(LoginActivity.EXTRA_USERNAME, "user")
putExtra(LoginActivity.EXTRA_PASSWORD, "password")
}
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com:444/nextcloud", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_implicit() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com/path"))
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com/path", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_implicit_withPort() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("davx5://user:password@example.com:0/path"))
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals("https://example.com:0/path", loginInfo.baseUri.toString())
assertEquals("user", loginInfo.credentials!!.username)
assertEquals("password", loginInfo.credentials.password?.concatToString())
}
@Test
fun loginInfoFromIntent_implicit_email() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:user@example.com"))
val loginInfo = LoginActivity.loginInfoFromIntent(intent)
assertEquals(null, loginInfo.baseUri)
assertEquals("user@example.com", loginInfo.credentials!!.username)
assertEquals(null, loginInfo.credentials.password?.concatToString())
}
}

View File

@@ -30,8 +30,8 @@ class CredentialsStoreTest {
@Test
fun testSetGetDelete() {
store.setCredentials(0, Credentials(username = "myname", password = "12345"))
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))
store.setCredentials(0, Credentials(username = "myname", password = "12345".toCharArray()))
assertEquals(Credentials(username = "myname", password = "12345".toCharArray()), store.getCredentials(0))
store.setCredentials(0, null)
assertNull(store.getCredentials(0))

View File

@@ -6,7 +6,6 @@ package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
@@ -14,6 +13,8 @@ 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 io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import okhttp3.CookieJar
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -25,6 +26,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
@@ -34,75 +36,82 @@ class DavDocumentsProviderTest {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var credentialsStore: CredentialsStore
@Inject
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var logger: java.util.logging.Logger
lateinit var testDispatcher: TestDispatcher
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
@Before
fun setUp() {
hiltRule.inject()
}
server = MockWebServer().apply {
dispatcher = testDispatcher
start()
}
private var mockServer = MockWebServer()
private lateinit var client: HttpClient
@Before
fun mockServerSetup() {
// Start mock web server
mockServer.dispatcher = TestDispatcher(logger)
mockServer.start()
client = HttpClient.Builder(context).build()
client = httpClientBuilder.build()
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun cleanUp() {
mockServer.shutdown()
db.close()
fun tearDown() {
client.close()
server.shutdown()
}
@Test
fun testDoQueryChildren_insert() {
fun testDoQueryChildren_insert() = runTest {
// Create parent and root in database
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(id)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
// Query
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
// Assert new children were inserted into db
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
}
@Test
fun testDoQueryChildren_update() {
fun testDoQueryChildren_update() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
// Create a folder
@@ -120,23 +129,25 @@ class DavDocumentsProviderTest {
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
// Query - should update the parent displayname and folder name
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
// Assert parent and children were updated in database
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
}
@Test
fun testDoQueryChildren_delete() {
fun testDoQueryChildren_delete() = runTest {
// Create parent and root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
// Create a folder
val folderId = db.webDavDocumentDao().insert(
@@ -145,22 +156,24 @@ class DavDocumentsProviderTest {
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent)
// Assert folder got deleted
assertEquals(null, db.webDavDocumentDao().get(folderId))
}
@Test
fun testDoQueryChildren_updateTwoParentsSimultaneous() {
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
// Create root in database
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", mockServer.url(PATH_WEBDAV_ROOT)))
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
val webDavMount = db.webDavMountDao().getById(mountId)
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
val cookieStore = mutableMapOf<Long, CookieJar>()
// Create two parents
// Create two directories
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
@@ -169,10 +182,12 @@ class DavDocumentsProviderTest {
assertEquals("parent2", parent2.name)
// Query - find children of two nodes simultaneously
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent1)
DavDocumentsProvider.DavDocumentsActor(context, db, logger, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent2)
val actor = davDocumentsActorFactory.create(
cookieStore = mutableMapOf<Long, CookieJar>(),
credentialsStore = credentialsStore
)
actor.queryChildren(parent1)
actor.queryChildren(parent2)
// Assert the two folders names have changed
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
@@ -182,8 +197,8 @@ class DavDocumentsProviderTest {
// mock server
class TestDispatcher(
private val logger: java.util.logging.Logger
class TestDispatcher @Inject constructor(
private val logger: Logger
): Dispatcher() {
data class Resource(
@@ -192,10 +207,10 @@ class DavDocumentsProviderTest {
)
override fun dispatch(request: RecordedRequest): MockResponse {
logger.info("Request: $request")
val requestPath = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val propsMap = mutableMapOf(
PATH_WEBDAV_ROOT to arrayOf(
Resource("",
@@ -239,7 +254,6 @@ class DavDocumentsProviderTest {
responses +
"</multistatus>"
logger.info("Query path: $requestPath")
logger.info("Response: $multistatus")
return MockResponse()
.setResponseCode(207)

View File

@@ -6,7 +6,7 @@ package at.bitfire.davdroid.webdav
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertFalse
@@ -34,13 +34,13 @@ class WebDavMountRepositoryTest {
val url = web.url("/")
@Test
fun testHasWebDav_NoDavHeader() = runBlocking {
fun testHasWebDav_NoDavHeader() = runTest {
web.enqueue(MockResponse().setResponseCode(200))
assertFalse(repository.hasWebDav(url, null))
}
@Test
fun testHasWebDav_DavClass1() = runBlocking {
fun testHasWebDav_DavClass1() = runTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1"))
@@ -48,7 +48,7 @@ class WebDavMountRepositoryTest {
}
@Test
fun testHasWebDav_DavClass2() = runBlocking {
fun testHasWebDav_DavClass2() = runTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1, 2"))
@@ -56,7 +56,7 @@ class WebDavMountRepositoryTest {
}
@Test
fun testHasWebDav_DavClass3() = runBlocking {
fun testHasWebDav_DavClass3() = runTest {
web.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("DAV: 1, 3"))

View File

@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
@@ -41,6 +45,7 @@
<application
android:name=".App"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -62,7 +67,7 @@
<activity android:name=".ui.intro.IntroActivity" />
<activity
android:name=".ui.AccountsActivity"
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -73,12 +78,12 @@
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:parentActivityName=".ui.AccountsActivity"/>
android:parentActivityName=".ui.MainActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
@@ -106,7 +111,7 @@
<activity
android:name=".ui.setup.LoginActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@@ -134,7 +139,7 @@
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
</activity>
<activity
@@ -156,7 +161,7 @@
<activity
android:name=".ui.webdav.WebdavMountsActivity"
android:exported="true"
android:parentActivityName=".ui.AccountsActivity" />
android:parentActivityName=".ui.MainActivity" />
<activity
android:name=".ui.webdav.AddWebdavMountActivity"
android:parentActivityName=".ui.webdav.WebdavMountsActivity"
@@ -268,18 +273,16 @@
android:resource="@xml/debug_paths" />
</provider>
<!-- UnifiedPush receiver -->
<receiver android:exported="true" android:enabled="true" android:name=".push.UnifiedPushReceiver" tools:ignore="ExportedReceiver">
<!-- UnifiedPush -->
<service android:exported="false" android:name=".push.UnifiedPushService">
<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"/>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</receiver>
</service>
<!-- Widgets -->
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
<receiver android:name=".ui.widget.LabeledSyncButtonWidgetReceiver"
android:label="@string/widget_labeled_sync_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -287,7 +290,18 @@
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info_sync_button" />
android:resource="@xml/widget_info_labeled_sync_button" />
</receiver>
<receiver android:name=".ui.widget.IconSyncButtonWidgetReceiver"
android:label="@string/widget_icon_sync_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info_icon_sync_button" />
</receiver>
</application>

View File

@@ -1 +1 @@
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces","pikamoku"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Waldmeisda","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}

View File

@@ -22,11 +22,18 @@ object Constants {
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
val MANUAL_URL = "https://manual.davx5.com".toUri()
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
const val MANUAL_PATH_INTRODUCTION = "introduction.html"
const val MANUAL_FRAGMENT_AUTHENTICATION_METHODS = "authentication-methods"
const val MANUAL_PATH_SETTINGS = "settings.html"
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
const val MANUAL_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
const val MANUAL_PATH_WEBDAV_PUSH = "webdav_push.html"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()

View File

@@ -7,7 +7,7 @@ package at.bitfire.davdroid
import java.util.Collections
class TextTable(
vararg val headers: String
val headers: List<String>
) {
companion object {
@@ -18,10 +18,12 @@ class TextTable(
}
constructor(vararg headers: String): this(headers.toList())
private val lines = mutableListOf<Array<String>>()
fun addLine(vararg values: Any?) {
fun addLine(values: List<Any?>) {
if (values.size != headers.size)
throw IllegalArgumentException("Table line must have ${headers.size} column(s)")
lines += values.map {
@@ -29,6 +31,8 @@ class TextTable(
}.toTypedArray()
}
fun addLine(vararg values: Any?) = addLine(values.toList())
override fun toString(): String {
val sb = StringBuilder()

View File

@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.app.NotificationCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration
import androidx.room.Database
@@ -21,8 +22,10 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.db.migration.*
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.db.migration.AutoMigration12
import at.bitfire.davdroid.db.migration.AutoMigration16
import at.bitfire.davdroid.db.migration.AutoMigration18
import at.bitfire.davdroid.ui.MainActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
import dagger.Provides
@@ -40,7 +43,9 @@ import javax.inject.Singleton
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 16, autoMigrations = [
], exportSchema = true, version = 18, autoMigrations = [
AutoMigration(from = 17, to = 18, spec = AutoMigration18::class),
AutoMigration(from = 16, to = 17), // collection: add VAPID key
AutoMigration(from = 15, to = 16, spec = AutoMigration16::class),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 13, to = 14),
@@ -74,13 +79,17 @@ abstract class AppDatabase: RoomDatabase() {
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
val launcherIntent = Intent(context, MainActivity::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))
.setContentIntent(
TaskStackBuilder.create(context)
.addNextIntent(launcherIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.setAutoCancel(true)
.build()
}

View File

@@ -21,6 +21,7 @@ import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.push.WebPush
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
@@ -136,6 +137,9 @@ data class Collection(
@ColumnInfo(defaultValue = "0")
val supportsWebPush: Boolean = false,
/** WebDAV-Push: VAPID public key */
val pushVapidKey: String? = null,
/** WebDAV-Push subscription URL */
val pushSubscription: String? = null,
@@ -223,8 +227,13 @@ data class Collection(
// WebDAV-Push
var supportsWebPush = false
var vapidPublicKey: String? = null
dav[PushTransports::class.java]?.let { pushTransports ->
supportsWebPush = pushTransports.hasWebPush()
for (transport in pushTransports.transports)
if (transport is WebPush) {
supportsWebPush = true
vapidPublicKey = transport.vapidPublicKey?.key
}
}
val pushTopic = dav[Topic::class.java]?.topic
@@ -242,6 +251,7 @@ data class Collection(
supportsVJOURNAL = supportsVJOURNAL,
source = source,
supportsWebPush = supportsWebPush,
pushVapidKey = vapidPublicKey,
pushTopic = pushTopic
)
}
@@ -249,6 +259,7 @@ data class Collection(
}
// calculated properties
fun title() = displayName ?: url.lastSegment
fun readOnly() = forceReadOnly || !privWriteContent

View File

@@ -20,23 +20,29 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE id=:id")
fun get(id: Long): Collection?
@Query("SELECT * FROM collection WHERE id=:id")
suspend fun getAsync(id: Long): Collection?
@Query("SELECT * FROM collection WHERE id=:id")
fun getFlow(id: Long): Flow<Collection?>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Collection>
suspend fun getByService(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId")
fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List<Collection>
@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>
fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List<Collection>
@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
fun getSyncableByPushTopic(topic: String): Collection?
suspend fun getSyncableByPushTopic(topic: String): Collection?
@Query("SELECT pushVapidKey FROM collection WHERE serviceId=:serviceId AND pushVapidKey IS NOT NULL LIMIT 1")
suspend fun getFirstVapidKey(serviceId: Long): String?
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
suspend fun anyOfType(serviceId: Long, type: String): Boolean
suspend fun anyOfType(serviceId: Long, @CollectionType type: String): Boolean
@Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL")
suspend fun anyPushCapable(): Boolean
@@ -48,13 +54,13 @@ interface CollectionDao {
*/
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " +
"AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName COLLATE NOCASE, URL COLLATE NOCASE")
fun pageByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
fun getByServiceAndSync(serviceId: Long): List<Collection>
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName COLLATE NOCASE, collection.url COLLATE NOCASE")
fun pagePersonalByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url")
fun getByServiceAndUrl(serviceId: Long, url: String): Collection?
@@ -72,11 +78,14 @@ interface CollectionDao {
* 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 serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL")
suspend fun getPushCapableSyncCollections(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync")
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL")
suspend fun getPushRegistered(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND pushSubscription IS NOT NULL AND NOT sync")
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long): List<Collection>
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@@ -91,7 +100,7 @@ interface CollectionDao {
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
suspend fun updateSync(id: Long, sync: Boolean)
@@ -116,4 +125,4 @@ interface CollectionDao {
@Delete
fun delete(collection: Collection)
}
}

View File

@@ -8,7 +8,7 @@ import net.openid.appauth.AuthState
data class Credentials(
val username: String? = null,
val password: String? = null,
val password: CharArray? = null,
val certificateAlias: String? = null,
@@ -32,4 +32,27 @@ data class Credentials(
return "Credentials(" + s.joinToString(", ") + ")"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Credentials
if (username != other.username) return false
if (!password.contentEquals(other.password)) return false
if (certificateAlias != other.certificateAlias) return false
if (authState != other.authState) return false
return true
}
override fun hashCode(): Int {
var result = username?.hashCode() ?: 0
result = 31 * result + (password?.contentHashCode() ?: 0)
result = 31 * result + (certificateAlias?.hashCode() ?: 0)
result = 31 * result + (authState?.hashCode() ?: 0)
return result
}
}

View File

@@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.Flow
interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE id=:homesetId")
fun getById(homesetId: Long): HomeSet
fun getById(homesetId: Long): HomeSet?
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND url=:url")
fun getByUrl(serviceId: Long, url: String): HomeSet?
@@ -24,7 +24,7 @@ interface HomeSetDao {
fun getByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE")
fun getBindableByAccountAndServiceTypeFlow(accountName: String, serviceType: String): Flow<List<HomeSet>>
fun getBindableByAccountAndServiceTypeFlow(accountName: String, @ServiceType serviceType: String): Flow<List<HomeSet>>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByServiceFlow(serviceId: Long): Flow<List<HomeSet>>

View File

@@ -33,7 +33,7 @@ data class Service(
@ServiceType
val type: String,
val principal: HttpUrl?
val principal: HttpUrl? = null
) {
companion object {

View File

@@ -14,10 +14,10 @@ import kotlinx.coroutines.flow.Flow
interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndType(accountName: String, type: String): Service?
suspend fun getByAccountAndType(accountName: String, @ServiceType type: String): Service?
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndTypeFlow(accountName: String, type: String): Flow<Service?>
fun getByAccountAndTypeFlow(accountName: String, @ServiceType type: String): Flow<Service?>
@Query("SELECT id FROM service WHERE accountName=:accountName")
suspend fun getIdsByAccountAsync(accountName: String): List<Long>
@@ -25,6 +25,12 @@ interface ServiceDao {
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): Service?
@Query("SELECT * FROM service WHERE id=:id")
suspend fun getAsync(id: Long): Service?
@Query("SELECT * FROM service")
suspend fun getAll(): List<Service>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(service: Service): Long

View File

@@ -14,7 +14,7 @@ import androidx.room.PrimaryKey
ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE)
],
indices = [
Index("collectionId", "authority", unique = true),
Index(value = ["collectionId", "dataType"], unique = true)
]
)
data class SyncStats(
@@ -22,7 +22,7 @@ data class SyncStats(
val id: Long,
val collectionId: Long,
val authority: String,
val dataType: String,
val lastSync: Long
)

View File

@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow
interface SyncStatsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(syncStats: SyncStats)
suspend fun insertOrReplace(syncStats: SyncStats)
@Query("SELECT * FROM syncstats WHERE collectionId=:id")
fun getByCollectionIdFlow(id: Long): Flow<List<SyncStats>>

View File

@@ -32,6 +32,7 @@ import java.time.Instant
Index("parentId")
]
)
// If any column name is modified, also change it in [DavDocumentsProvider$queryChildDocuments]
data class WebDavDocument(
@PrimaryKey(autoGenerate = true)
@@ -110,7 +111,7 @@ data class WebDavDocument(
return bundle
}
fun toHttpUrl(db: AppDatabase): HttpUrl {
suspend fun toHttpUrl(db: AppDatabase): HttpUrl {
val mount = db.webDavMountDao().getById(mountId)
val segments = mutableListOf(name)

View File

@@ -4,12 +4,13 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.RoomRawQuery
import androidx.room.Transaction
import androidx.room.Update
@@ -22,11 +23,23 @@ interface WebDavDocumentDao {
@Query("SELECT * FROM webdav_document WHERE mountId=:mountId AND (parentId=:parentId OR (parentId IS NULL AND :parentId IS NULL)) AND name=:name")
fun getByParentAndName(mountId: Long, parentId: Long?, name: String): WebDavDocument?
@Query("SELECT * FROM webdav_document WHERE parentId=:parentId")
fun getChildren(parentId: Long): List<WebDavDocument>
@RawQuery
fun query(query: RoomRawQuery): List<WebDavDocument>
@Query("SELECT * FROM webdav_document WHERE parentId IS NULL")
fun getRootsLive(): LiveData<List<WebDavDocument>>
/**
* Gets all the child documents from a given parent id.
*
* @param parentId The id of the parent document to get the documents from.
* @param orderBy If desired, a SQL clause to specify how to order the results.
* **The caller is responsible for the correct formatting of this argument. Syntax won't be validated!**
*/
fun getChildren(parentId: Long, orderBy: String = DEFAULT_ORDER): List<WebDavDocument> {
return query(
RoomRawQuery("SELECT * FROM webdav_document WHERE parentId = ? ORDER BY $orderBy") {
it.bindLong(1, parentId)
}
)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(document: WebDavDocument): Long
@@ -39,7 +52,7 @@ interface WebDavDocumentDao {
@Update
fun update(document: WebDavDocument)
@Delete
fun delete(document: WebDavDocument)
@@ -80,4 +93,15 @@ interface WebDavDocumentDao {
return newDoc.copy(id = id)
}
companion object {
/**
* Default ORDER BY value to use when content provider doesn't specify a sort order:
* _sort by name (directories first)_
*/
const val DEFAULT_ORDER = "isDirectory DESC, name ASC"
}
}

View File

@@ -17,16 +17,16 @@ interface WebDavMountDao {
suspend fun deleteAsync(mount: WebDavMount)
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
fun getAll(): List<WebDavMount>
suspend fun getAll(): List<WebDavMount>
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
fun getAllFlow(): Flow<List<WebDavMount>>
@Query("SELECT * FROM webdav_mount WHERE id=:id")
fun getById(id: Long): WebDavMount
suspend fun getById(id: Long): WebDavMount
@Insert
fun insert(mount: WebDavMount): Long
suspend fun insert(mount: WebDavMount): Long
// complex queries

View File

@@ -0,0 +1,81 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.ical4android.TaskProvider
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Inject
/**
* Renames syncstats.authority to dataType, and maps values to SyncDataType enum names.
*/
@ProvidedAutoMigrationSpec
@RenameColumn(tableName = "syncstats", fromColumnName = "authority", toColumnName = "dataType")
class AutoMigration18 @Inject constructor() : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
// Drop old unique index
db.execSQL("DROP INDEX IF EXISTS index_syncstats_collectionId_authority")
val seen = mutableSetOf<Pair<Long, String>>() // (collectionId, dataType)
db.query(
"SELECT id, collectionId, dataType, lastSync FROM syncstats ORDER BY lastSync DESC"
).use { cursor ->
val idIndex = cursor.getColumnIndex("id")
val collectionIdIndex = cursor.getColumnIndex("collectionId")
val authorityIndex = cursor.getColumnIndex("dataType")
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val collectionId = cursor.getLong(collectionIdIndex)
val authority = cursor.getString(authorityIndex)
val dataType = when (authority) {
ContactsContract.AUTHORITY -> SyncDataType.CONTACTS.name
CalendarContract.AUTHORITY -> SyncDataType.EVENTS.name
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.TasksOrg.authority,
TaskProvider.ProviderName.OpenTasks.authority -> SyncDataType.TASKS.name
else -> {
db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id))
continue
}
}
val keyValue = collectionId to dataType
if (seen.contains(keyValue)) {
db.execSQL("DELETE FROM syncstats WHERE id = ?", arrayOf(id))
} else {
db.execSQL("UPDATE syncstats SET dataType = ? WHERE id = ?", arrayOf<Any>(dataType, id))
seen.add(keyValue)
}
}
}
// Create new unique index
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_syncstats_collectionId_dataType ON syncstats (collectionId, dataType)")
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AutoMigrationModule {
@Binds
@IntoSet
abstract fun provide(impl: AutoMigration18): AutoMigrationSpec
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class DefaultDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class IoDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class MainDispatcher
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class SyncDispatcher
@Module
@InstallIn(SingletonComponent::class)
class CoroutineDispatchersModule {
@Provides
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@MainDispatcher
fun mainDispatcher(): CoroutineDispatcher = Dispatchers.Main
/**
* A dispatcher for background sync operations. They're not run on [ioDispatcher] because there can
* be many long-blocking operations at the same time which shouldn't never block other I/O operations
* like database access for the UI.
*
* It uses the I/O dispatcher and limits the number of parallel operations to the number of available processors.
*/
@Provides
@SyncDispatcher
@Singleton
fun syncDispatcher(): CoroutineDispatcher =
Dispatchers.IO.limitedParallelism(Runtime.getRuntime().availableProcessors())
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
class CoroutineScopesModule {
@Singleton
@Provides
@ApplicationScope
fun applicationScope(@MainDispatcher mainDispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope(SupervisorJob() + mainDispatcher)
}

View File

@@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.log
package at.bitfire.davdroid.di
import dagger.Module
import dagger.Provides

View File

@@ -10,6 +10,7 @@ import android.content.Intent
import android.os.Process
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
@@ -138,8 +139,9 @@ class LogFileHandler @Inject constructor(
val shareIntent = DebugInfoActivity.IntentBuilder(context)
.newTask()
.share()
val pendingShare =
PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val pendingShare = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(shareIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_share,
@@ -151,8 +153,9 @@ class LogFileHandler @Inject constructor(
// 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)
val pendingPref = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(prefIntent)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_settings,

View File

@@ -0,0 +1,47 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.security.KeyChain
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.net.Socket
import java.security.Principal
import javax.net.ssl.X509ExtendedKeyManager
/**
* KeyManager that provides a client certificate and private key from the Android KeyChain.
*
* @throws IllegalArgumentException if the alias doesn't exist or is not accessible
*/
class ClientCertKeyManager @AssistedInject constructor(
@Assisted private val alias: String,
@ApplicationContext private val context: Context
): X509ExtendedKeyManager() {
@AssistedFactory
interface Factory {
fun create(alias: String): ClientCertKeyManager
}
val certs = KeyChain.getCertificateChain(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
val key = KeyChain.getPrivateKey(context, alias) ?: throw IllegalArgumentException("Alias doesn't exist or not accessible: $alias")
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) = arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) = alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}

View File

@@ -22,6 +22,7 @@ import java.util.LinkedList
import java.util.TreeMap
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.random.Random
/**
* Allows to resolve SRV/TXT records. Chooses the correct resolver, DNS servers etc.
@@ -102,7 +103,14 @@ class DnsRecordResolver @Inject constructor(
// record selection
fun bestSRVRecord(records: Array<out Record>): SRVRecord? {
/**
* Selects the best SRV record from a list of records, based on algorithm from RFC 2782.
*
* @param records the records to choose from
* @param randomGenerator a random number generator to use for random selection
* @return the best SRV record, or `null` if no SRV record is available
*/
fun bestSRVRecord(records: Array<out Record>, randomGenerator: Random = Random.Default): SRVRecord? {
val srvRecords = records.filterIsInstance<SRVRecord>()
if (srvRecords.size <= 1)
return srvRecords.firstOrNull()
@@ -141,7 +149,7 @@ class DnsRecordResolver @Inject constructor(
map[runningWeight] = record
}
val selector = (0..runningWeight).random()
val selector = (0..runningWeight).random(randomGenerator)
return map.ceilingEntry(selector)!!.value
}

View File

@@ -4,7 +4,7 @@
package at.bitfire.davdroid.network
import android.net.Uri
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import kotlinx.coroutines.CompletableDeferred
@@ -49,18 +49,18 @@ class GoogleLogin(
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token")
"https://accounts.google.com/o/oauth2/v2/auth".toUri(),
"https://oauth2.googleapis.com/token".toUri()
)
}
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
GoogleLogin.serviceConfig,
serviceConfig,
customClientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect")
(BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri()
)
return builder
.setScopes(*SCOPES)

View File

@@ -4,157 +4,286 @@
package at.bitfire.davdroid.network
import android.accounts.Account
import android.content.Context
import android.os.Build
import android.security.KeyChain
import androidx.annotation.WorkerThread
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import 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 at.bitfire.davdroid.ui.ForegroundTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import okhttp3.Authenticator
import okhttp3.Cache
import okhttp3.ConnectionSpec
import okhttp3.CookieJar
import okhttp3.Interceptor
import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.Protocol
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.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
import javax.net.ssl.KeyManager
import javax.net.ssl.SSLContext
class HttpClient @AssistedInject constructor(
@Assisted val okHttpClient: OkHttpClient,
@Assisted private var authService: AuthorizationService? = null,
val settingsManager: SettingsManager
class HttpClient(
val okHttpClient: OkHttpClient,
private val authorizationService: AuthorizationService? = null
): AutoCloseable {
companion object {
/** max. size of disk cache (10 MB) */
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
override fun close() {
authorizationService?.dispose()
okHttpClient.cache?.close()
}
/** Base Builder to build all clients from. Use rarely; [OkHttpClient]s should
* be reused as much as possible. */
fun baseBuilder() =
OkHttpClient.Builder()
// builder
/**
* Builder for the [HttpClient].
*
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
* there's only one [Builder] object and setting properties from one location would influence the others.
*
* To generate multiple clients, inject and use `Provider<HttpClient.Builder>` instead.
*/
class Builder @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val authorizationServiceProvider: Provider<AuthorizationService>,
@ApplicationContext private val context: Context,
defaultLogger: Logger,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val keyManagerFactory: ClientCertKeyManager.Factory,
private val settingsManager: SettingsManager
) {
// property setters/getters
private var logger: Logger = defaultLogger
fun setLogger(logger: Logger): Builder {
this.logger = logger
return this
}
private var loggerInterceptorLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
fun loggerInterceptorLevel(level: HttpLoggingInterceptor.Level): Builder {
loggerInterceptorLevel = level
return this
}
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
private var cookieStore: CookieJar = MemoryCookieStore()
fun setCookieStore(cookieStore: CookieJar): Builder {
this.cookieStore = cookieStore
return this
}
private var authenticationInterceptor: Interceptor? = null
private var authenticator: Authenticator? = null
private var authorizationService: AuthorizationService? = null
private var certificateAlias: String? = null
fun authenticate(host: String?, credentials: Credentials, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
if (credentials.authState != null) {
// OAuth
val authService = authorizationServiceProvider.get()
authenticationInterceptor = BearerAuthInterceptor.fromAuthState(authService, credentials.authState, authStateCallback)
authorizationService = authService
} else if (credentials.username != null && credentials.password != null) {
// basic/digest auth
val authHandler = BasicDigestAuthHandler(
domain = UrlUtils.hostToDomain(host),
username = credentials.username,
password = credentials.password,
insecurePreemptive = true
)
authenticationInterceptor = authHandler
authenticator = authHandler
}
// client certificate
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
return this
}
private var followRedirects = false
fun followRedirects(follow: Boolean): Builder {
followRedirects = follow
return this
}
private var cache: Cache? = null
@Suppress("unused")
fun withDiskCache(maxSize: Long = 10*1024*1024): Builder {
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
logger.fine("Using disk cache: $cacheDir")
cache = Cache(cacheDir, maxSize)
break
}
}
return this
}
// convenience builders from other classes
/**
* Takes authentication (basic/digest or OAuth and client certificate) from a given account.
*
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
*
* @param account the account to take authentication from
* @param onlyHost if set: only authenticate for this host name
*
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
*/
@WorkerThread
fun fromAccount(account: Account, onlyHost: String? = null): Builder {
val accountSettings = accountSettingsFactory.create(account)
authenticate(
host = onlyHost,
credentials = accountSettings.credentials(),
authStateCallback = { authState: AuthState ->
accountSettings.credentials(Credentials(authState = authState))
}
)
return this
}
/**
* Same as [fromAccount], but can be called on any thread.
*
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
*/
suspend fun fromAccountAsync(account: Account, onlyHost: String? = null): Builder = withContext(ioDispatcher) {
fromAccount(account, onlyHost)
}
// actual builder
fun build(): HttpClient {
val okBuilder = OkHttpClient.Builder()
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
// traffic within a minute, a sync will be cancelled.
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(
45,
TimeUnit.SECONDS
) // avoid cancellation because of missing traffic; only works for HTTP/2
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
.connectionSpecs(
listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.COMPATIBLE_TLS
)
)
// don't allow redirects by default, because it would break PROPFIND handling
.followRedirects(false)
// don't allow redirects by default because it would break PROPFIND handling
.followRedirects(followRedirects)
// add User-Agent to every request
.addInterceptor(UserAgentInterceptor)
}
@AssistedFactory
interface Factory {
fun create(okHttpClient: OkHttpClient, authService: AuthorizationService?): HttpClient
}
// connection-private cookie store
.cookieJar(cookieStore)
// allow cleartext and TLS 1.2+
.connectionSpecs(listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.MODERN_TLS
))
override fun close() {
authService?.dispose()
okHttpClient.cache?.close()
}
// offer Brotli and gzip compression (can be disabled per request with `Accept-Encoding: identity`)
.addInterceptor(BrotliInterceptor)
// add cache, if requested
.cache(cache)
class Builder(
val context: Context,
accountSettings: AccountSettings? = null,
val logger: Logger = Logger.getGlobal(),
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
) {
// app-wide custom proxy support
buildProxy(okBuilder)
@EntryPoint
@InstallIn(SingletonComponent::class)
interface HttpClientBuilderEntryPoint {
fun authorizationService(): AuthorizationService
fun httpClientFactory(): Factory
fun settingsManager(): SettingsManager
}
// add authentication
buildAuthentication(okBuilder)
private val entryPoint = EntryPointAccessors.fromApplication<HttpClientBuilderEntryPoint>(context)
fun interface CertManagerProducer {
fun certManager(): CustomCertManager
}
private var appInForeground: MutableStateFlow<Boolean>? =
MutableStateFlow(false)
private var authService: AuthorizationService? = null
private var certManagerProducer: CertManagerProducer? = null
private var certificateAlias: String? = null
private var offerCompression: Boolean = false
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
private var cookieStore: CookieJar? = MemoryCookieStore()
private val orig = baseBuilder()
init {
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
loggingInterceptor.level = loggerLevel
orig.addNetworkInterceptor(loggingInterceptor)
loggingInterceptor.level = loggerInterceptorLevel
okBuilder.addNetworkInterceptor(loggingInterceptor)
}
val settings = entryPoint.settingsManager()
return HttpClient(
okHttpClient = okBuilder.build(),
authorizationService = authorizationService
)
}
// custom proxy support
private fun buildAuthentication(okBuilder: OkHttpClient.Builder) {
// basic/digest auth and OAuth
authenticationInterceptor?.let { okBuilder.addInterceptor(it) }
authenticator?.let { okBuilder.authenticator(it) }
// client certificate
val keyManager: KeyManager? = certificateAlias?.let { alias ->
try {
val manager = keyManagerFactory.create(alias)
logger.fine("Using certificate $alias for authentication")
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
okBuilder.protocols(listOf(Protocol.HTTP_1_1))
manager
} catch (e: IllegalArgumentException) {
logger.log(Level.SEVERE, "Couldn't create KeyManager for certificate $alias", e)
null
}
}
// cert4android integration
val certManager = CustomCertManager(
context = context,
trustSystemCerts = !settingsManager.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES),
appInForeground = if (/* davx5-ose */ true)
ForegroundTracker.inForeground // interactive mode
else
null // non-interactive mode
)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
/* km = */ if (keyManager != null) arrayOf(keyManager) else null,
/* tm = */ arrayOf(certManager),
/* random = */ null
)
okBuilder
.sslSocketFactory(sslContext.socketFactory, certManager)
.hostnameVerifier(certManager.HostnameVerifier(OkHostnameVerifier))
}
private fun buildProxy(okBuilder: OkHttpClient.Builder) {
try {
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
val proxyTypeValue = settingsManager.getInt(Settings.PROXY_TYPE)
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
// we set our own proxy
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
InetSocketAddress(
settings.getString(Settings.PROXY_HOST),
settings.getInt(Settings.PROXY_PORT)
settingsManager.getString(Settings.PROXY_HOST),
settingsManager.getInt(Settings.PROXY_PORT)
)
}
val proxy =
@@ -164,173 +293,12 @@ class HttpClient @AssistedInject constructor(
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
else -> throw IllegalArgumentException("Invalid proxy type")
}
orig.proxy(proxy)
okBuilder.proxy(proxy)
logger.log(Level.INFO, "Using proxy setting", proxy)
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
customCertManager {
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
CustomCertManager(context, trustSystemCerts, appInForeground)
}
// use account settings for authentication and cookies
if (accountSettings != null)
addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState ->
accountSettings.credentials(Credentials(authState = authState))
})
}
constructor(context: Context, host: String?, credentials: Credentials?) : this(context) {
if (credentials != null)
addAuthentication(host, credentials)
}
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
if (credentials.username != null && credentials.password != null) {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive)
orig.addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
credentials.authState?.let { authState ->
val newAuthService = entryPoint.authorizationService()
authService = newAuthService
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
orig.addNetworkInterceptor(bearerAuthInterceptor)
}
}
return this
}
fun allowCompression(allow: Boolean): Builder {
offerCompression = allow
return this
}
fun cookieStore(store: CookieJar?): Builder {
cookieStore = store
return this
}
fun followRedirects(follow: Boolean): Builder {
orig.followRedirects(follow)
return this
}
fun customCertManager(producer: CertManagerProducer) {
certManagerProducer = producer
}
fun setForeground(foreground: Boolean): Builder {
appInForeground?.value = foreground
return this
}
fun withDiskCache(): Builder {
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
logger.fine("Using disk cache: $cacheDir")
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
break
}
}
return this
}
fun build(): HttpClient {
cookieStore?.let {
orig.cookieJar(it)
}
if (offerCompression)
// offer Brotli and gzip compression
orig.addInterceptor(BrotliInterceptor)
var keyManager: KeyManager? = null
certificateAlias?.let { alias ->
// get provider certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
logger?.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
// create KeyManager
keyManager = object : X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
orig.protocols(listOf(Protocol.HTTP_1_1))
}
if (certManagerProducer != null || keyManager != null) {
val manager = certManagerProducer?.certManager()
val trustManager = manager ?: /* fall back to system default trust manager */
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.let { factory ->
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}
val hostnameVerifier =
if (manager != null)
manager.HostnameVerifier(OkHostnameVerifier)
else
OkHostnameVerifier
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
orig.hostnameVerifier(hostnameVerifier)
}
return entryPoint.httpClientFactory().create(orig.build(), authService = authService)
}
}
object UserAgentInterceptor: Interceptor {
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {
Logger.getGlobal().info("Will set User-Agent: $userAgent")
}
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.network
import android.content.Context
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.Credentials
@@ -24,14 +23,15 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import javax.inject.Inject
/**
* Implements Nextcloud Login Flow v2.
*
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class NextcloudLoginFlow(
context: Context
class NextcloudLoginFlow @Inject constructor(
httpClientBuilder: HttpClient.Builder
): AutoCloseable {
companion object {
@@ -42,8 +42,7 @@ class NextcloudLoginFlow(
const val DAV_PATH = "remote.php/dav"
}
val httpClient = HttpClient.Builder(context)
.setForeground(true)
val httpClient = httpClientBuilder
.build()
override fun close() {
@@ -107,7 +106,7 @@ class NextcloudLoginFlow(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword")
password = json.getString("appPassword").toCharArray()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)

View File

@@ -26,8 +26,9 @@ object OAuthModule {
.setConnectionBuilder { uri ->
val url = URL(uri.toString())
(url.openConnection() as HttpURLConnection).apply {
setRequestProperty("User-Agent", HttpClient.UserAgentInterceptor.userAgent)
setRequestProperty("User-Agent", UserAgentInterceptor.userAgent)
}
}.build()
)
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.os.Build
import at.bitfire.davdroid.BuildConfig
import okhttp3.Interceptor
import okhttp3.OkHttp
import okhttp3.Response
import java.util.Locale
import java.util.logging.Logger
object UserAgentInterceptor: Interceptor {
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {
Logger.getGlobal().info("Will set User-Agent: $userAgent")
}
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.XmlReader
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.Lazy
import org.unifiedpush.android.connector.data.PushMessage
import org.xmlpull.v1.XmlPullParserException
import java.io.StringReader
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import at.bitfire.dav4jvm.property.push.PushMessage as DavPushMessage
/**
* Handles incoming WebDAV-Push messages.
*/
class PushMessageHandler @Inject constructor(
private val accountRepository: AccountRepository,
private val collectionRepository: DavCollectionRepository,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
) {
suspend fun processMessage(message: PushMessage, instance: String) {
if (!message.decrypted) {
logger.severe("Received a push message that could not be decrypted.")
return
}
val messageXml = message.content.toString(Charsets.UTF_8)
logger.log(Level.INFO, "Received push message", messageXml)
// parse push notification
val topic = parse(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 syncDataTypes = mutableSetOf<SyncDataType>()
// If the type is an address book, add the contacts type
if (collection.type == TYPE_ADDRESSBOOK)
syncDataTypes += SyncDataType.CONTACTS
// If the collection supports events, add the events type
if (collection.supportsVEVENT != false)
syncDataTypes += SyncDataType.EVENTS
// If the collection supports tasks, make sure there's a provider installed,
// and add the tasks type
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
if (tasksAppManager.get().currentProvider() != null)
syncDataTypes += SyncDataType.TASKS
// Schedule sync for all the types identified
val account = accountRepository.fromName(service.accountName)
for (syncDataType in syncDataTypes)
syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true)
}
}
} else {
// fallback when no known topic is present (shouldn't happen)
val service = instance.toLongOrNull()?.let { serviceRepository.getBlocking(it) }
if (service != null) {
logger.warning("Got push message without topic and service, syncing all accounts")
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)
}
}
}
/**
* 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
*/
@VisibleForTesting
internal fun parse(message: String): String? {
var topic: String? = null
val parser = XmlUtils.newPullParser()
try {
parser.setInput(StringReader(message))
XmlReader(parser).processTag(DavPushMessage.NAME) {
val pushMessage = DavPushMessage.Factory.create(parser)
topic = pushMessage.topic?.topic
}
} catch (e: XmlPullParserException) {
logger.log(Level.WARNING, "Couldn't parse push message", e)
}
return topic
}
}

View File

@@ -1,46 +0,0 @@
/*
* 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 at.bitfire.dav4jvm.property.push.Topic
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)
val properties = pushMessage.propStat?.properties ?: return@processTag
val pushTopic = properties.filterIsInstance<Topic>().firstOrNull()
topic = pushTopic?.topic
}
} catch (e: XmlPullParserException) {
logger.log(Level.WARNING, "Couldn't parse push message", e)
}
return topic
}
}

View File

@@ -10,6 +10,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.ui.NotificationRegistry
@@ -45,14 +46,13 @@ class PushNotificationManager @Inject constructor(
.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
)
TaskStackBuilder.create(context)
.addNextIntentWithParentStack(
Intent(context, AccountActivity::class.java).apply {
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
.build()
}

View File

@@ -0,0 +1,375 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.push.AuthSecret
import at.bitfire.dav4jvm.property.push.PushRegister
import at.bitfire.dav4jvm.property.push.PushResource
import at.bitfire.dav4jvm.property.push.Subscription
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
import at.bitfire.dav4jvm.property.push.WebPushSubscription
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.di.IoDispatcher
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.push.PushRegistrationManager.Companion.mutex
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.sync.account.InvalidAccountException
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.data.PushEndpoint
import java.io.StringWriter
import java.time.Duration
import java.time.Instant
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
/**
* Manages push registrations and subscriptions.
*
* To update push registrations and subscriptions (for instance after collections have been changed), call [update].
*
* Public API calls are protected by [mutex] so that there won't be multiple subscribe/unsubscribe operations at the same time.
* If you call other methods than [update], make sure that they don't interfere with other operations.
*/
class PushRegistrationManager @Inject constructor(
private val accountRepository: Lazy<AccountRepository>,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext private val context: Context,
private val httpClientBuilder: Provider<HttpClient.Builder>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
) {
/**
* Sets or removes (disable push) the distributor and updates the subscriptions + worker.
*
* Uses [update] which is protected by [mutex] so creating/deleting subscriptions doesn't
* interfere with other operations.
*
* @param pushDistributor new distributor or `null` to disable Push
*/
suspend fun setPushDistributor(pushDistributor: String?) {
// Disable UnifiedPush and remove all subscriptions
UnifiedPush.removeDistributor(context)
update()
if (pushDistributor != null) {
// If a distributor was passed, store it and create/register subscriptions
UnifiedPush.saveDistributor(context, pushDistributor)
update()
}
}
fun getCurrentDistributor() = UnifiedPush.getSavedDistributor(context)
fun getDistributors() = UnifiedPush.getDistributors(context)
/**
* Updates all push registrations and subscriptions so that if Push is available, it's up-to-date and
* working for all database services. If Push is not available, existing subscriptions are unregistered.
*
* Also makes sure that the [PushRegistrationWorker] is enabled if there's a Push-enabled collection.
*
* Acquires [mutex] so that this method can't be called twice at the same time, or at the same time
* with [update(serviceId)].
*/
suspend fun update() = mutex.withLock {
for (service in serviceRepository.getAll())
updateService(service.id)
updatePeriodicWorker()
}
/**
* Same as [update], but for a specific database service.
*
* Acquires [mutex] so that this method can't be called twice at the same time, or at the same time
* as [update()].
*/
suspend fun update(serviceId: Long) = mutex.withLock {
updateService(serviceId)
updatePeriodicWorker()
}
/**
* Registers or unregisters subscriptions depending on whether there is a distributor available.
*/
private suspend fun updateService(serviceId: Long) {
val service = serviceRepository.get(serviceId) ?: return
// use service ID from database as UnifiedPush instance name
val instance = serviceId.toString()
val distributorAvailable = getCurrentDistributor() != null
if (distributorAvailable)
try {
val vapid = collectionRepository.getVapidKey(serviceId)
logger.fine("Registering UnifiedPush instance $serviceId (${service.accountName})")
// message for distributor
val message = "${service.accountName} (${service.type})"
UnifiedPush.register(context, instance, message, vapid)
} catch (e: UnifiedPush.VapidNotValidException) {
logger.log(Level.WARNING, "Couldn't register invalid VAPID key for service $serviceId", e)
}
else {
logger.fine("Unregistering UnifiedPush instance $serviceId (${service.accountName})")
UnifiedPush.unregister(context, instance) // doesn't call UnifiedPushService.onUnregistered
unsubscribeAll(service)
}
// UnifiedPush has now been called. It will do its work and then asynchronously call back to UnifiedPushService, which
// will then call processSubscription or removeSubscription.
}
/**
* Called by [UnifiedPushService] when a subscription (endpoint) is available for the given service.
*
* Uses the subscription to subscribe to syncable collections, and then unsubscribes from non-syncable collections.
*/
suspend fun processSubscription(serviceId: Long, endpoint: PushEndpoint) = mutex.withLock {
val service = serviceRepository.get(serviceId) ?: return
try {
// subscribe to collections which are selected for synchronization
subscribeSyncable(service, endpoint)
// unsubscribe from collections which are not selected for synchronization
unsubscribeCollections(service, collectionRepository.getPushRegisteredAndNotSyncable(service.id))
} catch (_: InvalidAccountException) {
// couldn't create authenticating HTTP client because account is not available
}
}
private suspend fun subscribeSyncable(service: Service, endpoint: PushEndpoint) {
val subscribeTo = collectionRepository.getPushCapableAndSyncable(service.id)
if (subscribeTo.isEmpty())
return
val account = accountRepository.get().fromName(service.accountName)
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
.use { httpClient ->
for (collection in subscribeTo)
try {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2 * WORKER_INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond)
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
else {
// no existing subscription or expiring soon
logger.fine("Registering push subscription for ${collection.url}")
subscribe(httpClient, collection, endpoint)
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't register subscription at CalDAV/CardDAV server", e)
}
}
}
/**
* Called when no subscription is available (anymore) for the given service.
*
* Unsubscribes from all subscribed collections.
*/
suspend fun removeSubscription(serviceId: Long) = mutex.withLock {
val service = serviceRepository.get(serviceId) ?: return
unsubscribeAll(service)
}
private suspend fun unsubscribeAll(service: Service) {
val unsubscribeFrom = collectionRepository.getPushRegistered(service.id)
try {
unsubscribeCollections(service, unsubscribeFrom)
} catch (_: InvalidAccountException) {
// couldn't create authenticating HTTP client because account is not available
}
}
/**
* Registers the subscription to a given collection ("subscribe to a collection").
*
* @param httpClient HTTP client to use
* @param collection collection to subscribe to
* @param endpoint subscription to register
*/
private suspend fun subscribe(httpClient: HttpClient, collection: Collection, endpoint: PushEndpoint) {
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.insertTag(PushRegister.NAME) {
serializer.insertTag(Subscription.NAME) {
// subscription URL
serializer.insertTag(WebPushSubscription.NAME) {
serializer.insertTag(PushResource.NAME) {
text(endpoint.url)
}
endpoint.pubKeySet?.let { pubKeySet ->
serializer.insertTag(SubscriptionPublicKey.NAME) {
attribute(null, "type", "p256dh")
text(pubKeySet.pubKey)
}
serializer.insertTag(AuthSecret.NAME) {
text(pubKeySet.auth)
}
}
}
}
// requested expiration
serializer.insertTag(PushRegister.EXPIRES) {
text(HttpUtils.formatDate(requestedExpiration))
}
}
serializer.endDocument()
runInterruptible(ioDispatcher) {
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient.okHttpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
// update subscription URL and expiration in DB
val subscriptionUrl = response.header("Location")
val expires = response.header("Expires")?.let { expiresDate ->
HttpUtils.parseDate(expiresDate)
} ?: requestedExpiration
runBlocking {
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
}
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
}
}
/**
* Unsubscribe from the given collections.
*/
private suspend fun unsubscribeCollections(service: Service, from: List<Collection>) {
if (from.isEmpty())
return
val account = accountRepository.get().fromName(service.accountName)
httpClientBuilder.get()
.fromAccountAsync(account)
.build()
.use { httpClient ->
for (collection in from)
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
logger.info("Unsubscribing Push from ${collection.url}")
unsubscribe(httpClient, collection, url)
}
}
}
private suspend fun unsubscribe(httpClient: HttpClient, collection: Collection, url: HttpUrl) {
try {
runInterruptible(ioDispatcher) {
DavResource(httpClient.okHttpClient, 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(
id = collection.id,
subscriptionUrl = null,
expires = null
)
}
/**
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
*
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
* A potentially existing worker is replaced, so that the first run should be soon.
*
* Otherwise, a potentially existing worker is cancelled.
*/
private suspend fun updatePeriodicWorker() {
val workerNeeded = collectionRepository.anyPushCapable()
val workManager = WorkManager.getInstance(context)
if (workerNeeded) {
logger.info("Enqueuing periodic PushRegistrationWorker")
workManager.enqueueUniquePeriodicWork(
WORKER_UNIQUE_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, WORKER_INTERVAL_DAYS, TimeUnit.DAYS)
.setInitialDelay(5, TimeUnit.SECONDS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
)
} else {
logger.info("Cancelling periodic PushRegistrationWorker")
workManager.cancelUniqueWork(WORKER_UNIQUE_NAME)
}
}
companion object {
private const val WORKER_UNIQUE_NAME = "push-registration"
const val WORKER_INTERVAL_DAYS = 1L
/**
* Mutex to synchronize (un)subscription.
*/
val mutex = Mutex()
}
}

View File

@@ -4,189 +4,35 @@
package at.bitfire.davdroid.push
import android.accounts.Account
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
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.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
import java.time.Duration
import java.time.Instant
import java.util.logging.Level
import java.util.logging.Logger
/**
* 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).
* Worker that runs regularly and initiates push registration updates for all collections.
*
* Managed by [PushRegistrationManager].
*/
@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
private val pushRegistrationManager: PushRegistrationManager
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result {
logger.info("Running push registration worker")
try {
registerSyncable()
unregisterNotSyncable()
} catch (_: IOException) {
return Result.retry() // retry on I/O errors
}
// update registrations for all services
pushRegistrationManager.update()
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
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
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")) {
// subscription URL
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) {
text(endpoint)
}
}
}
// requested expiration
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "expires")) {
text(HttpUtils.formatDate(requestedExpiration))
}
}
serializer.endDocument()
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
val subscriptionUrl = response.header("Location")
val expires = response.header("Expires")?.let { expiresDate ->
HttpUtils.parseDate(expiresDate)
} ?: requestedExpiration
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
} 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()) {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2*PushRegistrationWorkerManager.INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond) {
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
continue
}
// no existing subscription or expiring soon
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(
id = collection.id,
subscriptionUrl = null,
expires = 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)
}
}
}
}
}

View File

@@ -1,98 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import at.bitfire.davdroid.repository.DavCollectionRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.coroutines.runBlocking
import java.util.concurrent.TimeUnit
import java.util.logging.Logger
import javax.inject.Inject
class PushRegistrationWorkerManager @Inject constructor(
@ApplicationContext val context: Context,
val collectionRepository: DavCollectionRepository,
val logger: Logger
) {
/**
* Determines whether there are any push-capable collections and updates the periodic worker accordingly.
*
* If there are push-capable collections, a unique periodic worker with an initial delay of 5 seconds is enqueued.
* A potentially existing worker is replaced, so that the first run should be soon.
*
* Otherwise, a potentially existing worker is cancelled.
*/
fun updatePeriodicWorker() {
val workerNeeded = runBlocking {
collectionRepository.anyPushCapable()
}
val workManager = WorkManager.getInstance(context)
if (workerNeeded) {
logger.info("Enqueuing periodic PushRegistrationWorker")
workManager.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
PeriodicWorkRequest.Builder(PushRegistrationWorker::class, INTERVAL_DAYS, TimeUnit.DAYS)
.setInitialDelay(5, TimeUnit.SECONDS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
)
} else {
logger.info("Cancelling periodic PushRegistrationWorker")
workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
}
}
companion object {
private const val UNIQUE_WORK_NAME = "push-registration"
const val INTERVAL_DAYS = 1L
}
/**
* Listener that enqueues a push registration worker when the collection list changes.
*/
class CollectionsListener @Inject constructor(
@ApplicationContext val context: Context,
val workerManager: PushRegistrationWorkerManager
): DavCollectionRepository.OnChangeListener {
override fun onCollectionsChanged() {
workerManager.updatePeriodicWorker()
}
}
/**
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
*/
@Module
@InstallIn(SingletonComponent::class)
interface PushRegistrationWorkerModule {
@Binds
@IntoSet
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
}
}

View File

@@ -1,117 +0,0 @@
/*
* 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.db.Collection.Companion.TYPE_ADDRESSBOOK
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.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@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 pushRegistrationWorkerManager: PushRegistrationWorkerManager
@Inject
lateinit var tasksAppManager: Lazy<TasksAppManager>
@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
pushRegistrationWorkerManager.updatePeriodicWorker()
}
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 syncDataTypes = mutableSetOf<SyncDataType>()
// If the type is an address book, add the contacts type
if (collection.type == TYPE_ADDRESSBOOK)
syncDataTypes += SyncDataType.CONTACTS
// If the collection supports events, add the events type
if (collection.supportsVEVENT != false)
syncDataTypes += SyncDataType.EVENTS
// If the collection supports tasks, make sure there's a provider installed,
// and add the tasks type
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
if (tasksAppManager.get().currentProvider() != null)
syncDataTypes += SyncDataType.TASKS
// Schedule sync for all the types identified
val account = accountRepository.fromName(service.accountName)
for (syncDataType in syncDataTypes)
syncWorkerManager.enqueueOneTime(account, syncDataType, 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

@@ -0,0 +1,80 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import at.bitfire.davdroid.di.ApplicationScope
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import java.util.logging.Logger
import javax.inject.Inject
/**
* Entry point for UnifiedPush.
*
* Calls [PushRegistrationManager] for most tasks, except incoming push messages,
* which are handled directly.
*/
@AndroidEntryPoint
class UnifiedPushService : PushService() {
/* Scope to run the requests asynchronously. UnifiedPush binds the service,
* sends the message and unbinds one second later. Our operations may take longer,
* so the scope should not be bound to the service lifecycle. */
@Inject
@ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject
lateinit var logger: Logger
@Inject
lateinit var pushMessageHandler: Lazy<PushMessageHandler>
@Inject
lateinit var pushRegistrationManager: Lazy<PushRegistrationManager>
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
val serviceId = instance.toLongOrNull() ?: return
logger.warning("Got UnifiedPush endpoint for service $serviceId: ${endpoint.url}")
// register new endpoint at CalDAV/CardDAV servers
applicationScope.launch {
pushRegistrationManager.get().processSubscription(serviceId, endpoint)
}
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
val serviceId = instance.toLongOrNull() ?: return
logger.warning("UnifiedPush registration failed for service $serviceId: $reason")
// unregister subscriptions
applicationScope.launch {
pushRegistrationManager.get().removeSubscription(serviceId)
}
}
override fun onUnregistered(instance: String) {
val serviceId = instance.toLongOrNull() ?: return
logger.warning("UnifiedPush unregistered for service $serviceId")
applicationScope.launch {
pushRegistrationManager.get().removeSubscription(serviceId)
}
}
override fun onMessage(message: PushMessage, instance: String) {
applicationScope.launch {
pushMessageHandler.get().processMessage(message, instance)
}
}
}

View File

@@ -8,11 +8,11 @@ import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.Context
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.servicedetection.DavResourceFinder
@@ -22,6 +22,7 @@ import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.account.InvalidAccountException
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
@@ -43,7 +44,7 @@ import javax.inject.Inject
*/
class AccountRepository @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
private val automaticSyncManager: AutomaticSyncManager,
private val automaticSyncManager: Lazy<AutomaticSyncManager>,
@ApplicationContext private val context: Context,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
@@ -51,7 +52,7 @@ class AccountRepository @Inject constructor(
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val syncWorkerManager: Lazy<SyncWorkerManager>,
private val tasksAppManager: Lazy<TasksAppManager>
) {
@@ -69,7 +70,7 @@ 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? {
fun createBlocking(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = fromName(accountName)
// create Android account
@@ -103,7 +104,7 @@ class AccountRepository @Inject constructor(
}
// set up automatic sync (processes inserted services)
automaticSyncManager.updateAutomaticSync(account)
automaticSyncManager.get().updateAutomaticSync(account)
} catch(e: InvalidAccountException) {
logger.log(Level.SEVERE, "Couldn't access account settings", e)
@@ -205,11 +206,11 @@ class AccountRepository @Inject constructor(
}
// account renamed, cancel maybe running synchronization of old account
syncWorkerManager.cancelAllWork(oldAccount)
syncWorkerManager.get().cancelAllWork(oldAccount)
// disable periodic syncs for old account
for (dataType in SyncDataType.entries)
syncWorkerManager.disablePeriodic(oldAccount, dataType)
syncWorkerManager.get().disablePeriodic(oldAccount, dataType)
// update account name references in database
serviceRepository.renameAccount(oldName, newName)
@@ -237,7 +238,7 @@ class AccountRepository @Inject constructor(
}
// update automatic sync
automaticSyncManager.updateAutomaticSync(newAccount)
automaticSyncManager.get().updateAutomaticSync(newAccount)
} finally {
// release AccountsCleanupWorker mutex at the end of this async coroutine
AccountsCleanupWorker.unlockAccountsCleanup()
@@ -247,14 +248,14 @@ class AccountRepository @Inject constructor(
// helpers
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
private fun insertService(accountName: String, @ServiceType type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = serviceRepository.insertOrReplace(service)
val serviceId = serviceRepository.insertOrReplaceBlocking(service)
// insert home sets
for (homeSet in info.homeSets)
homeSetRepository.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
homeSetRepository.insertOrUpdateByUrlBlocking(HomeSet(0, serviceId, true, homeSet))
// insert collections
for (collection in info.collections.values) {

View File

@@ -23,46 +23,39 @@ import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.CollectionType
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.ical4android.ICalendar
import at.bitfire.ical4android.util.DateUtils
import dagger.Lazy
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
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.Property
import net.fortuna.ical4j.model.PropertyList
import net.fortuna.ical4j.model.component.VTimeZone
import net.fortuna.ical4j.model.property.Version
import okhttp3.HttpUrl
import java.io.StringWriter
import java.util.Collections
import java.util.UUID
import javax.inject.Inject
import javax.inject.Provider
/**
* Repository for managing collections.
*
* Implements an observer pattern that can be used to listen for changes of collections.
*/
class DavCollectionRepository @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext private val context: Context,
private val db: AppDatabase,
defaultListeners: Lazy<Set<@JvmSuppressWildcards OnChangeListener>>,
private val httpClientBuilder: Provider<HttpClient.Builder>,
private val serviceRepository: DavServiceRepository
) {
private val listeners by lazy { Collections.synchronizedSet(defaultListeners.get().toMutableSet()) }
private val dao = db.collectionDao()
/**
@@ -107,8 +100,6 @@ class DavCollectionRepository @Inject constructor(
description = description
)
dao.insertAsync(collection)
notifyOnChangeListeners()
}
/**
@@ -167,36 +158,34 @@ 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 deleteRemote(collection: Collection) {
val service = serviceRepository.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
val service = serviceRepository.getBlocking(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, context.getString(R.string.account_type))
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 → delete locally, too
delete(collection)
}
httpClientBuilder.get()
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible(Dispatchers.IO) {
DavResource(httpClient.okHttpClient, collection.url).delete {
// success, otherwise an exception would have been thrown → delete locally, too
delete(collection)
}
}
}
}
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
suspend fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
fun get(id: Long) = dao.get(id)
suspend fun getAsync(id: Long) = dao.getAsync(id)
fun getFlow(id: Long) = dao.getFlow(id)
fun getByService(serviceId: Long) = dao.getByService(serviceId)
suspend fun getByService(serviceId: Long) = dao.getByService(serviceId)
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
@@ -209,11 +198,12 @@ class DavCollectionRepository @Inject constructor(
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 getPushCapableAndSyncable(serviceId: Long) = dao.getPushCapableSyncCollections(serviceId)
suspend fun getPushRegisteredAndNotSyncable(): List<Collection> =
dao.getPushRegisteredAndNotSyncable()
suspend fun getPushRegistered(serviceId: Long) = dao.getPushRegistered(serviceId)
suspend fun getPushRegisteredAndNotSyncable(serviceId: Long) = dao.getPushRegisteredAndNotSyncable(serviceId)
suspend fun getVapidKey(serviceId: Long) = dao.getFirstVapidKey(serviceId)
/**
* Inserts or updates the collection.
@@ -245,13 +235,12 @@ class DavCollectionRepository @Inject constructor(
*/
fun insertOrUpdateByUrl(collection: Collection) {
dao.insertOrUpdateByUrl(collection)
notifyOnChangeListeners()
}
fun pageByServiceAndType(serviceId: Long, type: String) =
fun pageByServiceAndType(serviceId: Long, @CollectionType type: String) =
dao.pageByServiceAndType(serviceId, type)
fun pagePersonalByServiceAndType(serviceId: Long, type: String) =
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String) =
dao.pagePersonalByServiceAndType(serviceId, type)
/**
@@ -259,7 +248,6 @@ class DavCollectionRepository @Inject constructor(
*/
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
dao.updateForceReadOnly(id, forceReadOnly)
notifyOnChangeListeners()
}
/**
@@ -267,10 +255,9 @@ class DavCollectionRepository @Inject constructor(
*/
suspend fun setSync(id: Long, forceReadOnly: Boolean) {
dao.updateSync(id, forceReadOnly)
notifyOnChangeListeners()
}
fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
suspend fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
dao.updatePushSubscription(
id = id,
pushSubscription = subscriptionUrl,
@@ -283,24 +270,22 @@ class DavCollectionRepository @Inject constructor(
*/
fun delete(collection: Collection) {
dao.delete(collection)
notifyOnChangeListeners()
}
// helpers
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
HttpClient.Builder(context, accountSettingsFactory.create(account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
runInterruptible {
DavResource(httpClient.okHttpClient, url).mkCol(
xmlBody = xmlBody,
method = method
) {
// success, otherwise an exception would have been thrown
}
httpClientBuilder.get()
.fromAccount(account)
.build()
.use { httpClient ->
runInterruptible(Dispatchers.IO) {
DavResource(httpClient.okHttpClient, url).mkCol(
xmlBody = xmlBody,
method = method
) {
// success, otherwise an exception would have been thrown
}
}
}
@@ -375,7 +360,15 @@ class DavCollectionRepository @Inject constructor(
insertTag(CalendarTimezone.NAME) {
text(
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
Calendar(ComponentList(listOf(vTimezone))).toString()
Calendar(
PropertyList<Property>().apply {
add(ICalendar.prodId)
add(Version.VERSION_2_0)
},
ComponentList(
listOf(vTimezone)
)
).toString()
)
}
}
@@ -413,32 +406,4 @@ class DavCollectionRepository @Inject constructor(
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

@@ -15,16 +15,16 @@ class DavHomeSetRepository @Inject constructor(
db: AppDatabase
) {
val dao = db.homeSetDao()
private val dao = db.homeSetDao()
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 getByIdBlocking(id: Long) = dao.getById(id)
fun getByService(serviceId: Long) = dao.getByService(serviceId)
fun getByServiceBlocking(serviceId: Long) = dao.getByService(serviceId)
fun getCalendarHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
@@ -38,13 +38,13 @@ class DavHomeSetRepository @Inject constructor(
* @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 =
fun insertOrUpdateByUrlBlocking(homeset: HomeSet): Long =
dao.getByUrl(homeset.serviceId, homeset.url.toString())?.let { existingHomeset ->
dao.update(homeset.copy(id = existingHomeset.id))
existingHomeset.id
} ?: dao.insert(homeset)
fun delete(homeSet: HomeSet) = dao.delete(homeSet)
fun deleteBlocking(homeSet: HomeSet) = dao.delete(homeSet)
}

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.db.ServiceType
import javax.inject.Inject
class DavServiceRepository @Inject constructor(
@@ -17,9 +18,12 @@ class DavServiceRepository @Inject constructor(
// Read
fun get(id: Long): Service? = dao.get(id)
fun getBlocking(id: Long): Service? = dao.get(id)
suspend fun get(id: Long): Service? = dao.getAsync(id)
fun getByAccountAndType(name: String, serviceType: String): Service? =
suspend fun getAll(): List<Service> = dao.getAll()
suspend fun getByAccountAndType(name: String, @ServiceType serviceType: String): Service? =
dao.getByAccountAndType(name, serviceType)
fun getCalDavServiceFlow(accountName: String) =
@@ -31,7 +35,7 @@ class DavServiceRepository @Inject constructor(
// Create & update
fun insertOrReplace(service: Service) =
fun insertOrReplaceBlocking(service: Service) =
dao.insertOrReplace(service)
suspend fun renameAccount(oldName: String, newName: String) =
@@ -40,7 +44,7 @@ class DavServiceRepository @Inject constructor(
// Delete
fun deleteAll() = dao.deleteAll()
fun deleteAllBlocking() = dao.deleteAll()
suspend fun deleteByAccount(accountName: String) =
dao.deleteByAccount(accountName)

View File

@@ -5,26 +5,24 @@
package at.bitfire.davdroid.repository
import android.content.Context
import android.content.pm.PackageManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncStats
import at.bitfire.davdroid.sync.SyncDataType
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(
@ApplicationContext val context: Context,
db: AppDatabase,
private val logger: Logger
db: AppDatabase
) {
private val dao = db.syncStatsDao()
data class LastSynced(
val appName: String,
val dataType: String,
val lastSynced: Long
)
fun getLastSyncedFlow(collectionId: Long): Flow<List<LastSynced>> =
@@ -32,46 +30,21 @@ class DavSyncStatsRepository @Inject constructor(
val collator = Collator.getInstance()
list.map { stats ->
LastSynced(
appName = appNameFromAuthority(stats.authority),
dataType = stats.dataType,
lastSynced = stats.lastSync
)
}.sortedWith { a, b ->
collator.compare(a.appName, b.appName)
collator.compare(a.dataType, b.dataType)
}
}
fun logSyncTime(collectionId: Long, authority: String, lastSync: Long = System.currentTimeMillis()) {
suspend fun logSyncTime(collectionId: Long, dataType: SyncDataType, lastSync: Long = System.currentTimeMillis()) {
dao.insertOrReplace(SyncStats(
id = 0,
collectionId = collectionId,
authority = authority,
dataType = dataType.name,
lastSync = lastSync
))
}
/**
* Tries to find the application name for given authority. Returns the authority if not
* found.
*
* @param authority authority to find the application name for (ie "at.techbee.jtx")
* @return the application name of authority (ie "jtx Board")
*/
private fun appNameFromAuthority(authority: String): String {
val packageManager = context.packageManager
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
if (appInfo != null) {
packageManager.getApplicationLabel(appInfo).toString()
} else {
logger.warning("Package name ($packageName) not found for authority: $authority")
authority
}
} catch (e: PackageManager.NameNotFoundException) {
logger.warning("Application name not found for authority: $authority")
authority
}
}
}

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.repository
import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
@@ -24,7 +25,6 @@ class PreferenceRepository @Inject constructor(
companion object {
const val LOG_TO_FILE = "log_to_file"
const val UNIFIED_PUSH_ENDPOINT = "unified_push_endpoint"
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -34,10 +34,9 @@ class PreferenceRepository @Inject constructor(
* Updates the "log to file" (verbose logging") preference.
*/
fun logToFile(logToFile: Boolean) {
preferences
.edit()
.putBoolean(LOG_TO_FILE, logToFile)
.apply()
preferences.edit {
putBoolean(LOG_TO_FILE, logToFile)
}
}
/**
@@ -54,21 +53,6 @@ class PreferenceRepository @Inject constructor(
}
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> =

View File

@@ -14,6 +14,6 @@ class PrincipalRepository @Inject constructor(
private val dao = db.principalDao()
fun get(id: Long): Principal = dao.get(id)
fun getBlocking(id: Long): Principal = dao.get(id)
}

View File

@@ -20,6 +20,7 @@ import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ONLY
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
@@ -40,11 +41,11 @@ import java.util.logging.Level
import java.util.logging.Logger
/**
* A local address book. Requires an own Android account, because Android manages contacts per
* A local address book. Requires its 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.
*
* @param account TODO
* @param account DAVx5 account which "owns" this 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.
@@ -79,6 +80,8 @@ open class LocalAddressBook @AssistedInject constructor(
override val title
get() = addressBookAccount.name
private val accountManager by lazy { AccountManager.get(context) }
/**
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
@@ -87,10 +90,9 @@ open class LocalAddressBook @AssistedInject constructor(
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
open val groupMethod: GroupMethod by lazy {
val manager = AccountManager.get(context)
val account = manager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
val account = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
collectionRepository.get(collectionId)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
Account(service.accountName, context.getString(R.string.account_type))
}
}
@@ -103,11 +105,11 @@ open class LocalAddressBook @AssistedInject constructor(
val includeGroups
get() = groupMethod == GroupMethod.GROUP_VCARDS
@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(addressBookAccount, USER_DATA_URL, url)
override var dbCollectionId: Long?
get() = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()
set(id) {
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, id.toString())
}
/**
* Read-only flag for the address book itself.
@@ -122,10 +124,10 @@ open class LocalAddressBook @AssistedInject constructor(
* Reading this flag returns the stored value from [USER_DATA_READ_ONLY].
*/
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
get() = accountManager.getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
set(readOnly) {
// set read-only flag for address book itself
AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
// update raw contacts
val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0)
@@ -213,7 +215,6 @@ open class LocalAddressBook @AssistedInject constructor(
addressBookAccount = newAccount
// delete old account
val accountManager = AccountManager.get(context)
accountManager.removeAccountExplicitly(oldAccount)
return true
@@ -336,14 +337,6 @@ open class LocalAddressBook @AssistedInject constructor(
const val USER_DATA_ACCOUNT_NAME = "account_name"
const val USER_DATA_ACCOUNT_TYPE = "account_type"
/**
* URL of the corresponding CardDAV address book.
*
* User data of the address book account (String).
*/
@Deprecated("Use the URL of the DB collection instead")
const val USER_DATA_URL = "url"
/**
* ID of the corresponding database [at.bitfire.davdroid.db.Collection].
*

View File

@@ -21,6 +21,7 @@ import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.DavUtils.lastSegment
import com.google.common.base.CharMatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
@@ -34,6 +35,9 @@ class LocalAddressBookStore @Inject constructor(
private val settings: SettingsManager
): LocalDataStore<LocalAddressBook> {
override val authority: String
get() = ContactsContract.AUTHORITY
/** whether a (usually managed) setting wants all address-books to be read-only **/
val forceAllReadOnly: Boolean
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
@@ -44,22 +48,25 @@ class LocalAddressBookStore @Inject constructor(
*
* The address book account name contains
*
* - the collection display name or last URL path segment
* - the collection display name or last URL path segment (filtered for dangerous special characters)
* - the actual account name
* - the collection ID, to make it unique.
*
* @param info Collection to take info from
*/
fun accountName(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
})
// Name of address book is given collection display name, otherwise the last URL path segment
var name = info.displayName.takeIf { !it.isNullOrEmpty() } ?: info.url.lastSegment
// Remove ISO control characters + SQL problematic characters
name = CharMatcher
.javaIsoControl()
.or(CharMatcher.anyOf("`'\""))
.removeFrom(name)
// Add the actual account name to the address book account name
serviceRepository.get(info.serviceId)?.let { service ->
val sb = StringBuilder(name)
serviceRepository.getBlocking(info.serviceId)?.let { service ->
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
@@ -67,17 +74,18 @@ class LocalAddressBookStore @Inject constructor(
return sb.toString()
}
override fun acquireContentProvider() =
context.contentResolver.acquireContentProviderClient(authority)
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
val service = serviceRepository.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
val name = accountName(fromCollection)
val addressBookAccount = createAddressBookAccount(
account = account,
name = name,
id = fromCollection.id,
url = fromCollection.url.toString()
id = fromCollection.id
) ?: return null
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
@@ -91,14 +99,13 @@ class LocalAddressBookStore @Inject constructor(
}
@OpenForTesting
internal fun createAddressBookAccount(account: Account, name: String, id: Long, url: String): Account? {
internal fun createAddressBookAccount(account: Account, name: String, id: Long): Account? {
// create address book account with reference to account, collection ID and URL
val addressBookAccount = Account(name, context.getString(R.string.account_type_address_book))
val userData = bundleOf(
LocalAddressBook.USER_DATA_ACCOUNT_NAME to account.name,
LocalAddressBook.USER_DATA_ACCOUNT_TYPE to account.type,
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString(),
LocalAddressBook.USER_DATA_URL to url
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString()
)
if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) {
logger.warning("Couldn't create address book account: $addressBookAccount")
@@ -108,7 +115,6 @@ class LocalAddressBookStore @Inject constructor(
return addressBookAccount
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
return accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
@@ -121,7 +127,6 @@ class LocalAddressBookStore @Inject constructor(
}
}
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
@@ -139,7 +144,6 @@ class LocalAddressBookStore @Inject constructor(
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, localCollection.account.name)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, localCollection.account.type)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, fromCollection.id.toString())
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_URL, fromCollection.url.toString())
// Set contacts provider settings
localCollection.settings = contactsProviderSettings
@@ -174,7 +178,6 @@ class LocalAddressBookStore @Inject constructor(
}
}
override fun delete(localCollection: LocalAddressBook) {
val accountManager = AccountManager.get(context)
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)

View File

@@ -23,7 +23,7 @@ import java.util.logging.Logger
/**
* Application-specific subclass of [AndroidCalendar] for local calendars.
*
* [Calendars.NAME] is used to store the calendar URL.
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
*/
class LocalCalendar private constructor(
account: Account,
@@ -40,8 +40,8 @@ class LocalCalendar private constructor(
}
override val collectionUrl: String?
get() = name
override val dbCollectionId: Long?
get() = syncId?.toLongOrNull()
override val tag: String
get() = "events-${account.name}-$id"

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