Compare commits

..

87 Commits

Author SHA1 Message Date
Arnau Mora
958be01a79 Merge branch 'migrate-main-activity' into migrate-intro-about
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt
#	app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt
2025-01-11 15:52:33 +01:00
Arnau Mora
597a3d293e Added redirection from AccountsActivity to MainActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-11 15:50:16 +01:00
Arnau Mora
0e59334e68 Moved AccountsDrawerHandler back to activity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-11 15:46:18 +01:00
Arnau Mora
d8aad544ff Merge branch 'migrate-main-activity' into migrate-intro-about
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt
2025-01-11 15:40:19 +01:00
Arnau Mora
5b935263d3 Merge branch 'main-ose' into migrate-main-activity 2025-01-11 15:35:49 +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
Ricki Hirner
013cb915fd Merge branch 'main-ose' into migrate-main-activity 2025-01-10 16:14:41 +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
Arnau Mora
754c971fb9 Merge branch 'main-ose' into migrate-main-activity
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/sync/account/AddressBookAuthenticatorService.kt
#	app/src/main/res/xml/sync_prefs.xml
2025-01-02 14:54:13 +01:00
Arnau Mora
282f1d1db6 Moved NavController
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-02 14:52:52 +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
Arnau Mora
52fde0c9f7 Completed migration
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:28:50 +01:00
Arnau Mora
97478fb7a3 Merge branch 'migrate-main-activity' into migrate-intro-about
# Conflicts:
#	app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt
#	app/src/main/kotlin/at/bitfire/davdroid/ui/MainActivity.kt
#	app/src/ose/kotlin/at/bitfire/davdroid/OseFlavorModule.kt
2025-01-01 18:25:20 +01:00
Arnau Mora
bdae74189b Moved AccountsDrawerHandler to model
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:23:47 +01:00
Arnau Mora
b2785bc296 Migrated AboutActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:18:52 +01:00
Arnau Mora
0d9be98547 Migrated IntroActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 18:07:40 +01:00
Arnau Mora
b8b38b600a Typo
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:34:19 +01:00
Arnau Mora
a544e53267 Created LocalNavController
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:33:57 +01:00
Arnau Mora
a02559ca9a Fixed import
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:32:56 +01:00
Arnau Mora
ddf881a504 Added navigation
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 17:29:55 +01:00
Arnau Mora
777b419a60 Renamed AccountsActivity to MainActivity
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
2025-01-01 15:17:17 +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
259 changed files with 8858 additions and 4468 deletions

View File

@@ -33,7 +33,7 @@ jobs:
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v3
- 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

View File

@@ -24,7 +24,7 @@ jobs:
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v3
- uses: gradle/actions/setup-gradle@v4
- name: Prepare keystore
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks

View File

@@ -21,7 +21,7 @@ jobs:
java-version: 21
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
- 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
@@ -39,7 +39,7 @@ jobs:
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v3
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true
@@ -60,7 +60,7 @@ jobs:
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v3
- uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
cache-read-only: true

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

@@ -8,6 +8,7 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@@ -18,8 +19,8 @@ android {
defaultConfig {
applicationId = "at.bitfire.davdroid"
versionCode = 404030200
versionName = "4.4.3.2"
versionCode = 404060001
versionName = "4.4.6-beta.1"
setProperty("archivesBaseName", "davx5-ose-$versionName")
@@ -82,9 +83,6 @@ android {
signingConfig = signingConfigs.findByName("bitfire")
}
getByName("debug") {
applicationIdSuffix = ".debug"
}
}
lint {
@@ -166,6 +164,7 @@ dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
implementation(libs.compose.materialIconsExtended)
implementation(libs.compose.navigation)
implementation(libs.compose.runtime.livedata)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.toolingPreview)
@@ -192,6 +191,7 @@ dependencies {
@Suppress("RedundantSuppression")
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.kotlinx.serialization)
implementation(libs.mikepenz.aboutLibraries)
implementation(libs.nsk90.kstatemachine)
implementation(libs.okhttp.base)

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

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

View File

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

View File

@@ -1,6 +1,10 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import at.bitfire.davdroid.push.PushRegistrationWorker
import at.bitfire.davdroid.push.PushRegistrationWorkerManager
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.startup.StartupPlugin
import at.bitfire.davdroid.startup.TasksAppWatcher
@@ -9,30 +13,26 @@ import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import dagger.multibindings.Multibinds
interface TestModules {
// remove PushRegistrationWorkerModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PushRegistrationWorker.PushRegistrationWorkerModule::class]
)
abstract class TestPushRegistrationWorkerModule {
// provides empty set of listeners
@Multibinds
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
}
// remove TasksAppWatcherModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
)
abstract class TestTasksAppWatcherModuleModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>
}
// remove PushRegistrationWorkerModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PushRegistrationWorkerManager.PushRegistrationWorkerModule::class]
)
abstract class TestPushRegistrationWorkerModule {
// provides empty set of listeners
@Multibinds
abstract fun empty(): Set<DavCollectionRepository.OnChangeListener>
}
// remove TasksAppWatcherModule from Android tests
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [TasksAppWatcher.TasksAppWatcherModule::class]
)
abstract class TestTasksAppWatcherModuleModule {
// provides empty set of plugins
@Multibinds
abstract fun empty(): Set<StartupPlugin>
}

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

@@ -92,35 +92,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!!)

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

@@ -1,3 +1,7 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.content.Context
@@ -5,6 +9,7 @@ import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.settings.AccountSettings
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -63,7 +68,17 @@ class DavCollectionRepositoryTest {
)
)
val testObserver = mockk<DavCollectionRepository.OnChangeListener>(relaxed = true)
val collectionRepository = DavCollectionRepository(accountSettingsFactory, context, db, mutableSetOf(testObserver), serviceRepository)
val collectionRepository = DavCollectionRepository(
accountSettingsFactory,
context,
db,
object : Lazy<Set<DavCollectionRepository.OnChangeListener>> {
override fun get(): Set<DavCollectionRepository.OnChangeListener> {
return mutableSetOf(testObserver)
}
},
serviceRepository
)
assert(db.collectionDao().get(collectionId)?.forceReadOnly == false)
verify(exactly = 0) {

View File

@@ -41,17 +41,17 @@ class DavHomeSetRepositoryTest {
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
val insertId1 = repository.insertOrUpdateByUrl(entry1)
assertEquals(1L, insertId1)
assertEquals(entry1.apply { id = 1L }, repository.getById(1L))
assertEquals(entry1.copy(id = 1L), repository.getById(1L))
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
val updateId1 = repository.insertOrUpdateByUrl(updatedEntry1)
assertEquals(1L, updateId1)
assertEquals(updatedEntry1.apply { id = 1L }, repository.getById(1L))
assertEquals(updatedEntry1.copy(id = 1L), repository.getById(1L))
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
val insertId2 = repository.insertOrUpdateByUrl(entry2)
assertEquals(2L, insertId2)
assertEquals(entry2.apply { id = 2L }, repository.getById(2L))
assertEquals(entry2.copy(id = 2L), repository.getById(2L))
}
@Test

View File

@@ -0,0 +1,226 @@
/*
* 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.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.logging.Logger
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookStoreTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
@SpyK
lateinit var context: Context
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
val addressBookAccount by lazy { Account("MrRobert@example.com", context.getString(R.string.account_type_address_book)) }
val provider = mockk<ContentProviderClient>(relaxed = true)
val addressBook: LocalAddressBook = mockk(relaxed = true) {
every { account } answers { this@LocalAddressBookStoreTest.account }
every { updateSyncFrameworkSettings() } just runs
every { addressBookAccount } answers { this@LocalAddressBookStoreTest.addressBookAccount }
every { settings } returns LocalAddressBookStore.contactsProviderSettings
}
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
@RelaxedMockK
lateinit var collectionRepository: DavCollectionRepository
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
val localAddressBookFactory = mockk<LocalAddressBook.Factory> {
every { create(any(), any(), provider) } returns addressBook
}
@Inject
@SpyK
lateinit var logger: Logger
@RelaxedMockK
lateinit var settingsManager: SettingsManager
@Suppress("unused") // used by @InjectMockKs LocalAddressBookStore
val serviceRepository = mockk<DavServiceRepository>(relaxed = true) {
every { get(any<Long>()) } returns null
every { get(200) } returns mockk<Service> {
every { accountName } returns "MrRobert@example.com"
}
}
@InjectMockKs
@SpyK
lateinit var localAddressBookStore: LocalAddressBookStore
@Before
fun setUp() {
hiltRule.inject()
// initialize global mocks
MockKAnnotations.init(this)
}
@After
fun tearDown() {
unmockkAll()
}
@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
}
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 200
}
val accountName = localAddressBookStore.accountName(collection)
assertEquals("funnyfriends (MrRobert@example.com) #42", accountName)
}
@Test
fun test_accountName_missingDisplayNameAndService() {
val collection = mockk<Collection>(relaxed = true) {
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 200
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
}
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns null
assertEquals(null, localAddressBookStore.create(provider, collection))
}
@Test
fun test_create_createAccountReturnsAccount() {
val collection = mockk<Collection>(relaxed = true) {
every { serviceId } returns 200
every { id } returns 1
every { url } returns "https://example.com/addressbook/funnyfriends".toHttpUrl()
}
every { localAddressBookStore.createAddressBookAccount(any(), any(), any(), any()) } returns addressBookAccount
every { addressBook.readOnly } returns true
val addrBook = localAddressBookStore.create(provider, collection)!!
verify(exactly = 1) { addressBook.updateSyncFrameworkSettings() }
assertEquals(addressBookAccount, addrBook.addressBookAccount)
assertEquals(LocalAddressBookStore.contactsProviderSettings, addrBook.settings)
assertEquals(true, addrBook.readOnly)
every { addressBook.readOnly } returns false
val addrBook2 = localAddressBookStore.create(provider, collection)!!
assertEquals(false, addrBook2.readOnly)
}
@Test
fun test_createAccount_succeeds() {
mockkObject(SystemAccountUtils)
every { SystemAccountUtils.createAccount(any(), any(), any()) } returns true
val result: Account = localAddressBookStore.createAddressBookAccount(
account, "MrRobert@example.com", 42, "https://example.com/addressbook/funnyfriends"
)!!
verify(exactly = 1) { SystemAccountUtils.createAccount(any(), any(), any()) }
assertEquals("MrRobert@example.com", result.name)
assertEquals(context.getString(R.string.account_type_address_book), result.type)
}
@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))
}
}

View File

@@ -13,38 +13,34 @@ import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.LabeledProperty
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import ezvcard.property.Telephone
import java.util.LinkedList
import javax.inject.Inject
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.BeforeClass
import org.junit.ClassRule
import org.junit.Rule
import org.junit.Test
import java.util.LinkedList
import javax.inject.Inject
@HiltAndroidTest
class LocalAddressBookTest {
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
val account = Account("Test Account", "Test Account Type")
lateinit var addressBook: LocalTestAddressBook
@@ -52,14 +48,13 @@ class LocalAddressBookTest {
fun setUp() {
hiltRule.inject()
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
LocalTestAddressBook.createAccount(context)
addressBook = LocalTestAddressBook.create(context, account, provider)
}
@After
fun tearDown() {
// remove address book
addressBook.deleteCollection()
addressBook.remove()
}
@@ -84,7 +79,7 @@ class LocalAddressBookTest {
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
assertEquals(newName, addressBook.addressBookAccount.name)
// check whether contact is still here (including data rows) and not dirty
val result = addressBook.findContactById(id)
@@ -112,8 +107,8 @@ class LocalAddressBookTest {
// rename address book
val newName = "New Name"
addressBook.renameAccount(newName)
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
assertTrue(addressBook.renameAccount(newName))
assertEquals(newName, addressBook.addressBookAccount.name)
// check whether group is still here and not dirty
val result = addressBook.findGroupById(id)
@@ -124,7 +119,6 @@ class LocalAddressBookTest {
}
companion object {
@JvmField
@@ -136,9 +130,8 @@ class LocalAddressBookTest {
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val context = InstrumentationRegistry.getInstrumentation().context
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
}
@AfterClass

View File

@@ -66,7 +66,7 @@ class LocalCalendarTest {
@After
fun tearDown() {
calendar.deleteCollection()
calendar.delete()
}
@@ -119,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

@@ -40,29 +40,6 @@ import java.util.UUID
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()
}
}
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
private lateinit var calendar: LocalCalendar
@@ -74,7 +51,7 @@ class LocalEventTest {
@After
fun removeCalendar() {
calendar.deleteCollection()
calendar.delete()
}
@@ -282,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!!)
@@ -481,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,6 +5,7 @@
package at.bitfire.davdroid.resource
import android.Manifest
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
@@ -20,6 +21,7 @@ import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -36,40 +38,13 @@ import javax.inject.Inject
@HiltAndroidTest
class LocalGroupTest {
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
assertNotNull(provider)
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
val account = Account("Test Account", "Test Account Type")
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
@@ -77,14 +52,20 @@ class LocalGroupTest {
fun setup() {
hiltRule.inject()
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
// clear contacts
addressBookGroupsAsCategories.clear()
addressBookGroupsAsVCards.clear()
}
@After
fun tearDown() {
addressBookGroupsAsCategories.remove()
addressBookGroupsAsVCards.remove()
}
@Test
fun testApplyPendingMemberships_addPendingMembership() {
@@ -278,4 +259,27 @@ class LocalGroupTest {
add()
}
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

@@ -10,31 +10,52 @@ import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.Context
import android.provider.ContactsContract
import at.bitfire.davdroid.R
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
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.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.junit.Assert.assertTrue
import java.io.FileNotFoundException
import java.util.Optional
import java.util.concurrent.atomic.AtomicInteger
import java.util.logging.Logger
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,
@ApplicationContext private val context: Context,
logger: Logger,
serviceRepository: DavServiceRepository
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
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
) {
@AssistedFactory
interface Factory {
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
fun create(account: Account, @Assisted("addressBook") addressBookAccount: Account, provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
}
override var readOnly: Boolean
@@ -82,14 +103,43 @@ class LocalTestAddressBook @AssistedInject constructor(
throw FileNotFoundException()
}
fun remove() {
val accountManager = AccountManager.get(context)
assertTrue(accountManager.removeAccountExplicitly(addressBookAccount))
}
companion object {
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
@dagger.hilt.EntryPoint
@InstallIn(SingletonComponent::class)
interface EntryPoint {
fun localTestAddressBookFactory(): Factory
}
fun createAccount(context: Context) {
val am = AccountManager.get(context)
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
val counter = AtomicInteger()
/**
* Creates a [at.bitfire.davdroid.resource.LocalTestAddressBook].
*
* Make sure to delete it with [at.bitfire.davdroid.resource.LocalTestAddressBook.remove] or [removeAll] after use.
*/
fun create(context: Context, account: Account, provider: ContentProviderClient, groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS): LocalTestAddressBook {
// create new address book account
val addressBookAccount = Account("Test Address Book ${counter.incrementAndGet()}", context.getString(R.string.account_type_address_book))
val accountManager = AccountManager.get(context)
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
// return address book with this account
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(context)
val factory = entryPoint.localTestAddressBookFactory()
return factory.create(account, addressBookAccount, provider, groupMethod)
}
fun removeAll(context: Context) {
val accountManager = AccountManager.get(context)
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
accountManager.removeAccountExplicitly(account)
}
}

View File

@@ -5,6 +5,7 @@
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
@@ -31,6 +32,38 @@ import javax.inject.Inject
@HiltAndroidTest
class CachedGroupMembershipHandlerTest {
@Inject
@ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership() {
val addressBook = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
try {
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())
} finally {
addressBook.remove()
}
}
companion object {
@JvmField
@@ -54,34 +87,4 @@ class CachedGroupMembershipHandlerTest {
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject
@ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership() {
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
val contact = Contact()
val localContact = LocalContact(addressBook, contact, null, null, 0)
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
put(CachedGroupMembership.GROUP_ID, 123456)
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
}, contact)
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
}
}

View File

@@ -5,6 +5,7 @@
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
@@ -30,38 +31,14 @@ import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipBuilderTest {
companion object {
@JvmField
@ClassRule
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
private lateinit var provider: ContentProviderClient
@BeforeClass
@JvmStatic
fun connect() {
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
}
@AfterClass
@JvmStatic
fun disconnect() {
provider.close()
}
}
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
val account = Account("Test Account", "Test Account Type")
@Inject
@ApplicationContext
lateinit var context: Context
@Before
fun inject() {
@@ -74,11 +51,15 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
assertEquals(1, result.size)
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
try {
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
assertEquals(1, result.size)
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
}
} finally {
addressBookGroupsAsCategories.remove()
}
}
@@ -87,11 +68,39 @@ class GroupMembershipBuilderTest {
val contact = Contact().apply {
categories += "TEST GROUP"
}
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
try {
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
// group membership is constructed during post-processing
assertEquals(0, result.size)
}
} finally {
addressBookGroupsAsVCards.remove()
}
}
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,6 +5,7 @@
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
@@ -33,6 +34,58 @@ import javax.inject.Inject
@HiltAndroidTest
class GroupMembershipHandlerTest {
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
var hiltRule = HiltAndroidRule(this)
val account = Account("Test Account", "Test Account Type")
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership_GroupsAsCategories() {
val addressBookGroupsAsCategories = LocalTestAddressBook.create(context, account, provider, GroupMethod.CATEGORIES)
try {
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())
} finally {
addressBookGroupsAsCategories.remove()
}
}
@Test
fun testMembership_GroupsAsVCards() {
val addressBookGroupsAsVCards = LocalTestAddressBook.create(context, account, provider, GroupMethod.GROUP_VCARDS)
try {
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())
} finally {
addressBookGroupsAsVCards.remove()
}
}
companion object {
@JvmField
@@ -57,49 +110,4 @@ class GroupMembershipHandlerTest {
}
@Inject
lateinit var addressbookFactory: LocalTestAddressBook.Factory
@Inject @ApplicationContext
lateinit var context: Context
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Before
fun inject() {
hiltRule.inject()
}
@Test
fun testMembership_GroupsAsCategories() {
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
val contact = Contact()
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
GroupMembershipHandler(localContact).handle(ContentValues().apply {
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 addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
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())
}
}

View File

@@ -37,24 +37,7 @@ import javax.inject.Inject
@HiltAndroidTest
class CollectionListRefresherTest {
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"
}
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
@Inject
@@ -69,6 +52,9 @@ class CollectionListRefresherTest {
@Inject
lateinit var settings: SettingsManager
@get:Rule
var hiltRule = HiltAndroidRule(this)
private val mockServer = MockWebServer()
private lateinit var client: HttpClient
@@ -100,9 +86,23 @@ class CollectionListRefresherTest {
// Query home sets
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)
}
@@ -114,7 +114,7 @@ class CollectionListRefresherTest {
// 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
@@ -248,7 +248,7 @@ class CollectionListRefresherTest {
// 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
@@ -482,7 +482,7 @@ class CollectionListRefresherTest {
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
@@ -506,7 +506,7 @@ class CollectionListRefresherTest {
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
@@ -531,7 +531,7 @@ class CollectionListRefresherTest {
url = url
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
@@ -555,7 +555,7 @@ class CollectionListRefresherTest {
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
@@ -579,7 +579,7 @@ class CollectionListRefresherTest {
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
@@ -604,7 +604,7 @@ class CollectionListRefresherTest {
url = collectionUrl
)
val homesets = listOf(
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
)
val refresher = refresherFactory.create(service, client.okHttpClient)
@@ -620,6 +620,20 @@ class CollectionListRefresherTest {
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(
private val logger: Logger
@@ -640,8 +654,11 @@ class CollectionListRefresherTest {
"<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>" +
@@ -649,8 +666,16 @@ class CollectionListRefresherTest {
"</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/>" +
@@ -661,6 +686,17 @@ class CollectionListRefresherTest {
" <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>" +
@@ -676,7 +712,7 @@ class CollectionListRefresherTest {
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>" +

View File

@@ -0,0 +1,60 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.AccountManager
import android.content.Context
import at.bitfire.davdroid.TestUtils
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.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsTest {
@Inject @ApplicationContext
lateinit var context: Context
@Inject
lateinit var accountSettingsFactory: AccountSettings.Factory
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context)
}
@Test(expected = IllegalArgumentException::class)
fun testUpdate_MissingMigrations() {
TestAccount.provide(version = 1) { account ->
// will run AccountSettings.update
accountSettingsFactory.create(account, abortOnMissingMigration = true)
}
}
@Test
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

@@ -0,0 +1,101 @@
/*
* 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() {
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, LocalAddressBook.USER_DATA_URL, 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, LocalAddressBook.USER_DATA_URL) == 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,135 @@
/*
* 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.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
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
@MockK
lateinit var db: AppDatabase
@InjectMockKs
lateinit var migration: AccountSettingsMigration18
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setUp() {
hiltRule.inject()
MockKAnnotations.init(this)
}
@Test
fun testMigrate_AddressBook_InvalidCollection() {
every { db.serviceDao() } returns mockk {
every { getByAccountAndType(any(), any()) } returns null
}
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
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() {
every { db.serviceDao() } returns mockk {
every { getByAccountAndType(any(), any()) } returns null
}
val addressBookAccountType = context.getString(R.string.account_type_address_book)
var addressBookAccount = Account("Address Book", addressBookAccountType)
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")
every { db.serviceDao() } returns mockk {
every { getByAccountAndType(any(), any()) } returns Service(
id = 10,
accountName = account.name,
type = Service.TYPE_CARDDAV,
principal = null
)
}
every { db.collectionDao() } returns mockk {
every { getByService(10) } returns listOf(Collection(
id = 100,
serviceId = 10,
url = "http://example.com".toHttpUrl(),
type = Collection.TYPE_ADDRESSBOOK
))
}
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,90 @@
/*
* 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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountSettingsMigration19Test {
@Inject @ApplicationContext
@SpyK
lateinit var context: Context
@MockK(relaxed = true)
lateinit var automaticSyncManager: AutomaticSyncManager
@InjectMockKs
lateinit var migration: AccountSettingsMigration19
@Inject
lateinit var workerFactory: HiltWorkerFactory
@get:Rule
val hiltRule = HiltAndroidRule(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)
MockKAnnotations.init(this)
}
@After
fun tearDown() {
unmockkAll()
}
@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

@@ -21,8 +21,6 @@ class LocalTestCollection(
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = true
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }

View File

@@ -9,16 +9,14 @@ import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.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.TestAccountAuthenticator
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
@@ -42,7 +40,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.Timeout
import java.util.concurrent.Executors
import java.util.logging.Logger
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@@ -85,20 +82,14 @@ class SyncAdapterServicesTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
account = TestAccountAuthenticator.create()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccount.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
TestAccount.remove(account)
unmockkAll()
}

View File

@@ -6,22 +6,20 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorkerFactory
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.TestUtils
import at.bitfire.davdroid.TestUtils.assertWithin
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.sync.account.TestAccount
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -78,22 +76,16 @@ class SyncManagerTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
account = TestAccount.create()
server.start()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
TestAccount.remove(account)
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()

View File

@@ -7,56 +7,42 @@ package at.bitfire.davdroid.sync
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import 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.spyk
import io.mockk.runs
import io.mockk.verify
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
import java.util.logging.Logger
@HiltAndroidTest
class SyncerTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
val mockkRule = MockKRule(this)
@Inject
lateinit var testSyncer: TestSyncer.Factory
@RelaxedMockK
lateinit var logger: Logger
lateinit var account: Account
val dataStore: LocalTestStore = mockk(relaxed = true)
val provider: ContentProviderClient = mockk(relaxed = true)
private lateinit var syncer: TestSyncer
@Before
fun setUp() {
hiltRule.inject()
account = TestAccountAuthenticator.create()
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
}
@SpyK
@InjectMockKs
var syncer = TestSyncer(mockk(relaxed = true), emptyArray(), SyncResult(), dataStore)
@Test
fun testSync_prepare_fails() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns false
every { syncer.getSyncEnabledCollections() } returns emptyMap()
@@ -68,7 +54,6 @@ class SyncerTest {
@Test
fun testSync_prepare_succeeds() {
val provider = mockk<ContentProviderClient>()
every { syncer.prepare(provider) } returns true
every { syncer.getSyncEnabledCollections() } returns emptyMap()
@@ -83,13 +68,12 @@ class SyncerTest {
fun testUpdateCollections_deletesCollection() {
val localCollection = mockk<LocalTestCollection>()
every { localCollection.collectionUrl } returns "http://delete.the/collection"
every { localCollection.deleteCollection() } returns true
every { localCollection.title } returns "Collection to be deleted locally"
// Should delete the localCollection if dbCollection (remote) does not exist
val localCollections = mutableListOf(localCollection)
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
verify(exactly = 1) { localCollection.deleteCollection() }
verify(exactly = 1) { dataStore.delete(localCollection) }
// Updated local collection list should be empty
assertTrue(result.isEmpty())
@@ -105,8 +89,8 @@ class SyncerTest {
every { localCollection.title } returns "The Local Collection"
// Should update the localCollection if it exists
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
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())
@@ -114,12 +98,18 @@ class SyncerTest {
@Test
fun testUpdateCollections_findsNewCollection() {
val dbCollection = mockk<Collection>()
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
val dbCollections = mapOf(dbCollection.url to dbCollection)
val dbCollection = mockk<Collection> {
every { url } returns "http://newly.found/collection".toHttpUrl()
}
val localCollections = listOf(mockk<LocalTestCollection> {
every { collectionUrl } returns "http://newly.found/collection"
})
val dbCollections = listOf(dbCollection)
val dbCollectionsMap = mapOf(dbCollection.url to dbCollection)
every { syncer.createLocalCollections(provider, dbCollections) } returns localCollections
// Should return the new collection, because it was not updated
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
val result = syncer.updateCollections(provider, emptyList(), dbCollectionsMap)
// Updated local collection list contain new entry
assertEquals(1, result.size)
@@ -129,21 +119,18 @@ class SyncerTest {
@Test
fun testCreateLocalCollections() {
val provider = mockk<ContentProviderClient>()
val localCollection = mockk<LocalTestCollection>()
val dbCollection = mockk<Collection>()
every { syncer.create(provider, dbCollection) } returns localCollection
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
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 provider = mockk<ContentProviderClient>()
val dbCollection1 = mockk<Collection>()
val dbCollection2 = mockk<Collection>()
val dbCollections = mapOf(
@@ -155,6 +142,7 @@ class SyncerTest {
val localCollections = listOf(localCollection1, localCollection2)
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
every { syncer.syncCollection(provider, any(), any()) } just runs
// Should call the collection content sync on both collections
syncer.syncCollectionContents(provider, localCollections, dbCollections)
@@ -165,41 +153,69 @@ class SyncerTest {
// Test helpers
class TestSyncer @AssistedInject constructor(
@Assisted account: Account,
@Assisted extras: Array<String>,
@Assisted syncResult: SyncResult
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
class TestSyncer (
account: Account,
extras: Array<String>,
syncResult: SyncResult,
theDataStore: LocalTestStore
) : Syncer<LocalTestStore, LocalTestCollection>(account, extras, syncResult) {
@AssistedFactory
interface Factory {
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
}
override val dataStore: LocalTestStore =
theDataStore
override val authority: String
get() = ""
get() = throw NotImplementedError()
override val serviceType: String
get() = ""
get() = throw NotImplementedError()
override fun prepare(provider: ContentProviderClient): Boolean =
true
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
emptyList()
throw NotImplementedError()
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
emptyList()
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
LocalTestCollection(remoteCollection.url.toString())
throw NotImplementedError()
override fun syncCollection(
provider: ContentProviderClient,
localCollection: LocalTestCollection,
remoteCollection: Collection
) {}
) {
throw NotImplementedError()
}
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
}
class LocalTestStore : LocalDataStore<LocalTestCollection> {
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

@@ -1,28 +1,29 @@
/*
* 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 android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTestAddressBook
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.HttpUrl.Companion.toHttpUrl
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
@@ -38,8 +39,7 @@ class AccountsCleanupWorkerTest {
@Inject
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
@Inject
@@ -59,23 +59,16 @@ class AccountsCleanupWorkerTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
service = createTestService(Service.TYPE_CARDDAV)
// Prepare test account
accountManager = AccountManager.get(context)
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account(
"Fancy address book account",
addressBookAccountType
)
service = createTestService()
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
addressBookAccountType = context.getString(R.string.account_type_address_book)
addressBookAccount = Account("Fancy address book account", addressBookAccountType)
// Make sure there are no address books
LocalTestAddressBook.removeAll(context)
}
@After
@@ -86,65 +79,87 @@ class AccountsCleanupWorkerTest {
@Test
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
// Create address book account without corresponding collection
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
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(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.setWorkerFactory(workerFactory)
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
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 testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
// Create address book account _with_ corresponding collection and verify
val randomCollectionId = 12345L
val userData = Bundle(1).apply {
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
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())
}
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
// Create the collection
val collectionDao = db.collectionDao()
collectionDao.insert(Collection(
randomCollectionId,
serviceId = service.id,
type = Collection.TYPE_ADDRESSBOOK,
url = "http://www.example.com/yay.php".toHttpUrl()
))
// Create worker and run the method
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
.setWorkerFactory(object: WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
accountsCleanupWorkerFactory.create(appContext, workerParameters)
})
.build()
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
// Verify account was _not_ deleted
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
}
// helpers
private fun createTestService(serviceType: String): Service {
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
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

@@ -8,9 +8,8 @@ import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.test.R
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@@ -22,24 +21,17 @@ import org.junit.Test
import javax.inject.Inject
@HiltAndroidTest
class AccountUtilsTest {
class SystemAccountUtilsTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
val testContext = InstrumentationRegistry.getInstrumentation().context
@Inject
lateinit var settingsManager: SettingsManager
val account = Account(
"AccountUtilsTest",
testContext.getString(R.string.account_type_test)
)
@Before
fun setUp() {
hiltRule.inject()
@@ -52,6 +44,7 @@ class AccountUtilsTest {
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(SystemAccountUtils.createAccount(context, account, userData))

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

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

View File

@@ -1,24 +1,21 @@
/***************************************************************************************************
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
*/
package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.work.workDataOf
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import at.bitfire.davdroid.test.R
import 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
@@ -35,10 +32,8 @@ import javax.inject.Inject
@HiltAndroidTest
class PeriodicSyncWorkerTest {
@Inject
@ApplicationContext
@Inject @ApplicationContext
lateinit var context: Context
val testContext = InstrumentationRegistry.getInstrumentation().context
@Inject
lateinit var syncWorkerFactory: PeriodicSyncWorker.Factory
@@ -51,29 +46,24 @@ class PeriodicSyncWorkerTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
account = TestAccount.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
TestAccount.remove(account)
}
@Test
fun doWork_cancelsItselfOnInvalidAccount() {
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))
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_DATA_TYPE to SyncDataType.EVENTS.toString(),
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
)

View File

@@ -6,14 +6,11 @@ package at.bitfire.davdroid.sync.worker
import android.accounts.Account
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.TestUtils
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
import 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
@@ -47,20 +44,14 @@ class SyncWorkerManagerTest {
@Before
fun setUp() {
hiltRule.inject()
TestUtils.setUpWorkManager(context, workerFactory)
// Initialize WorkManager for instrumentation tests.
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(workerFactory)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
account = TestAccountAuthenticator.create()
account = TestAccount.create()
}
@After
fun tearDown() {
TestAccountAuthenticator.remove(account)
TestAccount.remove(account)
}
@@ -68,10 +59,10 @@ class SyncWorkerManagerTest {
@Test
fun testEnqueueOneTime() {
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
val workerName = OneTimeSyncWorker.workerName(account, SyncDataType.EVENTS)
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
val returnedName = syncWorkerManager.enqueueOneTime(account, SyncDataType.EVENTS)
assertEquals(workerName, returnedName)
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
}
@@ -81,18 +72,18 @@ class SyncWorkerManagerTest {
@Test
fun enablePeriodic() {
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun disablePeriodic() {
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
syncWorkerManager.enablePeriodic(account, SyncDataType.EVENTS, 60, false).result.get()
syncWorkerManager.disablePeriodic(account, SyncDataType.EVENTS).result.get()
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
val workerName = PeriodicSyncWorker.workerName(account, SyncDataType.EVENTS)
assertFalse(workScheduledOrRunning(context, workerName))
}

View File

@@ -1,15 +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>
<string name="account_type_test">at.bitfire.davdroid.test</string>
</resources>

View File

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

View File

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

View File

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

View File

@@ -14,11 +14,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"/>
@@ -65,9 +60,8 @@
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
tools:node="remove" tools:selector="net.openid.appauth"/>
<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"/>
@@ -75,15 +69,18 @@
</intent-filter>
</activity>
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:parentActivityName=".ui.AccountsActivity"/>
<!--
Left here for external apps.
Automatically redirects to MainActivity.
Should be removed in the future.
-->
<!--suppress DeprecatedClassUsageInspection -->
<activity android:name=".ui.AccountsActivity" android:exported="true" />
<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 +108,7 @@
<activity
android:name=".ui.setup.LoginActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@@ -139,7 +136,7 @@
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
</activity>
<activity
@@ -161,7 +158,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"

View File

@@ -13,8 +13,6 @@ import androidx.core.app.NotificationCompat
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
@@ -23,8 +21,9 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.ui.AccountsActivity
import at.bitfire.davdroid.db.migration.AutoMigration12
import at.bitfire.davdroid.db.migration.AutoMigration16
import at.bitfire.davdroid.ui.MainActivity
import at.bitfire.davdroid.ui.NotificationRegistry
import dagger.Module
import dagger.Provides
@@ -32,10 +31,8 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.Writer
import java.util.logging.Logger
import javax.inject.Singleton
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
@@ -44,12 +41,14 @@ import javax.inject.Singleton
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 14, autoMigrations = [
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
], exportSchema = true, version = 16, autoMigrations = [
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 = 13, to = 14)
AutoMigration(from = 11, to = 12, spec = AutoMigration12::class),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 9, to = 10)
])
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
@@ -57,204 +56,43 @@ abstract class AppDatabase: RoomDatabase() {
@Module
@InstallIn(SingletonComponent::class)
object AppDatabaseModule {
@Provides
@Singleton
fun appDatabase(
autoMigrations: Set<@JvmSuppressWildcards AutoMigrationSpec>,
@ApplicationContext context: Context,
manualMigrations: Set<@JvmSuppressWildcards Migration>,
notificationRegistry: NotificationRegistry
): AppDatabase =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(*migrations)
.addAutoMigrationSpec(AutoMigration11_12(context))
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.addCallback(object: Callback() {
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_DATABASE_CORRUPTED) {
val launcherIntent = Intent(context, AccountsActivity::class.java)
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
.setContentText(context.getString(R.string.database_destructive_migration_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setAutoCancel(true)
.build()
}
// remove all accounts because they're unfortunately useless without database
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
am.removeAccountExplicitly(account)
): 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))
.setAutoCancel(true)
.build()
}
})
.build()
}
// auto migrations
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
Logger.getGlobal().info("Database update to v12, refreshing services to get display names of owners")
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
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.getGlobal().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,6 +15,7 @@ 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
@@ -24,9 +26,18 @@ import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
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
@Retention(AnnotationRetention.SOURCE)
@StringDef(
Collection.TYPE_ADDRESSBOOK,
Collection.TYPE_CALENDAR,
Collection.TYPE_WEBCAL
)
annotation class CollectionType
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
@@ -43,92 +54,96 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
)
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,
/**
* Whether we have the permission to change contents of the collection on the server.
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
*/
var privWriteContent: Boolean = true,
val privWriteContent: Boolean = true,
/**
* Whether we have the permission to delete the collection on the server
*/
var privUnbind: Boolean = true,
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.
*/
var forceReadOnly: Boolean = false,
val forceReadOnly: Boolean = false,
/**
* Human-readable name of the collection
*/
var displayName: String? = null,
val displayName: String? = null,
/**
* Human-readable description of the collection
*/
var description: String? = null,
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 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
) {
@@ -165,7 +180,7 @@ data class Collection(
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
@@ -177,7 +192,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
@@ -217,7 +236,7 @@ data class Collection(
displayName = displayName,
description = description,
color = color,
timezone = timezone,
timezoneId = timezoneId,
supportsVEVENT = supportsVEVENT,
supportsVTODO = supportsVTODO,
supportsVJOURNAL = supportsVJOURNAL,

View File

@@ -38,6 +38,9 @@ interface CollectionDao {
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
suspend fun anyOfType(serviceId: Long, type: String): Boolean
@Query("SELECT COUNT(*) FROM collection WHERE supportsWebPush AND pushTopic IS NOT NULL")
suspend fun anyPushCapable(): Boolean
/**
* Returns collections which
* - support VEVENT and/or VTODO (= supported calendar collections), or
@@ -87,8 +90,8 @@ interface CollectionDao {
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
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)

View File

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

View File

@@ -15,6 +15,9 @@ import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.util.trimToNull
import okhttp3.HttpUrl
/**
* A principal entity representing a WebDAV principal (rfc3744).
*/
@Entity(tableName = "principal",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
@@ -26,11 +29,11 @@ import okhttp3.HttpUrl
)
data class Principal(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
var serviceId: Long,
val id: Long = 0,
val serviceId: Long,
/** URL of the principal, always without trailing slash */
var url: HttpUrl,
var displayName: String? = null
val url: HttpUrl,
val displayName: String? = null
) {
companion object {

View File

@@ -4,11 +4,16 @@
package at.bitfire.davdroid.db
import androidx.annotation.StringDef
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Retention(AnnotationRetention.SOURCE)
@StringDef(Service.TYPE_CALDAV, Service.TYPE_CARDDAV)
annotation class ServiceType
/**
* A service entity.
*
@@ -21,12 +26,14 @@ import okhttp3.HttpUrl
])
data class Service(
@PrimaryKey(autoGenerate = true)
var id: Long,
val id: Long,
var accountName: String,
var type: String,
val accountName: String,
var principal: HttpUrl?
@ServiceType
val type: String,
val principal: HttpUrl?
) {
companion object {

View File

@@ -24,5 +24,5 @@ data class SyncStats(
val collectionId: Long,
val authority: String,
var lastSync: Long
val lastSync: Long
)

View File

@@ -8,17 +8,18 @@ import android.annotation.SuppressLint
import android.os.Bundle
import android.provider.DocumentsContract.Document
import android.webkit.MimeTypeMap
import androidx.core.os.bundleOf
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.DavUtils.MEDIA_TYPE_OCTET_STREAM
import at.bitfire.davdroid.webdav.DocumentState
import java.io.FileNotFoundException
import java.time.Instant
import okhttp3.HttpUrl
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import java.io.FileNotFoundException
import java.time.Instant
@Entity(
tableName = "webdav_document",
@@ -34,30 +35,30 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
data class WebDavDocument(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
val id: Long = 0,
/** refers to the [WebDavMount] the document belongs to */
val mountId: Long,
/** refers to parent document (*null* when this document is a root document) */
var parentId: Long?,
val parentId: Long?,
/** file name (without any slashes) */
var name: String,
var isDirectory: Boolean = false,
val name: String,
val isDirectory: Boolean = false,
var displayName: String? = null,
var mimeType: MediaType? = null,
var eTag: String? = null,
var lastModified: Long? = null,
var size: Long? = null,
val displayName: String? = null,
val mimeType: MediaType? = null,
val eTag: String? = null,
val lastModified: Long? = null,
val size: Long? = null,
var mayBind: Boolean? = null,
var mayUnbind: Boolean? = null,
var mayWriteContent: Boolean? = null,
val mayBind: Boolean? = null,
val mayUnbind: Boolean? = null,
val mayWriteContent: Boolean? = null,
var quotaAvailable: Long? = null,
var quotaUsed: Long? = null
val quotaAvailable: Long? = null,
val quotaUsed: Long? = null
) {
@@ -72,9 +73,10 @@ data class WebDavDocument(
if (parent?.isDirectory == false)
throw IllegalArgumentException("Parent must be a directory")
val bundle = Bundle()
bundle.putString(Document.COLUMN_DOCUMENT_ID, id.toString())
bundle.putString(Document.COLUMN_DISPLAY_NAME, name)
val bundle = bundleOf(
Document.COLUMN_DOCUMENT_ID to id.toString(),
Document.COLUMN_DISPLAY_NAME to name
)
displayName?.let { bundle.putString(Document.COLUMN_SUMMARY, it) }
size?.let { bundle.putLong(Document.COLUMN_SIZE, it) }

View File

@@ -77,8 +77,7 @@ interface WebDavDocumentDao {
displayName = mount.name
)
val id = insertOrReplace(newDoc)
newDoc.id = id
return newDoc
return newDoc.copy(id = id)
}
}

View File

@@ -11,13 +11,13 @@ import okhttp3.HttpUrl
@Entity(tableName = "webdav_mount")
data class WebDavMount(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
val id: Long = 0,
/** display name of the WebDAV mount */
var name: String,
val name: String,
/** URL of the WebDAV service, including trailing slash */
var url: HttpUrl
val url: HttpUrl
// credentials are stored using CredentialsStore

View File

@@ -0,0 +1,47 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import android.content.Context
import androidx.room.DeleteColumn
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import java.util.logging.Logger
import javax.inject.Inject
@ProvidedAutoMigrationSpec
@DeleteColumn(tableName = "collection", columnName = "owner")
class AutoMigration12 @Inject constructor(
@ApplicationContext val context: Context,
val logger: Logger
): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
logger.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)
}
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AutoMigrationModule {
@Binds @IntoSet
abstract fun provide(impl: AutoMigration12): AutoMigrationSpec
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.ProvidedAutoMigrationSpec
import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.ical4android.util.DateUtils
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Inject
/**
* The timezone column has been renamed to timezoneId, but still contains the VTIMEZONE.
* So we need to parse the VTIMEZONE, extract the timezone ID and save it back.
*/
@ProvidedAutoMigrationSpec
@RenameColumn(tableName = "collection", fromColumnName = "timezone", toColumnName = "timezoneId")
class AutoMigration16 @Inject constructor(): AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.query("SELECT id, timezoneId FROM collection").use { cursor ->
while (cursor.moveToNext()) {
val id: Long = cursor.getLong(0)
val timezoneDef: String = cursor.getString(1) ?: continue
val vTimeZone = DateUtils.parseVTimeZone(timezoneDef)
val timezoneId = vTimeZone?.timeZoneId?.value
db.execSQL("UPDATE collection SET timezoneId=? WHERE id=?", arrayOf(timezoneId, id))
}
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AutoMigrationModule {
@Binds @IntoSet
abstract fun provide(impl: AutoMigration16): AutoMigrationSpec
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration2 = Migration(1, 2) { db ->
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"))
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration2Module {
@Provides @IntoSet
fun provide(): Migration = Migration2
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import java.util.logging.Logger
val Migration3 = Migration(2, 3) { db ->
// 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.getGlobal().warning("Dropping settings distrustSystemCerts and overrideProxy*")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration3Module {
@Provides @IntoSet
fun provide(): Migration = Migration3
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration4 = Migration(3, 4) { db ->
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration4Module {
@Provides @IntoSet
fun provide(): Migration = Migration4
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration5 = Migration(4, 5) { db ->
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
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration5Module {
@Provides @IntoSet
fun provide(): Migration = Migration5
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration6 = Migration(5, 6) { db ->
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) }
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration6Module {
@Provides @IntoSet
fun provide(): Migration = Migration6
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration7 = Migration(6, 7) { db ->
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration7Module {
@Provides @IntoSet
fun provide(): Migration = Migration7
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration8 = Migration(7, 8) { db ->
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)")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration8Module {
@Provides @IntoSet
fun provide(): Migration = Migration8
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.db.migration
import androidx.room.migration.Migration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
val Migration9 = Migration(8, 9) { db ->
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)")
}
@Module
@InstallIn(SingletonComponent::class)
internal object Migration9Module {
@Provides @IntoSet
fun provide(): Migration = Migration9
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ package at.bitfire.davdroid.push
import at.bitfire.dav4jvm.XmlReader
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.push.PushMessage
import at.bitfire.dav4jvm.property.push.Topic
import org.xmlpull.v1.XmlPullParserException
import java.io.StringReader
import java.util.logging.Level
@@ -31,7 +32,9 @@ class PushMessageParser @Inject constructor(
XmlReader(parser).processTag(PushMessage.NAME) {
val pushMessage = PushMessage.Factory.create(parser)
topic = pushMessage.topic
val properties = pushMessage.propStat?.properties ?: return@processTag
val pushTopic = properties.filterIsInstance<Topic>().firstOrNull()
topic = pushTopic?.topic
}
} catch (e: XmlPullParserException) {
logger.log(Level.WARNING, "Couldn't parse push message", e)

View File

@@ -1,3 +1,7 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.push
import android.accounts.Account
@@ -7,6 +11,7 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.ui.NotificationRegistry
import at.bitfire.davdroid.ui.account.AccountActivity
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -20,16 +25,16 @@ class PushNotificationManager @Inject constructor(
/**
* Generates the notification ID for a push notification.
*/
private fun notificationId(account: Account, authority: String): Int {
return account.name.hashCode() + account.type.hashCode() + authority.hashCode()
private fun notificationId(account: Account, dataType: SyncDataType): Int {
return account.name.hashCode() + account.type.hashCode() + dataType.hashCode()
}
/**
* Sends a notification to inform the user that a push notification has been received, the
* sync has been scheduled, but it still has not run.
*/
fun notify(account: Account, authority: String) {
notificationRegistry.notifyIfPossible(notificationId(account, authority)) {
fun notify(account: Account, dataType: SyncDataType) {
notificationRegistry.notifyIfPossible(notificationId(account, dataType)) {
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_sync)
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
@@ -57,9 +62,9 @@ class PushNotificationManager @Inject constructor(
* Once the sync has been started, the notification is no longer needed and can be dismissed.
* It's safe to call this method even if the notification has not been shown.
*/
fun dismiss(account: Account, authority: String) {
fun dismiss(account: Account, dataType: SyncDataType) {
NotificationManagerCompat.from(context)
.cancel(notificationId(account, authority))
.cancel(notificationId(account, dataType))
}
}

View File

@@ -7,15 +7,11 @@ package at.bitfire.davdroid.push
import android.accounts.Account
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
@@ -28,31 +24,23 @@ import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.settings.AccountSettings
import dagger.Binds
import dagger.Module
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
import java.util.concurrent.TimeUnit
import java.time.Duration
import java.time.Instant
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
/**
* Worker that registers push for all collections that support it.
* To be run as soon as a collection that supports push is changed (selected for sync status
* changes, or collection is created, deleted, etc).
*
* TODO Should run periodically, too (to refresh registrations that are about to expire).
* Not required for a first demonstration version.
*/
@Suppress("unused")
@HiltWorker
@@ -66,34 +54,15 @@ class PushRegistrationWorker @AssistedInject constructor(
private val serviceRepository: DavServiceRepository
) : CoroutineWorker(context, workerParameters) {
companion object {
private const val UNIQUE_WORK_NAME = "push-registration"
/**
* Enqueues a push registration worker with a minimum delay of 5 seconds.
*/
fun enqueue(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
.build()
val workRequest = OneTimeWorkRequestBuilder<PushRegistrationWorker>()
.setInitialDelay(5, TimeUnit.SECONDS)
.setConstraints(constraints)
.build()
Logger.getGlobal().info("Enqueueing push registration worker")
WorkManager.getInstance(context)
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest)
}
}
override suspend fun doWork(): Result {
logger.info("Running push registration worker")
registerSyncable()
unregisterNotSyncable()
try {
registerSyncable()
unregisterNotSyncable()
} catch (_: IOException) {
return Result.retry() // retry on I/O errors
}
return Result.success()
}
@@ -108,27 +77,41 @@ class PushRegistrationWorker @AssistedInject constructor(
.use { client ->
val httpClient = client.okHttpClient
// requested expiration time: 3 days
val requestedExpiration = Instant.now() + Duration.ofDays(3)
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-register")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "subscription")) {
// subscription URL
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "web-push-subscription")) {
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "push-resource")) {
text(endpoint)
}
}
}
// requested expiration
serializer.insertTag(Property.Name(NS_WEBDAV_PUSH, "expires")) {
text(HttpUtils.formatDate(requestedExpiration))
}
}
serializer.endDocument()
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
DavCollection(httpClient, collection.url).post(xml) { response ->
if (response.isSuccessful) {
response.header("Location")?.let { subscriptionUrl ->
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
}
val subscriptionUrl = response.header("Location")
val expires = response.header("Expires")?.let { expiresDate ->
HttpUtils.parseDate(expiresDate)
} ?: requestedExpiration
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = subscriptionUrl,
expires = expires?.epochSecond
)
} else
logger.warning("Couldn't register push for ${collection.url}: $response")
}
@@ -142,6 +125,15 @@ class PushRegistrationWorker @AssistedInject constructor(
// register push subscription for syncable collections
if (endpoint != null)
for (collection in collectionRepository.getPushCapableAndSyncable()) {
val expires = collection.pushSubscriptionExpires
// calculate next run time, but use the duplicate interval for safety (times are not exact)
val nextRun = Instant.now() + Duration.ofDays(2*PushRegistrationWorkerManager.INTERVAL_DAYS)
if (expires != null && expires >= nextRun.epochSecond) {
logger.fine("Push subscription for ${collection.url} is still valid until ${collection.pushSubscriptionExpires}")
continue
}
// no existing subscription or expiring soon
logger.info("Registering push for ${collection.url}")
serviceRepository.get(collection.serviceId)?.let { service ->
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
@@ -176,7 +168,11 @@ class PushRegistrationWorker @AssistedInject constructor(
}
// remove registration URL from DB in any case
collectionRepository.updatePushSubscription(collection.id, null)
collectionRepository.updatePushSubscription(
id = collection.id,
subscriptionUrl = null,
expires = null
)
}
}
}
@@ -193,25 +189,4 @@ class PushRegistrationWorker @AssistedInject constructor(
}
}
/**
* Listener that enqueues a push registration worker when the collection list changes.
*/
class CollectionsListener @Inject constructor(
@ApplicationContext val context: Context
): DavCollectionRepository.OnChangeListener {
override fun onCollectionsChanged() = enqueue(context)
}
/**
* Hilt module that registers [CollectionsListener] in [DavCollectionRepository].
*/
@Module
@InstallIn(SingletonComponent::class)
interface PushRegistrationWorkerModule {
@Binds
@IntoSet
fun listener(impl: CollectionsListener): DavCollectionRepository.OnChangeListener
}
}

View File

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

View File

@@ -5,19 +5,23 @@
package at.bitfire.davdroid.push
import android.content.Context
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.MessagingReceiver
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
class UnifiedPushReceiver: MessagingReceiver() {
@@ -40,6 +44,12 @@ class UnifiedPushReceiver: MessagingReceiver() {
@Inject
lateinit var parsePushMessage: PushMessageParser
@Inject
lateinit var pushRegistrationWorkerManager: PushRegistrationWorkerManager
@Inject
lateinit var tasksAppManager: Lazy<TasksAppManager>
@Inject
lateinit var syncWorkerManager: SyncWorkerManager
@@ -49,7 +59,7 @@ class UnifiedPushReceiver: MessagingReceiver() {
preferenceRepository.unifiedPushEndpoint(endpoint)
// register new endpoint at CalDAV/CardDAV servers
PushRegistrationWorker.enqueue(context)
pushRegistrationWorkerManager.updatePeriodicWorker()
}
override fun onUnregistered(context: Context, instance: String) {
@@ -73,8 +83,25 @@ class UnifiedPushReceiver: MessagingReceiver() {
// Later: only sync affected collection and authorities
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
serviceRepository.get(collection.serviceId)?.let { service ->
val syncDataTypes = mutableSetOf<SyncDataType>()
// If the type is an address book, add the contacts type
if (collection.type == TYPE_ADDRESSBOOK)
syncDataTypes += SyncDataType.CONTACTS
// If the collection supports events, add the events type
if (collection.supportsVEVENT != false)
syncDataTypes += SyncDataType.EVENTS
// If the collection supports tasks, make sure there's a provider installed,
// and add the tasks type
if (collection.supportsVJOURNAL != false || collection.supportsVTODO != false)
if (tasksAppManager.get().currentProvider() != null)
syncDataTypes += SyncDataType.TASKS
// Schedule sync for all the types identified
val account = accountRepository.fromName(service.accountName)
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
for (syncDataType in syncDataTypes)
syncWorkerManager.enqueueOneTime(account, syncDataType, fromPush = true)
}
}

View File

@@ -7,25 +7,22 @@ package at.bitfire.davdroid.repository
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.ContentResolver
import android.content.Context
import android.provider.CalendarContract
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.resource.LocalAddressBookStore
import at.bitfire.davdroid.resource.LocalCalendarStore
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.vcard4android.GroupMethod
import dagger.Lazy
@@ -46,11 +43,13 @@ import javax.inject.Inject
*/
class AccountRepository @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
private val automaticSyncManager: AutomaticSyncManager,
@ApplicationContext private val context: Context,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
private val localCalendarStore: Lazy<LocalCalendarStore>,
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
private val logger: Logger,
private val settingsManager: SettingsManager,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
@@ -80,49 +79,31 @@ class AccountRepository @Inject constructor(
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to service DB
// add entries for account to database
logger.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = accountSettingsFactory.create(account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (config.cardDAV != null) {
// insert CardDAV service
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// initial CardDAV account settings and sync intervals
// set initial CardDAV account settings and set sync intervals (enables automatic sync)
val accountSettings = accountSettingsFactory.create(account)
accountSettings.setGroupMethod(groupMethod)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
}
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = tasksAppManager.get().currentProvider()
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
logger.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
logger.info("No tasks provider found. Did not enable tasks sync.")
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
}
// set up automatic sync (processes inserted services)
automaticSyncManager.updateAutomaticSync(account)
} catch(e: InvalidAccountException) {
logger.log(Level.SEVERE, "Couldn't access account settings", e)
@@ -140,7 +121,7 @@ class AccountRepository @Inject constructor(
// delete address books (= address book accounts)
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
collectionRepository.getByService(service.id).forEach { collection ->
LocalAddressBook.deleteByCollection(context, collection.id)
localAddressBookStore.get().deleteByCollectionId(collection.id)
}
}
@@ -201,16 +182,6 @@ class AccountRepository @Inject constructor(
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
throw IllegalArgumentException("Account with name \"$newName\" already exists")
// remember sync intervals
val oldSettings = accountSettingsFactory.create(oldAccount)
val authorities = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
)
val tasksProvider = tasksAppManager.get().currentProvider()
tasksProvider?.authority?.let { authorities.add(it) }
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
// rename account
try {
/* https://github.com/bitfireAT/davx5/issues/135
@@ -222,7 +193,7 @@ class AccountRepository @Inject constructor(
3. Now the services would be renamed, but they're not here anymore. */
AccountsCleanupWorker.lockAccountsCleanup()
// rename account
// rename account (also moves AccountSettings)
val future = accountManager.renameAccount(oldAccount, newName, null, null)
// wait for operation to complete
@@ -234,37 +205,39 @@ class AccountRepository @Inject constructor(
}
// account renamed, cancel maybe running synchronization of old account
BaseSyncWorker.cancelAllWork(context, oldAccount)
syncWorkerManager.cancelAllWork(oldAccount)
// disable periodic syncs for old account
syncIntervals.forEach { (authority, _) ->
syncWorkerManager.disablePeriodic(oldAccount, authority)
}
for (dataType in SyncDataType.entries)
syncWorkerManager.disablePeriodic(oldAccount, dataType)
// update account name references in database
serviceRepository.renameAccount(oldName, newName)
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again at next sync)
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(context, oldAccount.name, newName)
// update address books
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e)
// Couldn't update task lists, but this is not a fatal error (will be fixed at next sync)
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
}
// restore sync intervals
val newSettings = accountSettingsFactory.create(newAccount)
for ((authority, interval) in syncIntervals) {
if (interval == null)
ContentResolver.setIsSyncable(newAccount, authority, 0)
else {
ContentResolver.setIsSyncable(newAccount, authority, 1)
newSettings.setSyncInterval(authority, interval)
}
try {
// update calendar events
localCalendarStore.get().updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
}
try {
// update account_name of local tasks
val dataStore = tasksAppManager.get().getDataStore()
dataStore?.updateAccount(oldAccount, newAccount)
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
}
// update automatic sync
automaticSyncManager.updateAutomaticSync(newAccount)
} finally {
// release AccountsCleanupWorker mutex at the end of this async coroutine
AccountsCleanupWorker.unlockAccountsCleanup()
@@ -285,8 +258,7 @@ class AccountRepository @Inject constructor(
// insert collections
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionRepository.insertOrUpdateByUrl(collection)
collectionRepository.insertOrUpdateByUrl(collection.copy(serviceId = serviceId))
}
return serviceId

View File

@@ -29,15 +29,12 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.ical4android.util.DateUtils
import dagger.Lazy
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.Multibinds
import java.io.StringWriter
import java.util.Collections
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
@@ -46,6 +43,10 @@ import net.fortuna.ical4j.model.Component
import net.fortuna.ical4j.model.ComponentList
import net.fortuna.ical4j.model.component.VTimeZone
import okhttp3.HttpUrl
import java.io.StringWriter
import java.util.Collections
import java.util.UUID
import javax.inject.Inject
/**
* Repository for managing collections.
@@ -54,16 +55,21 @@ import okhttp3.HttpUrl
*/
class DavCollectionRepository @Inject constructor(
private val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
db: AppDatabase,
defaultListeners: Set<@JvmSuppressWildcards OnChangeListener>,
@ApplicationContext private val context: Context,
private val db: AppDatabase,
defaultListeners: Lazy<Set<@JvmSuppressWildcards OnChangeListener>>,
private val serviceRepository: DavServiceRepository
) {
private val listeners = Collections.synchronizedSet(defaultListeners.toMutableSet())
private val listeners by lazy { Collections.synchronizedSet(defaultListeners.get().toMutableSet()) }
private val dao = db.collectionDao()
/**
* Whether there are any collections that are registered for push.
*/
suspend fun anyPushCapable() = dao.anyPushCapable()
/**
* Creates address book collection on server and locally
*/
@@ -151,7 +157,7 @@ class DavCollectionRepository @Inject constructor(
displayName = displayName,
description = description,
color = color,
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
timezoneId = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
@@ -210,20 +216,28 @@ class DavCollectionRepository @Inject constructor(
dao.getPushRegisteredAndNotSyncable()
/**
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
* [Collection.forceReadOnly]), but use the values of the already existing collection.
* Inserts or updates the collection.
*
* On update, it will _not_ update the flags
* - [Collection.sync] and
* - [Collection.forceReadOnly],
* but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
*/
fun insertOrUpdateByUrlAndRememberFlags(newCollection: Collection) {
// remember locally set flags
dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())?.let { oldCollection ->
newCollection.sync = oldCollection.sync
newCollection.forceReadOnly = oldCollection.forceReadOnly
}
db.runInTransaction {
// remember locally set flags
val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())
val newCollectionWithFlags =
if (oldCollection != null)
newCollection.copy(sync = oldCollection.sync, forceReadOnly = oldCollection.forceReadOnly)
else
newCollection
// commit to database
insertOrUpdateByUrl(newCollection)
// commit new collection to database
insertOrUpdateByUrl(newCollectionWithFlags)
}
}
/**
@@ -256,8 +270,12 @@ class DavCollectionRepository @Inject constructor(
notifyOnChangeListeners()
}
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
dao.updatePushSubscription(id, subscriptionUrl)
fun updatePushSubscription(id: Long, subscriptionUrl: String?, expires: Long?) {
dao.updatePushSubscription(
id = id,
pushSubscription = subscriptionUrl,
pushSubscriptionExpires = expires
)
}
/**

View File

@@ -4,9 +4,10 @@
package at.bitfire.davdroid.repository
import android.app.Application
import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -18,7 +19,7 @@ import javax.inject.Inject
* [at.bitfire.davdroid.settings.SettingsManager].
*/
class PreferenceRepository @Inject constructor(
context: Application
@ApplicationContext context: Context
) {
companion object {

View File

@@ -6,11 +6,8 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.os.RemoteException
import android.provider.ContactsContract
@@ -18,16 +15,16 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
import at.bitfire.vcard4android.AndroidGroup
@@ -36,12 +33,9 @@ import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.util.LinkedList
import java.util.Optional
import java.util.logging.Level
import java.util.logging.Logger
@@ -50,29 +44,35 @@ import java.util.logging.Logger
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book.
*
* @param account TODO
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
* the new name will only be available in [addressBookAccount], so usually that one should be used.
*
* @param provider Content provider needed to access and modify the address book
* @param provider Content provider needed to access and modify the address book
*/
@OpenForTesting
open class LocalAddressBook @AssistedInject constructor(
@Assisted _addressBookAccount: Account,
@Assisted("account") val account: Account,
@Assisted("addressBookAccount") _addressBookAccount: Account,
@Assisted provider: ContentProviderClient,
private val accountSettingsFactory: AccountSettings.Factory,
private val collectionRepository: DavCollectionRepository,
@ApplicationContext val context: Context,
@ApplicationContext private val context: Context,
internal val dirtyVerifier: Optional<ContactDirtyVerifier>,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
private val serviceRepository: DavServiceRepository,
private val syncFramework: SyncFrameworkIntegration
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
@AssistedFactory
interface Factory {
fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook
fun create(
@Assisted("account") account: Account,
@Assisted("addressBookAccount") addressBookAccount: Account,
provider: ContentProviderClient
): LocalAddressBook
}
override val tag: String
get() = "contacts-${addressBookAccount.name}"
@@ -100,7 +100,7 @@ open class LocalAddressBook @AssistedInject constructor(
val accountSettings = accountSettingsFactory.create(account)
accountSettings.getGroupMethod()
}
private val includeGroups
val includeGroups
get() = groupMethod == GroupMethod.GROUP_VCARDS
@Deprecated("Local collection should be identified by ID, not by URL")
@@ -109,9 +109,36 @@ open class LocalAddressBook @AssistedInject constructor(
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
/**
* Read-only flag for the address book itself.
*
* Setting this flag:
*
* - stores the new value in [USER_DATA_READ_ONLY] and
* - sets the read-only flag for all contacts and groups in the address book in the content provider, which will
* prevent non-sync-adapter apps from modifying them. However new entries can still be created, so the address book
* is not really read-only.
*
* Reading this flag returns the stored value from [USER_DATA_READ_ONLY].
*/
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
set(readOnly) {
// set read-only flag for address book itself
AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
// update raw contacts
val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update data rows
val dataValues = contentValuesOf(ContactsContract.Data.IS_READ_ONLY to if (readOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update group rows
val groupValues = contentValuesOf(Groups.GROUP_IS_READ_ONLY to if (readOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
}
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
@@ -123,8 +150,7 @@ open class LocalAddressBook @AssistedInject constructor(
/* operations on the collection (address book) itself */
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
val values = contentValuesOf(LocalContact.COLUMN_FLAGS to flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
@@ -147,58 +173,6 @@ open class LocalAddressBook @AssistedInject constructor(
return number
}
/**
* Updates the address book settings.
*
* @param info collection where to take the settings from
* @param forceReadOnly `true`: set the address book to "force read-only";
* `false`: determine read-only flag from [info];
* `null`: don't change the existing value
*/
fun update(info: Collection, forceReadOnly: Boolean? = null) {
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
val accountManager = AccountManager.get(context)
// Update the account name
val newAccountName = accountName(context, info)
if (addressBookAccount.name != newAccountName)
// rename, move contacts/groups and update [AndroidAddressBook.]account
renameAccount(newAccountName)
// Update the account user data
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
// Update force read only
if (forceReadOnly != null) {
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
logger.info("Address book now read-only = $nowReadOnly, updating contacts")
// update address book itself
readOnly = nowReadOnly
// update raw contacts
val rawContactValues = ContentValues(1)
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update data rows
val dataValues = ContentValues(1)
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
// update group rows
val groupValues = ContentValues(1)
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(groupsSyncUri(), groupValues, null, null)
}
}
// make sure it will still be synchronized when contacts are updated
updateSyncFrameworkSettings()
}
/**
* Renames an address book account and moves the contacts and groups (without making them dirty).
* Does not keep user data of the old account, so these have to be set again.
@@ -212,7 +186,6 @@ open class LocalAddressBook @AssistedInject constructor(
*
* @return whether the account was renamed successfully
*/
@VisibleForTesting
internal fun renameAccount(newName: String): Boolean {
val oldAccount = addressBookAccount
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
@@ -246,32 +219,17 @@ open class LocalAddressBook @AssistedInject constructor(
return true
}
override fun deleteCollection(): Boolean {
val accountManager = AccountManager.get(context)
return accountManager.removeAccountExplicitly(addressBookAccount)
}
/**
* Updates the sync framework settings for this address book:
*
* - Contacts sync of this address book account shall be possible -> isSyncable = 1
* - When a contact is changed, a sync shall be initiated -> syncAutomatically = true
* - Remove unwanted sync framework periodic syncs created by setSyncAutomatically, as
* we use PeriodicSyncWorker for scheduled syncs
* Makes contacts of this address book available to be synced and activates synchronization upon
* contact data changes.
*/
fun updateSyncFrameworkSettings() {
// Enable sync-ability
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
// Enable sync-ability of contacts
syncFramework.enableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
// Enable content trigger
if (!ContentResolver.getSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY, true)
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
for (periodicSync in ContentResolver.getPeriodicSyncs(addressBookAccount, ContactsContract.AUTHORITY))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
// Changes in contact data should trigger syncs
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
}
@@ -313,12 +271,10 @@ open class LocalAddressBook @AssistedInject constructor(
override fun forgetETags() {
if (includeGroups) {
val values = ContentValues(1)
values.putNull(AndroidGroup.COLUMN_ETAG)
val values = contentValuesOf(AndroidGroup.COLUMN_ETAG to null)
provider!!.update(groupsSyncUri(), values, null, null)
}
val values = ContentValues(1)
values.putNull(AndroidContact.COLUMN_ETAG)
val values = contentValuesOf(AndroidContact.COLUMN_ETAG to null)
provider!!.update(rawContactsSyncUri(), values, null, null)
}
@@ -344,39 +300,6 @@ open class LocalAddressBook @AssistedInject constructor(
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
* @throws RemoteException on content provider errors
*/
fun verifyDirty(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("verifyDirty() should not be called on Android != 7.0")
var reallyDirty = 0
for (contact in findDirtyContacts()) {
val lastHash = contact.getLastHashCode()
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
if (includeGroups)
reallyDirty += findDirtyGroups().size
return reallyDirty
}
/* special group operations */
/**
@@ -393,8 +316,7 @@ open class LocalAddressBook @AssistedInject constructor(
return cursor.getLong(0)
}
val values = ContentValues(1)
values.put(Groups.TITLE, title)
val values = contentValuesOf(Groups.TITLE to title)
val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
return ContentUris.parseId(uri)
}
@@ -411,126 +333,31 @@ open class LocalAddressBook @AssistedInject constructor(
companion object {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface LocalAddressBookCompanionEntryPoint {
fun localAddressBookFactory(): Factory
fun serviceRepository(): DavServiceRepository
fun logger(): Logger
}
const val USER_DATA_ACCOUNT_NAME = "account_name"
const val USER_DATA_ACCOUNT_TYPE = "account_type"
/**
* URL of the corresponding CardDAV address book.
*
* User data of the address book account (String).
*/
@Deprecated("Use the URL of the DB collection instead")
const val USER_DATA_URL = "url"
/**
* ID of the corresponding database [at.bitfire.davdroid.db.Collection].
*
* User data of the address book account (Long).
*/
const val USER_DATA_COLLECTION_ID = "collection_id"
/**
* Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag).
*
* User data of the address book account (Boolean).
*/
const val USER_DATA_READ_ONLY = "read_only"
// create/query/delete
/**
* Creates a new local address book.
*
* @param context app context to resolve string resources
* @param provider contacts provider client
* @param info collection where to take the name and settings from
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
*/
fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val logger = entryPoint.logger()
val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book))
val userData = initialUserData(info.url.toString(), info.id.toString())
logger.log(Level.INFO, "Creating local address book $account", userData)
if (!SystemAccountUtils.createAccount(context, account, userData))
throw IllegalStateException("Couldn't create address book account")
val factory = entryPoint.localAddressBookFactory()
val addressBook = factory.create(account, provider)
addressBook.updateSyncFrameworkSettings()
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
return addressBook
}
/**
* Finds a [LocalAddressBook] based on its corresponding collection.
*
* @param id collection ID to look for
*
* @return The [LocalAddressBook] for the given collection or *null* if not found
*/
fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? {
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val factory = entryPoint.localAddressBookFactory()
val accountManager = AccountManager.get(context)
return accountManager
.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { account ->
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
.map { account -> factory.create(account, provider) }
.firstOrNull()
}
/**
* Deletes a [LocalAddressBook] based on its corresponding database collection.
*
* @param id collection ID to look for
*/
fun deleteByCollection(context: Context, id: Long) {
val accountManager = AccountManager.get(context)
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
if (addressBookAccount != null)
accountManager.removeAccountExplicitly(addressBookAccount)
}
// helpers
/**
* Creates a name for the address book account from its corresponding db collection info.
*
* The address book account name contains
* - the collection display name or last URL path segment
* - the actual account name
* - the collection ID, to make it unique.
*
* @param info The corresponding collection
*/
fun accountName(context: Context, info: Collection): String {
// Name the address book after given collection display name, otherwise use last URL path segment
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
info.url.lastSegment
else
it
})
// Add the actual account name to the address book account name
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
val serviceRepository = entryPoint.serviceRepository()
serviceRepository.get(info.serviceId)?.let { service ->
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
sb.append(" #${info.id}")
return sb.toString()
}
private fun initialUserData(url: String, collectionId: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_COLLECTION_ID, collectionId)
bundle.putString(USER_DATA_URL, url)
return bundle
}
}
}

View File

@@ -0,0 +1,224 @@
/*
* 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 android.provider.ContactsContract
import androidx.annotation.OpenForTesting
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf
import androidx.core.os.bundleOf
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.sync.account.SystemAccountUtils
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.DavUtils.lastSegment
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class LocalAddressBookStore @Inject constructor(
@ApplicationContext private val context: Context,
private val localAddressBookFactory: LocalAddressBook.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val settings: SettingsManager
): LocalDataStore<LocalAddressBook> {
/** whether a (usually managed) setting wants all address-books to be read-only **/
val forceAllReadOnly: Boolean
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
/**
* Assembles a name for the address book (account) from its corresponding database [Collection].
*
* The address book account name contains
*
* - the collection display name or last URL path segment
* - the actual account name
* - the collection ID, to make it unique.
*
* @param info Collection to take info from
*/
fun accountName(info: Collection): String {
// Name the address book after given collection display name, otherwise use last URL path segment
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
info.url.lastSegment
else
it
})
// Add the actual account name to the address book account name
serviceRepository.get(info.serviceId)?.let { service ->
sb.append(" (${service.accountName})")
}
// Add the collection ID for uniqueness
sb.append(" #${info.id}")
return sb.toString()
}
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
val service = serviceRepository.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
val name = accountName(fromCollection)
val addressBookAccount = createAddressBookAccount(
account = account,
name = name,
id = fromCollection.id,
url = fromCollection.url.toString()
) ?: return null
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
// update settings
addressBook.updateSyncFrameworkSettings()
addressBook.settings = contactsProviderSettings
addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
return addressBook
}
@OpenForTesting
internal fun createAddressBookAccount(account: Account, name: String, id: Long, url: String): Account? {
// create address book account with reference to account, collection ID and URL
val addressBookAccount = Account(name, context.getString(R.string.account_type_address_book))
val userData = bundleOf(
LocalAddressBook.USER_DATA_ACCOUNT_NAME to account.name,
LocalAddressBook.USER_DATA_ACCOUNT_TYPE to account.type,
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString(),
LocalAddressBook.USER_DATA_URL to url
)
if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) {
logger.warning("Couldn't create address book account: $addressBookAccount")
return null
}
return addressBookAccount
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
return accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == account.name &&
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == account.type
}
.map { addressBookAccount ->
localAddressBookFactory.create(account, addressBookAccount, provider)
}
}
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
var currentAccount = localCollection.addressBookAccount
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
// Update the account name
val newAccountName = accountName(fromCollection)
if (currentAccount.name != newAccountName) {
// rename, move contacts/groups and update [AndroidAddressBook.]account
localCollection.renameAccount(newAccountName)
currentAccount = Account(newAccountName, currentAccount.type)
}
// Update the account user data
val accountManager = AccountManager.get(context)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, localCollection.account.name)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, localCollection.account.type)
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, fromCollection.id.toString())
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_URL, fromCollection.url.toString())
// Set contacts provider settings
localCollection.settings = contactsProviderSettings
// Update force read only
val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
if (nowReadOnly != localCollection.readOnly) {
logger.info("Address book has changed to read-only = $nowReadOnly")
localCollection.readOnly = nowReadOnly
}
// make sure it will still be synchronized when contacts are updated
localCollection.updateSyncFrameworkSettings()
}
/**
* Updates address books which are assigned to [oldAccount] so that they're assigned to [newAccount] instead.
*
* @param oldAccount The old account
* @param newAccount The new account
*/
override fun updateAccount(oldAccount: Account, newAccount: Account) {
val accountManager = AccountManager.get(context)
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.filter { addressBookAccount ->
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == oldAccount.name &&
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == oldAccount.type
}
.forEach { addressBookAccount ->
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, newAccount.name)
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, newAccount.type)
}
}
override fun delete(localCollection: LocalAddressBook) {
val accountManager = AccountManager.get(context)
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)
}
/**
* Deletes a [LocalAddressBook] based on its corresponding database collection.
*
* @param id [Collection.id] to look for
*/
fun deleteByCollectionId(id: Long) {
val accountManager = AccountManager.get(context)
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
}
if (addressBookAccount != null)
accountManager.removeAccountExplicitly(addressBookAccount)
}
companion object {
/**
* Contacts Provider Settings (equal for every address book)
*/
val contactsProviderSettings
get() = contentValuesOf(
// SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable.
ContactsContract.Settings.SHOULD_SYNC to 1,
// UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems).
ContactsContract.Settings.UNGROUPED_VISIBLE to 1
)
/**
* Determines whether the address book should be set to read-only.
*
* @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information
* @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege)
*/
@VisibleForTesting
internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean =
info.readOnly() || forceAllReadOnly
}
}

View File

@@ -8,17 +8,13 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import java.util.LinkedList
import java.util.logging.Level
@@ -42,60 +38,6 @@ class LocalCalendar private constructor(
private val logger: Logger
get() = Logger.getGlobal()
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollectionInfo(info, withColor = true)
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name)
values.put(Calendars.ACCOUNT_TYPE, account.type)
// Email address for scheduling. Used by the calendar provider to determine whether the
// user is ORGANIZER/ATTENDEE for a certain event.
values.put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
return create(account, provider, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url.toString())
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
} else
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
info.timezone?.let { tzData ->
try {
val timeZone = DateUtils.parseVTimeZone(tzData)
timeZone.timeZoneId?.let { tzId ->
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
}
} catch(e: IllegalArgumentException) {
logger.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}
}
override val collectionUrl: String?
@@ -111,8 +53,6 @@ class LocalCalendar private constructor(
override val readOnly
get() = accessLevel <= Calendars.CAL_ACCESS_READ
override fun deleteCollection(): Boolean = delete()
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
@@ -121,8 +61,7 @@ class LocalCalendar private constructor(
null
}
set(state) {
val values = ContentValues(1)
values.put(COLUMN_SYNC_STATE, state.toString())
val values = contentValuesOf(COLUMN_SYNC_STATE to state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
@@ -132,9 +71,6 @@ class LocalCalendar private constructor(
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
@@ -175,8 +111,7 @@ class LocalCalendar private constructor(
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
val values = contentValuesOf(LocalEvent.COLUMN_FLAGS to flags)
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
@@ -202,8 +137,7 @@ class LocalCalendar private constructor(
}
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.toString()))
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendar.Companion.calendarBaseValues
import at.bitfire.ical4android.util.DateUtils
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
class LocalCalendarStore @Inject constructor(
@ApplicationContext private val context: Context,
private val accountSettingsFactory: AccountSettings.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository
): LocalDataStore<LocalCalendar> {
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
val service = serviceRepository.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
// If the collection doesn't have a color, use a default color.
val collectionWithColor =
if (fromCollection.color != null)
fromCollection
else
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
val values = valuesFromCollectionInfo(
info = collectionWithColor,
withColor = true
).apply {
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
put(Calendars.ACCOUNT_NAME, account.name)
put(Calendars.ACCOUNT_TYPE, account.type)
// Email address for scheduling. Used by the calendar provider to determine whether the
// user is ORGANIZER/ATTENDEE for a certain event.
put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & syncable at creation, might be changed by user at any time
put(Calendars.VISIBLE, 1)
put(Calendars.SYNC_EVENTS, 1)
}
logger.log(Level.INFO, "Adding local calendar", values)
val uri = AndroidCalendar.create(account, provider, values)
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
}
override fun getAll(account: Account, provider: ContentProviderClient) =
AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${Calendars.SYNC_EVENTS}!=0", null)
override fun update(provider: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.account)
val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())
logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values)
localCollection.update(values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url.toString())
values.put(Calendars.CALENDAR_DISPLAY_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor && info.color != null)
values.put(Calendars.CALENDAR_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
} else
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
info.timezoneId?.let { tzId ->
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
}
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
override fun delete(localCollection: LocalCalendar) {
logger.log(Level.INFO, "Deleting local calendar", localCollection)
localCollection.delete()
}
}

View File

@@ -4,7 +4,6 @@
package at.bitfire.davdroid.resource
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.db.SyncState
interface LocalCollection<out T: LocalResource<*>> {
@@ -27,13 +26,6 @@ interface LocalCollection<out T: LocalResource<*>> {
*/
val readOnly: Boolean
/**
* Deletes the local collection.
*
* @return true if the collection was deleted, false otherwise
*/
fun deleteCollection(): Boolean
/**
* Finds local resources of this collection which have been marked as *deleted* by the user
* or an app acting on their behalf.

View File

@@ -5,11 +5,11 @@
package at.bitfire.davdroid.resource
import android.content.ContentValues
import android.os.Build
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
@@ -25,7 +25,7 @@ import at.bitfire.vcard4android.Contact
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.UUID
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull
class LocalContact: AndroidContact, LocalAddress {
@@ -39,8 +39,6 @@ class LocalContact: AndroidContact, LocalAddress {
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
private val logger: Logger = Logger.getGlobal()
override val addressBook: LocalAddressBook
get() = super.addressBook as LocalAddressBook
@@ -78,8 +76,7 @@ class LocalContact: AndroidContact, LocalAddress {
val newUid = UUID.randomUUID().toString()
// update in contacts provider
val values = ContentValues(1)
values.put(COLUMN_UID, newUid)
val values = contentValuesOf(COLUMN_UID to newUid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
// update this event
@@ -91,6 +88,13 @@ class LocalContact: AndroidContact, LocalAddress {
return "$uid.vcf"
}
/**
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
*/
fun clearCachedContact() {
_contact = null
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
@@ -101,12 +105,8 @@ class LocalContact: AndroidContact, LocalAddress {
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
logger.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
// Android 7 workaround
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
@@ -116,74 +116,23 @@ class LocalContact: AndroidContact, LocalAddress {
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
val values = contentValuesOf(COLUMN_FLAGS to flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.flags = flags
}
/**
* Calculates a hash code from the contact's data (VCard) and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
* @return hash code of contact data (including group memberships)
*/
internal fun dataHashCode(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
// reset contact so that getContact() reads from database
_contact = null
// groupMemberships is filled by getContact()
val dataHash = getContact().hashCode()
val groupHash = groupMemberships.hashCode()
logger.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
return dataHash xor groupHash
}
fun updateHashCode(batch: BatchOperation?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
val hashCode = dataHashCode()
logger.fine("Storing contact hash = $hashCode")
if (batch == null) {
val values = ContentValues(1)
values.put(COLUMN_HASHCODE, hashCode)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} else
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode))
}
fun getLastHashCode(): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
}
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
@@ -238,6 +187,7 @@ class LocalContact: AndroidContact, LocalAddress {
// data rows
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
@@ -250,4 +200,4 @@ class LocalContact: AndroidContact, LocalAddress {
LocalContact(addressBook as LocalAddressBook, values)
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import at.bitfire.davdroid.db.Collection
/**
* Represents a local data store for a specific collection type.
* Manages creation, update, and deletion of collections of the given type.
*/
interface LocalDataStore<T: LocalCollection<*>> {
/**
* Creates a new local collection from the given (remote) collection info.
*
* @param provider the content provider client
* @param fromCollection collection info
*
* @return the new local collection, or `null` if creation failed
*/
fun create(provider: ContentProviderClient, fromCollection: Collection): T?
/**
* Returns all local collections of the data store, including those which don't have a corresponding remote
* [Collection] entry.
*
* @param account the account that the data store is associated with
* @param provider the content provider client
*
* @return a list of all local collections
*/
fun getAll(account: Account, provider: ContentProviderClient): List<T>
/**
* Updates the local collection with the data from the given (remote) collection info.
*
* @param provider the content provider client
* @param localCollection the local collection to update
* @param fromCollection collection info
*/
fun update(provider: ContentProviderClient, localCollection: T, fromCollection: Collection)
/**
* Deletes the local collection.
*
* @param localCollection the local collection to delete
*/
fun delete(localCollection: T)
/**
* Changes the account assigned to the containing data to another one.
*
* @param oldAccount The old account.
* @param newAccount The new account.
*/
fun updateAccount(oldAccount: Account, newAccount: Account)
}

View File

@@ -10,6 +10,7 @@ import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.BuildConfig
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
@@ -43,10 +44,8 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
Events.CONTENT_URI,
eventID
).asSyncAdapter(account),
ContentValues(1).apply {
put(Events.DELETED, 1)
},
null,null
contentValuesOf(Events.DELETED to 1),
null, null
)
}
@@ -208,8 +207,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
val newUid = UUID.randomUUID().toString()
// update in calendar provider
val values = ContentValues(1)
values.put(Events.UID_2445, newUid)
val values = contentValuesOf(Events.UID_2445 to newUid)
calendar.provider.update(eventSyncURI(), values, null, null)
// update this event
@@ -249,15 +247,14 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
val values = contentValuesOf(COLUMN_FLAGS to flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
}
override fun resetDeleted() {
val values = ContentValues(1).apply { put(Events.DELETED, 0) }
val values = contentValuesOf(Events.DELETED to 0)
calendar.provider.update(eventSyncURI(), values, null, null)
}

View File

@@ -7,13 +7,13 @@ package at.bitfire.davdroid.resource
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.vcard4android.AndroidAddressBook
import at.bitfire.vcard4android.AndroidContact
@@ -25,6 +25,7 @@ import at.bitfire.vcard4android.Contact
import java.util.LinkedList
import java.util.UUID
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull
class LocalGroup: AndroidGroup, LocalAddress {
@@ -90,11 +91,14 @@ class LocalGroup: AndroidGroup, LocalAddress {
changeContactIDs += missingMember.id!!
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
addressBook.dirtyVerifier.getOrNull()?.let { verifier ->
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
changeContactIDs
.map { addressBook.findContactById(it) }
.forEach { it.updateHashCode(batch) }
.map { id -> addressBook.findContactById(id) }
.forEach { contact ->
verifier.updateHashCode(contact, batch)
}
}
batch.commit()
}
@@ -144,8 +148,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
// generate new UID
uid = UUID.randomUUID().toString()
val values = ContentValues(1)
values.put(AndroidContact.COLUMN_UID, uid)
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
_contact?.uid = uid
@@ -207,14 +210,12 @@ class LocalGroup: AndroidGroup, LocalAddress {
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(Groups.DELETED, 0)
val values = contentValuesOf(Groups.DELETED to 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
val values = contentValuesOf(COLUMN_FLAGS to flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags

View File

@@ -6,64 +6,18 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentValues
import android.net.Uri
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.JtxCollectionFactory
import at.bitfire.ical4android.JtxICalObject
import at.techbee.jtx.JtxContract
import java.util.logging.Logger
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
LocalCollection<LocalJtxICalObject>{
companion object {
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri {
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollection(info, account, owner, withColor = true)
return create(account, client, values)
}
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
ContentValues().apply {
put(JtxContract.JtxCollection.URL, info.url.toString())
put(
JtxContract.JtxCollection.DISPLAYNAME,
info.displayName ?: info.url.lastSegment
)
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
if (owner != null)
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
else
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
if (withColor && info.color != null)
put(JtxContract.JtxCollection.COLOR, info.color)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
}
}
override val readOnly: Boolean
get() = throw NotImplementedError()
override fun deleteCollection(): Boolean = delete()
override val tag: String
get() = "jtx-${account.name}-$id"
override val collectionUrl: String?
@@ -74,10 +28,6 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
get() = SyncState.fromString(syncstate)
set(value) { syncstate = value.toString() }
fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) {
val values = valuesFromCollection(info, account, owner, updateColor)
update(values)
}
override fun findDeleted(): List<LocalJtxICalObject> {
val values = queryDeletedICalObjects()

View File

@@ -0,0 +1,108 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.PrincipalRepository
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.JtxCollection
import at.bitfire.ical4android.TaskProvider
import at.techbee.jtx.JtxContract
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.logging.Logger
import javax.inject.Inject
class LocalJtxCollectionStore @Inject constructor(
@ApplicationContext val context: Context,
val accountSettingsFactory: AccountSettings.Factory,
db: AppDatabase,
val principalRepository: PrincipalRepository
): LocalDataStore<LocalJtxCollection> {
private val serviceDao = db.serviceDao()
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
// If the collection doesn't have a color, use a default color.
val collectionWithColor =
if (fromCollection.color != null)
fromCollection
else
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
val values = valuesFromCollection(
info = collectionWithColor,
account = account,
withColor = true
)
val uri = JtxCollection.create(account, provider, values)
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
}
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
val owner = info.ownerId?.let { principalRepository.get(it) }
return ContentValues().apply {
put(JtxContract.JtxCollection.URL, info.url.toString())
put(
JtxContract.JtxCollection.DISPLAYNAME,
info.displayName ?: info.url.lastSegment
)
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
if (owner != null)
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
else
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
if (withColor && info.color != null)
put(JtxContract.JtxCollection.COLOR, info.color)
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
}
}
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
val accountSettings = accountSettingsFactory.create(localCollection.account)
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
localCollection.update(values)
}
override fun delete(localCollection: LocalJtxCollection) {
localCollection.delete()
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
}

View File

@@ -5,6 +5,7 @@
package at.bitfire.davdroid.resource
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DmfsTask
import at.bitfire.ical4android.DmfsTaskFactory
@@ -63,8 +64,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
val newUid = UUID.randomUUID().toString()
// update in tasks provider
val values = ContentValues(1)
values.put(Tasks._UID, newUid)
val values = contentValuesOf(Tasks._UID to newUid)
taskList.provider.update(taskSyncURI(), values, null, null)
// update this task
@@ -95,8 +95,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
override fun updateFlags(flags: Int) {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
val values = contentValuesOf(COLUMN_FLAGS to flags)
taskList.provider.update(taskSyncURI(), values, null, null)
}

View File

@@ -5,19 +5,16 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentProviderClient
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.db.Collection
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.DmfsTaskListFactory
import at.bitfire.ical4android.TaskProvider
import org.dmfs.tasks.contract.TaskContract.*
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import java.util.logging.Logger
@@ -33,53 +30,6 @@ class LocalTaskList private constructor(
id: Long
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
companion object {
fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri {
// If the collection doesn't have a color, use a default color.
if (info.color != null)
info.color = Constants.DAVDROID_GREEN_RGBA
val values = valuesFromCollectionInfo(info, withColor = true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
values.put(TaskLists.VISIBLE, 1)
return create(account, provider, providerName, values)
}
@SuppressLint("Recycle")
@Throws(Exception::class)
fun onRenameAccount(context: Context, oldName: String, newName: String) {
TaskProvider.acquire(context)?.use { provider ->
val values = ContentValues(1)
values.put(Tasks.ACCOUNT_NAME, newName)
provider.client.update(
Tasks.getContentUri(provider.name.authority),
values,
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
)
}
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url.toString())
values.put(TaskLists.LIST_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor && info.color != null)
values.put(TaskLists.LIST_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly)
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
else
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
return values
}
}
private val logger = Logger.getGlobal()
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
@@ -88,8 +38,6 @@ class LocalTaskList private constructor(
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
override fun deleteCollection(): Boolean = delete()
override val collectionUrl: String?
get() = syncId
@@ -115,8 +63,7 @@ class LocalTaskList private constructor(
return null
}
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
provider.update(taskListSyncUri(), values, null, null)
}
@@ -126,9 +73,6 @@ class LocalTaskList private constructor(
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
@@ -154,8 +98,7 @@ class LocalTaskList private constructor(
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalTask.COLUMN_FLAGS, flags)
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
return provider.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
@@ -167,8 +110,7 @@ class LocalTaskList private constructor(
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
val values = contentValuesOf(LocalEvent.COLUMN_ETAG to null)
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.core.content.contentValuesOf
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils.lastSegment
import at.bitfire.ical4android.DmfsTaskList
import at.bitfire.ical4android.TaskProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import java.util.logging.Logger
class LocalTaskListStore @AssistedInject constructor(
@Assisted private val providerName: TaskProvider.ProviderName,
val accountSettingsFactory: AccountSettings.Factory,
@ApplicationContext val context: Context,
val db: AppDatabase,
val logger: Logger
): LocalDataStore<LocalTaskList> {
@AssistedFactory
interface Factory {
fun create(providerName: TaskProvider.ProviderName): LocalTaskListStore
}
private val serviceDao = db.serviceDao()
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
val account = Account(service.accountName, context.getString(R.string.account_type))
logger.log(Level.INFO, "Adding local task list", fromCollection)
val uri = create(account, provider, providerName, fromCollection)
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
}
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
// If the collection doesn't have a color, use a default color.
val collectionWithColor = if (fromCollection.color != null)
fromCollection
else
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
val values = valuesFromCollectionInfo(
info = collectionWithColor,
withColor = true
).apply {
put(TaskLists.OWNER, account.name)
put(TaskLists.SYNC_ENABLED, 1)
put(TaskLists.VISIBLE, 1)
}
return DmfsTaskList.Companion.create(account, provider, providerName, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url.toString())
values.put(TaskLists.LIST_NAME,
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
if (withColor && info.color != null)
values.put(TaskLists.LIST_COLOR, info.color)
if (info.privWriteContent && !info.forceReadOnly)
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
else
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
return values
}
override fun getAll(account: Account, provider: ContentProviderClient) =
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
val accountSettings = accountSettingsFactory.create(localCollection.account)
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
}
override fun delete(localCollection: LocalTaskList) {
localCollection.delete()
}
override fun updateAccount(oldAccount: Account, newAccount: Account) {
TaskProvider.acquire(context, providerName)?.use { provider ->
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
val uri = Tasks.getContentUri(providerName.authority)
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
}
}
}

View File

@@ -12,7 +12,6 @@ import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import at.bitfire.vcard4android.contactrow.DataRowHandler
import java.io.FileNotFoundException
import java.util.logging.Logger
class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {

View File

@@ -0,0 +1,160 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.workaround
import android.content.ContentValues
import android.os.Build
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalContact.Companion.COLUMN_HASHCODE
import at.bitfire.vcard4android.BatchOperation
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.Optional
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Inject
import javax.inject.Provider
/**
* Android 7.x introduced a new behavior in the Contacts provider: when metadata of a contact (like the "last contacted" time)
* changes, the contact is marked as "dirty" (i.e. the [android.provider.ContactsContract.RawContacts.DIRTY] flag is set).
* So, under Android 7.x, every time a user calls a contact or writes an SMS to a contact, the contact is marked as dirty.
*
* **This behavior is not present in Android ≤ 6.x nor in ≥ Android 8.x, where a contact is only marked as dirty
* when its data actually change.**
*
* So, as a dirty workaround for Android 7.x, we need to calculate a hash code from the contact data and group memberships every
* time we change the contact. When then a contact is marked as dirty, we compare the hash code of the current contact data with
* the previous hash code. If the hash code has changed, the contact is "really dirty" and we need to upload it. Otherwise,
* we reset the dirty flag to ignore the meta-data change.
*
* @constructor May only be called on Android 7.x, otherwise an [IllegalStateException] is thrown.
*/
class Android7DirtyVerifier @Inject constructor(
val logger: Logger
): ContactDirtyVerifier {
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("Android7DirtyVerifier must not be used on Android != 7.x")
}
// address-book level functions
override fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean {
val reallyDirty = verifyDirtyContacts(addressBook)
val deleted = addressBook.findDeleted().size
if (isUpload && reallyDirty == 0 && deleted == 0) {
logger.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
return false
}
return true
}
/**
* Queries all contacts with the [android.provider.ContactsContract.RawContacts.DIRTY] flag and checks whether their data
* checksum has changed, i.e. if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
*
* The dirty flag is removed from contacts which are not "really dirty", i.e. from contacts whose contact data
* checksum has not changed.
*
* @return number of "really dirty" contacts
*/
private fun verifyDirtyContacts(addressBook: LocalAddressBook): Int {
var reallyDirty = 0
for (contact in addressBook.findDirtyContacts()) {
val lastHash = getLastHashCode(addressBook, contact)
val currentHash = contactDataHashCode(contact)
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
if (addressBook.includeGroups)
reallyDirty += addressBook.findDirtyGroups().size
return reallyDirty
}
private fun getLastHashCode(addressBook: LocalAddressBook, contact: LocalContact): Int {
addressBook.provider!!.query(contact.rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
}
// contact level functions
/**
* Calculates a hash code from the [at.bitfire.vcard4android.Contact] data and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory!
*
* @return hash code of contact data (including group memberships)
*/
private fun contactDataHashCode(contact: LocalContact): Int {
contact.clearCachedContact()
// groupMemberships is filled by getContact()
val dataHash = contact.getContact().hashCode()
val groupHash = contact.groupMemberships.hashCode()
val combinedHash = dataHash xor groupHash
logger.log(Level.FINE, "Calculated data hash = $dataHash, group memberships hash = $groupHash → combined hash = $combinedHash", contact)
return combinedHash
}
override fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues) {
val hashCode = contactDataHashCode(contact)
toValues.put(COLUMN_HASHCODE, hashCode)
}
override fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact) {
val values = ContentValues(1)
setHashCodeColumn(contact, values)
addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null)
}
override fun updateHashCode(contact: LocalContact, batch: BatchOperation) {
val hashCode = contactDataHashCode(contact)
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(contact.rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode))
}
// factory
@Module
@InstallIn(SingletonComponent::class)
object Android7DirtyVerifierModule {
/**
* Provides an [Android7DirtyVerifier] on Android 7.x, or an empty [Optional] on other versions.
*/
@Provides
fun provide(android7DirtyVerifier: Provider<Android7DirtyVerifier>): Optional<ContactDirtyVerifier> =
if (/* Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && */ Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
Optional.of(android7DirtyVerifier.get())
else
Optional.empty()
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.resource.workaround
import android.content.ContentValues
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.vcard4android.BatchOperation
/**
* Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8,
* this interface and all calls to it can be removed as well.
*/
interface ContactDirtyVerifier {
// address-book level functions
/**
* Checks whether contacts which are marked as "dirty" are really dirty, i.e. their data has changed.
* If contacts are not really dirty (because only the metadata like "last contacted" changed), the "dirty" flag is removed.
*
* Intended to be called by [at.bitfire.davdroid.sync.ContactsSyncManager.prepare].
*
* @param addressBook the address book
* @param isUpload whether this sync is an upload
*
* @return `true` if the address book should be synced, `false` if the sync is an upload and no contacts have been changed
*/
fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean
// contact level functions
/**
* Sets the [LocalContact.COLUMN_HASHCODE] column in the given [ContentValues] to the hash code of the contact data.
*
* @param contact the contact to calculate the hash code for
* @param toValues set the hash code into these values
*/
fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues)
/**
* Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data directly in the content provider.
*/
fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact)
/**
Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data in a content provider batch operation.
*/
fun updateHashCode(contact: LocalContact, batch: BatchOperation)
}

View File

@@ -9,10 +9,19 @@ import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
import at.bitfire.dav4jvm.property.caldav.Source
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.GroupMembership
@@ -41,8 +50,8 @@ import java.util.logging.Logger
* Logic for refreshing the list of collections and home-sets and related information.
*/
class CollectionListRefresher @AssistedInject constructor(
@Assisted val service: Service,
@Assisted val httpClient: OkHttpClient,
@Assisted private val service: Service,
@Assisted private val httpClient: OkHttpClient,
private val db: AppDatabase,
private val collectionRepository: DavCollectionRepository,
private val homeSetRepository: DavHomeSetRepository,
@@ -55,68 +64,120 @@ class CollectionListRefresher @AssistedInject constructor(
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
}
private val alreadyQueried = mutableSetOf<HttpUrl>()
/**
* Principal properties to ask the server for.
*/
private val principalProperties = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* Starting at current-user-principal URL, tries to recursively find and save all user relevant home sets.
*
*
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
* @param level Current recursion level (limited to 0, 1 or 2):
*
* - 0: We assume found home sets belong to the current-user-principal
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
* Home-set class to use depending on the given service type.
*/
internal fun discoverHomesets(principalUrl: HttpUrl, level: Int = 0) {
logger.fine("Discovering homesets of $principalUrl")
val relatedResources = mutableSetOf<HttpUrl>()
// Define homeset class and properties to look for
val homeSetClass: Class<out HrefListProperty>
val properties: Array<Property.Name>
private val homeSetClass: Class<out HrefListProperty> =
when (service.type) {
Service.TYPE_CARDDAV -> {
homeSetClass = AddressbookHomeSet::class.java
properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME, ResourceType.NAME)
}
Service.TYPE_CALDAV -> {
homeSetClass = CalendarHomeSet::class.java
properties = arrayOf(
DisplayName.NAME,
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME,
GroupMembership.NAME,
ResourceType.NAME
)
}
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
else -> throw IllegalArgumentException()
}
/**
* Home-set properties to ask for in a PROPFIND request to the principal URL,
* depending on the given service type.
*/
private val homeSetProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
DisplayName.NAME,
GroupMembership.NAME,
ResourceType.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookHomeSet.NAME,
)
Service.TYPE_CALDAV -> arrayOf(
CalendarHomeSet.NAME,
CalendarProxyReadFor.NAME,
CalendarProxyWriteFor.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Collection properties to ask for in a PROPFIND request on a collection.
*/
private val collectionProperties: Array<Property.Name> =
arrayOf( // generic WebDAV properties
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
ResourceType.NAME,
PushTransports.NAME, // WebDAV-Push
Topic.NAME
) + when (service.type) { // service-specific CalDAV/CardDAV properties
Service.TYPE_CARDDAV -> arrayOf(
AddressbookDescription.NAME
)
Service.TYPE_CALDAV -> arrayOf(
CalendarColor.NAME,
CalendarDescription.NAME,
CalendarTimezone.NAME,
CalendarTimezoneId.NAME,
SupportedCalendarComponentSet.NAME,
Source.NAME
)
else -> throw IllegalArgumentException()
}
/**
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
*
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
* @param level Current recursion level (limited to 0, 1 or 2):
* - 0: We assume found home sets belong to the current-user-principal
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
* more than once, which could overwrite the already set "personal" flag with `false`.
*
* @throws java.io.IOException on I/O errors
* @throws HttpException on HTTP errors
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
*/
internal fun discoverHomesets(
principalUrl: HttpUrl,
level: Int = 0,
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
) {
logger.fine("Discovering homesets of $principalUrl")
val relatedResources = mutableSetOf<HttpUrl>()
// Query the URL
val principal = DavResource(httpClient, principalUrl)
val personal = level == 0
try {
principal.propfind(0, *properties) { davResponse, _ ->
alreadyQueried += davResponse.href
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
alreadyQueriedPrincipals += davResponse.href
// If response holds home sets, save them
davResponse[homeSetClass]?.let { homeSets ->
for (homeSetHref in homeSets.hrefs)
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
// Homeset is considered personal if this is the outer recursion call,
// This is because we assume the first call to query the current-user-principal
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
// other principals and still be considered "personal" (belonging to the current-user-principal).
homeSetRepository.insertOrUpdateByUrl(
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
)
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
homeSetRepository.insertOrUpdateByUrl(
// HomeSet is considered personal if this is the outer recursion call,
// This is because we assume the first call to query the current-user-principal
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
// other principals while still being considered "personal" (belonging to the current-user-principal)
// and an owned home set need not always be personal either.
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
)
alreadySavedHomeSets += resolvedHomeSetUrl
}
}
}
@@ -158,10 +219,15 @@ class CollectionListRefresher @AssistedInject constructor(
// query related resources
if (level <= 1)
for (resource in relatedResources)
if (alreadyQueried.contains(resource))
if (alreadyQueriedPrincipals.contains(resource))
logger.warning("$resource already queried, skipping")
else
discoverHomesets(resource, level + 1)
discoverHomesets(
principalUrl = resource,
level = level + 1,
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
alreadySavedHomeSets = alreadySavedHomeSets
)
}
/**
@@ -186,34 +252,29 @@ class CollectionListRefresher @AssistedInject constructor(
.toMutableMap()
try {
DavResource(httpClient, homeSetUrl).propfind(1, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, relation ->
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
// Note: This callback may be called multiple times ([MultiResponseCallback])
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
localHomeset.displayName = response[DisplayName::class.java]?.displayName
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
homeSetRepository.insertOrUpdateByUrl(localHomeset)
}
if (relation == Response.HrefRelation.SELF)
// this response is about the home set itself
homeSetRepository.insertOrUpdateByUrl(localHomeset.copy(
displayName = response[DisplayName::class.java]?.displayName,
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
))
// in any case, check whether the response is about a usable collection
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.serviceId = service.id
collection.homeSetId = localHomeset.id
collection.sync = shouldPreselect(collection, homesets.values)
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
?.let { response.href.resolve(it) }
?.let { principalUrl ->
val principal = Principal.fromServiceAndUrl(service, principalUrl)
val id = db.principalDao().insertOrUpdate(service.id, principal)
collection.ownerId = id
}
var collection = Collection.fromDavResponse(response) ?: return@propfind
collection = collection.copy(
serviceId = service.id,
homeSetId = localHomeset.id,
sync = shouldPreselect(collection, homesets.values),
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
)
logger.log(Level.FINE, "Found collection", collection)
// save or update collection if usable (ignore it otherwise)
@@ -230,10 +291,10 @@ class CollectionListRefresher @AssistedInject constructor(
}
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
for ((_, homelessCollection) in localHomesetCollections) {
homelessCollection.homeSetId = null
collectionRepository.insertOrUpdateByUrlAndRememberFlags(homelessCollection)
}
for ((_, homelessCollection) in localHomesetCollections)
collectionRepository.insertOrUpdateByUrlAndRememberFlags(
homelessCollection.copy(homeSetId = null)
)
}
}
@@ -246,7 +307,7 @@ class CollectionListRefresher @AssistedInject constructor(
internal fun refreshHomelessCollections() {
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
for((url, localCollection) in homelessCollections) try {
DavResource(httpClient, url).propfind(0, *RefreshCollectionsWorker.DAV_COLLECTION_PROPERTIES) { response, _ ->
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
if (!response.isSuccess()) {
collectionRepository.delete(localCollection)
return@propfind
@@ -256,18 +317,13 @@ class CollectionListRefresher @AssistedInject constructor(
Collection.fromDavResponse(response)?.let { collection ->
if (!isUsableCollection(collection))
return@let
collection.serviceId = localCollection.serviceId // use same service ID as previous entry
// .. and save the principal url (collection owner)
response[Owner::class.java]?.href
?.let { response.href.resolve(it) }
?.let { principalUrl ->
val principal = Principal.fromServiceAndUrl(service, principalUrl)
val principalId = db.principalDao().insertOrUpdate(service.id, principal)
collection.ownerId = principalId
}
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection)
collectionRepository.insertOrUpdateByUrlAndRememberFlags(collection.copy(
serviceId = localCollection.serviceId, // use same service ID as previous entry
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
?.let { response.href.resolve(it) }
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
))
} ?: collectionRepository.delete(localCollection)
}
} catch (e: HttpException) {
@@ -291,7 +347,7 @@ class CollectionListRefresher @AssistedInject constructor(
val principalUrl = oldPrincipal.url
logger.fine("Querying principal $principalUrl")
try {
DavResource(httpClient, principalUrl).propfind(0, *RefreshCollectionsWorker.DAV_PRINCIPAL_PROPERTIES) { response, _ ->
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
if (!response.isSuccess())
return@propfind
Principal.fromDavResponse(service.id, response)?.let { principal ->
@@ -331,10 +387,10 @@ class CollectionListRefresher @AssistedInject constructor(
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
*
* @param collection the collection to check
* @param homesets list of home-sets (to check whether collection is in a personal home-set)
* @param homeSets list of home-sets (to check whether collection is in a personal home-set)
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
*/
internal fun shouldPreselect(collection: Collection, homesets: Iterable<HomeSet>): Boolean {
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
val excluded by lazy {
@@ -352,7 +408,7 @@ class CollectionListRefresher @AssistedInject constructor(
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
// preselect if is personal (in a personal home-set), but not excluded
homesets
homeSets
.filter { homeset -> homeset.personal }
.map { homeset -> homeset.id }
.contains(collection.homeSetId)

View File

@@ -22,23 +22,10 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.Source
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
import at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.Owner
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationRegistry
@@ -85,26 +72,6 @@ class RefreshCollectionsWorker @AssistedInject constructor(
const val ARG_SERVICE_ID = "serviceId"
const val WORKER_TAG = "refreshCollectionsWorker"
// Collection properties to ask for in a propfind request to the Cal- or CardDAV server
val DAV_COLLECTION_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
Owner.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME,
// WebDAV Push
PushTransports.NAME,
Topic.NAME
)
// Principal properties to ask the server
val DAV_PRINCIPAL_PROPERTIES = arrayOf(
DisplayName.NAME,
ResourceType.NAME
)
/**
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
*

View File

@@ -5,48 +5,51 @@ package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import android.os.Looper
import android.provider.CalendarContract
import androidx.annotation.WorkerThread
import androidx.core.os.bundleOf
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.davdroid.settings.migration.AccountSettingsMigration
import at.bitfire.davdroid.sync.AutomaticSyncManager
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
import at.bitfire.davdroid.util.trimToNull
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.AuthState
import java.util.Collections
import java.util.logging.Level
import java.util.logging.Logger
import javax.inject.Provider
import kotlin.collections.mutableSetOf
/**
* Manages settings of an account.
*
* Must not be called from main thread as it uses blocking I/O
* and may run migrations.
* Must not be called from main thread as it uses blocking I/O and may run migrations.
*
* @param account account to take settings from
* @param account account to take settings from
* @param abortOnMissingMigration whether to throw an [IllegalArgumentException] when migrations are missing (useful for testing)
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
* @throws IllegalArgumentException when the account is not a DAVx5 account
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
* @throws IllegalArgumentException when the account is not a DAVx5 account or migrations are missing and [abortOnMissingMigration] is set
*/
@WorkerThread
class AccountSettings @AssistedInject constructor(
@Assisted val account: Account,
@ApplicationContext val context: Context,
@Assisted val abortOnMissingMigration: Boolean,
private val automaticSyncManager: AutomaticSyncManager,
@ApplicationContext private val context: Context,
private val logger: Logger,
private val migrationsFactory: AccountSettingsMigrations.Factory,
private val settingsManager: SettingsManager,
private val syncWorkerManager: SyncWorkerManager
private val migrations: Map<Int, @JvmSuppressWildcards Provider<AccountSettingsMigration>>,
private val settingsManager: SettingsManager
) {
@AssistedFactory
@@ -56,7 +59,7 @@ class AccountSettings @AssistedInject constructor(
* migrations.
*/
@WorkerThread
fun create(account: Account): AccountSettings
fun create(account: Account, abortOnMissingMigration: Boolean = false): AccountSettings
}
init {
@@ -71,27 +74,29 @@ class AccountSettings @AssistedInject constructor(
"at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest
)
if (!allowedAccountTypes.contains(account.type))
throw IllegalArgumentException("Invalid account type: ${account.type}")
throw IllegalArgumentException("Invalid account type for AccountSettings(): ${account.type}")
// synchronize because account migration must only be run one time
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
}
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
synchronized(currentlyUpdating) {
if (currentlyUpdating.contains(account))
logger.warning("AccountSettings created during migration of $account not running update()")
else {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
}
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION) {
if (currentlyUpdating) {
logger.severe("Redundant call: migration created AccountSettings(). This must never happen.")
throw IllegalStateException("Redundant call: migration created AccountSettings()")
} else {
currentlyUpdating = true
update(version)
currentlyUpdating = false
if (version < CURRENT_VERSION) {
currentlyUpdating += account
try {
update(version, abortOnMissingMigration)
} finally {
currentlyUpdating -= account
}
}
}
}
@@ -127,114 +132,41 @@ class AccountSettings @AssistedInject constructor(
// sync. settings
/**
* Gets the currently set sync interval for this account in seconds.
* Gets the currently set sync interval for this account and data type in seconds.
*
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
* @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set
* @param dataType data type of desired sync interval
* @return sync interval in seconds, or `null` if not set (not applicable or only manual sync)
*/
fun getSyncInterval(authority: String): Long? {
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (ContentResolver.getIsSyncable(account, authority) <= 0 && authority != addrBookAuthority)
return null
val key = when {
authority == addrBookAuthority ->
KEY_SYNC_INTERVAL_ADDRESSBOOKS
authority == CalendarContract.AUTHORITY ->
KEY_SYNC_INTERVAL_CALENDARS
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
KEY_SYNC_INTERVAL_TASKS
else -> return null
fun getSyncInterval(dataType: SyncDataType): Long? {
val key = when (dataType) {
SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
}
return accountManager.getUserData(account, key)?.toLong()
}
fun getTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
/**
* Sets the sync interval and en- or disables periodic sync for the given account and authority.
* Does *not* call [ContentResolver.setIsSyncable].
*
* This method blocks until a worker as been created and enqueued (sync active) or removed
* (sync disabled), so it should not be called from the UI thread.
*
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @param _seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
* otherwise (must be ≥ 15 min): automatic sync will be enabled and set to the given number of seconds
*/
@WorkerThread
fun setSyncInterval(authority: String, _seconds: Long) {
val seconds =
if (_seconds != SYNC_INTERVAL_MANUALLY && _seconds < 60*15)
60*15
else
_seconds
// Store (user defined) sync interval in account settings
val key = when {
authority == context.getString(R.string.address_books_authority) ->
KEY_SYNC_INTERVAL_ADDRESSBOOKS
authority == CalendarContract.AUTHORITY ->
KEY_SYNC_INTERVAL_CALENDARS
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
KEY_SYNC_INTERVAL_TASKS
else -> {
logger.warning("Sync interval not applicable to authority $authority")
return
}
val seconds = accountManager.getUserData(account, key)?.toLong()
return when (seconds) {
null -> settingsManager.getLongOrNull(Settings.DEFAULT_SYNC_INTERVAL) // no setting → default value
SYNC_INTERVAL_MANUALLY -> null // manual sync
else -> seconds
}
accountManager.setAndVerifyUserData(account, key, seconds.toString())
// update sync workers (needs already updated sync interval in AccountSettings)
updatePeriodicSyncWorker(authority, seconds, getSyncWifiOnly())
// Also enable/disable content change triggered syncs (SyncFramework automatic sync).
// We could make this a separate user adjustable setting later on.
setSyncOnContentChange(authority, seconds != SYNC_INTERVAL_MANUALLY)
}
/**
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
* Sets the sync interval for the given data type and updates the automatic sync.
*
* We use the sync adapter framework only for the trigger, actual syncing is implemented
* with WorkManager. The trigger comes in through SyncAdapterService.
*
* This method blocks until the sync-on-content-change has been enabled or disabled, so it
* should not be called from the UI thread.
*
* @param enable *true* enables automatic sync; *false* disables it
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @return whether the content triggered sync was enabled successfully
* @param dataType data type of the sync interval to set
* @param seconds sync interval in seconds; _null_ for no periodic sync
*/
@WorkerThread
fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean {
// Enable content change triggers (sync adapter framework)
val setContentTrigger: () -> Boolean =
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
if (enable) {{
logger.fine("Enabling content-triggered sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers
// Remove unwanted sync framework periodic syncs created by setSyncAutomatically
for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority))
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
/* return */ ContentResolver.getSyncAutomatically(account, authority)
}} else {{
logger.fine("Disabling content-triggered sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
}}
// try up to 10 times with 100 ms pause
for (idxTry in 0 until 10) {
if (setContentTrigger())
// successfully set
return true
Thread.sleep(100)
fun setSyncInterval(dataType: SyncDataType, seconds: Long?) {
val key = when (dataType) {
SyncDataType.CONTACTS -> KEY_SYNC_INTERVAL_ADDRESSBOOKS
SyncDataType.EVENTS -> KEY_SYNC_INTERVAL_CALENDARS
SyncDataType.TASKS -> KEY_SYNC_INTERVAL_TASKS
}
return false
val newValue = if (seconds == null) SYNC_INTERVAL_MANUALLY else seconds
accountManager.setAndVerifyUserData(account, key, newValue.toString())
automaticSyncManager.updateAutomaticSync(account, dataType)
}
fun getSyncWifiOnly() =
@@ -245,10 +177,7 @@ class AccountSettings @AssistedInject constructor(
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
// update sync workers (needs already updated wifi-only flag in AccountSettings)
for (authority in SyncUtils.syncAuthorities(context))
updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly)
automaticSyncManager.updateAutomaticSync(account)
}
fun getSyncWifiOnlySSIDs(): List<String>? =
@@ -273,30 +202,6 @@ class AccountSettings @AssistedInject constructor(
fun setIgnoreVpns(ignoreVpns: Boolean) =
accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0")
/**
* Updates the periodic sync worker of an authority according to
*
* - the sync interval and
* - the _Sync WiFi only_ flag.
*
* @param authority periodic sync workers for this authority will be updated
* @param seconds sync interval in seconds (`null` or [SYNC_INTERVAL_MANUALLY] disables periodic sync)
* @param wiFiOnly sync Wifi only flag
*/
fun updatePeriodicSyncWorker(authority: String, seconds: Long?, wiFiOnly: Boolean) {
try {
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
logger.fine("Disabling periodic sync of $account/$authority")
syncWorkerManager.disablePeriodic(account, authority)
} else {
logger.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
syncWorkerManager.enablePeriodic(account, authority, seconds, wiFiOnly)
}.result.get() // On operation (enable/disable) failure exception is thrown
} catch (e: Exception) {
logger.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
}
}
// CalDAV settings
@@ -313,7 +218,7 @@ class AccountSettings @AssistedInject constructor(
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
/**
* Takes the default alarm setting (in this order) from
@@ -325,8 +230,8 @@ class AccountSettings @AssistedInject constructor(
* non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun getDefaultAlarm() =
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
/**
* Sets the default alarm value in the local account settings, if the new value differs
@@ -338,11 +243,11 @@ class AccountSettings @AssistedInject constructor(
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun setDefaultAlarm(minBefore: Int?) =
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
null
else
minBefore?.toString())
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
if (minBefore == settingsManager.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
null
else
minBefore?.toString())
fun getManageCalendarColors() =
if (settingsManager.containsKey(KEY_MANAGE_CALENDAR_COLORS))
@@ -415,29 +320,33 @@ class AccountSettings @AssistedInject constructor(
// update from previous account settings
private fun update(baseVersion: Int) {
private fun update(baseVersion: Int, abortOnMissingMigration: Boolean) {
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
val fromVersion = toVersion-1
logger.info("Updating account ${account.name} from version $fromVersion to $toVersion")
try {
val migrations = migrationsFactory.create(
account = account,
accountSettings = this
)
val updateProc = AccountSettingsMigrations::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
updateProc.invoke(migrations)
val fromVersion = toVersion - 1
logger.info("Updating account ${account.name} settings version $fromVersion $toVersion")
logger.info("Account version update successful")
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't update account settings", e)
val migration = migrations[toVersion]
if (migration == null) {
logger.severe("No AccountSettings migration $fromVersion$toVersion")
if (abortOnMissingMigration)
throw IllegalArgumentException("Missing AccountSettings migration $fromVersion$toVersion")
} else {
try {
migration.get().migrate(account)
logger.info("Account settings version update to $toVersion successful")
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't run AccountSettings migration $fromVersion$toVersion", e)
}
}
}
}
companion object {
const val CURRENT_VERSION = 17
const val CURRENT_VERSION = 19
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
@@ -493,16 +402,13 @@ class AccountSettings @AssistedInject constructor(
"1" show only personal collections */
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
const val SYNC_INTERVAL_MANUALLY = -1L
internal const val SYNC_INTERVAL_MANUALLY = -1L
/** Static property to indicate whether AccountSettings migration is currently running.
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
@Volatile
var currentlyUpdating = false
/** Static property to remember which AccountSettings updates/migrations are currently running */
val currentlyUpdating = Collections.synchronizedSet(mutableSetOf<Account>())
fun initialUserData(credentials: Credentials?): Bundle {
val bundle = Bundle()
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
val bundle = bundleOf(KEY_SETTINGS_VERSION to CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.username != null)

View File

@@ -1,423 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Base64
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.sync.SyncUtils
import at.bitfire.davdroid.sync.TasksAppManager
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.UnknownProperty
import at.techbee.jtx.JtxContract.asSyncAdapter
import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.property.Url
import org.dmfs.tasks.contract.TaskContract
import java.io.ByteArrayInputStream
import java.io.ObjectInputStream
import java.util.logging.Level
import java.util.logging.Logger
class AccountSettingsMigrations @AssistedInject constructor(
@Assisted val account: Account,
@Assisted val accountSettings: AccountSettings,
@ApplicationContext val context: Context,
private val accountRepository: AccountRepository,
private val collectionRepository: DavCollectionRepository,
private val db: AppDatabase,
private val localAddressBookFactory: LocalAddressBook.Factory,
private val logger: Logger,
private val serviceRepository: DavServiceRepository,
private val syncWorkerManager: SyncWorkerManager,
private val tasksAppManager: Lazy<TasksAppManager>
) {
@AssistedFactory
interface Factory {
fun create(account: Account, accountSettings: AccountSettings): AccountSettingsMigrations
}
val accountManager: AccountManager = AccountManager.get(context)
/**
* With DAVx5 4.3.3 address book account names now contain the collection ID as a unique
* identifier. We need to update the address book account names.
*/
@Suppress("unused","FunctionName")
fun update_16_17() {
val addressBookAccountType = context.getString(R.string.account_type_address_book)
try {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
} catch (e: SecurityException) {
// Not setting the collection ID will cause the address books to removed and fully re-synced as soon as there are permissions.
logger.log(Level.WARNING, "Missing permissions for contacts authority, won't set collection ID for address books", e)
null
}?.use { provider ->
val service = serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV) ?: return
// Get all old address books of this account, i.e. the ones which have a "real_account_name" of this account.
// After this migration is run, address books won't be associated to accounts anymore but only to their respective collection/URL.
val oldAddressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
.filter { addressBookAccount ->
account.name == accountManager.getUserData(addressBookAccount, "real_account_name")
}
for (oldAddressBookAccount in oldAddressBookAccounts) {
// Old address books only have a URL, so use it to determine the collection ID
logger.info("Migrating address book ${oldAddressBookAccount.name}")
val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL)
collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection ->
// Set collection ID and rename the account
val localAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider)
localAddressBook.update(collection)
}
}
}
}
/**
* Between DAVx5 4.4.1-beta.1 and 4.4.1-rc.1 (both v15), the periodic sync workers were renamed (moved to another
* package) and thus automatic synchronization stopped (because the enqueued workers rely on the full class
* name and no new workers were enqueued). Here we enqueue all periodic sync workers again with the correct class name.
*/
@Suppress("unused","FunctionName")
fun update_15_16() {
for (authority in SyncUtils.syncAuthorities(context)) {
logger.info("Re-enqueuing periodic sync workers for $account/$authority, if necessary")
/* A maybe existing periodic worker references the old class name (even if it failed and/or is not active). So
we need to explicitly disable and prune all workers. Just updating the worker is not enough WorkManager will update
the work details, but not the class name. */
val disableOp = syncWorkerManager.disablePeriodic(account, authority)
disableOp.result.get() // block until worker with old name is disabled
val pruneOp = WorkManager.getInstance(context).pruneWork()
pruneOp.result.get() // block until worker with old name is removed from DB
val interval = accountSettings.getSyncInterval(authority)
if (interval != null && interval != AccountSettings.SYNC_INTERVAL_MANUALLY) {
// There's a sync interval for this account/authority; a periodic sync worker should be there, too.
val onlyWifi = accountSettings.getSyncWifiOnly()
syncWorkerManager.enablePeriodic(account, authority, interval, onlyWifi)
}
}
}
/**
* Updates the periodic sync workers by re-setting the same sync interval.
*
* The goal is to add the [BaseSyncWorker.commonTag] to all existing periodic sync workers so that they can be detected by
* the new [BaseSyncWorker.exists] and [at.bitfire.davdroid.ui.AccountsActivity.Model].
*/
@Suppress("unused","FunctionName")
fun update_14_15() {
for (authority in SyncUtils.syncAuthorities(context)) {
val interval = accountSettings.getSyncInterval(authority)
accountSettings.setSyncInterval(authority, interval ?: AccountSettings.SYNC_INTERVAL_MANUALLY)
}
}
/**
* Disables all sync adapter periodic syncs for every authority. Then enables
* corresponding PeriodicSyncWorkers
*/
@Suppress("unused","FunctionName")
fun update_13_14() {
// Cancel any potentially running syncs for this account (sync framework)
ContentResolver.cancelSync(account, null)
val authorities = listOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.JtxBoard.authority,
TaskProvider.ProviderName.OpenTasks.authority,
TaskProvider.ProviderName.TasksOrg.authority
)
for (authority in authorities) {
// Enable PeriodicSyncWorker (WorkManager), with known intervals
v14_enableWorkManager(authority)
// Disable periodic syncs (sync adapter framework)
v14_disableSyncFramework(authority)
}
}
private fun v14_enableWorkManager(authority: String) {
val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval ->
accountSettings.setSyncInterval(authority, syncInterval)
} ?: false
logger.info("PeriodicSyncWorker for $account/$authority enabled=$enabled")
}
private fun v14_disableSyncFramework(authority: String) {
// Disable periodic syncs (sync adapter framework)
val disable: () -> Boolean = {
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
for (sync in ContentResolver.getPeriodicSyncs(account, authority))
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
// check whether syncs are really disabled
var result = true
for (sync in ContentResolver.getPeriodicSyncs(account, authority)) {
logger.info("Sync framework still has a periodic sync for $account/$authority: $sync")
result = false
}
result
}
// try up to 10 times with 100 ms pause
var success = false
for (idxTry in 0 until 10) {
success = disable()
if (success)
break
Thread.sleep(200)
}
logger.info("Sync framework periodic syncs for $account/$authority disabled=$success")
}
@Suppress("unused","FunctionName")
/**
* Not a per-account migration, but not a database migration, too, so it fits best there.
* Best future solution would be that SettingsManager manages versions and migrations.
*
* Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port.
*/
private fun update_12_13() {
// proxy settings are managed by SharedPreferencesProvider
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
// old setting names
val overrideProxy = "override_proxy"
val overrideProxyHost = "override_proxy_host"
val overrideProxyPort = "override_proxy_port"
val edit = preferences.edit()
if (preferences.contains(overrideProxy)) {
if (preferences.getBoolean(overrideProxy, false))
// override_proxy set, migrate to proxy_type = HTTP
edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP)
edit.remove(overrideProxy)
}
if (preferences.contains(overrideProxyHost)) {
preferences.getString(overrideProxyHost, null)?.let { host ->
edit.putString(Settings.PROXY_HOST, host)
}
edit.remove(overrideProxyHost)
}
if (preferences.contains(overrideProxyPort)) {
val port = preferences.getInt(overrideProxyPort, 0)
if (port != 0)
edit.putInt(Settings.PROXY_PORT, port)
edit.remove(overrideProxyPort)
}
edit.apply()
}
@Suppress("unused","FunctionName")
/**
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
* convert legacy unknown properties to the current format.
*/
private fun update_11_12() {
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
// to the given account! So all extended properties will be processed number-of-accounts times.
val extUri = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account)
provider.query(
extUri, arrayOf(
CalendarContract.ExtendedProperties._ID, // idx 0
CalendarContract.ExtendedProperties.NAME, // idx 1
CalendarContract.ExtendedProperties.VALUE // idx 2
), null, null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val rawValue = cursor.getString(2)
val uri by lazy {
ContentUris.withAppendedId(CalendarContract.ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account)
}
when (cursor.getString(1)) {
UnknownProperty.CONTENT_ITEM_TYPE -> {
// unknown property; check whether it's a URL
try {
val property = UnknownProperty.fromJsonString(rawValue)
if (property is Url) { // rewrite to MIMETYPE_URL
val newValues = ContentValues(2)
newValues.put(CalendarContract.ExtendedProperties.NAME, AndroidEvent.EXTNAME_URL)
newValues.put(CalendarContract.ExtendedProperties.VALUE, property.value)
provider.update(uri, newValues, null, null)
}
} catch (e: Exception) {
logger.log(
Level.WARNING,
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
e
)
}
}
"unknown-property" -> {
// unknown property (deprecated format); convert to current format
try {
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
ObjectInputStream(stream).use {
(it.readObject() as? Property)?.let { property ->
// rewrite to current format
val newValues = ContentValues(2)
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
newValues.put(CalendarContract.ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
provider.update(uri, newValues, null, null)
}
}
} catch (e: Exception) {
logger.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
}
}
"unknown-property.v2" -> {
// unknown property (deprecated MIME type); rewrite to current MIME type
val newValues = ContentValues(1)
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
provider.update(uri, newValues, null, null)
}
}
}
}
}
}
}
@Suppress("unused","FunctionName")
/**
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
* again when the tasks provider is switched.
*/
private fun update_10_11() {
tasksAppManager.get().currentProvider()?.let { provider ->
val interval = accountSettings.getSyncInterval(provider.authority)
if (interval != null)
accountManager.setAndVerifyUserData(account,
AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString())
}
}
@Suppress("unused","FunctionName")
/**
* Task synchronization now handles alarms, categories, relations and unknown properties.
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
*
* Also update the allowed reminder types for calendars.
**/
private fun update_9_10() {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
val tasksUri = provider.tasksUri().asSyncAdapter(account)
val emptyETag = ContentValues(1)
emptyETag.putNull(LocalTask.COLUMN_ETAG)
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
}
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
provider.update(
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
AndroidCalendar.calendarBaseValues, null, null)
}
}
@Suppress("unused","FunctionName")
/**
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
* Disable it on those accounts for the future.
*/
private fun update_8_9() {
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) {
logger.info("Disabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
}
}
@Suppress("unused","FunctionName")
@SuppressLint("Recycle")
/**
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
* SEQUENCE and should not be used for the eTag.
*/
private fun update_7_8() {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
// ETag is now in sync_version instead of sync1
// UID is now in _uid instead of sync2
provider.client.query(provider.tasksUri().asSyncAdapter(account),
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
arrayOf(account.type, account.name), null)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
val uid = cursor.getString(2)
val values = ContentValues(4)
values.put(TaskContract.Tasks._UID, uid)
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
values.putNull(TaskContract.Tasks.SYNC1)
values.putNull(TaskContract.Tasks.SYNC2)
logger.log(Level.FINER, "Updating task $id", values)
provider.client.update(
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
values, null, null)
}
}
}
}
@Suppress("unused")
@SuppressLint("Recycle")
private fun update_6_7() {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
AndroidCalendar.insertColors(provider, account)
}
// update allowed WiFi settings key
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID)
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
}
// updates from AccountSettings version 5 and below are not supported anymore
}

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