Compare commits

...

380 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
Ricki Hirner
f3333b7b54 Update AUTHORS and copyright notices (#1232)
* Update AUTHORS

* Add Android Studio copyright profiles

* Update copyright notices
2025-01-10 16:41:37 +01:00
Sunik Kupfer
226560230d Collection list refresh: Don't update fetched homesets (#1222)
* Collection list refresh: Don't update home sets that have been fetched already

* Expand testDiscoverHomesets for personal flag

* Add comment

* Rename property; Update its kdoc

* Make class properties function params

* Extract home set class and property definitions for home set discovery

* Pull out HomeSetClassName and property names

* Minor KDoc changes

* Move collection and principal query properties

* Make properties private

* Make collectionProperties service-specific; drop unused SupportedAddressData

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-10 15:34:48 +01:00
Sunik Kupfer
6a08497b3a Make room entity properties immutable (#1218)
* Make entity properties immutable where possible

* Make WebDavDocument room entity fully immutable

* Make HomeSet room entity fully immutable

* Make Collection room entity fully immutable

* Minor change

* KDoc, use transaction for combined read/write access

* Minor changes

---------

Co-authored-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2025-01-10 15:02:51 +01:00
Ricki Hirner
356183084f Update AGP, dependencies 2025-01-10 14:11:08 +01:00
Ricki Hirner
5ea7273c94 Update dependencies 2025-01-09 15:29:49 +01:00
Sunik Kupfer
843013a0f0 Use StringDef to annotate possible service and collection types (#1227)
* Add StringDef annotation to collection type param

* Add StringDef annotation to service type param
2025-01-09 12:08:11 +01:00
Ricki Hirner
ac8de37b6f Remove test account type (#1224)
* [WIP] Remove "test account" account type

* Fix tests
2025-01-08 16:49:12 +01:00
Sunik Kupfer
62dc374774 Drop address books authority (#1217)
* Remove obsolete method

* Remove address_books_authority

* Don't use contacts authority needlessly
2025-01-07 12:39:16 +01:00
Ricki Hirner
1f83e1bf12 Version bump to 4.4.6-beta.1 2025-01-03 14:13:56 +01:00
Arnau Mora
4c9b67a9e5 Rename account with jtx collection: IllegalArgumentException (#1198)
* Moved account renaming to `LocalTaskListStore` and trying to fix issue

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

* Fixed missing import

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

* Improved renaming algorithm

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

* Added `asSyncAdapter`

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

* Split account renaming responsibility

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

* Split account renaming responsibility

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

* Got rid of unused `SettingsManager`

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

* Simplified updateAccount on LocalDataStore

* Added explanatory comment

* Changed provider acquiring to the store one

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

* LocalTaskListStore takes provider name instead of authority

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

* Got rid of throws

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

* Simplified expressions

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

* Added renaming of calendar accounts

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

* Fixed imports

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

* Moved calls to try-catch

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

* Typo

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

* Ignore exceptions of every store.updateAccount()

---------

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-03 10:09:27 +01:00
Ricki Hirner
4cbe03b351 Hide sync entries in system accounts (#1214)
Hide sync entries in system settings; don't allow to manage accounts over system accounts anymore
2025-01-01 19:02:30 +01:00
Ricki Hirner
365f87991a DB: move migrations to separate files and use DI (#1206)
* DB: move migrations to separate files

* Use Hilt for AutoMigrationSpecs

* Tests in separate package

* Use Hilt for explicit Migrations
2024-12-31 16:24:50 +01:00
Ricki Hirner
77a795dfe5 Use bundleOf and contentValuesOf, if applicable (#1204)
Use bundleOf and contentValuesOf if applicable
2024-12-30 11:33:25 +01:00
Arnau Mora
794007fa38 [Push] Upon notification, only enqueue sync for the respective service type (#1175)
* Added enqueuing of the correct authority

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

* Using new `SyncDataType`

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

* Refactoring

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

* Added VEVENT check

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

* Style updates

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-29 12:31:17 +01:00
Arnau Mora
1e17e1883b Trimmed URLs for URI generation (#1202)
Trimming URLs for uri generation

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-28 14:14:09 +01:00
Ricki Hirner
48ecb5e008 Bump version to 4.4.6-alpha.1 2024-12-26 18:38:32 +01:00
Ricki Hirner
f503ce5ff6 Sync workers: use data type enum instead of specific authority (#1177)
* Introduce SyncDataType for Workers

* Fix tests, remove SyncWorkerManager.enqueueOneTime(authority)

* TasksAppManager.currentProviderFlow(): return Flow instead of StateFlow

* Simplify TasksAppManager and TasksAppWatcher

* [WIP] AutomaticSyncManager

* [WIP] AutomaticSyncManager

* AccountSettings: optimize imports

* SyncWorkManager: remove deprecated methods

* AutomaticSyncManager: disable unused authorities in sync framework

* Add migration draft

* AccountSettings: minor changes

* Migration, BaseSyncWorker: use sync data type

* Tests, set default sync interval when tasks app is installed, notify on missing tasks app permission during sync

* Remove deprecated AccountSettings methods

* AccountSettings: actually increase version number

* Use automaticSyncManager.updateAutomaticSync where applicable; better handle manual sync interval

* KDoc

* Remove deprecated SyncWorkerManager.syncAuthorities; fix cancelAllWork

* AccountSettings: minor changes

* TasksAppManager: show notification on missing permissions; always update automatic syncs

* AutomaticSyncWorker: only provide updateAutomaticSync() as public method

* AccountSettings: simplify setSyncInterval

* AutomaticSyncManager: disable automatic task sync when no tasks provider is available
2024-12-26 18:34:09 +01:00
Ricki Hirner
98578feeb2 TestUtils: add common method to initialize WorkManager for instrumentation tests 2024-12-25 20:05:37 +01:00
Ricki Hirner
0762cc6c27 AccountSettings: allow to create new instances during migrations (#1195)
AccountSettings: allow to create new instances during migrations
2024-12-25 19:50:40 +01:00
Ricki Hirner
b267291e93 TasksAppManager: use Flow instead of StateFlow 2024-12-25 12:42:59 +01:00
Ricki Hirner
eb8db47cea Simplify TasksAppManager and TasksAppWatcher (#1193)
* Simplify TasksAppManager and TasksAppWatcher

* AutomaticSyncManager: renable setSyncInterval to enable

* TasksAppWatcher: actually select provider
2024-12-24 13:32:30 +01:00
Ricki Hirner
7384feeafb Update dependencies 2024-12-24 10:01:11 +01:00
Ricki Hirner
d10add8367 Fetch translations from Transifex 2024-12-23 14:14:05 +01:00
Ricki Hirner
51bd163069 Version bump to 4.4.5 2024-12-23 14:12:01 +01:00
Ricki Hirner
90280066ee Version bump to 4.4.5-beta.2 2024-12-21 12:55:20 +01:00
Ricki Hirner
03a52e96ad Address book accounts: bind to accounts (again) (#1184)
* [WIP] Scope address book acounts to accounts again

* [WIP] Tests

* Fix LocalAddressBookStoreTest

* Adapt AccountsCleanupWorker

* Migration to assign accounts to address books (again)

* Change account in address books on account rename
2024-12-21 12:53:53 +01:00
Ricki Hirner
5890b3cc5e AccountSettings: one class per migration, tests (#1181)
* [WIP] new AccountSettingsMigrations + tests for v17

* [WIP] Tests

* Finish tests for v17

* Move migrations to separate classes

* Improve test

* KDoc
2024-12-20 11:45:13 +01:00
Ricki Hirner
a02bc56b44 Fetch translations from Transifex 2024-12-17 10:37:17 +01:00
Ricki Hirner
4939c9fc4d Version bump to 4.4.5 2024-12-17 10:19:21 +01:00
Ricki Hirner
c2524b085e LocalAddressBookStore: return all address books, including orphaned ones (#1168)
* LocalAddressBookStore: return all address books, including orphaned ones

* Add test

* Update tests
2024-12-17 10:18:46 +01:00
Arnau Mora
d892dd2b9c Fixed padding problems with Edge-To-Edge (#1171)
* Added navigation bars paddign

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

* Only consuming top padding

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-15 20:01:31 +01:00
Ricki Hirner
95ebce5722 Update Compose 2024-12-12 17:11:22 +01:00
Arnau Mora
4b2f032a57 [Push] Update specification compliance (#1151)
* Update dav4jvm, agp, kotlin and ksp

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

* Updated topic extraction

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

* Updated push-message definition

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

* Newer dav4jvm

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-12-12 17:08:29 +01:00
Ricki Hirner
bc596edfb3 Version bump to 4.4.5-alpha.1 2024-12-07 13:55:02 +01:00
Ricki Hirner
e18534ab9f Use AutomaticSyncManager to manage periodic workers and sync framework (#1157)
* Introduce AutomaticSyncManager

* AccountRepository: TasksAppManager: use AutomaticSyncManager

* Fix AccountSettings.account visibility
2024-12-07 13:52:52 +01:00
Ricki Hirner
9ae03dbc6f AccountSettings: make enable flag for sync intervals explicit (#1156) 2024-12-07 12:41:53 +01:00
Ricki Hirner
042dd3fba2 [DI] Replace injection of Application by Context with ApplicationContext annotation 2024-12-07 12:00:57 +01:00
Ricki Hirner
5d6959c47e Pass sync errors back to sync framework (bitfireAT/davx5#632)
* [WIP] Adapt SyncResult

* Remove numeric stats (not well-defined and not really used)

* Pass "too many retries" or "database error" back to sync framework on hard errors
2024-12-07 11:45:04 +01:00
Arnau Mora
239038ab77 Get rid of Head response cache (#1155)
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-12-06 11:36:24 +01:00
Ricki Hirner
7097bf9523 Move BaseSyncWorker.exists to SyncWorkerManager 2024-12-05 11:24:19 +01:00
Ricki Hirner
53bc5a6641 Update dependencies, AGP, gradle 2024-12-04 11:49:36 +01:00
Arnau Mora
9e060f6651 [Push] Allow UP distributor selection (#1146)
* Added UP distributor selector

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

* Load distributor selection from UnifiedPush

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

* Moved logic to model

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

* Added UP distributor selector

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

* Load distributor selection from UnifiedPush

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

* Moved logic to model

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

* Updated usages

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

* Got rid of stateIn

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

* Code cleanup and auto-selection

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

* Cleanup

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

* Code cleanup

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

* Added push distributor disabling

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

* Added push distributor selection dialog preview

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

* Update strings

* Minor changes

* Got rid of push endpoint

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

* Got rid of automatic push distributor selection

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

* Typo

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

* Moved init to the bottom

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

* MutableStateFlow: use setValue instead of emit

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-12-04 11:38:49 +01:00
Ricki Hirner
cc8fc4734f Move SyncUtils to SyncWorkerManager 2024-12-02 21:57:20 +01:00
Ricki Hirner
0733fef213 AppSettingsModel: clearly identify task app-related entries 2024-11-30 11:34:40 +01:00
Ricki Hirner
f977cc01eb Update dependencies (including ical4android) 2024-11-30 11:20:56 +01:00
Arnau Mora
30dc2cb221 Use specific help URL from current login type (originally in bitfireAT/davx5#611)
---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-29 16:48:26 +01:00
Ricki Hirner
fc6b605693 Remove unused drawables 2024-11-29 16:23:56 +01:00
Sunik Kupfer
4f1176fd99 Don't show app intro again after recomposition (#1142)
* Remember whether we showed the app intro already

* Add comment
2024-11-27 22:53:54 +01:00
Sunik Kupfer
4ff7ff8746 Extract sync framework interaction to separate class (#1137)
* Add SyncFrameworkIntegration class

* Use SyncFrameworkIntegration class

* Update kdoc

* Fix test

* Fix isSyncable check

* Update kdoc

* Remove singleton requirement

* Extract anonymous method setting the content trigger
2024-11-25 15:33:25 +01:00
Ricki Hirner
2f26c6c365 Fetch translations from Transifex 2024-11-24 14:53:37 +01:00
Ricki Hirner
d8bff41bc4 Version bump to 4.4.4 2024-11-24 14:51:32 +01:00
Ricki Hirner
878e2bb3ad Fetch translations from Trasifex 2024-11-22 17:16:19 +01:00
Ricki Hirner
dc1c72cdd3 Version bump to 4.4.4-rc.1 2024-11-22 17:14:36 +01:00
Arnau Mora
1dc7f3de64 Use timezone ID instead of full VTIMEZONE in DB (#1104)
* Migrated to using timezone id directly

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

* added tests for collection timezone

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

* Fixed calendar definition

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

* Added missing line break

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

* Unnecessary forced null

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

* Added ksp.incremental

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

* Version is now 16

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

* Comments cleanup

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

* Added automatic migrations tests

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

* Renamed block to assertionsBlock

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

* Added comment

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

* Got rid of extra comments

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

* Got rid of PR url

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

* Added column drop with try-catch

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

* Added `SdkSuppress`

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

* Added version check for column drop

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

* Included SDK 34

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

* Commit recover

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

* DB tests: inject Context

* DB migration tests: validate in target schema version; manual migration definitions: use new Kotlin syntax

* Use auto-migration instead of manual migration.

- No manual DROP COLUMN required.
- Added support for migration of unparseable VTIMEZONE.

* Update ical4android

* Service detection: ask for calendar default timezone (+ ID)

* Remove dropColumn because we don't need it for the current migration.

Leave the v4 -> v5 migration as it is.

* KDoc

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-22 17:13:57 +01:00
Ricki Hirner
d20c613044 Update dependencies, including ical4android (closes bitfireAT/davx5#625) 2024-11-22 16:56:13 +01:00
Ricki Hirner
fe8eabce1b Show sync error notification even with big local resource (#1139)
Sync error notification: truncate local info
2024-11-22 00:46:48 +01:00
Ricki Hirner
b5790bfd09 Update dependencies and gradle wrapper 2024-11-20 16:11:40 +01:00
Arnau Mora
9e7de1c8ca Enforced edge-to-edge (#1078)
* Enforced edge-to-edge

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

* Fixed bottom padding

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

* Recovered ime padding

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

* Updated theme colors for intro

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

* Always using light primary color in intro

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

* Simplified settings

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

* Added disabling of top padding

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

* Improved syntax

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

* Fixed padding

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

* Add comments

* Use default fallback colors for status/system bar instead of transparent

- With transparent, system bar buttons are white on very-light-grey

* Use safeContentPadding for intro

* Use scrim color from theme

* IntroPage: correctly consume insets; Assistant: KDoc/example

* Branding box extends to upper edge

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

* Set correct navigation bar color, if applicable

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

* Let content shine through navigation bar / especially for lists like settings

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

* Brand background is no longer padded

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

* Window insets are now consumed by AppTheme

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

* Fixed padding

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

* Do not consume insets

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

* Move content

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

* Enabled E2E in intro

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

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-20 13:05:09 +01:00
Ricki Hirner
15d2072f16 Version bump to 4.4.4-alpha.1 2024-11-19 19:55:41 +01:00
Ricki Hirner
0f4e48ad4d Move local collection management from companion objects to LocalDataStore (#1125)
* [WIP] Move local collection management from companion objects to LocalDataStore

* Move Syncer.getLocalCollections to LocalDataStore.getAll()

- note that things like the "sync_enabled" Android calendar flag are not supported and always set to true

* Minor changes

* Implement LocalJtxCollectionStore

* Implement LocalTaskListStore

* Fix tests

* Drop initialUserData

* Address book read-only applies to entries of address book itself, so moving to LocalAddressBook

* KDoc, shouldBeReadOnly

* Test accountName

* Remove obsolete address book factory

* Test create address book

* Test createAccount

---------

Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
2024-11-18 12:46:14 +01:00
Ricki Hirner
41075e442c Push: fix expiration XML element in push-register message 2024-11-17 14:47:41 +01:00
Ricki Hirner
3a16b5ca3f [Push] Handle subscription expiration (#1131)
* Store subscription expiration in DB

* Regularly run PushRegistrationWorker, if needed

* Skip re-registering subscriptions that are not about to expire

* Add back-off for PushRegistrationWorkerManager

* Request expiration in 3 days

* Show expiration in UI, timestamps in seconds

* Fix tests
2024-11-17 14:39:22 +01:00
Sunik Kupfer
32925dc18b Set or update read-only flag when address books are renamed (#1124)
* Always evaluate read only

* Extract read only evaluation to companion method

* Extract read only evaluation to companion method

* Add test

* Always pass forceReadOnly flag

* Minor KDoc changes

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-11-07 11:45:27 +01:00
Sunik Kupfer
cf15dd3e0e Set contacts provider settings when address books are renamed or update (#1123)
Set contacts provider settings when address books are renamed and updated
2024-11-06 22:10:44 +01:00
Ricki Hirner
3317a8d355 Move dirty verifier logic to ContactDirtyVerifier (#1122)
* Move dirty verifier logic to ContactDirtyVerifier

* KDoc

* KDoc, fix contactDataHashCode

* Fix tests

* Add KDoc
2024-11-06 12:05:09 +01:00
Ricki Hirner
154d1e6bc8 AccountSettingsMigrations: fix comment, require tests (#1116)
AccountSettingsMigration: fix comment, require tests
2024-11-01 15:59:14 +01:00
Ricki Hirner
1a19d5cd17 Update AGP 2024-11-01 11:38:58 +01:00
Ricki Hirner
b721e83377 Update dependencies 2024-10-31 09:55:19 +01:00
Ricki Hirner
f69533b049 Remove obsolete permissions for SDK level 22 (min SDK = 24) 2024-10-28 11:51:59 +01:00
Sunik Kupfer
d00353ba9c Replace android sync framework result class with our own (#1094)
* Use our own SyncResult data class

* Minor comment changes

---------

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

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

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

* Replaced all usages of addressBookAccount

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

* Minor changes

---------

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

* Don't make contacts dirty when moving

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

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

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

* Move explanation to top and add paragraph

* Remove unnecessary parenthesis

* Use two text composables and no spacer

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

* Update vcard4android which doesn't dump Contact photos anymore

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

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

* Account screen: higher contrast for collection cards

* Account screen: use normal instead of elevated cards

* Adapt colors of card lists

---------

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

* Syncer: log when local collection is removed

* Update KDoc and tests

* Handle all CRUD work in updateCollections

* Update naming, KDoc, tests

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

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

* Moved notify function to PushNotificationManager

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

* Added ongoing and only-alert-once

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

* Added notification hiding

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

* Got rid of `cancel`

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

* Fixed comments

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

* Added content intent and sub text

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

* Updated usages

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

* Review changes

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

---------

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

* Run sync and accounts cleanup in migration

* Rename accounts in migration

* Run account settings migrations on background thread

* Revert "Run account settings migrations on background thread"

This reverts commit 6b578da4f1.

* Add tests for AccountsCleanupWorker

* Move companion object to end of class

* Don't use AccountRepository for address book accounts

* Update account user data in LocalAddressBook

* Minor changes (naming etc)

* Add log line when migrating

* Try to fix test error

---------

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

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

* separate CalendarTimezone and CalendarTimezoneId

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

* Fixed timezone name setting

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

* Fixed `VTIMEZONE` conversion

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

* Using text instead of CDATA

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

* Fixed spec

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

* Added comment

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

* Renamed `timezoneDef` to `timezoneId`

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

* Upgrade dav4jvm

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

* separate CalendarTimezone and CalendarTimezoneId

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

* Fixed timezone name setting

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

* Fixed `VTIMEZONE` conversion

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

* Using text instead of CDATA

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

* Fixed spec

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

* Added comment

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

* Renamed `timezoneDef` to `timezoneId`

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

* [CI] Update workflows to Java 21

* Set default value of the timezone state to null

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

---------

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

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

* Use the correct id

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

* UiUtils: use DI for Logger

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

* Fix tests

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

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

* Throw exception when AccountSettings are used on the main thread

* Don't access AccountSettings on main thread

* Don't access AccountSettings on main thread

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

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

* Use collection id as reference in address book account

* Remove obsolete baos

* Find main account directly from collection in SyncManager

* Require main account to get account settings

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

* Require content provider and introduce static deleteByCollection method

* Update KDoc

* Show all address book accounts separately

* Drop mainAccount method

* [DI] Use AssistedInject for LocalAddressBook

* Renaming, remove "main account" concept

* Fix debug info

* AccountsCleanupWorker: Rename main account to account

* Further remove main accounts

* Reduce redundancy

* AccountSettings: check account type

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

* AccountRepository: directly delete accounts

* Remove obsolete workerAccount

* Get all address books, even if not sync enabled

* Delete orphan address book accounts

* Rename two more occurrences of main account concept

* AccountSettings: allow test accounts

* Syncer: rename methods for clarity, add KDoc

* Drop empty test class

* Make code more readable and add comment

---------

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

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

* Deprecated and suggested a ReplaceWith

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

* Optimized imports

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

* Replaced usages of `ClickableTextWithLink`

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

* Removed `ClickableTextWithLink`

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

* Migrated `ClickableTextWithLink`

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

* Remove experimental text api annotations

---------

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

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

* Upgrade dependencies

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

* Migrated pull to refresh

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

* Migrated `LocalMinimumInteractiveComponentEnforcement`

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

* Removed disabling of linting

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

* Optimize imports

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

* Increased indicator show time

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

---------

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

* Remove subscription from DB, too

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

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

* Fixed nullability issues

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

* Increase SDK level

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

* Fixed nullability issues

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

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

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

* Switched to null check instead of NPE catch

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

* Using orEmpty

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

---------

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

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

* Adjusted bar color

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

* Got rid of theme changes

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

* Added wrapping scaffold

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

* Changed colors

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

---------

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

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

* Excluded `generated`

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

* Removed argument

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

* Using build time from git

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

* Removed unused import

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

* Got rid of build date

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

* Got rid of build date

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

---------

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

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

* Moved disable

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

---------

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

* Use standard content provider in TaskSyncer

* Check version instead of acquiring TaskProvider

* Add sync result error

---------

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

* Test sync honors preparation result

* Refactor sync algorithm into smaller testable methods

* Write weak tests for the individual methods

* Update KDoc

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

---------

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

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

* Fix overlapping property name

* Update logger usage

---------

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

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

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

* Fixed imports

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

* Fix tests

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

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

* SyncManagerTest: don't write SyncStats to DB

---------

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

* Close any acquired content provider after sync

* Acquire/Check task provider provider as preparation step

* Provide sync arguments at syncer creation

* Acquire ContentProviderClient in syncer implementations

* Generalize sync algorithm in Syncer

* Use contacts authority for address books

* Generalize sync algorithm in CalendarSyncer

* Generalize sync algorithm in SyncerTest

* Generalize sync algorithm in TaskSyncer

* Rename preparation method and add an after sync method

* Generalize sync algorithm in JtxSyncer

* Generalize sync algorithm in AddressBookSyncer

* Use repositories instead of DAOs

* Replace deprecated log statements

* Use generic type for collection types

* Infer authorities when possible and pass only task authorities along

* No need to close TaskProvider explicitly

* Use colors only where needed

* Use provider with auto closable

* Get sync collections in syncer implementations

* Delete syncer test

* Pass provider through methods instead of using lateinit property

* Reorder constructor arguments

* Remove trailing commas

* Remove obsolete undocumented conditional

* Reorder methods

* Reorder methods

* Abort sync when preparations fail

* Drop obsolete permission check

* Use generics for url and delete

* Use generic for update

* Use generic for syncCollection

* Rename create to createCollection for consistency

* Revert "Rename create to createCollection for consistency"

This reverts commit 0fee4fe7fcf3ec8ef965c9a2e0db991a1bbbbcf7.

* Revert "Use generic for syncCollection"

This reverts commit ae129fd17e06146a1e9f8631e3cdbd2dc0a4db06.

* Revert "Use generic for update"

This reverts commit 42dd665851ba83a75bb498b98bd56624e2b09647.

* Revert "Use generics for url and delete"

This reverts commit 7ae1425039656d4a9937628ca1799ce8c59ceebb.

* Move delete() to LocalCollection

* Move url to LocalCollection

* Fix local test collection

* Minor changes

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

* Query for sync collections once only

* Add KDoc and update comments

* Make property a local variable

* Update KDoc

* Add back ose conditional

* Remove blank line at beginning of method

---------

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

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

* Using `XmlReader`

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

---------

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

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

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

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

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

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

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

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

* Fix tests

* Remove BaseDefaultsProvider

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

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

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

* Increased version number

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

* Increased version number

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

* Added version

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

* Added pushTopic as index

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

* Moved index

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

* Changed uniqueness

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

* Changed indexing method

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

* Moved `parentId` to independent index

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

---------

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

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

* Replace NotificationUtils by NotificationRegistry

* Re-factor SyncConditions; move tests to default location

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

* Replace ExtendedLruCache by Guava cache

* Use injected logger in DavDocumentsProvider

* Use DI for RandomAccessCallback, move Wrapper to outer class

* Fix tests

* Use Hilt for more classes

* Use Guava LoadingCache as page cache

* Fix tests

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

* KDoc

* Rewrite bestSRVRecord test

* Add pathsFromTXTRecords tests

* Add desugaring note

* Rename @AssistedFactory method to "create"

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

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

* AccountSettings: clarify different account variables

* Fix tests

* Inject @ApplicationContext instead of Application

* Add Hilt rule to various tests

* Fix tests

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

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

* Replace DateFormatUtils and NumberUtils

* Fix tests

* Replace ReflectionToStringBuilder

* Rewrite MemoryCookieStore to get rid of MultiMap

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

* Replace org.apache.commons.collections4.CollectionUtils

* Replace DigestUtils

* Replace ContextedException by SyncException

* Fix HttpClientTest

* SyncManager: remove obsolete code

* Fix StringUtilsTest

* DiskCache: add hit/miss logging

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

* Separate LogManager and Logger usage

* Improve StringHandler for DavResourceFinder

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

    Version bump to 4.4.1.1

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

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

* Provide collection to ContactsSyncManager

* Provide collection to JtxSyncManager

* Provide collection to TasksSyncManager

* Provide collection to SyncManager

* Fix test

* Minor changes

* Add kdoc

* Minor changes

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

* Mock collection in test

* Add comments separating the sync process into separate steps

---------

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

* Drop address book sync adapter service

* Replace address books authority with contacts authority

* Update kdoc

* Revert "Update kdoc"

This reverts commit dfb14d466f6c58d9422e59c770e0a5348a497b7d.

* Revert "Replace address books authority with contacts authority"

This reverts commit 0e15bf11b3235dfbc38696741100210fbe497dbd.

* Don't enable addressbook sync for main account

* Acquire content provider in Syncer

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

* Set default sync interval for address book authority again

* Minor re-ordering of lines

* Add comment, rename variable

* Move SyncAdapterServices out of adapter package

---------

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

* Drop other Apache Commons

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

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

* Add KDoc

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

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

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

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

* Changed GPU to swiftshader

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

* Added more options

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

* Cleaning managed devices

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

* Removed cleanup of GMD

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

* Cleaning cache

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

* Removed app clean

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

* Removal of cached system image

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

* Got rid of cached image removal

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

* Disabled build cache

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

* Disabled configuration cache

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

* Changed run command

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

* Downgrade AGP

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

* Restore to original

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

* Upgrade AGP again

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

* Add setupTimeoutMinutes=180 option

* Use large runner

* Update gradle, add maxConcurrentDevices=1

* Remove maxConcurrentDevices=1 again

* Use gradle 8.7 again, clean caches before

* Create "compile" task that caches dependencies etc.

* Don't use incremental build cache anymore

---------

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

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

* Got rid of AppIntro

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

* Imports cleanup

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

* Imports cleanup

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

* Removed padding

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

* When launching intro, going back closes the app

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

* Added content description

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

* Moved IntroActivity.Model to IntroModel

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

* Given fixed padding

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

* Moved intro composables together

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

* Do not create new task

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

* Minor changes

* Remove last XML styles that were required for AppIntro

---------

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

* Add UP dependency

* [WIP] UnifiedPush basic implementation

* Handle endpoint unregistration

* [WIP] Parse push notification message

* Parse push message in PushMessageParser

* Sync only affected account on push message

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

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

* Handle invalid/non-XML push messages

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

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

* [wip] Subscription registration request

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

* Don't enqueue push registration worker from UI

* Enqueue PushRegistrationWorker on collection changes

* Fix tests

* Update dav4jvm; Use new post method

* Remove obsolete context

* Add get and deleteAll methods to serviceRepository and update usages

* requestPushRegistration: make endpoint an argument

* Update push subscription fields in DB on successful registration

* Don't create notification channels in test class

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

* Require network connection to run PushRegistrationWorker

* Move module declaration to a separate TestModules interface

---------

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

* Make collection repository changes observable

* Add kdoc

* Add basic test

* Extract RefreshCollectionsWorker; move some HomeSetDao calls to DavHomeSetRepository

* Replace more HomeSetDao calls

* Remove duplicate copyright notice

* Drop weak reference for observers

* Rename method

* Remove test service after run

* Verify notifying works with mockk

* Rename test

* Use construction injection

* Remove unused SettingsManager

* Remove obsolete mockk rule

* Use runBlocking instead of runTest

* Change to observer linkedList to mutableSetOf, remove synchronized calls

* Change to hilt multibinding

* Remove some unnecessary lines; allow empty set by Hilt

* CollectionListRefresher: delete collections using repository

* deleteRemote: call callback too; adapt KDoc

---------

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

* Replace more HomeSetDao calls

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

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-06-07 10:11:00 +02:00
Ricki Hirner
4823d6d671 [CI] Update gh-release action 2024-06-05 11:45:10 +02:00
Ricki Hirner
9b5d5c982b Version bump to 4.4.0.1 2024-05-31 08:49:25 +02:00
Ricki Hirner
a03c83450d App settings: mention that verbose logs can be viewed in debug info (#824) 2024-05-31 08:47:52 +02:00
Ricki Hirner
5d97161c9b Update dependencies (including ical4android, closes bitfireAT/davx5#551) 2024-05-31 08:11:32 +02:00
Ricki Hirner
671c17376a PreferenceRepository.observeAsFlow: emit initial value (#822) 2024-05-30 10:09:34 +02:00
454 changed files with 26471 additions and 12710 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,6 +1,7 @@
name: Qualified Feature Request
description: "[Developers only] For qualified feature requests. (Use Discussions if unsure.)"
labels: ["feature"]
description: "For qualified feature requests. (Use Discussions if unsure.)"
type: feature
labels: ["enhancement"]
body:
- type: checkboxes
attributes:

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

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

3
.github/release.yml vendored
View File

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

View File

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

View File

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

View File

@@ -2,64 +2,69 @@ name: Development tests
on:
push:
branches:
- '*'
- 'main-ose'
pull_request:
concurrency:
group: test-dev-${{ github.ref }}
cancel-in-progress: true
env:
is_main_branch: ${{ github.ref == 'refs/heads/main-ose' }}
jobs:
compile:
name: Compile for build cache
if: ${{ github.ref == 'refs/heads/main-ose' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v4 # creates build cache when on main branch
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
dependency-graph: generate-and-submit # submit Github Dependency Graph info
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
test:
name: Tests without emulator
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Lint and unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
java-version: 21
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true # gradle user home cache is generated by test_on_emulator
cache-read-only: true
- name: Use incremental build cache
uses: actions/cache/restore@v4
with:
key: incremental-build-tests
restore-keys: incremental-build-tests # restore cache from main branch
path: app/build
- name: Run lint
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:lintOseDebug
- name: Run unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
- name: Run lint and unit tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:check
# generates the build caches because it uses more gradle dependencies
test_on_emulator:
name: Tests with emulator
needs: compile
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
name: Instrumented tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v3
java-version: 21
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
gradle-home-cache-cleanup: true # avoid ever-growing gradle user home cache
- name: Use incremental build cache
if: ${{ !env.is_main_branch }}
uses: actions/cache/restore@v4
with:
key: incremental-build-tests
restore-keys: incremental-build-tests # restore cache from main branch
path: |
.gradle/configuration-cache
app/build
cache-read-only: true
- name: Enable KVM group perms
run: |
@@ -74,11 +79,4 @@ jobs:
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
- name: Run device tests
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:virtualCheck
- name: Create incremental build cache
if: ${{ env.is_main_branch }}
uses: actions/cache/save@v4
with:
key: incremental-build-tests-${{ github.run_id }}
path: app/build
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:virtualCheck

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>

6
.idea/copyright/LICENSE.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
<option name="myName" value="LICENSE" />
</copyright>
</component>

3
.idea/copyright/profiles_settings.xml generated Normal file
View File

@@ -0,0 +1,3 @@
<component name="CopyrightManager">
<settings default="LICENSE" />
</component>

14
AUTHORS
View File

@@ -1,11 +1,7 @@
# This is the list of significant contributors to DAVx5.
#
# This does not necessarily list everyone who has contributed work.
# To see the full list of contributors, see the revision history in
# source control.
You can view the list of people who have contributed to the code base in the version control history:
https://github.com/bitfireAT/davx5-ose/graphs/contributors
Ricki Hirner (bitfire.at)
Bernhard Stockmann (bitfire.at)
Translators are not mentioned in the history explicitly.
The list of translators can be found in the About screen.
Sunik Kupfer (bitfire.at)
Patrick Lang (techbee.at)
Every contribution is welcome. There are many other forms of contributing besides writing code!

View File

@@ -1,44 +1,47 @@
/***************************************************************************************************
/*
* 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 = 34
compileSdk = 36
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404000004
versionName = "4.4"
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
versionCode = 404110004
versionName = "4.4.11"
setProperty("archivesBaseName", "davx5-ose-$versionName")
minSdk = 24 // Android 7.0
targetSdk = 34 // Android 14
targetSdk = 36 // Android 16
buildConfigField("String", "userAgent", "\"DAVx5\"")
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
}
testInstrumentationRunner = "at.bitfire.davdroid.CustomTestRunner"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
compileOptions {
// enable because ical4android requires desugaring
// required for
// - dnsjava 3.x: java.nio.file.Path
// - ical4android: time API
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
@@ -84,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"
}
}
@@ -115,15 +119,9 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
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")
}
aboutLibraries {
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
excludeFields = arrayOf("generated")
}
dependencies {
@@ -141,29 +139,27 @@ dependencies {
implementation(libs.androidx.activityCompose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser)
implementation(libs.androidx.cardView)
implementation(libs.androidx.concurrentFuture)
implementation(libs.androidx.constraintLayout)
implementation(libs.androidx.core)
implementation(libs.androidx.fragment)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.lifecycle.runtime.compose)
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)
implementation(libs.androidx.security)
implementation(libs.androidx.work.base)
implementation(libs.android.flexbox)
// Jetpack Compose
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,25 +177,36 @@ 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
implementation(libs.appintro)
implementation(libs.commons.collections)
@Suppress("RedundantSuppression")
implementation(libs.commons.io)
implementation(libs.commons.lang)
implementation(libs.commons.text)
@Suppress("RedundantSuppression")
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.mikepenz.aboutLibraries)
implementation(libs.nsk90.kstatemachine)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)
implementation(libs.okhttp.logging)
implementation(libs.openid.appauth)
implementation(libs.unifiedpush) {
// 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)
@@ -210,10 +217,13 @@ 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,52 +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.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,669 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "9a0eb47f27473eab254db568081a4585",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `ownerId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, `pushTopic` TEXT, `supportsWebPush` INTEGER NOT NULL DEFAULT 0, `pushSubscription` TEXT, `pushSubscriptionCreated` INTEGER, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`ownerId`) REFERENCES `principal`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pushTopic",
"columnName": "pushTopic",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsWebPush",
"columnName": "supportsWebPush",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pushSubscription",
"columnName": "pushSubscription",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pushSubscriptionCreated",
"columnName": "pushSubscriptionCreated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_ownerId_type",
"unique": false,
"columnNames": [
"ownerId",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_ownerId_type` ON `${TABLE_NAME}` (`ownerId`, `type`)"
},
{
"name": "index_collection_pushTopic_type",
"unique": false,
"columnNames": [
"pushTopic",
"type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_pushTopic_type` ON `${TABLE_NAME}` (`pushTopic`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
},
{
"table": "principal",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"ownerId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "principal",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `url` TEXT NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_principal_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_document",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mountId` INTEGER NOT NULL, `parentId` INTEGER, `name` TEXT NOT NULL, `isDirectory` INTEGER NOT NULL, `displayName` TEXT, `mimeType` TEXT, `eTag` TEXT, `lastModified` INTEGER, `size` INTEGER, `mayBind` INTEGER, `mayUnbind` INTEGER, `mayWriteContent` INTEGER, `quotaAvailable` INTEGER, `quotaUsed` INTEGER, FOREIGN KEY(`mountId`) REFERENCES `webdav_mount`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parentId`) REFERENCES `webdav_document`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mountId",
"columnName": "mountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "eTag",
"columnName": "eTag",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastModified",
"columnName": "lastModified",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayBind",
"columnName": "mayBind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayUnbind",
"columnName": "mayUnbind",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "mayWriteContent",
"columnName": "mayWriteContent",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaAvailable",
"columnName": "quotaAvailable",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "quotaUsed",
"columnName": "quotaUsed",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_webdav_document_mountId_parentId_name",
"unique": true,
"columnNames": [
"mountId",
"parentId",
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_webdav_document_mountId_parentId_name` ON `${TABLE_NAME}` (`mountId`, `parentId`, `name`)"
},
{
"name": "index_webdav_document_parentId",
"unique": false,
"columnNames": [
"parentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_webdav_document_parentId` ON `${TABLE_NAME}` (`parentId`)"
}
],
"foreignKeys": [
{
"table": "webdav_mount",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"mountId"
],
"referencedColumns": [
"id"
]
},
{
"table": "webdav_document",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"parentId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "webdav_mount",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a0eb47f27473eab254db568081a4585')"
]
}
}

View File

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

View File

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

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

@@ -7,4 +7,4 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
</manifest>
</manifest>

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

@@ -1,17 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.testing.HiltTestApplication
class CustomTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, name: String, context: Context) =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View File

@@ -1,6 +1,6 @@
/***************************************************************************************************
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
*/
package at.bitfire.davdroid

View File

@@ -0,0 +1,36 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
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

@@ -6,91 +6,118 @@ package at.bitfire.davdroid
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import androidx.annotation.RequiresApi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import org.junit.Assert.assertNotNull
import org.junit.rules.ExternalResource
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.util.logging.Logger
/**
* JUnit ClassRule which initializes the AOSP CalendarProvider.
* Needed for some "flaky" tests which would otherwise only succeed on second run.
*
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
* if this comment) can find out on how to initialize the calendar provider so that the
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
* please let us know!
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
* maybe by some wrongly synchronized database initialization. So things like querying the instances
* fails in this case.
*
* If you run tests manually, just make sure to ignore the first run after the calendar
* provider has been accessed the first time.
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
* is used the very first time (especially in CI tests / a fresh emulator).
*
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
*/
class InitCalendarProviderRule private constructor(): TestRule {
class InitCalendarProviderRule private constructor(): ExternalResource() {
companion object {
fun getInstance() = RuleChain
private var isInitialized = false
private val logger = Logger.getLogger(InitCalendarProviderRule::javaClass.name)
fun getInstance(): RuleChain = RuleChain
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
.around(InitCalendarProviderRule())
}
override fun apply(base: Statement, description: Description): Statement {
Logger.log.info("Initializing calendar provider before running ${description.displayName}")
return InitCalendarProviderStatement(base)
}
class InitCalendarProviderStatement(val base: Statement): Statement() {
override fun evaluate() {
override fun before() {
if (!isInitialized) {
logger.info("Initializing calendar provider")
if (Build.VERSION.SDK_INT < 31)
Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
initCalendarProvider()
logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
base.evaluate()
}
private fun initCalendarProvider() {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
val uri = AndroidCalendar.create(account, provider, ContentValues())
val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
try {
// single event init
val normalEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
normalLocalEvent.add()
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
assertNotNull("Couldn't acquire calendar provider", client)
// recurring event init
val recurringEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
}
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
localRecurringEvent.add()
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
} finally {
calendar.delete()
client!!.use {
initCalendarProvider(client)
isInitialized = true
}
}
}
private fun initCalendarProvider(provider: ContentProviderClient) {
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
var calendarOrNull: LocalCalendar? = null
for (i in 0..50) {
calendarOrNull = createAndVerifyCalendar(account, provider)
if (calendarOrNull != null)
break
else
Thread.sleep(100)
}
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
try {
// single event init
val normalEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event with 1 instance"
}
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
normalLocalEvent.add()
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
// recurring event init
val recurringEvent = Event().apply {
dtStart = DtStart("20220120T010203Z")
summary = "Event over 22 years"
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
}
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
localRecurringEvent.add()
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
} finally {
calendar.delete()
}
}
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
val uri = AndroidCalendar.create(account, provider, ContentValues())
return try {
AndroidCalendar.findByID(
account,
provider,
LocalCalendar.Factory,
ContentUris.parseId(uri)
)
} catch (e: Exception) {
logger.warning("Couldn't find calendar after creation: $e")
null
}
}
}

View File

@@ -4,9 +4,7 @@
package at.bitfire.davdroid
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
@@ -18,12 +16,12 @@ import javax.inject.Inject
@HiltAndroidTest
class OkhttpClientTest {
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
@@ -32,13 +30,14 @@ class OkhttpClientTest {
@Test
fun testIcloudWithSettings() {
val client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.build()
client.okHttpClient.newCall(Request.Builder()
httpClientBuilder.build().use { client ->
client.okHttpClient
.newCall(Request.Builder()
.get()
.url("https://icloud.com")
.build())
.execute()
}
}
}

View File

@@ -5,16 +5,14 @@
package at.bitfire.davdroid
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import android.util.Log
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import org.jetbrains.annotations.TestOnly
import androidx.work.WorkerFactory
import androidx.work.testing.WorkManagerTestInitHelper
import org.junit.Assert.assertTrue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.math.abs
object TestUtils {
@@ -27,22 +25,16 @@ object TestUtils {
)
}
@TestOnly
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
workInStates(context, workerName, listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING
))
/**
* Initializes WorkManager for instrumentation tests.
*/
fun setUpWorkManager(context: Context, workerFactory: WorkerFactory? = null) {
val config = Configuration.Builder().setMinimumLoggingLevel(Log.DEBUG)
if (workerFactory != null)
config.setWorkerFactory(workerFactory)
WorkManagerTestInitHelper.initializeTestWorkManager(context, config.build())
}
@TestOnly
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
workInStates(context, workerName, listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING,
WorkInfo.State.SUCCEEDED
))
@TestOnly
fun workInStates(context: Context, workerName: String, states: List<WorkInfo.State>): Boolean =
WorkManager.getInstance(context).getWorkInfos(WorkQuery.Builder
.fromUniqueWorkNames(listOf(workerName))
@@ -50,33 +42,17 @@ object TestUtils {
.build()
).get().isNotEmpty()
fun workScheduledOrRunning(context: Context, workerName: String): Boolean =
workInStates(context, workerName, listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING
))
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
@TestOnly
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(value: T) {
data = value
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
@Suppress("UNCHECKED_CAST")
return data as T
}
fun workScheduledOrRunningOrSuccessful(context: Context, workerName: String): Boolean =
workInStates(context, workerName, listOf(
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING,
WorkInfo.State.SUCCEEDED
))
}

View File

@@ -6,45 +6,74 @@ package at.bitfire.davdroid.db
import android.content.Context
import androidx.room.Room
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class AppDatabaseTest {
val TEST_DB = "test"
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject @ApplicationContext
lateinit var context: Context
@Rule
@JvmField
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(), // no auto migrations until v8
FrameworkSQLiteOpenHelperFactory()
)
@Inject
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
@Inject
lateinit var logger: Logger
@Inject
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
@Before
fun setup() {
hiltRule.inject()
}
/**
* Creates a database with schema version 8 (the first exported one) and then migrates it to the latest version.
*/
@Test
fun testAllMigrations() {
// DB schema is available since version 8, so create DB with v8
helper.createDatabase(TEST_DB, 8).close()
// Create DB with v8
MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(), // no auto migrations until v8
FrameworkSQLiteOpenHelperFactory()
).createDatabase(TEST_DB, 8).close()
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
// open and migrate (to current version) database
Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
// manual migrations
.addMigrations(*AppDatabase.migrations)
.addMigrations(*manualMigrations.toTypedArray())
// auto-migrations that need to be specified explicitly
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
.apply {
for (spec in autoMigrations)
addAutoMigrationSpec(spec)
}
.build()
try {
// open (with version 8) + migrate (to current version) database
db.openHelper.writableDatabase
} finally {
db.close()
}
.openHelper.writableDatabase // this will run all migrations
.close()
}
companion object {
const val TEST_DB = "test"
}
}

View File

@@ -6,11 +6,9 @@ package at.bitfire.davdroid.db
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -30,29 +28,25 @@ import javax.inject.Inject
@HiltAndroidTest
class CollectionTest {
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
fun setup() {
hiltRule.inject()
httpClient = httpClientBuilder.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}
@After
fun shutDown() {
fun teardown() {
httpClient.close()
}
@@ -91,35 +85,93 @@ class CollectionTest {
@Test
@SmallTest
fun testFromDavResponseCalendar() {
fun testFromDavResponseCalendar_FullTimezone() {
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>BEGIN:VCALENDAR\n" +
"PRODID:-//Example Corp.//CalDAV Client//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTIMEZONE\n" +
"TZID:US-Eastern\n" +
"LAST-MODIFIED:19870101T000000Z\n" +
"BEGIN:STANDARD\n" +
"DTSTART:19671029T020000\n" +
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" +
"TZOFFSETFROM:-0400\n" +
"TZOFFSETTO:-0500\n" +
"TZNAME:Eastern Standard Time (US & Canada)\n" +
"END:STANDARD\n" +
"BEGIN:DAYLIGHT\n" +
"DTSTART:19870405T020000\n" +
"RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" +
"TZOFFSETFROM:-0500\n" +
"TZOFFSETTO:-0400\n" +
"TZNAME:Eastern Daylight Time (US & Canada)\n" +
"END:DAYLIGHT\n" +
"END:VTIMEZONE\n" +
"END:VCALENDAR\n" +
"</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response)!!
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
assertFalse(info.privWriteContent)
assertFalse(info.privUnbind)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timezone)
assertEquals("US-Eastern", info.timezoneId)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
@SmallTest
fun testFromDavResponseCalendar_OnlyTzId() {
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone-id>US-Eastern</CAL:calendar-timezone-id>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response)!!
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
assertFalse(info.privWriteContent)
assertFalse(info.privUnbind)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("US-Eastern", info.timezoneId)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
@@ -152,4 +204,4 @@ class CollectionTest {
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
}
}
}

View File

@@ -6,6 +6,7 @@ package at.bitfire.davdroid.db
import android.content.Context
import androidx.room.Room
import androidx.room.migration.AutoMigrationSpec
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -24,10 +25,17 @@ class MemoryDbModule {
@Provides
@Singleton
fun inMemoryDatabase(@ApplicationContext context: Context): AppDatabase =
fun inMemoryDatabase(
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
@ApplicationContext context: Context
): AppDatabase =
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
// auto-migrations that need to be specified explicitly
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
// auto-migration specs that need to be specified explicitly
.apply {
for (spec in autoMigrations) {
addAutoMigrationSpec(spec)
}
}
.build()
}

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,83 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import at.bitfire.davdroid.db.Collection.Companion.TYPE_CALENDAR
import at.bitfire.davdroid.db.Service
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@HiltAndroidTest
class AutoMigration16Test: DatabaseMigrationTest(toVersion = 16) {
@Test
fun testMigrate_WithTimeZone() = testMigration(
prepare = { db ->
val minimalVTimezone = """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5
BEGIN:VTIMEZONE
TZID:America/New_York
END:VTIMEZONE
END:VCALENDAR
""".trimIndent()
db.execSQL(
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
)
db.execSQL(
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, minimalVTimezone)
)
}
) { db ->
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
cursor.moveToFirst()
assertEquals("America/New_York", cursor.getString(0))
}
}
@Test
fun testMigrate_WithTimeZone_Unparseable() = testMigration(
prepare = { db ->
db.execSQL(
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
)
db.execSQL(
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync, timezone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false, "Some Garbage Content")
)
}
) { db ->
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
cursor.moveToFirst()
assertNull(cursor.getString(0))
}
}
@Test
fun testMigrate_WithoutTimezone() = testMigration(
prepare = { db ->
db.execSQL(
"INSERT INTO service (id, accountName, type) VALUES (?, ?, ?)",
arrayOf(1, "test", Service.Companion.TYPE_CALDAV)
)
db.execSQL(
"INSERT INTO collection (id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, sync) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf(1, 1, TYPE_CALENDAR, "https://example.com", true, true, false, false)
)
}
) { db ->
db.query("SELECT timezoneId FROM collection WHERE id=1").use { cursor ->
cursor.moveToFirst()
assertNull(cursor.getString(0))
}
}
}

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,79 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.db.AppDatabase
import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.Before
import org.junit.Rule
import javax.inject.Inject
/**
* Helper for testing the database migration from [toVersion] - 1 to [toVersion].
*
* @param toVersion The target version to migrate to.
*/
abstract class DatabaseMigrationTest(
private val toVersion: Int
) {
@Inject
lateinit var autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>
@Inject
lateinit var manualMigrations: Set<@JvmSuppressWildcards Migration>
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setup() {
hiltRule.inject()
}
/**
* Used for testing the migration process from [toVersion]-1 to [toVersion].
*
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
*/
protected fun testMigration(
prepare: (SupportSQLiteDatabase) -> Unit,
validate: (SupportSQLiteDatabase) -> Unit
) {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
autoMigrations.toList(),
FrameworkSQLiteOpenHelperFactory()
)
// Prepare the database with the initial version.
val dbName = "test"
helper.createDatabase(dbName, version = toVersion - 1).apply {
prepare(this)
close()
}
// Re-open the database with the new version and provide all the migrations.
val db = helper.runMigrationsAndValidate(
name = dbName,
version = toVersion,
validateDroppedTables = true,
migrations = manualMigrations.toTypedArray()
)
validate(db)
}
}

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

@@ -0,0 +1,24 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.di
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.startup.TasksAppWatcher
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dagger.multibindings.Multibinds
// remove TasksAppWatcherModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
)
abstract class TestTasksAppWatcherModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>
}

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.xbill.DNS.DClass
import org.xbill.DNS.Name
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import javax.inject.Inject
import kotlin.random.Random
@HiltAndroidTest
class DnsRecordResolverTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var dnsRecordResolver: DnsRecordResolver
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun testBestSRVRecord_Empty() {
assertNull(dnsRecordResolver.bestSRVRecord(emptyArray()))
}
@Test
fun testBestSRVRecord_MultipleRecords_Priority_Different() {
val dns1010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
)
val dns2010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 20, 20, 8443, Name.fromString("dav2010.example.com.")
)
// lowest priority first
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010, dns2010))
assertEquals(dns1010, result)
}
@Test
fun testBestSRVRecord_MultipleRecords_Priority_Same() {
val dns1010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
)
val dns1020 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 20, 8443, Name.fromString("dav1020.example.com.")
)
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)
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))
}
}
@Test
fun testBestSRVRecord_OneRecord() {
val dns1010 = SRVRecord(
Name.fromString("_caldavs._tcp.example.com."),
DClass.IN, 3600, 10, 10, 8443, Name.fromString("dav1010.example.com.")
)
val result = dnsRecordResolver.bestSRVRecord(arrayOf(dns1010))
assertEquals(dns1010, result)
}
@Test
fun testPathsFromTXTRecords_Empty() {
assertTrue(dnsRecordResolver.pathsFromTXTRecords(arrayOf()).isEmpty())
}
@Test
fun testPathsFromTXTRecords_OnePath() {
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("something=else", "path=/path1"))
)).toTypedArray()
assertArrayEquals(arrayOf("/path1"), result)
}
@Test
fun testPathsFromTXTRecords_TwoPaths() {
val result = dnsRecordResolver.pathsFromTXTRecords(arrayOf(
TXTRecord(Name.fromString("example.com."), 0, 0L, listOf("path=/path1", "something-else", "path=/path2"))
)).toTypedArray()
result.sort()
assertArrayEquals(arrayOf("/path1", "/path2"), result)
}
}

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -0,0 +1,225 @@
/*
* 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.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
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.every
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import io.mockk.mockkObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class 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)
@get:Rule
val mockkRule = MockKRule(this)
lateinit var addressBookAccountType: String
lateinit var addressBookAccount: Account
lateinit var account: Account
lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
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() {
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 // missing service
}
assertEquals("funnyfriends #42", localAddressBookStore.accountName(collection))
}
@Test
fun test_accountName_missingDisplayName() {
val collection = mockk<Collection> {
every { id } returns 42
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
every { displayName } returns null
every { serviceId } returns service.id
}
val accountName = localAddressBookStore.accountName(collection)
assertEquals("funnyfriends (${account.name}) #42", accountName)
}
@Test
fun test_accountName_missingDisplayNameAndService() {
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
}
assertEquals("funnyfriends #1", localAddressBookStore.accountName(collection))
}
@Test
fun test_create_createAccountReturnsNull() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns service.id
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
}
mockkObject(localAddressBookStore)
every { localAddressBookStore.createAddressBookAccount(any(), any(), any()) } returns null
assertEquals(null, localAddressBookStore.create(provider, collection))
}
@Test
fun test_create_ReadOnly() {
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 true
}
val addrBook = localAddressBookStore.create(provider, collection)!!
assertEquals(Account("funnyfriends (Test Account) #1", addressBookAccountType), addrBook.addressBookAccount)
assertTrue(addrBook.readOnly)
}
@Test
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)
}
@Test
fun test_getAll_differentAccount() {
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns "Another Unrelated Account"
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
val result = localAddressBookStore.getAll(account, provider)
assertTrue(result.isEmpty())
}
@Test
fun test_getAll_sameAccount() {
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(any()) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) } returns account.name
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) } returns account.type
val result = localAddressBookStore.getAll(account, provider)
assertEquals(1, result.size)
assertEquals(addressBookAccount, result.first().addressBookAccount)
}
/**
* Tests the calculation of read only state is correct
*/
@Test
fun test_shouldBeReadOnly() {
val collectionReadOnly = mockk<Collection> { every { readOnly() } returns true }
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, false))
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionReadOnly, true))
val collectionNotReadOnly = mockk<Collection> { every { readOnly() } returns false }
assertFalse(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, false))
assertTrue(LocalAddressBookStore.shouldBeReadOnly(collectionNotReadOnly, true))
}
// helpers
private fun removeAddressBooks() {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(addressBookAccountType).forEach {
accountManager.removeAccountExplicitly(it)
}
}
}

View File

@@ -4,75 +4,173 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import androidx.test.rule.GrantPermissionRule
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.LabeledProperty
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import ezvcard.property.Telephone
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.io.FileNotFoundException
import java.util.LinkedList
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context = InstrumentationRegistry.getInstrumentation().targetContext
val mainAccountType = context.getString(R.string.account_type)
val mainAccount = Account("main", mainAccountType)
val addressBookAccountType = context.getString(R.string.account_type_address_book)
val addressBookAccount = Account("sub", addressBookAccountType)
val accountManager = AccountManager.get(context)
val account = Account("Test Account", "Test Account Type")
@Before
fun setUp() {
hiltRule.inject()
// TODO DOES NOT WORK: the account immediately starts to sync, which creates the sync adapter services.
// The services however can't be created because Hilt is "not ready" (although it has been initialized in the line above).
// assertTrue(AccountUtils.createAccount(context, mainAccount, AccountSettings.initialUserData(null)))
}
@After
fun cleanup() {
accountManager.removeAccount(addressBookAccount, null, null)
accountManager.removeAccount(mainAccount, null, null)
}
// TODO see above
/*@Test
fun testMainAccount_AddressBookAccount_WithMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle().apply {
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
}))
/**
* Tests whether contacts are moved (and not lost) when an address book is renamed.
*/
@Test
fun test_renameAccount_retainsContacts() {
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))
// check mainAccount()
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
// 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", isContactDirty(addressBook, id))
val contact2 = result.getContact()
assertEquals(uid, contact2.uid)
assertEquals("Test Contact", contact2.displayName)
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
}
}
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
// create address book account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle()))
/**
* Tests whether groups are moved (and not lost) when an address book is renamed.
*/
@Test
fun test_renameAccount_retainsGroups() {
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)
// check mainAccount(); should fail because there's no main account
LocalAddressBook.mainAccount(context, addressBookAccount)
}*/
// make sure it's not dirty
localGroup.clearDirty(null, null, null)
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
@Test(expected = IllegalArgumentException::class)
fun testMainAccount_OtherAccount() {
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
// 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", isGroupDirty(addressBook, id))
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()
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -15,19 +15,25 @@ import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.InitCalendarProviderRule
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import at.bitfire.ical4android.util.MiscUtils.closeCompat
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.*
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
class LocalCalendarTest {
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
@@ -36,14 +42,14 @@ class LocalCalendarTest {
@BeforeClass
@JvmStatic
fun setUpProvider() {
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun closeProvider() {
fun tearDownClass() {
provider.closeCompat()
}
@@ -53,13 +59,13 @@ class LocalCalendarTest {
private lateinit var calendar: LocalCalendar
@Before
fun prepare() {
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@After
fun shutdown() {
fun tearDown() {
calendar.delete()
}
@@ -113,7 +119,7 @@ class LocalCalendarTest {
}
@Test
// Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule)
// Needs InitCalendarProviderRule
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
val event = Event().apply {
dtStart = DtStart("20220120T010203Z")

View File

@@ -21,41 +21,30 @@ import at.techbee.jtx.JtxContract.asSyncAdapter
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.DateList
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.*
import org.junit.*
import org.junit.Assert.*
import net.fortuna.ical4j.model.property.DtStart
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.RRule
import net.fortuna.ical4j.model.property.RecurrenceId
import net.fortuna.ical4j.model.property.Status
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Test
import org.junit.rules.TestRule
import java.util.*
import java.util.UUID
class LocalEventTest {
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var provider: ContentProviderClient
private lateinit var calendar: LocalCalendar
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.closeCompat()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var calendar: LocalCalendar
@Before
fun createCalendar() {
fun setUp() {
val uri = AndroidCalendar.create(account, provider, ContentValues())
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
@@ -270,7 +259,7 @@ class LocalEventTest {
})
}
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
val uri = localEvent.add()
localEvent.add()
calendar.findById(localEvent.id!!)
@@ -469,4 +458,28 @@ class LocalEventTest {
}
}
}
companion object {
@JvmField
@ClassRule
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun setUpClass() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun tearDownClass() {
provider.closeCompat()
}
}
}

View File

@@ -5,68 +5,252 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.CachedGroupMembership
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.*
import org.junit.Assert.*
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
val account = Account("Test Account", "Test Account Type")
@Before
fun inject() {
fun setup() {
hiltRule.inject()
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@Test
fun testApplyPendingMemberships_addPendingMembership() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { ab ->
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
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
)
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
// pending membership -> contact1 should be added to group
LocalGroup.applyPendingMemberships(ab)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
// 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))
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
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())
}
}
}
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
@Test
fun testApplyPendingMemberships_removeMembership() {
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 group = newGroup(ab)
// 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)
// 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() {
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()
// 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())
}
}
}
@Test
fun testClearDirty_removeCachedGroupMembership() {
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()
// 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())
}
}
}
@Test
fun testMarkMembersDirty() {
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 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)
}
}
@Test
fun testPrepareForUpload() {
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)
}
}
// helpers
private fun newGroup(addressBook: LocalAddressBook): LocalGroup =
LocalGroup(addressBook,
Contact().apply {
displayName = "Test Group"
@@ -76,192 +260,26 @@ class LocalGroupTest {
}
companion object {
@Before
fun clearContacts() {
addressBookGroupsAsCategories.clear()
addressBookGroupsAsVCards.clear()
}
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@Test
fun testApplyPendingMemberships_addPendingMembership() {
val ab = addressBookGroupsAsVCards
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
)
// 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))
assertFalse(cursor.moveToNext())
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
// 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())
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
@Test
fun testApplyPendingMemberships_removeMembership() {
val ab = addressBookGroupsAsVCards
val contact1 = LocalContact(ab, Contact().apply {
uid = "test1"
displayName = "Test"
}, "test1.vcf", null, 0)
contact1.add()
val group = newGroup(ab)
// 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)
// 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)
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)
}
)
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)
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)
}
)
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)
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()
assertEquals(0, ab.findDirty().size)
group.markMembersDirty()
assertEquals(contact1.id, ab.findDirty().first().id)
}
@Test
fun testPrepareForUpload() {
val group = newGroup()
assertNull(group.getContact().uid)
val fileName = group.prepareForUpload()
val newUid = group.getContact().uid
assertNotNull(newUid)
assertEquals("$newUid.vcf", fileName)
}
}

View File

@@ -7,32 +7,53 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Optional
import java.util.logging.Logger
class LocalTestAddressBook(
context: Context,
provider: ContentProviderClient,
override val groupMethod: GroupMethod
): LocalAddressBook(context, ACCOUNT, provider) {
/**
* 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,
@Assisted provider: ContentProviderClient,
@Assisted override val groupMethod: GroupMethod,
accountSettingsFactory: AccountSettings.Factory,
collectionRepository: DavCollectionRepository,
@ApplicationContext context: Context,
logger: Logger,
serviceRepository: DavServiceRepository,
syncFramework: SyncFrameworkIntegration
): LocalAddressBook(
account = account,
_addressBookAccount = addressBookAccount,
provider = provider,
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
context = context,
dirtyVerifier = Optional.empty(),
logger = logger,
serviceRepository = serviceRepository,
syncFramework = syncFramework
) {
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
}
override var mainAccount: Account?
get() = throw NotImplementedError()
set(_) = throw NotImplementedError()
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()
@AssistedFactory
interface Factory {
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

@@ -5,21 +5,65 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
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
import org.junit.*
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
import org.junit.Assert.assertArrayEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class CachedGroupMembershipHandlerTest {
@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")
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership() {
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBook ->
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 123456)
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
}, contact)
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
}
}
companion object {
@JvmField
@@ -27,37 +71,20 @@ class CachedGroupMembershipHandlerTest {
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
private lateinit var addressBook: LocalTestAddressBook
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
provider.close()
}
}
@Test
fun testMembership() {
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 123456)
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
}, contact)
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
}
}

View File

@@ -5,47 +5,47 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.net.Uri
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 org.junit.*
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipBuilderTest {
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
@Inject @ApplicationContext
lateinit var context: Context
private lateinit var provider: ContentProviderClient
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
@get:Rule
val hiltRule = HiltAndroidRule(this)
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
val account = Account("Test Account", "Test Account Type")
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@AfterClass
@JvmStatic
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
}
@Before
fun inject() {
hiltRule.inject()
}
@@ -54,10 +54,12 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
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])
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])
}
}
}
@@ -66,10 +68,36 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
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)
}
}
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context: Context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -5,75 +5,106 @@
package at.bitfire.davdroid.resource.contactrow
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
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
import org.junit.*
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.AfterClass
import org.junit.Assert
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipHandlerTest {
@JvmField
@Rule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
@Inject @ApplicationContext
lateinit var context: Context
private lateinit var provider: ContentProviderClient
@Inject
lateinit var localTestAddressBookProvider: LocalTestAddressBookProvider
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private var addressBookGroupsAsCategoriesGroup: Long = -1
@get:Rule
var hiltRule = HiltAndroidRule(this)
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
val account = Account("Test Account", "Test Account Type")
@Before
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
}
@After
fun disconnect() {
@Suppress("DEPRECATION")
provider.release()
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership_GroupsAsCategories() {
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
}, contact)
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
localTestAddressBookProvider.provide(account, provider, GroupMethod.CATEGORIES) { addressBookGroupsAsCategories ->
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
}, contact)
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
}
}
@Test
fun testMembership_GroupsAsVCards() {
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
}, contact)
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
assertTrue(contact.categories.isEmpty())
localTestAddressBookProvider.provide(account, provider, GroupMethod.GROUP_VCARDS) { addressBookGroupsAsVCards ->
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
}, contact)
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
assertTrue(contact.categories.isEmpty())
}
}
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context: Context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
Assert.assertNotNull(provider)
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
}

View File

@@ -4,33 +4,26 @@
package at.bitfire.davdroid.servicedetection
import android.content.Context
import android.security.NetworkSecurityPolicy
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.NotificationUtils
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.apache.commons.lang3.StringUtils
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -39,122 +32,104 @@ import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class RefreshCollectionsWorkerTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Before
fun setUp() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
// Test dependencies
companion object {
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
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 CollectionListRefresherTest {
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var refresherFactory: CollectionListRefresher.Factory
@BindValue
@MockK(relaxed = true)
lateinit var settings: SettingsManager
var mockServer = MockWebServer()
@get:Rule
val hiltRule = HiltAndroidRule(this)
lateinit var client: HttpClient
@get:Rule
val mockKRule = MockKRule(this)
private lateinit var client: HttpClient
private lateinit var mockServer: MockWebServer
private lateinit var service: Service
@Before
fun mockServerSetup() {
fun setUp() {
hiltRule.inject()
// Start mock web server
mockServer.dispatcher = TestDispatcher()
mockServer.start()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).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 cleanUp() {
fun tearDown() {
client.close()
mockServer.shutdown()
db.close()
}
// Actual tests
/* Often fails for unknown reasons:
@Test
fun testRefreshCollections_enqueuesWorker() {
val service = createTestService(Service.TYPE_CALDAV)!!
val (workerName, enqueueOp) = RefreshCollectionsWorker.enqueue(context, service.id)
enqueueOp.result.get()
assertTrue(workScheduledOrRunningOrSuccessful(context, workerName))
}*/
@Test
fun testDiscoverHomesets() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
// Query home sets
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.discoverHomesets(baseUrl)
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
// Check home sets have been saved to database
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
assertEquals(1, db.homeSetDao().getByService(service.id).size)
// Check home set has been saved correctly to database
val savedHomesets = db.homeSetDao().getByService(service.id)
assertEquals(2, savedHomesets.size)
// Home set from current-user-principal
val personalHomeset = savedHomesets[1]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
assertEquals(service.id, personalHomeset.serviceId)
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
assertEquals(true, personalHomeset.personal)
// Home set found in a group principal
val groupHomeset = savedHomesets[0]
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
assertEquals(service.id, groupHomeset.serviceId)
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
assertEquals(false, groupHomeset.personal)
}
// refreshHomesetsAndTheirCollections
@Test
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
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"))
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection defined in homeset is now in the database
assertEquals(
@@ -174,8 +149,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -191,8 +164,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
@@ -212,8 +184,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// save "old" collection in DB - with set flags
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -231,8 +201,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection got updated
assertEquals(
@@ -254,8 +223,6 @@ class RefreshCollectionsWorkerTest {
@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"))
@@ -273,9 +240,8 @@ class RefreshCollectionsWorkerTest {
)
)
// Refresh - should mark collection as homeless, because serverside homeset is empty
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
// Refresh - should mark collection as homeless, because serverside homeset is empty.
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check the collection, is now marked as homeless
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
@@ -283,11 +249,9 @@ class RefreshCollectionsWorkerTest {
@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"))
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
// place collection in DB - as part of the homeset
@@ -304,8 +268,7 @@ class RefreshCollectionsWorkerTest {
// Refresh - homesets and their collections
assertEquals(0, db.principalDao().getByService(service.id).size)
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomesetsAndTheirCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
@@ -323,8 +286,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshHomelessCollections_updatesExistingCollection() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -338,8 +299,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got updated - with display name and description
assertEquals(
@@ -359,8 +319,6 @@ class RefreshCollectionsWorkerTest {
@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(
@@ -374,8 +332,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh - should delete collection
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check the collection got deleted
assertEquals(null, db.collectionDao().get(collectionId))
@@ -383,8 +340,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshHomelessCollections_addsOwnerUrls() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place homeless collection in DB
val collectionId = db.collectionDao().insertOrUpdateByUrl(
Collection(
@@ -399,8 +354,7 @@ class RefreshCollectionsWorkerTest {
// Refresh homeless collections
assertEquals(0, db.principalDao().getByService(service.id).size)
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshHomelessCollections()
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
// Check principal saved and the collection was updated with its reference
val principals = db.principalDao().getByService(service.id)
@@ -418,8 +372,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshPrincipals_inaccessiblePrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
@@ -442,8 +394,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh principals
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was not updated
val principals = db.principalDao().getByService(service.id)
@@ -454,8 +405,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshPrincipals_updatesPrincipal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without display name in db
val principalId = db.principalDao().insert(
Principal(
@@ -478,8 +427,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh principals
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal now got a display name
val principals = db.principalDao().getByService(service.id)
@@ -490,8 +438,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
val service = createTestService(Service.TYPE_CARDDAV)!!
// place principal without collections in DB
db.principalDao().insert(
Principal(
@@ -502,8 +448,7 @@ class RefreshCollectionsWorkerTest {
)
// Refresh principals - detecting it does not own collections
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
.refreshPrincipals()
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
// Check principal was deleted
val principals = db.principalDao().getByService(service.id)
@@ -514,9 +459,6 @@ class RefreshCollectionsWorkerTest {
@Test
fun shouldPreselect_none() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
@@ -525,21 +467,23 @@ class RefreshCollectionsWorkerTest {
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_all() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
@@ -548,68 +492,75 @@ class RefreshCollectionsWorkerTest {
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
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/")
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
val collection = Collection(
0,
service.id,
0,
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = url
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_notPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
val collection = Collection(
0,
service.id,
0,
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(
id = 0,
serviceId = service.id,
personal = false,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
val refresher = refresherFactory.create(service, client.okHttpClient)
assertFalse(refresher.shouldPreselect(collection, homesets))
}
@Test
fun shouldPreselect_personal_isPersonal() {
val service = createTestService(Service.TYPE_CARDDAV)!!
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
@@ -618,52 +569,72 @@ class RefreshCollectionsWorkerTest {
service.id,
0,
type = Collection.TYPE_ADDRESSBOOK,
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
url = mockServer.url("/addressbook-homeset/addressbook/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
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/")
val settings = mockk<SettingsManager>()
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
val collection = Collection(
0,
service.id,
0,
id = 0,
serviceId = service.id,
homeSetId = 0,
type = Collection.TYPE_ADDRESSBOOK,
url = collectionUrl
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(
id = 0,
serviceId = service.id,
personal = true,
url = mockServer.url("/addressbook-homeset/")
)
)
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
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"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
}
class TestDispatcher: Dispatcher() {
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = StringUtils.removeEnd(request.path!!, "/")
val path = request.path!!.trimEnd('/')
if (request.method.equals("PROPFIND", true)) {
val properties = when (path) {
@@ -677,8 +648,11 @@ class RefreshCollectionsWorkerTest {
"<resourcetype><principal/></resourcetype>" +
"<displayname>Mr. Wobbles</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
"</CARD:addressbook-home-set>"
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
"</CARD:addressbook-home-set>" +
"<group-membership>" +
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
"</group-membership>"
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
"<CARD:addressbook-home-set>" +
@@ -686,8 +660,16 @@ class RefreshCollectionsWorkerTest {
"</CARD:addressbook-home-set>" +
"<displayname>Mr. Wobbles Jr.</displayname>"
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
"<resourcetype><principal/></resourcetype>" +
"<displayname>All address books</displayname>" +
"<CARD:addressbook-home-set>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
@@ -698,6 +680,17 @@ class RefreshCollectionsWorkerTest {
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
"</owner>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
"<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>" +
"<displayname>Freds Contacts (not mine)</displayname>" +
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
"<owner>" +
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
"</owner>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
"<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
@@ -713,7 +706,7 @@ class RefreshCollectionsWorkerTest {
var responseBody = ""
var responseCode = 207
when (path) {
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
responseBody =
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
@@ -741,8 +734,8 @@ class RefreshCollectionsWorkerTest {
"</multistatus>"
}
Logger.log.info("Queried: $path")
Logger.log.info("Response: $responseBody")
logger.info("Queried: $path")
logger.info("Response: $responseBody")
return MockResponse()
.setResponseCode(responseCode)
.setBody(responseBody)
@@ -752,4 +745,5 @@ class RefreshCollectionsWorkerTest {
}
}
}

View File

@@ -5,16 +5,12 @@
package at.bitfire.davdroid.servicedetection
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.mockwebserver.Dispatcher
@@ -32,22 +28,12 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class DavResourceFinderTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
@Before
fun inject() {
hiltRule.inject()
}
companion object {
private const val PATH_NO_DAV = "/nodav"
private const val PATH_CALDAV = "/caldav"
@@ -59,35 +45,49 @@ class DavResourceFinderTest {
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
}
val server = MockWebServer()
@get:Rule
val hiltRule = HiltAndroidRule(this)
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var logger: Logger
@Inject
lateinit var resourceFinderFactory: DavResourceFinder.Factory
private lateinit var server: MockWebServer
private lateinit var client: HttpClient
private lateinit var finder: DavResourceFinder
@Before
fun initServerAndClient() {
server.dispatcher = TestDispatcher()
server.start()
fun setUp() {
hiltRule.inject()
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 = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, baseURI, credentials)
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.addAuthentication(null, credentials)
.build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
finder = resourceFinderFactory.create(baseURI, credentials)
}
@After
fun stopServer() {
fun tearDown() {
client.close()
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()
@@ -156,7 +156,9 @@ class DavResourceFinderTest {
// mock server
class TestDispatcher: Dispatcher() {
class TestDispatcher(
private val logger: Logger
): Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (!checkAuth(request)) {
@@ -205,7 +207,7 @@ class DavResourceFinderTest {
else -> props = null
}
Logger.log.info("Sending props: $props")
logger.info("Sending props: $props")
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +

View File

@@ -4,137 +4,57 @@
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import android.content.Context
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.TaskProvider
import 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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
/*@HiltAndroidTest
@HiltAndroidTest
class AccountSettingsTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var settingsManager: SettingsManager
private val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
val fakeCredentials = Credentials("test", "test")
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
@Before
fun setUp() {
hiltRule.inject()
assertTrue(AccountUtils.createAccount(
context,
account,
AccountSettings.initialUserData(fakeCredentials)
))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
TestUtils.setUpWorkManager(context)
}
@After
@RequiresApi(22)
fun removeAccount() {
AccountManager.get(context).removeAccountExplicitly(account)
}
@Test
fun testSyncIntervals() {
val settings = AccountSettings(context, account)
val presetIntervals =
context.resources.getStringArray(R.array.settings_sync_interval_seconds)
.map { it.toLong() }
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
for (interval in presetIntervals) {
assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval))
assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY))
}
}
@Test
fun testSyncIntervals_Syncable() {
val settings = AccountSettings(context, account)
val interval = 15*60L // 15 min
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
assertTrue(result)
}
@Test(expected = IllegalArgumentException::class)
fun testSyncIntervals_TooShort() {
val settings = AccountSettings(context, account)
val interval = 60L // 1 min is not supported by Android
settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
}
@Test
fun testSyncIntervals_activatesPeriodicSyncWorker() {
val settings = AccountSettings(context, account)
val interval = 15*60L
for (authority in authorities) {
ContentResolver.setIsSyncable(account, authority, 1)
assertTrue(settings.setSyncInterval(authority, interval))
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
assertEquals(interval, settings.getSyncInterval(authority))
fun testUpdate_MissingMigrations() {
TestAccount.provide(version = 1) { account ->
// will run AccountSettings.update
accountSettingsFactory.create(account, abortOnMissingMigration = true)
}
}
@Test
fun testSyncIntervals_disablesPeriodicSyncWorker() {
val settings = AccountSettings(context, account)
val interval = AccountSettings.SYNC_INTERVAL_MANUALLY // -1
for (authority in authorities) {
ContentResolver.setIsSyncable(account, authority, 1)
assertTrue(settings.setSyncInterval(authority, interval))
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
assertEquals(AccountSettings.SYNC_INTERVAL_MANUALLY, settings.getSyncInterval(authority))
fun testUpdate_RunAllMigrations() {
TestAccount.provide(version = 6) { account ->
// will run AccountSettings.update
accountSettingsFactory.create(account, abortOnMissingMigration = true)
val accountManager = AccountManager.get(context)
val version = accountManager.getUserData(account, AccountSettings.KEY_SETTINGS_VERSION).toInt()
assertEquals(AccountSettings.CURRENT_VERSION, version)
}
}
}*/
}

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

@@ -0,0 +1,102 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.test.rule.GrantPermissionRule
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
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.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration17Test {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var migration: AccountSettingsMigration17
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val permissionRule = GrantPermissionRule.grant(android.Manifest.permission.READ_CONTACTS, android.Manifest.permission.WRITE_CONTACTS)
@Before
fun setUp() {
hiltRule.inject()
}
@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)
var addressBookAccount = Account("Address Book", addressBookAccountType)
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
try {
// address book has account + URL
val url = "https://example.com/address-book"
accountManager.setAndVerifyUserData(addressBookAccount, "real_account_name", account.name)
accountManager.setAndVerifyUserData(addressBookAccount, localAddressBookUserDataUrl, url)
// and is known in database
db.serviceDao().insertOrReplace(
Service(
id = 1, accountName = account.name, type = Service.TYPE_CARDDAV, principal = null
)
)
db.collectionDao().insert(
Collection(
id = 100,
serviceId = 1,
url = url.toHttpUrl(),
type = Collection.TYPE_ADDRESSBOOK,
displayName = "Some Address Book"
)
)
// run migration
migration.migrate(account)
// migration renames address book, update account
addressBookAccount = accountManager.getAccountsByType(addressBookAccountType).filter {
accountManager.getUserData(it, localAddressBookUserDataUrl) == url
}.first()
assertEquals("Some Address Book (${account.name}) #100", addressBookAccount.name)
// ID is now assigned
assertEquals(100L, accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLong())
} finally {
accountManager.removeAccountExplicitly(addressBookAccount)
}
}
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration18Test {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var migration: AccountSettingsMigration18
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testMigrate_AddressBook_InvalidCollection() {
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
val account = Account("test", "test")
migration.migrate(account)
verify(exactly = 0) {
accountManager.setUserData(addressBookAccount, any(), any())
}
}
@Test
fun testMigrate_AddressBook_NoCollection() {
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "123"
val account = Account("test", "test")
migration.migrate(account)
verify(exactly = 0) {
accountManager.setUserData(addressBookAccount, any(), any())
}
}
@Test
fun testMigrate_AddressBook_ValidCollection() {
val account = Account("test", "test")
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)
val accountManager = AccountManager.get(context)
mockkObject(accountManager)
every { accountManager.getAccountsByType(addressBookAccountType) } returns arrayOf(addressBookAccount)
every { accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID) } returns "100"
migration.migrate(account)
verify {
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, account.name)
accountManager.setUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, account.type)
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings.migration
import android.accounts.Account
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
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.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import io.mockk.mockkObject
import io.mockk.verify
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration19Test {
@Inject @ApplicationContext
lateinit var context: Context
@BindValue
@RelaxedMockK
lateinit var automaticSyncManager: AutomaticSyncManager
@Inject
lateinit var migration: AccountSettingsMigration19
@Inject
lateinit var workerFactory: HiltWorkerFactory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
hiltRule.inject()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
@Test
fun testMigrate_CancelsOldWorkersAndUpdatesAutomaticSync() {
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
val account = Account("Some", "Test")
migration.migrate(account)
verify {
workManager.cancelUniqueWork("periodic-sync at.bitfire.davdroid.addressbooks Test/Some")
workManager.cancelUniqueWork("periodic-sync com.android.calendar Test/Some")
workManager.cancelUniqueWork("periodic-sync at.techbee.jtx.provider Test/Some")
workManager.cancelUniqueWork("periodic-sync org.dmfs.tasks Test/Some")
workManager.cancelUniqueWork("periodic-sync org.tasks.opentasks Test/Some")
automaticSyncManager.updateAutomaticSync(account)
}
}
}

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

@@ -2,12 +2,14 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalCollection
class LocalTestCollection: LocalCollection<LocalTestResource> {
class LocalTestCollection(
override val dbCollectionId: Long = 0L
): LocalCollection<LocalTestResource> {
override val tag = "LocalTestCollection"
override val title = "Local Test Collection"

View File

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

View File

@@ -0,0 +1,182 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.WorkInfo
import androidx.work.WorkManager
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccount
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.Awaits
import io.mockk.coEvery
import io.mockk.every
import io.mockk.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@HiltAndroidTest
class SyncAdapterServicesTest {
lateinit var account: Account
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@Inject
lateinit var collectionRepository: DavCollectionRepository
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var logger: Logger
@Inject
lateinit var serviceRepository: DavServiceRepository
@Inject
lateinit var syncConditionsFactory: SyncConditions.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@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)
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
}
@After
fun tearDown() {
TestAccount.remove(account)
}
private fun syncAdapter(
syncWorkerManager: SyncWorkerManager
): SyncAdapterService.SyncAdapter =
SyncAdapterService.SyncAdapter(
accountSettingsFactory = accountSettingsFactory,
collectionRepository = collectionRepository,
serviceRepository = serviceRepository,
context = context,
logger = logger,
syncConditionsFactory = syncConditionsFactory,
syncWorkerManager = syncWorkerManager
)
@Test
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
val sync = launch {
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
// simulate incoming cancellation from sync framework
syncAdapter.onSyncCanceled()
// wait for sync to finish (should happen immediately)
sync.join()
}
}
@Test
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker takes a long time
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function
// immediate timeout (instead of really waiting)
coEvery { withTimeout(any<Long>(), any<suspend CoroutineScope.() -> Unit>()) } throws CancellationException("Simulated timeout")
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
}
}
@Test
fun testSyncAdapter_onPerformSync_runsInTime() {
val syncWorkerManager = mockk<SyncWorkerManager>()
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
val workManager = WorkManager.getInstance(context)
mockkObject(workManager) {
// don't actually create a worker
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
// assume worker immediately returns with success
val success = mockk<WorkInfo>()
every { success.state } returns WorkInfo.State.SUCCEEDED
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow {
emit(listOf(success))
delay(60000) // keep the flow active
}
// should just run
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
}
}
}

View File

@@ -2,148 +2,102 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.content.SyncResult
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.dav4jvm.PropStat
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
import at.bitfire.dav4jvm.property.webdav.GetETag
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.assertWithin
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.sync.account.TestAccount
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.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
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import java.time.Instant
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltAndroidTest
class SyncManagerTest {
companion object {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account("SyncManagerTest", context.getString(R.string.account_type))
@BeforeClass
@JvmStatic
fun createAccount() {
assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"))))
}
@AfterClass
@JvmStatic
fun removeAccount() {
assertTrue(AccountManager.get(context).removeAccount(account, null, null).getResult(10, TimeUnit.SECONDS))
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()
}
}
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@get:Rule
val mockKRule = MockKRule(this)
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@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
val server = MockWebServer()
private lateinit var account: Account
private lateinit var server: MockWebServer
@Before
fun inject() {
fun setUp() {
hiltRule.inject()
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
// However, we need notification channels for the ongoing work notifications.
NotificationUtils.createChannels(context)
TestUtils.setUpWorkManager(context, workerFactory)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}
account = TestAccount.create()
private fun syncManager(collection: LocalTestCollection, syncResult: SyncResult = SyncResult()) =
TestSyncManager(
context,
account,
arrayOf(),
"TestAuthority",
HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build(),
syncResult,
collection,
server
)
@Before
fun startServer() {
server.start()
server = MockWebServer().apply {
start()
}
}
@After
fun stopServer() {
fun tearDown() {
TestAccount.remove(account)
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()
server.close()
}
@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(
@@ -168,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
@@ -186,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())
@@ -201,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 {
@@ -244,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 {
@@ -291,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 {
@@ -336,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 {
@@ -382,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 {
@@ -419,7 +374,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_DownloadNewMember() {
fun testPerformSync_DownloadNewMember() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
}
@@ -453,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 {
@@ -491,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 {
@@ -511,7 +466,7 @@ class SyncManagerTest {
}
@Test
fun testPerformSync_CTagDidntChange() {
fun testPerformSync_CTagDidntChange() = runTest {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
}
@@ -527,4 +482,22 @@ class SyncManagerTest {
assertTrue(collection.entries.isEmpty())
}
// helpers
private fun syncManager(
localCollection: LocalTestCollection,
syncResult: SyncResult = SyncResult(),
collection: Collection = mockk<Collection>(relaxed = true) {
every { id } returns 1
every { url } returns server.url("/")
}
) = syncManagerFactory.create(
account,
httpClientBuilder.build(),
syncResult,
localCollection,
collection
)
}

View File

@@ -0,0 +1,228 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.resource.LocalDataStore
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.junit4.MockKRule
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
class SyncerTest {
@get:Rule
val mockkRule = MockKRule(this)
@RelaxedMockK
lateinit var logger: Logger
val dataStore: LocalTestStore = mockk(relaxed = true)
val provider: ContentProviderClient = mockk(relaxed = true)
@SpyK
@InjectMockKs
var syncer = TestSyncer(mockk(relaxed = true), null, SyncResult(), dataStore)
@Test
fun testSync_prepare_fails() {
every { syncer.prepare(provider) } returns false
every { syncer.getSyncEnabledCollections() } returns emptyMap()
// Should stop the sync after prepare returns false
syncer.sync(provider)
verify(exactly = 1) { syncer.prepare(provider) }
verify(exactly = 0) { syncer.getSyncEnabledCollections() }
}
@Test
fun testSync_prepare_succeeds() {
every { syncer.prepare(provider) } returns true
every { syncer.getSyncEnabledCollections() } returns emptyMap()
// Should continue the sync after prepare returns true
syncer.sync(provider)
verify(exactly = 1) { syncer.prepare(provider) }
verify(exactly = 1) { syncer.getSyncEnabledCollections() }
}
@Test
fun testUpdateCollections_deletesCollection() {
val localCollection = mockk<LocalTestCollection> {
every { 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)
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
verify(exactly = 1) { dataStore.delete(localCollection) }
// Updated local collection list should be empty
assertTrue(result.isEmpty())
}
@Test
fun testUpdateCollections_updatesCollection() {
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)
verify(exactly = 1) { dataStore.update(provider, localCollection, dbCollection) }
// Updated local collection list should be same as input
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
}
@Test
fun testUpdateCollections_findsNewCollection() {
val dbCollection = mockk<Collection> {
every { id } returns 0L
}
val localCollections = listOf(mockk<LocalTestCollection> {
every { dbCollectionId } returns 0L
})
val dbCollections = listOf(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
val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap)
// Updated local collection list contain new entry
assertEquals(1, result.size)
assertEquals(dbCollection.id, result[0].dbCollectionId)
}
@Test
fun testCreateLocalCollections() {
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
every { dataStore.create(provider, dbCollection) } returns localCollection
// Should return list of newly created local collections
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
assertEquals(listOf(localCollection), result)
}
@Test
fun testSyncCollectionContents() {
val dbCollection1 = mockk<Collection>()
val dbCollection2 = mockk<Collection>()
val dbCollections = mapOf(
0L to dbCollection1,
1L to dbCollection2
)
val localCollection1 = mockk<LocalTestCollection> { every { dbCollectionId } returns 0L }
val localCollection2 = mockk<LocalTestCollection> { every { dbCollectionId } returns 1L }
val localCollections = listOf(localCollection1, localCollection2)
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
syncer.syncCollectionContents(provider, localCollections, dbCollections)
verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) }
verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) }
}
// Test helpers
class TestSyncer(
account: Account,
resyncType: ResyncType?,
syncResult: SyncResult,
theDataStore: LocalTestStore
) : Syncer<LocalTestStore, LocalTestCollection>(account, resyncType, syncResult) {
override val dataStore: LocalTestStore =
theDataStore
override val serviceType: String
get() = throw NotImplementedError()
override fun prepare(provider: ContentProviderClient): Boolean =
throw NotImplementedError()
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
throw NotImplementedError()
override fun syncCollection(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
remoteCollection: Collection
) {
throw NotImplementedError()
}
}
class LocalTestStore : LocalDataStore<LocalTestCollection> {
override val authority: String
get() = throw NotImplementedError()
override fun acquireContentProvider(): ContentProviderClient? {
throw NotImplementedError()
}
override fun create(
provider: ContentProviderClient,
fromCollection: Collection
): LocalTestCollection? {
throw NotImplementedError()
}
override fun getAll(
account: Account,
provider: ContentProviderClient
): List<LocalTestCollection> {
throw NotImplementedError()
}
override fun update(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
fromCollection: Collection
) {
throw NotImplementedError()
}
override fun delete(localCollection: LocalTestCollection) {
throw NotImplementedError()
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
throw NotImplementedError()
}
}
}

View File

@@ -2,46 +2,64 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.caldav.GetCTag
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.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
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher
import okhttp3.HttpUrl
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
class TestSyncManager(
context: Context,
account: Account,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
val mockWebServer: MockWebServer
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(context, account, AccountSettings(context, account), httpClient, extras, authority, syncResult, localCollection) {
class TestSyncManager @AssistedInject constructor(
@Assisted account: Account,
@Assisted httpClient: HttpClient,
@Assisted syncResult: SyncResult,
@Assisted localCollection: LocalTestCollection,
@Assisted collection: Collection,
@SyncDispatcher syncDispatcher: CoroutineDispatcher
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(
account,
httpClient,
SyncDataType.EVENTS,
syncResult,
localCollection,
collection,
resync = null,
syncDispatcher
) {
@AssistedFactory
interface Factory {
fun create(
account: Account,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
collection: Collection
): TestSyncManager
}
override fun prepare(): Boolean {
collectionURL = mockWebServer.url("/")
davCollection = DavCollection(httpClient.okHttpClient, collectionURL)
davCollection = DavCollection(httpClient.okHttpClient, collection.url)
return true
}
var didQueryCapabilities = false
override fun queryCapabilities(): SyncState? {
override suspend fun queryCapabilities(): SyncState? {
if (didQueryCapabilities)
throw IllegalStateException("queryCapabilities() must not be called twice")
didQueryCapabilities = true
@@ -67,7 +85,7 @@ class TestSyncManager(
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
@@ -77,7 +95,7 @@ class TestSyncManager(
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

@@ -0,0 +1,163 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.testing.TestListenableWorkerBuilder
import at.bitfire.davdroid.R
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.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountsCleanupWorkerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var settingsManager: SettingsManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var accountManager: AccountManager
lateinit var addressBookAccountType: String
lateinit var addressBookAccount: Account
lateinit var service: Service
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
accountManager = AccountManager.get(context)
service = createTestService()
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account("Fancy address book account", addressBookAccountType)
}
@After
fun tearDown() {
// Remove the account here in any case; Nice to have when the test fails
accountManager.removeAccountExplicitly(addressBookAccount)
}
@Test
fun testCleanUpServices_noAccount() {
// Insert service that reference to invalid account
db.serviceDao().insertOrReplace(Service(id = 1, accountName = "test", type = Service.TYPE_CALDAV, principal = null))
assertNotNull(db.serviceDao().get(1))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpServices()
// Verify that service is deleted
assertNull(db.serviceDao().get(1))
}
@Test
fun testCleanUpServices_oneAccount() {
TestAccount.provide { existingAccount ->
// Insert services, one that reference the existing account and one that references an invalid account
db.serviceDao().insertOrReplace(Service(id = 1, accountName = existingAccount.name, type = Service.TYPE_CALDAV, principal = null))
assertNotNull(db.serviceDao().get(1))
db.serviceDao().insertOrReplace(Service(id = 2, accountName = "not existing", type = Service.TYPE_CARDDAV, principal = null))
assertNotNull(db.serviceDao().get(2))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpServices()
// Verify that one service is deleted and the other one is kept
assertNotNull(db.serviceDao().get(1))
assertNull(db.serviceDao().get(2))
}
}
@Test
fun testCleanUpAddressBooks_deletesAddressBookWithoutAccount() {
// Create address book account without corresponding account
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpAddressBooks()
// Verify account was deleted
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
}
@Test
fun testCleanUpAddressBooks_keepsAddressBookWithAccount() {
TestAccount.provide { existingAccount ->
// Create address book account _with_ corresponding account and verify
val userData = Bundle(2).apply {
putString(LocalAddressBook.USER_DATA_ACCOUNT_NAME, existingAccount.name)
putString(LocalAddressBook.USER_DATA_ACCOUNT_TYPE, existingAccount.type)
}
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(workerFactory)
.build()
worker.cleanUpAddressBooks()
// Verify account was _not_ deleted
assertEquals(listOf(addressBookAccount), accountManager.getAccountsByType(addressBookAccountType).toList())
}
}
// helpers
private fun createTestService(): Service {
val service = Service(id=0, accountName="test", type=Service.TYPE_CARDDAV, principal = null)
val serviceId = db.serviceDao().insertOrReplace(service)
return db.serviceDao().get(serviceId)!!
}
}

View File

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

View File

@@ -0,0 +1,53 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.account
import android.accounts.Account
import android.accounts.AccountManager
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.AccountSettings
import org.junit.Assert.assertTrue
object TestAccount {
private val targetContext by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
/**
* Creates a test account, usually in the `Before` setUp of a test.
*
* Remove it with [remove].
*/
fun create(version: Int = AccountSettings.CURRENT_VERSION): Account {
val accountType = targetContext.getString(R.string.account_type)
val account = Account("Test Account", accountType)
val initialData = AccountSettings.initialUserData(null)
initialData.putString(AccountSettings.KEY_SETTINGS_VERSION, version.toString())
assertTrue(SystemAccountUtils.createAccount(targetContext, account, initialData))
return account
}
/**
* Removes a test account, usually in the `@After` tearDown of a test.
*/
fun remove(account: Account) {
val am = AccountManager.get(targetContext)
assertTrue(am.removeAccountExplicitly(account))
}
/**
* Convenience method to create a test account and remove it after executing the block.
*/
fun provide(version: Int = AccountSettings.CURRENT_VERSION, block: (Account) -> Unit) {
val account = create(version)
try {
block(account)
} finally {
remove(account)
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.sync.SyncDataType
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.test.runTest
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class PeriodicSyncWorkerTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val mockkRule = MockKRule(this)
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context)
account = TestAccount.create()
}
@After
fun tearDown() {
TestAccount.remove(account)
}
@Test
fun doWork_cancelsItselfOnInvalidAccount() = runTest {
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
// Run PeriodicSyncWorker as TestWorker
val inputData = workDataOf(
BaseSyncWorker.INPUT_DATA_TYPE to SyncDataType.EVENTS.toString(),
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)
// observe WorkManager cancellation call
val workManager = WorkManager.getInstance(context)
mockkObject(workManager)
// run test worker, expect failure
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
syncWorkerFactory.create(appContext, workerParameters)
})
.build()
val result = testWorker.doWork()
assertTrue(result is ListenableWorker.Result.Failure)
// verify that worker called WorkManager.cancelWorkById(<its ID>)
verify {
workManager.cancelWorkById(testWorker.id)
}
}
}

View File

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

View File

@@ -0,0 +1,90 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.sync.SyncDataType
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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class SyncWorkerManagerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var syncWorkerManager: SyncWorkerManager
@Inject
lateinit var workerFactory: HiltWorkerFactory
lateinit var account: Account
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccount.create()
}
@After
fun tearDown() {
TestAccount.remove(account)
}
// one-time sync workers
@Test
fun testEnqueueOneTime() {
val workerName = OneTimeSyncWorker.workerName(account, SyncDataType.EVENTS)
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
val returnedName = syncWorkerManager.enqueueOneTime(account, SyncDataType.EVENTS)
assertEquals(workerName, returnedName)
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
// periodic sync workers
@Test
fun enablePeriodic() {
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disablePeriodic() {
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, SyncDataType.EVENTS).result.get()
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
assertFalse(workScheduledOrRunning(context, workerName))
}
}

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,15 +6,15 @@ package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import okhttp3.CookieJar
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -26,76 +26,92 @@ 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 DavDocumentsProviderTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
@Inject lateinit var db: AppDatabase
@Before
fun setUp() {
hiltRule.inject()
}
private var mockServer = MockWebServer()
private lateinit var client: HttpClient
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
}
@Before
fun mockServerSetup() {
// Start mock web server
mockServer.dispatcher = TestDispatcher()
mockServer.start()
@Inject @ApplicationContext
lateinit var context: Context
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
@Inject
lateinit var credentialsStore: CredentialsStore
@Inject
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
@Inject
lateinit var httpClientBuilder: HttpClient.Builder
@Inject
lateinit var db: AppDatabase
@Inject
lateinit var testDispatcher: TestDispatcher
@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()
}
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, 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
@@ -113,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, 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(
@@ -138,22 +156,24 @@ class DavDocumentsProviderTest {
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
// Query - discovers serverside deletion
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.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)!!
@@ -162,10 +182,12 @@ class DavDocumentsProviderTest {
assertEquals("parent2", parent2.name)
// Query - find children of two nodes simultaneously
DavDocumentsProvider.DavDocumentsActor(context, db, cookieStore, CredentialsStore(context), context.getString(R.string.webdav_authority))
.queryChildren(parent1)
DavDocumentsProvider.DavDocumentsActor(context, db, 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)
@@ -175,7 +197,9 @@ class DavDocumentsProviderTest {
// mock server
class TestDispatcher: Dispatcher() {
class TestDispatcher @Inject constructor(
private val logger: Logger
): Dispatcher() {
data class Resource(
val name: String,
@@ -183,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("",
@@ -230,8 +254,7 @@ class DavDocumentsProviderTest {
responses +
"</multistatus>"
Logger.log.info("Query path: $requestPath")
Logger.log.info("Response: $multistatus")
logger.info("Response: $multistatus")
return MockResponse()
.setResponseCode(207)
.setBody(multistatus)

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,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<resources>
<string name="app_name">Davx5Test</string>
</resources>
</resources>

View File

@@ -1,3 +0,0 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="at.bitfire.davdroid.SyncManagerTest"
android:label="Test Account" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
@@ -14,11 +18,6 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- account management permissions not required for own accounts since API level 22 -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
@@ -46,10 +45,11 @@
<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"
android:theme="@style/AppTheme"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:resizeableActivity="true"
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
@@ -67,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"/>
@@ -78,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"/>
@@ -111,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>
@@ -130,14 +130,16 @@
<intent-filter>
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="http" />
<data
tools:ignore="AppLinkUrlError"
android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
</activity>
<activity
@@ -159,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"
@@ -167,7 +169,7 @@
<!-- account type "DAVx⁵" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:name=".sync.account.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
@@ -177,7 +179,7 @@
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:name=".sync.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -188,7 +190,7 @@
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.JtxSyncAdapterService"
android:name=".sync.JtxSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -199,7 +201,7 @@
android:resource="@xml/sync_notes"/>
</service>
<service
android:name=".syncadapter.OpenTasksSyncAdapterService"
android:name=".sync.OpenTasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -210,7 +212,7 @@
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".syncadapter.TasksOrgSyncAdapterService"
android:name=".sync.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -233,7 +235,7 @@
<!-- account type "DAVx⁵ Address book" -->
<service
android:name=".syncadapter.AddressBookAuthenticatorService"
android:name=".sync.account.AddressBookAuthenticatorService"
android:exported="true"
tools:ignore="ExportedService"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
<intent-filter>
@@ -244,25 +246,8 @@
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator_address_book"/>
</service>
<provider
android:authorities="@string/address_books_authority"
android:exported="false"
android:label="@string/address_books_authority_title"
android:name=".syncadapter.AddressBookProvider" />
<service
android:name=".syncadapter.AddressBooksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_address_books"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:name=".sync.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
@@ -288,8 +273,16 @@
android:resource="@xml/debug_paths" />
</provider>
<!-- UnifiedPush -->
<service android:exported="false" android:name=".push.UnifiedPushService">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
</intent-filter>
</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" />
@@ -297,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

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

View File

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

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

View File

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

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

View File

@@ -4,11 +4,10 @@
package at.bitfire.davdroid
import org.apache.commons.lang3.StringUtils
import java.util.Collections
class TextTable(
vararg val headers: String
val headers: List<String>
) {
companion object {
@@ -19,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 {
@@ -30,6 +31,8 @@ class TextTable(
}.toTypedArray()
}
fun addLine(vararg values: Any?) = addLine(values.toList())
override fun toString(): String {
val sb = StringBuilder()
@@ -41,7 +44,7 @@ class TextTable(
// first line
sb.append("\n")
for (colIdx in headers.indices)
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┐' else '┬')
sb.append('\n')
@@ -49,14 +52,14 @@ class TextTable(
sb.append('│')
for (colIdx in headers.indices)
sb .append(' ')
.append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1))
.append(headers[colIdx].padEnd(colWidths[colIdx] + 1))
.append('│')
sb.append('\n')
// separator between header and body
sb.append('├')
for (colIdx in headers.indices) {
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┤' else '┼')
}
sb.append('\n')
@@ -65,7 +68,7 @@ class TextTable(
for (line in lines) {
for (colIdx in headers.indices)
sb .append("")
.append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1))
.append(line[colIdx].padEnd(colWidths[colIdx] + 1))
sb.append("\n")
}

View File

@@ -10,12 +10,10 @@ import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.database.getStringOrNull
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@@ -24,11 +22,11 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.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
import dagger.hilt.InstallIn
@@ -37,7 +35,6 @@ import dagger.hilt.components.SingletonComponent
import java.io.Writer
import javax.inject.Singleton
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
@@ -46,11 +43,16 @@ import javax.inject.Singleton
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 13, autoMigrations = [
AutoMigration(from = 9, to = 10),
], 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),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 11, to = 12, spec = AutoMigration12::class),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
AutoMigration(from = 12, to = 13)
AutoMigration(from = 9, to = 10)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@@ -58,201 +60,47 @@ abstract class AppDatabase: RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object AppDatabaseModule {
@Provides
@Singleton
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(*migrations)
.addAutoMigrationSpec(AutoMigration11_12(context))
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
val nm = NotificationManagerCompat.from(context)
val launcherIntent = Intent(context, AccountsActivity::class.java)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL)
fun appDatabase(
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
@ApplicationContext context: Context,
manualMigrations: Set<@JvmSuppressWildcards Migration>,
notificationRegistry: NotificationRegistry
): AppDatabase = Room
.databaseBuilder(context, AppDatabase::class.java, "services.db")
.addMigrations(*manualMigrations.toTypedArray())
.apply {
for (spec in autoMigrations)
addAutoMigrationSpec(spec)
}
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
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()
nm.notifyIfPossible(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify)
// remove all accounts because they're unfortunately useless without database
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
am.removeAccount(account, null, null)
}
})
.build()
}
// auto migrations
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
Logger.log.info("Database update to v12, refreshing services to get display names of owners")
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
RefreshCollectionsWorker.enqueue(context, serviceId)
// remove all accounts because they're unfortunately useless without database
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
am.removeAccountExplicitly(account)
}
}
}
}
companion object {
// manual migrations
val migrations: Array<Migration> = arrayOf(
object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE syncstats (" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
"authority TEXT NOT NULL," +
"lastSync INTEGER NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
}
},
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
}
},
object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
}
},
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
val sql = arrayOf(
// migrate "services" to "service": rename columns, make id NOT NULL
"CREATE TABLE service(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"accountName TEXT NOT NULL," +
"type TEXT NOT NULL," +
"principal TEXT DEFAULT NULL" +
")",
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
"DROP TABLE services",
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
"CREATE TABLE homeset(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"url TEXT NOT NULL," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
"DROP TABLE homesets",
// migrate "collections" to "collection": rename columns, make id NOT NULL
"CREATE TABLE collection(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"type TEXT NOT NULL," +
"url TEXT NOT NULL," +
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
"privUnbind INTEGER NOT NULL DEFAULT 1," +
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
"displayName TEXT DEFAULT NULL," +
"description TEXT DEFAULT NULL," +
"color INTEGER DEFAULT NULL," +
"timezone TEXT DEFAULT NULL," +
"supportsVEVENT INTEGER DEFAULT NULL," +
"supportsVTODO INTEGER DEFAULT NULL," +
"supportsVJOURNAL INTEGER DEFAULT NULL," +
"source TEXT DEFAULT NULL," +
"sync INTEGER NOT NULL DEFAULT 0," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
"DROP TABLE collections"
)
sql.forEach { db.execSQL(it) }
}
},
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
}
},
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
},
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// We don't have access to the context in a Room migration now, so
// we will just drop those settings from old DAVx5 versions.
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
try {
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
when (cursor.getString(0)) {
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
}
}
}
db.execSQL("DROP TABLE settings")
} finally {
edit.apply()
}*/
}
},
object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
db.execSQL("UPDATE collections SET type=(" +
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
"FROM services WHERE _id=collections.serviceID" +
")",
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
}
}
)
})
.build()
}

View File

@@ -4,6 +4,7 @@
package at.bitfire.davdroid.db
import androidx.annotation.StringDef
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@@ -14,18 +15,29 @@ import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.Source
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.push.WebPush
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.ical4android.util.DateUtils
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
@Retention(AnnotationRetention.SOURCE)
@StringDef(
Collection.TYPE_ADDRESSBOOK,
Collection.TYPE_CALENDAR,
Collection.TYPE_WEBCAL
)
annotation class CollectionType
@Entity(tableName = "collection",
foreignKeys = [
@@ -36,80 +48,106 @@ import org.apache.commons.lang3.StringUtils
indices = [
Index("serviceId","type"),
Index("homeSetId","type"),
Index("ownerId","type"),
Index("pushTopic","type"),
Index("url")
]
)
data class Collection(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
val id: Long = 0,
/**
* Service, which this collection belongs to. Services are unique, so a [Collection] is uniquely
* identifiable via its [serviceId] and [url].
*/
var serviceId: Long = 0,
val serviceId: Long = 0,
/**
* A home set this collection belongs to. Multiple homesets are not supported.
* If *null* the collection is considered homeless.
*/
var homeSetId: Long? = null,
val homeSetId: Long? = null,
/**
* Principal who is owner of this collection.
*/
var ownerId: Long? = null,
val ownerId: Long? = null,
/**
* Type of service. CalDAV or CardDAV
*/
var type: String,
@CollectionType
val type: String,
/**
* Address where this collection lives - with trailing slash
*/
var url: HttpUrl,
val url: HttpUrl,
var privWriteContent: Boolean = true,
var privUnbind: Boolean = true,
var forceReadOnly: Boolean = false,
/**
* Whether we have the permission to change contents of the collection on the server.
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
*/
val privWriteContent: Boolean = true,
/**
* Whether we have the permission to delete the collection on the server
*/
val privUnbind: Boolean = true,
/**
* Whether the user has manually set the "force read-only" flag.
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
*/
val forceReadOnly: Boolean = false,
var displayName: String? = null,
var description: String? = null,
/**
* Human-readable name of the collection
*/
val displayName: String? = null,
/**
* Human-readable description of the collection
*/
val description: String? = null,
// CalDAV only
var color: Int? = null,
val color: Int? = null,
/** timezone definition (full VTIMEZONE) - not a TZID! **/
var timezone: String? = null,
/** default timezone (only timezone ID, like `Europe/Vienna`) */
val timezoneId: String? = null,
/** whether the collection supports VEVENT; in case of calendars: null means true */
var supportsVEVENT: Boolean? = null,
val supportsVEVENT: Boolean? = null,
/** whether the collection supports VTODO; in case of calendars: null means true */
var supportsVTODO: Boolean? = null,
val supportsVTODO: Boolean? = null,
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
var supportsVJOURNAL: Boolean? = null,
val supportsVJOURNAL: Boolean? = null,
/** Webcal subscription source URL */
var source: HttpUrl? = null,
val source: HttpUrl? = null,
/** whether this collection has been selected for synchronization */
var sync: Boolean = false,
val sync: Boolean = false,
/** WebDAV-Push topic */
var pushTopic: String? = null,
val pushTopic: String? = null,
/** WebDAV-Push: whether this collection supports the Web Push Transport */
@ColumnInfo(defaultValue = "0")
var supportsWebPush: Boolean = false,
val supportsWebPush: Boolean = false,
/** WebDAV-Push: VAPID public key */
val pushVapidKey: String? = null,
/** WebDAV-Push subscription URL */
var pushSubscription: String? = null,
val pushSubscription: String? = null,
/** when the [pushSubscription] was created/updated (used to determine whether we need to re-subscribe) */
var pushSubscriptionCreated: Long? = null
/** when the [pushSubscription] expires (timestamp, used to determine whether we need to re-subscribe) */
val pushSubscriptionExpires: Long? = null,
/** when the [pushSubscription] was created/updated (timestamp) */
val pushSubscriptionCreated: Long? = null
) {
@@ -142,11 +180,11 @@ data class Collection(
privUnbind = privilegeSet.mayUnbind
}
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
val displayName = dav[DisplayName::class.java]?.displayName.trimToNull()
var description: String? = null
var color: Int? = null
var timezone: String? = null
var timezoneId: String? = null
var supportsVEVENT: Boolean? = null
var supportsVTODO: Boolean? = null
var supportsVJOURNAL: Boolean? = null
@@ -158,7 +196,11 @@ data class Collection(
TYPE_CALENDAR, TYPE_WEBCAL -> {
dav[CalendarDescription::class.java]?.let { description = it.description }
dav[CalendarColor::class.java]?.let { color = it.color }
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
dav[CalendarTimezoneId::class.java]?.let { timezoneId = it.identifier }
if (timezoneId == null)
dav[CalendarTimezone::class.java]?.vTimeZone?.let {
timezoneId = DateUtils.parseVTimeZone(it)?.timeZoneId?.value
}
if (type == TYPE_CALENDAR) {
supportsVEVENT = true
@@ -185,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
@@ -198,12 +245,13 @@ data class Collection(
displayName = displayName,
description = description,
color = color,
timezone = timezone,
timezoneId = timezoneId,
supportsVEVENT = supportsVEVENT,
supportsVTODO = supportsVTODO,
supportsVJOURNAL = supportsVJOURNAL,
source = source,
supportsWebPush = supportsWebPush,
pushVapidKey = vapidPublicKey,
pushTopic = pushTopic
)
}
@@ -211,6 +259,7 @@ data class Collection(
}
// calculated properties
fun title() = displayName ?: url.lastSegment
fun readOnly() = forceReadOnly || !privWriteContent

View File

@@ -20,20 +20,32 @@ 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")
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
/**
* Returns collections which
@@ -42,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?
@@ -62,6 +74,19 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncTaskLists(serviceId: Long): List<Collection>
/**
* Get a list of collections that are both sync enabled and push capable (supportsWebPush and
* pushTopic is available).
*/
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync AND supportsWebPush AND pushTopic IS NOT NULL")
suspend fun getPushCapableSyncCollections(serviceId: Long): 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
@@ -74,6 +99,9 @@ interface CollectionDao {
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
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)
@@ -94,24 +122,7 @@ interface CollectionDao {
localCollection.id
} ?: insert(collection)
/**
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
* [Collection.forceReadOnly]), but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
// remember locally set flags
getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
newCollection.sync = oldCollection.sync
newCollection.forceReadOnly = oldCollection.forceReadOnly
}
// commit to database
insertOrUpdateByUrl(newCollection)
}
@Delete
fun delete(collection: Collection)
}
}

View File

@@ -8,7 +8,7 @@ import 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

@@ -8,7 +8,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.lastSegment
import at.bitfire.davdroid.util.DavUtils.lastSegment
import okhttp3.HttpUrl
@Entity(tableName = "homeset",
@@ -22,20 +22,20 @@ import okhttp3.HttpUrl
)
data class HomeSet(
@PrimaryKey(autoGenerate = true)
var id: Long,
val id: Long,
var serviceId: Long,
val serviceId: Long,
/**
* Whether this homeset belongs to the [Service.principal] given by [serviceId].
*/
var personal: Boolean,
val personal: Boolean,
var url: HttpUrl,
val url: HttpUrl,
var privBind: Boolean = true,
val privBind: Boolean = true,
var displayName: String? = null
val displayName: String? = null
) {
fun title() = displayName ?: url.lastSegment

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