Compare commits

...
556 Commits
Author SHA1 Message Date
Ricki Hirner 80df1eecaa Add F-Droid changelog 2020-05-31 21:05:40 +02:00
Ricki Hirner 4e062bc621 Version bump to 3.1 2020-05-31 20:52:14 +02:00
Ricki Hirner 02a1899109 Fetch translations from Transifex 2020-05-31 20:51:36 +02:00
Ricki Hirner 59180cda06 Version bump to 3.1-rc4 2020-05-31 17:36:48 +02:00
Ricki Hirner 74cb4963e4 Update libraries; use Kotlin for JDK7 2020-05-31 16:32:48 +02:00
Ricki Hirner 0e4e655d51 Link PermissionsActivity from app settings 2020-05-31 16:32:48 +02:00
Ricki Hirner 76020736bb Don't use R8 shrinking for debug builds/tests 2020-05-31 01:03:33 +02:00
Ricki Hirner 1e14bbfe42 Version bump to 3.1-rc3 2020-05-30 23:53:42 +02:00
Ricki Hirner 6f5363ef7a Workaround: Android loses initial account data sometimes 2020-05-30 23:53:42 +02:00
Ricki Hirner cd85c4b05a Completely remove customCerts build flag; shrink resources for release builds 2020-05-30 23:53:42 +02:00
Ricki Hirner 4bef2420df Fix account name selection 2020-05-30 20:29:04 +02:00
Ricki Hirner 9f052f6231 Documentation: link to JDK8 2020-05-30 14:19:59 +02:00
Ricki Hirner 877d06abba Version bump to 3.1-rc2 2020-05-30 14:19:58 +02:00
Ricki Hirner 822720e597 Enable R8 desugaring also for debug builds 2020-05-30 14:19:02 +02:00
Ricki Hirner 24c583b784 Fetch translations from Transifex 2020-05-29 00:27:08 +02:00
Ricki Hirner c3f6791baa Version bump to 3.1-rc1 2020-05-29 00:27:08 +02:00
Ricki Hirner a09f1575c2 Update dependencies 2020-05-29 00:27:08 +02:00
Ricki Hirner 63898da6e3 Fix some TextInputLayouts 2020-05-27 16:20:47 +02:00
Ricki Hirner 558ced82da Show all detected email addresses as account name suggestion 2020-05-27 16:20:47 +02:00
Ricki Hirner b59f6c8b7e Update logger tags 2020-05-27 16:20:47 +02:00
Ricki Hirner 85beb90b2f Fix exception in OpenTasksWatcher when account is not available (anymore) 2020-05-27 16:20:47 +02:00
Ricki Hirner e732aa7ebd Version bump to 3.1-beta7 2020-05-27 16:20:47 +02:00
Ricki Hirner eb38791d61 Drop customCerts build flag (alwase use cert4android and thus Conscrypt) 2020-05-27 16:20:47 +02:00
Ricki Hirner 447e7c3bdf Update dependencies 2020-05-27 16:20:47 +02:00
Ricki Hirner a81eb8be51 Nextcloud login fragment: always use fragment view for Snackbar 2020-05-27 16:20:47 +02:00
Ricki Hirner c0e2488ef8 Rename account: handle situation when account doesn't exist (anymore) 2020-05-27 16:20:47 +02:00
Ricki Hirner 34adafeac2 Account settings activity: handle situation when account is not available (anymore) 2020-05-27 16:20:47 +02:00
Ricki Hirner cad2b15a81 Synchronize possibly simultaneous calls to ServiceLoader 2020-05-27 16:20:47 +02:00
Ricki Hirner 0670732cc8 Version bump to 3.1-beta6 2020-05-27 16:20:47 +02:00
Ricki Hirner 3089d42c9f Update dependencies (ical4android: minify VTIMEZONEs) 2020-05-27 16:20:47 +02:00
Ricki Hirner b6bdf2dfaa Update dependencies, remove obsolete version checks 2020-05-27 16:20:47 +02:00
Ricki Hirner 5a657ba5e0 Update okhttp, use new version constant for User-Agent 2020-05-27 16:20:47 +02:00
Ricki Hirner 9055b34a1d Fetch translations from Transifex 2020-05-27 16:20:47 +02:00
Ricki Hirner 7bf1d5618a Version bump to 3.1-beta5 2020-05-27 16:20:47 +02:00
Ricki Hirner f7d001ae1d ical4j: fix XParameter type cast problem with ATTENDEE EMAILs 2020-05-27 16:20:47 +02:00
Ricki Hirner 8cf8dadb8f Update copyright 2020-05-27 16:20:47 +02:00
Michael Biebl d5d9f276a3 Clear all errors when a different login method is chosen
See https://forums.bitfire.at/topic/2275/error-message-not-updated-when-switching-to-different-login-method/
2020-05-27 16:20:47 +02:00
Ricki Hirner a1509f8ce4 Update cert4android, add comment 2020-05-27 16:20:47 +02:00
Michael Biebl af227737f5 Override Brotli meta data in About activity
This is a workaround until
https://github.com/mikepenz/AboutLibraries/issues/490 has been resolved.
2020-05-27 16:20:47 +02:00
Ricki Hirner 65ca1c37e8 Update dependencies 2020-05-27 16:20:47 +02:00
Ricki Hirner 1c2bfc582c Version bump to 3.1-beta4 2020-05-27 16:20:47 +02:00
Ricki Hirner dbc7ca7cc3 Fetch translations from Transifex 2020-05-27 16:20:47 +02:00
Ricki Hirner 6ceca3793c Update dependencies 2020-05-27 16:20:46 +02:00
Ricki Hirner 2ff46e5219 Add app settings to debug info 2020-05-27 16:20:46 +02:00
Ricki Hirner 00cf40a694 Version bump to 3.1-beta3 2020-05-27 16:20:46 +02:00
Ricki Hirner 3445d0df54 Fetch translations from Transifex 2020-05-27 16:20:46 +02:00
Ricki Hirner 1b0720d798 Handle exceptions when event/task SEQUENCE is increased; update ical4android 2020-05-27 16:20:46 +02:00
Ricki Hirner fa1ed29659 Show Donate in Navigation drawer only in ose 2020-05-27 16:20:46 +02:00
Ricki Hirner f01b57bb66 Version bump to 3.1-beta2 2020-05-27 16:20:46 +02:00
Ricki Hirner 94fc3b7cdd Make sure Thread.getContextClassLoader is set while syncing (for ical4j)
* use dav4jvm version that doesn't depend on ServiceLoader anymore
2020-05-27 16:20:46 +02:00
Ricki Hirner ffa40e5e13 Use coroutines instead of threads, when possible 2020-05-27 16:20:46 +02:00
Ricki Hirner 17846c2413 Permissions activity: add App settings button 2020-05-27 16:20:46 +02:00
Ricki Hirner cc6194fdf6 Sync algorithm: use Kotlin coroutines instead of thread-pool executors 2020-05-27 16:20:46 +02:00
Ricki Hirner ebcec6a5a4 Update dependencies; version bump to 3.1.0-beta1 2020-05-27 16:20:46 +02:00
Ricki Hirner 4977a1ab58 New permissions model 2020-05-27 16:20:46 +02:00
Ricki Hirner 7ef847ea35 Update gradle plugin 2020-05-27 16:20:46 +02:00
Ricki Hirner 5e0e26a5e5 Sync cancellation: show in logs, cancel whole thread group 2020-05-27 16:20:46 +02:00
Ricki Hirner dcdc659882 Version bump to 3.0.1-beta1 2020-05-27 16:20:46 +02:00
Ricki Hirner 96adb926c6 Use ical4j 3.x 2020-05-27 16:20:46 +02:00
Ricki Hirner 14bdaaa189 minor change in evil manufacturer warning in intro fragment 2020-05-27 16:15:19 +02:00
Ricki Hirner 86bb432625 Fix intro fragment checkbox/button interaction 2020-05-27 16:15:16 +02:00
Ricki Hirner 97d657e501 Fix crash because of empty drawable in -ose version 2020-04-27 12:04:09 +02:00
Ricki Hirner 4067afb20d Fix build 2020-04-24 21:04:43 +02:00
Ricki Hirner 843f1e099b Merge branch 'dev-3.x-ose' into master-ose 2020-04-22 13:16:02 +02:00
Ricki Hirner 4ed9d8a6a8 F-Droid changelog for 3.0 2020-04-22 13:15:19 +02:00
Ricki Hirner 5b60ccaa2e Merge branch 'dev-3.x-ose' into master-ose 2020-04-22 11:53:27 +02:00
Ricki Hirner 9bdfcb5dc1 Update AboutLibraries, bump version to 3.0 2020-04-22 11:49:28 +02:00
Ricki Hirner fd92267c4d Replace stock images by images from undraw.co 2020-04-21 19:19:20 +02:00
Ricki Hirner 3c100274ad Fetch translations from Transifex 2020-04-21 18:49:46 +02:00
Ricki Hirner ede9f2a472 cert4android: update strings 2020-04-21 18:49:46 +02:00
Ricki Hirner 884dbdbd3d Adapt WebView progress bar; don't use okhttp BOM 2020-04-21 18:49:46 +02:00
Ricki Hirner e267e620ba Fix build; bump version to 3.0-beta2 2020-04-21 18:49:46 +02:00
Ricki Hirner f32006254f Nextcloud Login flow: show progress and errors (including TLS errors) 2020-04-21 18:49:46 +02:00
Ricki Hirner 2f92002fc3 Update dependencies; clean up ProGuard/R8 rules 2020-04-21 18:49:46 +02:00
Ricki Hirner a7c814bafd Use new AboutLibraries gradle plugin 2020-04-21 18:49:46 +02:00
Ricki Hirner e124ce2f70 Update gradle plugin 2020-04-21 18:49:46 +02:00
Ricki Hirner 17c9537fc5 Adapt login styles 2020-04-21 18:49:46 +02:00
Ricki Hirner ad4c7c97c2 Don't cancel notifications of other sync threads (lets important notifications disappear sometimes) 2020-04-21 18:49:46 +02:00
Ricki Hirner 27675de7ec Version bump to 3.0-beta1 2020-04-21 18:49:46 +02:00
Ricki Hirner 204ca9120d Update About activity 2020-04-21 18:49:46 +02:00
Ricki Hirner 061df6a014 Enable Brotli (in addition to gzip) compression 2020-04-21 18:49:46 +02:00
Ricki Hirner aab676b7e4 Code cleanup 2020-04-21 18:49:46 +02:00
Ricki Hirner 7fd2a99edb Keep TLS 1.0 and 1.1 for now; update dependencies 2020-04-21 18:49:46 +02:00
Ricki Hirner b7893398bd Fix some vector icons 2020-04-21 18:49:46 +02:00
Ricki Hirner 07f2c85ae1 Remove bitmap notification icons 2020-04-21 18:49:45 +02:00
Ricki Hirner 08cefe9f80 Fix tests 2020-04-21 18:49:45 +02:00
Ricki Hirner 0b9aeb05ab Update okhttp to 4.5.0 and dav4jvm to 2.0 2020-04-21 18:49:45 +02:00
Ricki Hirner 1d7e4e8d33 Revert "Update libraries (including dnsjava 2.x -> 3.x)"
This reverts commit bcd7700dc7d2d67a6c3a25c8f9a18694e496a29c.
2020-04-21 18:49:45 +02:00
Ricki Hirner 621c32da2a Remove vectorDrawables support library and multidex 2020-04-21 18:49:45 +02:00
Ricki Hirner dcb3dd577a Require Android 5 (SDK level 21); update gradle
Recent okhttp versions require Android 5. Also, a lot of workarounds
(like non-vector graphics for notification icons) which are cumbersome
to maintain have accumulated.

So, DAVx5 will require Android 5 (SDK level 21) from the next release,
which will probably tagged as 3.0.

2.6.x (currently 2.6.5) will be the last branch which supports Android 4.4.
Maybe there will never be a version after 2.6.5, which will still be offered for
Android 4.4 devices. If there's big need for a maintenance release (like severe security
problems), there may be further 2.6.x releases (branched from 2.6.5), but
development and new features will only go to the master branch.
2020-04-21 18:49:45 +02:00
Ricki Hirner a07d849c35 Version bump to 2.7.0-beta2 2020-04-21 18:49:45 +02:00
Ricki Hirner cea8d54556 lint; remove soldupe for now 2020-04-21 18:49:45 +02:00
Ricki Hirner 6189eef55f Update libraries (including dnsjava 2.x -> 3.x)
* dnsjava 3.x is now compatible with Android by default; removed compatibility code
2020-04-21 18:49:45 +02:00
Ricki Hirner 07dc7592b7 Minor code cleanup 2020-04-21 18:49:45 +02:00
Ricki Hirner da42ad63a2 Intro optimizations 2020-04-21 18:49:45 +02:00
Ricki Hirner a2eabbdcee battery intro: capitalize manufacturer and re-check on activity resume; update dependencies 2020-04-21 18:49:45 +02:00
Ricki Hirner ad185865c4 Version bump to 2.7.0-beta1 2020-04-21 18:49:45 +02:00
Ricki Hirner b0a5bfccad Introduce IntroActivity instead of startup fragments 2020-04-21 18:49:45 +02:00
Ricki Hirner 4303132a38 Fetch translations from Transifex 2020-04-21 18:48:47 +02:00
Ricki Hirner a0da93f910 cert4android: update strings 2020-04-21 18:48:47 +02:00
Ricki Hirner 3c38f063c5 Adapt WebView progress bar; don't use okhttp BOM 2020-04-21 18:48:47 +02:00
Ricki Hirner c18ef6304b Fix build; bump version to 3.0-beta2 2020-04-21 18:48:47 +02:00
Ricki Hirner 2542aba3f0 Nextcloud Login flow: show progress and errors (including TLS errors) 2020-04-21 18:48:47 +02:00
Ricki Hirner 75ef28bd1d Update dependencies; clean up ProGuard/R8 rules 2020-04-21 18:48:47 +02:00
Ricki Hirner 3aa729051f Use new AboutLibraries gradle plugin 2020-04-21 18:48:47 +02:00
Ricki Hirner 49fafb40e3 Update gradle plugin 2020-04-21 18:48:47 +02:00
Ricki Hirner d1dcf889f3 Adapt login styles 2020-04-21 18:48:47 +02:00
Ricki Hirner 91f9baf042 Don't cancel notifications of other sync threads (lets important notifications disappear sometimes) 2020-04-21 18:48:47 +02:00
Ricki Hirner 453e703bc0 Version bump to 3.0-beta1 2020-04-21 18:48:47 +02:00
Ricki Hirner 0d5b3c2816 Update About activity 2020-04-21 18:48:47 +02:00
Ricki Hirner 86744db3b3 Enable Brotli (in addition to gzip) compression 2020-04-21 18:48:47 +02:00
Ricki Hirner 2965ae4b1e Code cleanup 2020-04-21 18:48:47 +02:00
Ricki Hirner 3a1edbafbf Keep TLS 1.0 and 1.1 for now; update dependencies 2020-04-21 18:48:47 +02:00
Ricki Hirner 7f5f3e492b Fix some vector icons 2020-04-21 18:48:47 +02:00
Ricki Hirner e7dce774c8 Remove bitmap notification icons 2020-04-21 18:48:47 +02:00
Ricki Hirner 25b97e96e1 Fix tests 2020-04-21 18:48:47 +02:00
Ricki Hirner 0d66d29380 Update okhttp to 4.5.0 and dav4jvm to 2.0 2020-04-21 18:48:47 +02:00
Ricki Hirner 029c4737bd Revert "Update libraries (including dnsjava 2.x -> 3.x)"
This reverts commit bcd7700dc7d2d67a6c3a25c8f9a18694e496a29c.
2020-04-21 18:48:47 +02:00
Ricki Hirner 5e5d59889f Remove vectorDrawables support library and multidex 2020-04-21 18:48:47 +02:00
Ricki Hirner bc9aaf04fe Require Android 5 (SDK level 21); update gradle
Recent okhttp versions require Android 5. Also, a lot of workarounds
(like non-vector graphics for notification icons) which are cumbersome
to maintain have accumulated.

So, DAVx5 will require Android 5 (SDK level 21) from the next release,
which will probably tagged as 3.0.

2.6.x (currently 2.6.5) will be the last branch which supports Android 4.4.
Maybe there will never be a version after 2.6.5, which will still be offered for
Android 4.4 devices. If there's big need for a maintenance release (like severe security
problems), there may be further 2.6.x releases (branched from 2.6.5), but
development and new features will only go to the master branch.
2020-04-21 18:48:47 +02:00
Ricki Hirner 3ba2922bde Version bump to 2.7.0-beta2 2020-04-21 18:48:47 +02:00
Ricki Hirner 6277f35db4 lint; remove soldupe for now 2020-04-21 18:48:47 +02:00
Ricki Hirner b0c53fb852 Update libraries (including dnsjava 2.x -> 3.x)
* dnsjava 3.x is now compatible with Android by default; removed compatibility code
2020-04-21 18:48:47 +02:00
Ricki Hirner ce0e623912 Minor code cleanup 2020-04-21 18:48:47 +02:00
Ricki Hirner d1709df0b6 Intro optimizations 2020-04-21 18:48:47 +02:00
Ricki Hirner 5469dee1f2 battery intro: capitalize manufacturer and re-check on activity resume; update dependencies 2020-04-21 18:48:47 +02:00
Ricki Hirner 489f7ac639 Version bump to 2.7.0-beta1 2020-04-21 18:48:47 +02:00
Ricki Hirner 58ca99198f Introduce IntroActivity instead of startup fragments 2020-04-21 18:48:47 +02:00
Ricki Hirner 8a46fcedba Do full resync if "past event time limit" is changed from number to null 2020-03-29 17:57:25 +02:00
Ricki Hirner a0816c11d2 Show languages in About 2020-03-07 18:51:02 +01:00
Ricki Hirner c06950751b Version bump to 2.6.5 2020-03-07 12:30:52 +01:00
Ricki Hirner cfc0130bec Version bump to 2.6.5-beta1; update dependencies 2020-03-06 12:08:36 +01:00
Alex Baker 213851856e Clear password error on text changed 2020-03-06 12:04:48 +01:00
Ricki Hirner b7e60cd143 Only use multi-get for tasks sync, too 2020-03-06 12:04:48 +01:00
Ricki Hirner 37a299d0f7 Fetch translations from Transifex 2020-03-06 00:23:42 +01:00
Ricki Hirner 41d33fac44 Version bump to 2.6.5 2020-03-06 00:22:36 +01:00
Ricki Hirner 3dc2aa65df Sync algorithm bug fixes/improvements
- Collection sync: don't save new sync state before downloading is finished
- throw exception when waiting for completion times out
- always use multi-get, even for single vCards/iCalendars
2020-03-06 00:06:07 +01:00
Ricki Hirner 43f4d9c05d Merge branch 'textlayout_errors' into 'master-ose'
Move errors from EditTexts to TextInputLayouts

See merge request bitfireAT/davx5-ose!28
2020-03-03 21:38:09 +00:00
Ricki Hirner 6c0b555ec9 lint 2020-03-03 18:59:33 +01:00
Ricki Hirner 8918382003 Use requireView() instead of view!! and requireActivity() instead of activity!! 2020-03-03 16:28:32 +01:00
Ricki Hirner e30c41828f Update gradle plugin, okhttp 2020-03-03 14:46:00 +01:00
Ricki Hirner 224c92cc87 Update gradle version and Android plugin; dependencies 2020-03-03 14:46:00 +01:00
Ricki Hirner 4498f9bf03 Update okhttp to 3.12.9 2020-03-03 14:45:52 +01:00
Alex Baker b3c7f1f9ef Move errors from EditTexts to TextInputLayouts 2020-02-27 08:57:07 -06:00
Ricki Hirner 5622d743c3 Version bump to 2.6.4 2020-02-20 18:31:01 +01:00
Ricki Hirner ae4c6e94c8 Fetch translations from Transifex 2020-02-20 18:15:50 +01:00
Ricki Hirner 7bdb5c5a97 Update cert4android 2020-02-20 18:13:57 +01:00
Ricki Hirner 62c0dfbaee Update support libraries 2020-02-20 18:10:57 +01:00
Ricki Hirner 53f35d5ee3 Event sync: delete exceptions from events when events are mass-deleted, too 2020-02-20 18:10:57 +01:00
Ricki Hirner c9dab31067 Webcal calendars: use UrlUtils.equals to find matching calendar; update dependencies 2020-01-24 20:22:02 +01:00
Ricki Hirner ac3e9fb825 Fetch translations from Transifex 2020-01-23 18:39:07 +01:00
Ricki Hirner 8325fdcf86 Version bump to 2.6.4-beta1 2020-01-23 18:37:41 +01:00
Ricki Hirner 3f2090ecc7 Update ical4android (alarm handling) and AboutLibraries 2020-01-20 22:17:19 +01:00
Ricki Hirner e236b07184 Use okhttp version defined by DAVx5 (dav4jvm version may be older) 2020-01-16 19:57:38 +01:00
Ricki Hirner 3905679576 Version bump to 3.6.2.1 2020-01-14 21:11:58 +01:00
Ricki Hirner 2f33374649 Update dependencies (including okhttp 3.12.8) 2020-01-14 21:08:49 +01:00
Ricki Hirner 54222d3328 Version bump to 2.6.3; add F-Droid changelog 2020-01-07 14:47:55 +01:00
Ricki Hirner 31339f3014 Don't create default reminder for full-day events 2020-01-07 09:47:32 +01:00
Ricki Hirner 6f1d513e54 Version bump to 2.6.3-beta6 2020-01-06 20:59:44 +01:00
Ricki Hirner fef8ce2366 Improve resource detection: detect address books/calendars when they are identical with their home-set 2020-01-06 16:25:39 +01:00
Ricki Hirner c9ca84f14b Account activity: make space for FAB at end of collection list 2020-01-06 14:40:30 +01:00
Ricki Hirner 53698adda2 Version bump to 2.6.3-beta5 2020-01-06 13:50:29 +01:00
Ricki Hirner 27c89e762e Fetch translations from Transifex 2020-01-06 13:49:35 +01:00
Ricki Hirner 92394bb6af Take default reminder from settings provider, if available 2020-01-06 13:39:46 +01:00
Ricki Hirner eba5280a58 Update ical4android, okhttp 2020-01-05 13:58:16 +01:00
Ricki Hirner c3157f1256 Bump version to 2.6.3-beta4 2019-12-30 17:35:50 +01:00
Ricki Hirner 5c795950af Fetch translations from Transifex 2019-12-30 17:28:28 +01:00
Ricki Hirner 15f5152a7d UiUtils.launchUri: show toast if no browser is installed 2019-12-28 15:33:59 +01:00
Ricki Hirner 03f54f5402 Add link to Privacy policy to Accounts drawer 2019-12-28 15:16:03 +01:00
Ricki Hirner 41ba92cc4c Default alarms: don't take app-wide Settings into account 2019-12-27 23:22:27 +01:00
Ricki Hirner c267c92a87 Rewrite account settings to use ViewModel 2019-12-27 19:00:33 +01:00
Ricki Hirner 3111e54d1a Rename "default alarm" to "default reminder" 2019-12-27 19:00:19 +01:00
Ricki Hirner ebd20866b3 Version bump to 2.6.3-beta3 2019-12-26 17:51:07 +01:00
Ricki Hirner a789246925 Account settings: always call sync adapter for re-sync 2019-12-26 17:49:12 +01:00
Ricki Hirner 11452caa13 cert4android: use new Material theme 2019-12-26 17:26:29 +01:00
Ricki Hirner 7577eb10e8 Rename SyncAdapterService.SYNC_EXTRAS_RELOAD_ALL to SYNC_EXTRAS_FULL_RESYNC 2019-12-26 17:26:29 +01:00
Ricki Hirner 4b579ca419 Introduce default alarm setting
* move .ui.AccountSettingsActivity to .ui.account.SettingsActivity
* add setting for default alarms
* add sync extra: SyncAdapterService.SYNC_EXTRAS_RELOAD_ALL (forces full re-synchronization of all members)
* LocalCollection: add forgetETags() which resets the ETags of members
* account settings: automatic reloading of members when certain settings are modified
2019-12-26 17:26:29 +01:00
Ricki Hirner f9a8de29e2 Fix vcard4android; bump version to 2.6.3-beta2 2019-12-23 22:42:07 +01:00
Ricki Hirner d28f5115fb vCard sync: improve compatibility (for instance with Samsung "Edge panel"); version bump to 2.6.3-beta1 2019-12-22 17:24:52 +01:00
Ricki Hirner 2a40432494 Fetch translations from Transifex 2019-12-22 11:03:28 +01:00
Ricki Hirner 06d79087b0 Version bump to 2.6.2 2019-12-22 11:01:32 +01:00
Ricki Hirner 3e6243901e Version bump to 2.6.2-beta5 2019-12-20 17:14:52 +01:00
Ricki Hirner 054955b89e Use String for SYNC_EXTRAS_PRIORITY_COLLECTIONS becuase LongArray mustn't be used for sync extras 2019-12-20 12:14:39 +01:00
Ricki Hirner c28001f0f1 Version bump to 2.6.2-beta4 2019-12-17 14:01:27 +01:00
Ricki Hirner c4d7e6857b CardDAV: handle (non-standard) TYPE=other for phone numbers and addresses, too (for better compatibility) 2019-12-17 13:34:01 +01:00
Ricki Hirner 4f237d82fb Update theme 2019-12-17 12:55:24 +01:00
Ricki Hirner 05cb460cf5 Version bump to 2.6.2-beta3 2019-12-15 22:22:26 +01:00
Ricki Hirner 2a31ebb2a3 Increase vCard compatibility 2019-12-15 22:14:51 +01:00
Ricki Hirner 372ad8b704 SYNC_EXTRAS_PRIORITY_COLLECTIONS: use LongArray instead of String 2019-12-13 11:06:55 +01:00
Ricki Hirner 296f55651e Introduce SYNC_EXTRAS_PRIORITY_COLLECTIONS; ical4android: map event categories to special extended property 2019-12-12 20:15:30 +01:00
Ricki Hirner fe5c5737ef Update gradle plugin, version bump to 2.6.2-beta2 2019-12-11 12:48:31 +01:00
Ricki Hirner 32fe98a196 ProGuard: keep enum classes 2019-12-07 23:48:48 +01:00
Ricki Hirner de687eaf52 Version bump to 2.6.2-beta1 2019-12-06 12:50:37 +01:00
Ricki Hirner 2062802cb6 Improve usage of Material theme 2019-12-03 18:52:35 +01:00
Ricki Hirner b280941b2b Don't handle uncaught exceptions in debug builds 2019-12-03 18:52:33 +01:00
Ricki Hirner 049145d0ea use Material theme; update AboutLibraries dependency 2019-12-03 18:51:47 +01:00
Ricki Hirner c1162fce8e Library updates; show permissions in debug info 2019-11-30 23:50:42 +01:00
Ricki Hirner 40fd412064 Fetch translations from Transifex 2019-11-25 22:58:38 +01:00
Ricki Hirner c13243f3ae Version bump to 2.6.1.1 2019-11-25 22:58:38 +01:00
Ricki Hirner bda2d64ca4 ical4android: generate DTSTAMP again 2019-11-25 22:57:16 +01:00
Ricki Hirner f974ca2ffa ical4android: unify VEVENT/VTODO parsing 2019-11-24 15:35:42 +01:00
Ricki Hirner d6cd0faeeb ical4android: better compatibility with Outlook timezone IDs 2019-11-24 14:38:44 +01:00
Ricki Hirner 33345db0f0 Managed DAVx5: don't show OpenTasks startup dialog 2019-11-23 14:52:33 +01:00
Ricki Hirner 6e2d7b10d7 Fetch translations from Transifex 2019-11-22 18:04:10 +01:00
Ricki Hirner b9fb983b97 Version bump to 2.6.1 2019-11-22 18:02:55 +01:00
Ricki Hirner fe738636a2 Events: ignore empty strings when processing locally stored events; tasks: clear parent_id when it's not set anymore after a remote update 2019-11-16 18:57:41 +01:00
Ricki Hirner 46df7a63c7 Version bump to 2.6.1-beta4 2019-11-16 16:21:57 +01:00
Ricki Hirner b4ebfa4fe5 SyncManager: generate all UID/file names before uploading any resources
- provider queries: use boolean syntax ("WHERE dirty" instead of "WHERE dirty!=0" etc.)
2019-11-16 16:21:39 +01:00
Ricki Hirner 9979dc95c2 Fetch translations from Transifex 2019-11-15 23:48:45 +01:00
Ricki Hirner ebf1273c37 Version bump to 2.6.1-beta3 2019-11-15 23:47:59 +01:00
Ricki Hirner 3bfe33e37a Correctly handle EMAIL reminders 2019-11-15 23:47:41 +01:00
Ricki Hirner 6d273f8b0d Account settings migration: download all tasks again to parse relations etc. 2019-11-13 17:17:23 +01:00
Ricki Hirner 6922a68b77 Version bump to 2.6.1-beta2 2019-11-13 14:12:15 +01:00
Ricki Hirner af3a588632 Tasks: directly use Relation rows, no need for DelayedRelation anymore 2019-11-13 14:11:58 +01:00
Ricki Hirner 285dfe9307 Fetch translations from Transifex 2019-11-13 00:04:00 +01:00
Ricki Hirner bc80e74a83 Version bump to 2.6.1-beta1 2019-11-13 00:03:17 +01:00
Ricki Hirner 6fe5fafed7 Tasks: support RELATED-TO (subtasks) 2019-11-13 00:02:55 +01:00
Ricki Hirner 058354b9a4 Update dependencies 2019-11-12 14:02:56 +01:00
Ricki Hirner 16e1d5041f Dokka: add links to source code and libraries 2019-11-12 14:02:56 +01:00
Ricki Hirner 4bca002892 ical4android: save/restore unknown properties of tasks; disable allowBackup because it won't work anyway 2019-11-11 00:10:30 +01:00
Ricki Hirner fba759583d Login with URL: assume https:// URI scheme if none given 2019-11-10 10:36:12 +01:00
Ricki Hirner 3ed16ae5b2 Add CONTRIBUTING 2019-11-09 22:06:47 +01:00
Ricki Hirner 888ffc8d90 Require privileged container for tests 2019-11-09 19:52:18 +01:00
Ricki Hirner 5ff90d8e20 Update dependencies 2019-11-09 19:52:04 +01:00
Ricki Hirner edab897732 Use headless emulator from new repo for testing 2019-11-08 00:57:01 +01:00
Ricki Hirner 06637b4b47 Update dependencies 2019-11-08 00:56:56 +01:00
Ricki Hirner 2ff839837a Fetch translations from Transifex 2019-10-25 11:09:35 +02:00
Ricki Hirner e80243b133 Version bump to 2.6 2019-10-25 11:08:55 +02:00
Ricki Hirner 481eccc4d6 Login Flow: optimization 2019-10-24 11:21:35 +02:00
Ricki Hirner 2e641a9e9b Merge branch 'fixLoginFlow' into 'master-ose'
Nextcloud login flow: correct concatenate url with davPath

See merge request bitfireAT/davx5-ose!22
2019-10-24 08:48:21 +00:00
Ricki Hirner fe8b4b1d24 Merge branch 'fixUsername' into 'master-ose'
username was retrieved from intent, but not used

See merge request bitfireAT/davx5-ose!23
2019-10-24 08:44:13 +00:00
tobiasKaminsky 2fc9d2862e username was retrieved from intent, but not used 2019-10-22 09:48:01 +02:00
tobiasKaminsky 23a9be403b correct concatenate url with davPath.
Previously a subfolder was omitted:
serverUrl: http://localhost/nc
davPath: /remote.php/dav
-->
wrong: http://localhost/remote.php/dav
correct: http://localhost/nc/remote.php/dav
2019-10-22 09:37:09 +02:00
Ricki Hirner cd9518c619 Version bump to 2.6-beta3 2019-10-09 11:04:28 +02:00
Ricki Hirner 29c0a9b586 Account list fragment network detection: compatibility with Android <6 2019-10-08 19:07:57 +02:00
Ricki Hirner aac25b3bdc Connectivity check: check for VALIDATED INTERNET capability 2019-10-07 17:17:35 +02:00
Ricki Hirner 31b47a8554 Fetch translations from Transifex 2019-10-07 11:45:34 +02:00
Ricki Hirner f8eb7a6d56 cert4android: fetch translations from Transifex 2019-10-07 11:43:53 +02:00
Ricki Hirner f41786f9f1 Version bump to 2.6-beta1 2019-10-07 11:43:52 +02:00
Ricki Hirner 80f31bbc03 Implement Nextcloud Login Flow 2019-10-07 11:37:13 +02:00
Ricki Hirner 4c229c81a1 Account list fragment: Show "Network unavailable" message if there is no Internet connection (only API 21+) 2019-10-07 11:35:05 +02:00
Ricki Hirner 0ac6dc8538 Set target SDK to level 29 (Android 10)
- ask for ACCESS_FINE_LOCATION/ACCESS_BACKGROUND_LOCATION when sync is restricted to specific WiFi SSIDs
- use Android 5+ way to determine active network connections, if possible
2019-09-30 17:28:11 +02:00
Ricki Hirner 1a52794bd1 Keep "force read only" flag when refreshing collections 2019-09-30 15:36:52 +02:00
Ricki Hirner 5f9f5e2732 Get dav4jvm from jitpack instead of using a submodule 2019-09-30 13:45:34 +02:00
Ricki Hirner 9c50903fd6 Fetch translations from Transifex 2019-09-21 11:48:08 +02:00
Ricki Hirner aa4b3657e5 Version bump to 2.5.5 2019-09-21 11:46:33 +02:00
Ricki Hirner ed23b4834c Fetch translations from Transifex (thanks for Bulgarian and Silesian!) 2019-09-20 00:10:18 +02:00
Ricki Hirner da81482bad lint 2019-09-19 14:27:27 +02:00
Ricki Hirner f453d06929 Minor method signature updates for SDK level 29 2019-09-19 13:58:28 +02:00
Ricki Hirner 0d07fe9020 Version bump to 2.5.5-beta1, compile against SDK level 29 2019-09-19 13:43:30 +02:00
Ricki Hirner ddd3ffb2c2 Remove conscrypt version (set by cert4android) 2019-09-19 13:43:03 +02:00
Ricki Hirner b4b87bc4f5 Update cert4android 2019-09-18 23:40:24 +02:00
Ricki Hirner e74a919812 Update dependencies; new findPreference syntax 2019-09-18 23:40:24 +02:00
Ricki Hirner ef8931c9c4 Tasks: support CATEGORIES 2019-09-18 23:40:24 +02:00
Ricki Hirner 13e9d7c7b4 Enable Autofill for login screen 2019-08-25 16:35:43 +02:00
Ricki Hirner 4c4cbf6f2e Version bump to 2.5.4.1 2019-08-25 15:32:05 +02:00
Ricki Hirner 55a51c0b87 Fetch translations from Transifex 2019-08-25 15:31:01 +02:00
Ricki Hirner e7833e403f Update gradle plugin 2019-08-25 15:29:27 +02:00
Ricki Hirner ee36b10fd4 Add ProGuard line to avoid AppCompat crash 2019-08-13 18:59:57 +02:00
Ricki Hirner 79c4e24816 Version bump to 2.5.4 2019-08-06 12:45:47 +02:00
Ricki Hirner 4a8bf47aa3 Update material components to 1.1.0-alpha09, allegedly fixes Meizu crash 2019-08-05 22:57:02 +02:00
Ricki Hirner d32a142ef0 Fetch translations from Transifex 2019-07-20 22:12:18 +02:00
Ricki Hirner 1a8171a55e Version bump to 2.5.3 2019-07-20 22:07:37 +02:00
Ricki Hirner d5a03d7837 Update dependencies 2019-07-19 14:32:47 +02:00
Ricki Hirner bcc8e02d77 Create collections: enable sync of created collections by default 2019-07-19 14:30:55 +02:00
Ricki Hirner 75124d99bc Home set enumeration: use correct displayName, also honor bind privilege; version bump to 2.5.3-beta1 2019-07-19 14:30:55 +02:00
Ricki Hirner a081ef210b Re-enable HTTP/2 again (was disabled for all connections and not only when using client certificates by mistake) 2019-07-19 14:30:55 +02:00
Ricki Hirner 1882dee0c2 Process home-set display names 2019-07-16 12:52:41 +02:00
Ricki Hirner fb7012e7c6 Update gradle plugin 2019-07-12 22:48:10 +02:00
Ricki Hirner bbb32d501f Fetch translations from Transifex 2019-07-10 14:57:29 +02:00
Ricki Hirner 338375789a Fetch translations from Transifex 2019-07-08 21:46:08 +02:00
Ricki Hirner 123c5906bd Update ical4j; version bump to 2.5.2 2019-07-08 16:45:54 +02:00
devvv eeac458d93 Update README.md 2019-06-24 17:18:14 +00:00
Ricki Hirner 471722496e ical4android: fix threading problem 2019-06-23 15:53:53 +02:00
Ricki Hirner 1ecd16c229 Address books accounts: set initial user data twice for older Android versions 2019-06-20 12:26:19 +02:00
Ricki Hirner 1804c4af96 Debug info: also show UnknownHostException; update Room dependency 2019-06-19 01:06:27 +02:00
Ricki Hirner 5974ffc7f6 Fetch translations from Transifex 2019-06-09 11:18:31 +02:00
Ricki Hirner 5073a56fd6 Version bump to 2.5.1 2019-06-09 11:18:31 +02:00
Ricki Hirner 1e76a92d02 Version bump to 2.5.1-beta2 2019-06-07 21:26:56 +02:00
Ricki Hirner 5709fd38d9 Webcal subscriptions: handle webcal(s):// URLs and treat them as http(s):// 2019-06-07 21:26:31 +02:00
Ricki Hirner 4bf9ba5f2d Update dependencies 2019-06-07 21:26:31 +02:00
Ricki Hirner f5e317a08a Fetch translations from Transifex 2019-05-29 18:12:07 +02:00
Ricki Hirner 8936ad7810 Update dokka, Kotlin 2019-05-29 18:10:14 +02:00
Ricki Hirner 1d20dbe4e8 Version bump to 2.5.1-beta1 2019-05-29 18:05:58 +02:00
Ricki Hirner b247e70d2a Use android:hint in TextInputLayout instead of TextInputEditText (should fix Meizu crashes) 2019-05-29 18:05:31 +02:00
Ricki Hirner 477ee085fc Show unhandled exceptions with DebugInfoActivity 2019-05-25 11:14:45 +02:00
Ricki Hirner 8f62c185ec Don't use HTTP/2 with client certificates 2019-05-22 23:58:00 +02:00
Ricki Hirner f6144dc0ab Update gradle plugin, library dependencies 2019-05-17 12:04:03 +02:00
Ricki Hirner a958ed48f9 AccountActivity: set color for swipe refresh 2019-05-15 11:52:21 +02:00
Ricki Hirner 3458f786f5 Fetch translations from Transifex; add Finnish 2019-05-15 11:43:56 +02:00
Ricki Hirner db037f1dfb Bump version code to 288 2019-05-15 11:39:43 +02:00
Ricki Hirner c4ee6037ec Don't show permantenly pending sync for CalDAV if OpenTasks is not installed 2019-05-14 21:12:56 +02:00
Ricki Hirner 82310b2c0d Fetch translations from Transifex 2019-05-13 20:20:09 +02:00
Ricki Hirner 292b72b62f Version bump to 2.5; minor lint fixes 2019-05-13 20:13:08 +02:00
Ricki Hirner 4cdf26d674 cert4android: update translations 2019-05-10 16:47:07 +02:00
Ricki Hirner c5c68ad7c9 Fetch translations from Transifex; new translation: Slovak (thanks brango67!) 2019-05-10 16:46:51 +02:00
Ricki Hirner c734e1a1f2 Debug info: set informational text for shared logs 2019-05-09 21:21:56 +02:00
Ricki Hirner 459f9da486 Fetch translations from Transifex 2019-05-09 12:22:12 +02:00
Ricki Hirner 1fbd532237 Version bump to 2.5-beta2 2019-05-09 12:17:21 +02:00
Ricki Hirner a67a56e0c5 Fix NPE in WebcalFragment 2019-05-09 12:16:57 +02:00
Ricki Hirner 787c5e480a Update icon resolutions, names and action bar icon color 2019-05-09 12:16:48 +02:00
Ricki Hirner 38ecd7a4c4 Update libraries 2019-05-09 12:16:41 +02:00
Ricki Hirner 17cf4a6289 Version bump to 2.5-beta1 2019-05-06 21:42:11 +02:00
Ricki Hirner ac8558ce6a Progress bars for AccountActivity (which now also show pending syncs) 2019-05-06 21:41:42 +02:00
Ricki Hirner 4db40ac223 Fetch translations from Transifex 2019-05-06 19:35:54 +02:00
Ricki Hirner 38ab785382 Update ical4j: now allows UTC properties to have non-UTC values for compatibility 2019-05-06 19:23:30 +02:00
Ricki Hirner 0c5bb19be9 Minor fixes 2019-05-06 19:20:22 +02:00
Ricki Hirner 310e5a1720 More AccountActivity; fix bug in CreateCalendarActivity & co 2019-05-06 19:20:15 +02:00
Ricki Hirner 9df0db1ec3 Correctly handle permissions in WebcalFragment 2019-05-06 19:20:08 +02:00
Ricki Hirner f6f3ebb48b DavService: don't delete all homesets/collections on refresh, but update only changed rows 2019-05-06 19:20:01 +02:00
Ricki Hirner ffe59915d9 Rename AccountActivity2 to AccountActivity 2019-05-06 19:19:54 +02:00
Ricki Hirner 94a8c4c218 Make AccountActivity2 fully working again 2019-05-06 19:19:46 +02:00
Ricki Hirner efa168b941 Use AppBarLayout for AccountActivity 2019-05-06 19:19:38 +02:00
Ricki Hirner b8abc60752 Linuxtage Action 2019-05-06 19:19:01 +02:00
Ricki Hirner 77d6fc7243 Fix Android 4.4 compatibility; version code bump 2019-04-19 14:15:49 +02:00
Ricki Hirner f50f86c1ad vCard: use "groupX" instead of "davdroidX" for property grouping 2019-04-19 12:57:55 +02:00
Ricki Hirner 3c07bc3abc Use Conscrypt; show progress bar when loading account activity 2019-04-19 02:33:01 +02:00
Ricki Hirner 400b7babbc Improve database migrations 2019-04-18 13:35:29 +02:00
Ricki Hirner 5ff86d809f Fix crash in account setup 2019-04-18 13:35:29 +02:00
Ricki Hirner 9af246a01a Version bump to 2.4.1-beta1 2019-04-18 13:25:39 +02:00
Ricki Hirner d7cefa84f4 Update Kotlin, gradle, gradle plugin 2019-04-17 22:53:19 +02:00
Ricki Hirner d7b73a35e1 Tests 2019-04-17 12:24:33 +02:00
Ricki Hirner f20d921559 Handle permissions with LiveData; manifest: add app settings/debug info activity 2019-04-16 21:36:14 +02:00
Ricki Hirner cdec545568 Use live paged data for AccountActivity 2019-04-16 21:36:14 +02:00
Ricki Hirner 6af50dbc43 Use Room for database 2019-04-16 21:36:14 +02:00
Ricki Hirner 7a44f61887 Minor cert4android/ical4android update 2019-04-03 00:00:53 +02:00
Ricki Hirner 4ec98acdd1 Fetch translations from Transifex 2019-04-02 23:48:55 +02:00
Ricki Hirner e0a000cbd8 Hotfix: AccountActivity: avoid endless loop of asking for permissions; version bump to 2.4.0.1 2019-04-02 23:48:14 +02:00
Ricki Hirner 7c8f74ab14 Version bump to 2.4 2019-03-29 15:07:42 +01:00
Ricki Hirner 03cbe4f665 Update read-only state or raw contacts/data rows according to the address book 2019-03-27 12:34:44 +01:00
Ricki Hirner a76829c3dd Version bump to 2.4-beta4 2019-03-27 12:15:20 +01:00
Ricki Hirner d83a4df524 Account activity: update "force read only" responsively 2019-03-27 12:13:21 +01:00
Ricki Hirner afdfb735bd Fetch translations from Transifex 2019-03-26 22:02:48 +01:00
Ricki Hirner 414f771156 Remove unused sync status notification channel 2019-03-26 22:00:03 +01:00
Ricki Hirner c690dfd148 Support read-only flag in raw contacts/data rows for read-only address books 2019-03-26 21:11:56 +01:00
Ricki Hirner 50775f26ce Change secondary text color to darker gray 2019-03-26 18:19:06 +01:00
Ricki Hirner 40a1748461 Use .closeCompat for ContentProviderClient instances 2019-03-26 18:19:06 +01:00
Ricki Hirner 63e360fb3a AccountActivity: remove "Select collections to synchronize" 2019-03-26 13:01:33 +01:00
Ricki Hirner d7216f5ffa Update libraries; bump version to 2.4-beta3 2019-03-26 12:14:53 +01:00
Ricki Hirner a749a9fd34 Rename "debug model" (artifact from search/replace) to "debug info" again 2019-03-26 12:14:25 +01:00
Ricki Hirner 6951397945 AccountActivity: use ViewModel, RecyclerView 2019-03-25 11:37:44 +01:00
Ricki Hirner 6153b3aafe AccountAdapter not-null assertion 2019-03-24 17:17:37 +01:00
Ricki Hirner 325dd41820 Version bump to 2.4-beta2 2019-03-24 00:05:59 +01:00
Ricki Hirner be94ee19d8 Enable gradle incubation 2019-03-23 23:27:17 +01:00
Ricki Hirner d03ef78db9 Update tests 2019-03-23 23:24:37 +01:00
Ricki Hirner e7e580132f Enable Gitlab CI again 2019-03-23 23:24:37 +01:00
Ricki Hirner a7c62d11ca Update libs, About logo 2019-03-23 22:54:38 +01:00
Ricki Hirner d978e6a17d Fetch translations from Transifex 2019-03-23 21:19:20 +01:00
Ricki Hirner 87457bf6d4 Small fixes 2019-03-23 21:19:20 +01:00
Ricki Hirner bff9c87c1f Create collections: use ViewModel 2019-03-23 20:53:49 +01:00
Ricki Hirner d27261f18a Handle exceptions when acquiring task provider 2019-03-23 20:52:09 +01:00
Ricki Hirner affce28652 Don't rely on Manifest-declared implicit broadcast to detect when a tasks app is being installed/removed (Android 8 compatibility) 2019-03-23 20:50:28 +01:00
Ricki Hirner b319fef88a Version bump to 2.4-beta1 2019-03-23 20:37:18 +01:00
Ricki Hirner 6369ae1d36 About: use vector icon 2019-03-23 20:37:18 +01:00
Ricki Hirner 8fe093d975 Create calendar: new UI, use ViewModel 2019-03-23 20:37:18 +01:00
Ricki Hirner e99acb2921 Delete collection: use ViewModel 2019-03-23 20:37:18 +01:00
Ricki Hirner 88e0c16f83 AboutActivity: use ViewModel 2019-03-23 20:37:18 +01:00
Ricki Hirner b348f54f95 Update beta feedback email address, libraries 2019-03-23 20:37:18 +01:00
Ricki Hirner b75f8d140a Login: couple user name and email address 2019-03-23 20:37:18 +01:00
Ricki Hirner 206c2e8aa9 Account setup: fix crash 2019-03-23 20:37:18 +01:00
Ricki Hirner e8f14c3745 Enable Multidex for >64k methods on Android 4.4 2019-03-23 20:37:18 +01:00
Ricki Hirner c2e6514a3c AccountListFragment: use ViewModel 2019-03-23 20:37:18 +01:00
Ricki Hirner 676f74a528 HttpClient: close cache, provide app:html binding 2019-03-16 13:05:43 +01:00
Ricki Hirner 22b9624c0a Use ViewModel and data binding for login process 2019-03-16 13:02:10 +01:00
Ricki Hirner f22dd3e013 Debug info: use ViewModel 2019-03-16 13:01:38 +01:00
Ricki Hirner 913b395e56 Update libraries; enable Java 8 compatibility 2019-03-16 13:01:26 +01:00
Ricki Hirner f9974dbb7e Move CustomTlsSocketFactory to cert4android so it can be used in other apps like ICSx⁵; update ical4android 2019-02-20 18:29:43 +01:00
Ricki Hirner 49fae85852 Notify on invalid events, too 2019-02-19 21:24:09 +01:00
Ricki Hirner 3addab5bc0 Version bump to 2.3 2019-02-19 21:24:09 +01:00
Ricki Hirner 1bfacff3df Merge branch 'remove-stray-settings' into 'master-ose'
Remove stray Settings.kt file

See merge request bitfireAT/davx5-ose!18
2019-02-14 10:13:41 +00:00
Michael Biebl 643c4f04c9 Remove stray Settings.kt file
It was most likely added by accident in commit
7d4689969a
2019-02-14 01:40:54 +01:00
Ricki Hirner 517b60aa21 Metadata: allow translations over Transifex 2019-02-12 16:46:55 +01:00
Ricki Hirner 98eeaf171e Fetch translations from Transifex 2019-02-09 14:20:41 +01:00
Ricki Hirner 9320de54c3 Metadata: add feature graphic 2019-02-08 17:50:41 +01:00
Ricki Hirner 24730d1925 Notify on invalid iCalendar/vCard objects
* add notification channel: sync warnings
* notify on invalid iCalendar/vCard objects
* add app setting: Notification settings (only when notification channels are available)
2019-02-08 17:42:51 +01:00
Ricki Hirner 94fd665b6d Update Kotlin, gradle, dav4jvm 2019-02-07 13:21:25 +01:00
Ricki Hirner 7d9d9ea57c Translations: fix positional argument (thanks @mbiebl) 2019-02-06 17:28:33 +01:00
Ricki Hirner bc1b1ca40f Update README, use absolute submodules URLs 2019-02-06 17:01:24 +01:00
Ricki Hirner 4ff8a5eb4e README update 2019-02-06 16:46:00 +01:00
Ricki Hirner 517a859dbf Fetch translations from Transifex 2019-02-06 16:45:38 +01:00
Ricki Hirner 1b6689133a Add Greek translation (thanks Efstathios Iosifidis) 2019-02-06 16:44:40 +01:00
Ricki Hirner 8a18e1e738 CalenderSyncManager/TaskSyncManager: log created/updated data; update
libs
2019-02-06 01:25:41 +01:00
Ricki Hirner f241de264d Update ical4j; show new davx5 logger in scripts/adb-log.sh 2019-01-30 17:45:57 +01:00
Ricki Hirner 2cf73f0f07 Use AppCompatResources.getDrawable instead of context.getDrawable 2019-01-29 23:01:01 +01:00
Ricki Hirner 15008c1a53 Use HtmlCompat.fromHtml instead of deprected Html.fromHtml 2019-01-29 22:55:00 +01:00
Ricki Hirner c90e491739 Update gradle, Kotlin 2019-01-29 20:02:08 +01:00
Ricki Hirner 1b5ea52138 Settings provider: more logging, scripts update, lib updates 2019-01-29 19:18:38 +01:00
Ricki Hirner 48478c421f Finish renaming dav4android → dav4jvm (thanks @mbiebl) 2019-01-27 12:15:52 +01:00
Ricki Hirner f4823f9524 Version bump to 2.2.3.1 2019-01-18 16:16:01 +01:00
Ricki Hirner 2abe164e08 Don't use shouldShowRequestPermissionRationale in debug info (crashes on Motorola Android 6 when permission name is unknown) 2019-01-18 16:14:47 +01:00
Ricki Hirner fa5856604d update homepage links; version bump to 2.2.3 2019-01-18 11:33:14 +01:00
Ricki Hirner c88508ea66 Add Galician translation (thanks Xosé M. Lamas) 2019-01-18 09:58:55 +01:00
Ricki Hirner e1c5fb2bfd Add Galician translation (thanks) 2019-01-18 09:58:32 +01:00
Ricki Hirner 9536d43047 Debug info: show location permission; show "Dont ask again" for permissions; show CalDAV "use event colors" option 2019-01-18 02:08:58 +01:00
Ricki Hirner 273c23ee44 Change some DAVdroid leftovers to DAVx⁵; remove Lombok config (thanks @mbiebl) 2019-01-18 01:43:33 +01:00
Ricki Hirner a34b3b1fee AccountSettings version 9: disable OpenTasks isSyncable for non-CalDAV accounts 2019-01-18 01:33:30 +01:00
Ricki Hirner 6157d6e767 Disable OpenTasks sync on account creation without CalDAV service; Account UI: finish activity from main thread on account deletion 2019-01-18 01:33:24 +01:00
Ricki Hirner 013729ed73 Account settings: fix sync interval setting once more. Maybe it now works like this? 2019-01-17 12:27:51 +01:00
Ricki Hirner 2287b802ce Update README
* fix Twitter link (thanks Felix Eckhofer)
* update dav4jvm URL
2019-01-16 17:22:28 +01:00
Ricki Hirner 0166008ab6 Fetch translations from Transifex 2019-01-13 21:46:46 +01:00
Ricki Hirner 739bf3e53d Version bump to 2.2.3-beta1 2019-01-13 21:46:20 +01:00
Ricki Hirner e36102721f Restrict sync to WiFi SSIDs: explain why Location permissions are required 2019-01-12 16:01:51 +01:00
Ricki Hirner 5ef9693ac8 Add some phone screenshots 2019-01-08 19:27:29 +01:00
Ricki Hirner 19320dda17 Version bump to 2.2.2 2019-01-08 19:19:43 +01:00
Ricki Hirner 322f9a0da0 Fetch translations from Transifex 2019-01-08 19:17:31 +01:00
Ricki Hirner ba20d74b64 User-Agent and iCalendar/vCard product IDs: useBuild config; change to dav4jvm 2019-01-08 19:14:03 +01:00
Ricki Hirner 6a512e1f0f Fix crash on single-core devices and ProGuard rules 2019-01-08 01:21:37 +01:00
Ricki Hirner 4300954101 Fix CI in libs; include dav4jvm as standalone project 2019-01-06 20:01:29 +01:00
Ricki Hirner 2fe19e9342 Rename dav4android to dav4jvm; update gradle 2019-01-06 18:17:19 +01:00
Ricki Hirner e764d31d18 Login: Show error message when account name is already taken 2019-01-05 11:55:32 +01:00
Ricki Hirner 8d0d920033 Version bump to 2.2.1 2019-01-04 22:30:41 +01:00
Ricki Hirner e2fcbbea0b Fetch translations from Transifex 2019-01-04 22:30:41 +01:00
Ricki Hirner 1cefa88b98 Notifications: fix crashes (for Android 4.4 and when wrong CardDAV password occurs) 2019-01-04 22:13:47 +01:00
Ricki Hirner be4a650deb Remove unnecessary permissions; ical4android update
* remove external storage permissions because logs are now written to the app data directory
* ical4android: use ical4j 2.2.3 with built-in RFC 7986 properties
2019-01-04 14:26:04 +01:00
Ricki Hirner ab47b2b222 Debug info: fix onShare
* bump version code to 261
2019-01-04 01:04:40 +01:00
Ricki Hirner 7f301640da Account settings: reload in UI thread on Android callback
* version bump to 260
2019-01-03 19:20:52 +01:00
Ricki Hirner 83656ef497 Bump version code to 259 2019-01-03 18:55:09 +01:00
Ricki Hirner 77c54a2150 Fetch translations from Transifex 2019-01-03 18:54:52 +01:00
Ricki Hirner 16e3526a66 Retain sync intervals and isSyncable when renaming an account 2019-01-03 18:14:38 +01:00
Ricki Hirner 3ae5653da4 Install Webcal app: rename ICSdroid to ICSx⁵ 2019-01-03 17:13:40 +01:00
Ricki Hirner c02b033f8c Version bump to 2.2 2019-01-03 16:53:24 +01:00
Ricki Hirner 4afe4837e7 Update strings and ical4j 2019-01-03 16:53:01 +01:00
Ricki Hirner 56813ad4d6 Logging: Use FileProvider instead of external file 2019-01-02 23:56:24 +01:00
Ricki Hirner 036fc405f4 Make sure the account is seen by OpenTasks 2019-01-02 22:07:25 +01:00
Ricki Hirner 95173dcd7d Ask for contacts/calendar permissions as soon there is a known CalDAV/CardDAV service 2019-01-02 22:07:25 +01:00
Ricki Hirner be754d6ede Add F-Droid metadata 2018-12-31 11:26:34 +01:00
Ricki Hirner 20097c0b69 Version bump to 2.1 2018-12-30 20:34:36 +01:00
Ricki Hirner 0d1c265e97 Show Color Picker in README 2018-12-30 18:57:25 +01:00
Ricki Hirner 7ce739c271 Further DAVdroid -> DAVx5 replacements 2018-12-30 16:52:06 +01:00
Ricki Hirner 241957f2dd New launcher icons 2018-12-30 15:37:41 +01:00
Ricki Hirner 3dea33e100 Fix string references 2018-12-30 14:16:39 +01:00
Ricki Hirner f8f60f4b52 Update CI script 2018-12-30 13:41:05 +01:00
Ricki Hirner 0d75cf0cdc Update strings 2018-12-30 12:37:54 +01:00
Ricki Hirner 556843df4f Remove "Accounts may be gone after rebooting" startup dialog 2018-12-30 12:08:45 +01:00
Ricki Hirner 8b12cb7a29 Fetch translations from Transifex 2018-12-30 11:59:27 +01:00
Ricki Hirner 07c5cdc754 Rename DAVdroid to DAVx⁵ 2018-12-30 11:16:23 +01:00
Ricki Hirner 1d33e4c311 Replace AmbilWarna by ColorPicker 2018-12-26 13:58:41 +01:00
Ricki Hirner 7d4689969a Refactor Settings provider
* don't use a separate :sync process anymore, so that settings management doesn't need IPC
* remove Settings service and IPC, use singleton with application Context instead
* adapt default number of sync worker threads
* library updates
2018-12-26 13:30:00 +01:00
Ricki Hirner b863d355f6 Fetch translations from Transifex 2018-12-22 11:52:27 +01:00
Ricki Hirner bcd468f2e5 DAV service detection: cancel previous notification (MR #15 thanks @mbiebl) 2018-12-22 11:52:27 +01:00
Ricki Hirner 21fdf2cebc Version bump to 2.0.7, fix small NPE 2018-12-22 11:52:27 +01:00
Ricki Hirner ea071bbd1a Debug info: show details about DAVdroid, contact/calendar providers and contact/calendar/task apps 2018-12-21 18:03:36 +01:00
Ricki Hirner bc5f1e935e Ignore HTTP 403 when the resource upload fails because of missing permissions (server will win) 2018-12-21 17:02:33 +01:00
Ricki Hirner 72749addcd Settings: add some icons; rearrange account settings 2018-12-08 23:25:19 +01:00
Ricki Hirner 6abbd019ad Version bump to 2.0.6 2018-12-05 12:56:51 +01:00
Ricki Hirner 67b1685d01 Version bump to 2.0.6-beta2 2018-12-03 11:19:01 +01:00
Ricki Hirner 4bbb2b8419 Ignore non-successful multiget responses 2018-12-01 22:01:37 +01:00
Ricki Hirner 8bdf03bfc5 Minor changes (lint/remove warnings) 2018-11-30 13:46:52 +01:00
Ricki Hirner 8b43677d01 Don't do emulator checks (because we can only use shared runners at the moment) 2018-11-30 11:40:24 +01:00
Ricki Hirner e454fa398a Update ProGuard rules 2018-11-29 23:37:55 +01:00
Ricki Hirner f5b1194599 Version bump to 2.0.6-beta1 2018-11-29 23:26:13 +01:00
Ricki Hirner e549a25812 Update to okhttp 3.12.0 2018-11-29 23:25:43 +01:00
Ricki Hirner fb90955b2a Switch to AndroidX 2018-11-29 23:03:38 +01:00
Ricki Hirner a7f0161983 Merge branch 'dont-hardcode-account_type' into 'master-ose'
Don't hard-code accountType and use @string/account_type instead

See merge request bitfireAT/davdroid!12
2018-11-04 17:28:41 +00:00
Ricki Hirner 45efb88b1f Fetch translations from Transifex 2018-11-04 18:16:58 +01:00
Ricki Hirner 2c022d46fe Version bump to 2.0.5 2018-11-04 18:16:11 +01:00
Ricki Hirner 50c4ee94e2 Handle unresolvable ACTION_VIEW intents; update gradle, Kotlin 2018-11-04 18:15:07 +01:00
Michael Biebl d06d24a13e Don't hard-code accountType and use @string/account_type instead 2018-10-18 19:57:05 +02:00
Ricki Hirner 2c5cdf813a Allow clear-text traffic (sync with http://) explicitly for Android 9 2018-10-07 18:53:39 +02:00
Ricki Hirner 0250baeeb3 Lint cleanup for Android P 2018-10-05 13:41:03 +02:00
Ricki Hirner 05d1970754 Version bump to 2.1-beta1 2018-10-05 13:21:04 +02:00
Ricki Hirner a97be98b11 Mark as compatible with Android P (SDK level 28) 2018-10-05 13:21:04 +02:00
Ricki Hirner 1625f092fd Update Kotlin, gradle, build tools 2018-10-05 13:21:04 +02:00
Ricki Hirner c0ff8f61f9 Resource detection: separate CalDAV/CardDAV detection; handle SocketTimeoutException better; version bump to 2.0.4 2018-09-08 21:49:21 +02:00
Ricki Hirner dbfb74a16f Fetch translations from Transifex 2018-08-28 13:40:14 +02:00
Ricki Hirner 39b277bc6d Version bump to 2.0.3 2018-08-28 13:38:03 +02:00
Ricki Hirner d09c70f52b More fine-grained WebDAV permissions
* service DB: split readOnly into privWriteContent and privUnbind
* collections: use privWriteContent (DAV:write-content privilege) for read-only detection
* AccountActivity: allow collection deletion only when privUnbind (DAV:unbind privilege) is set
2018-08-26 20:05:48 +02:00
Ricki Hirner 2f0ee8e230 Make debug info text selectable 2018-08-26 20:05:40 +02:00
Ricki Hirner c320094b3b Service detection: ignore HTTP 4xx errors when looking for homesets 2018-08-25 15:59:05 +02:00
Ricki Hirner 5b12015307 Don't offer collection deletion for read-only collections 2018-08-25 15:59:05 +02:00
Ricki Hirner e7d2c23989 Update Kotlin, dokka, build tools, FAQ URL, libs 2018-08-22 13:17:53 +02:00
Ricki Hirner 80971c52b5 Version bump to 2.0.2 2018-08-15 13:09:46 +02:00
Ricki Hirner 23abf7c1f0 DAVdroid address book sync: only sync address book sub-accounts of current account 2018-08-15 13:08:37 +02:00
Ricki Hirner 613b88ad5e Fix crash in resource detection 2018-08-15 12:43:22 +02:00
Ricki Hirner b09d6c13b5 Fetch translations from Transifex 2018-08-10 21:25:43 +02:00
Ricki Hirner e30ae04534 Version bump to 2.0.1 2018-08-10 21:25:01 +02:00
Ricki Hirner 0ed54bb671 Don't keep Acticity context when creating an account 2018-08-09 12:56:23 +02:00
Ricki Hirner 889ebe160e AccountActivity: use AsyncTask instead of own Executor 2018-08-07 19:20:36 +02:00
Ricki Hirner 602e14d2bf Really cancel resource detection when dialog is cancelled 2018-08-06 12:33:29 +02:00
Ricki Hirner 7ed275712e Use AsyncTask and progress indicator for creating an account 2018-08-06 12:33:25 +02:00
Ricki Hirner 3dc3e75721 Fetch translations from Transifex; always replace "..." by "…" 2018-08-05 10:55:15 +02:00
Ricki Hirner a63e717ca7 OSE AboutActivity: take GPL from file and use loader 2018-08-05 10:47:58 +02:00
Ricki Hirner c0044ae901 Don't use gradle variables in version name (F-Droid workaround) 2018-08-03 14:30:27 +02:00
Ricki Hirner edabdbadc9 Collection properties: replace "Copy URL" button by selectable TextView 2018-08-03 14:30:04 +02:00
Ricki Hirner 47543e448c Version bump to 2.0 2018-07-30 10:16:19 +02:00
Ricki Hirner 5ff88c5b2b Fetch translations from Transifex 2018-07-28 14:33:53 +02:00
Ricki Hirner eadfa39760 Version bump to 2.0-rc1 2018-07-28 14:33:05 +02:00
Ricki Hirner 2342bc92ca Vector drawable: fix Android 4.4 compatibility 2018-07-28 14:33:00 +02:00
Ricki Hirner f0d150d491 Fetch translations from Transifex 2018-07-27 16:52:39 +02:00
Ricki Hirner 949a1916b5 Fix/improve error handling; bump version to 1.12-beta4 2018-07-27 16:49:57 +02:00
Ricki Hirner efa4b7dc66 Version bump to 1.12-beta3 2018-07-22 11:24:14 +02:00
Ricki Hirner 971be44ffe Fetch translations from Transifex 2018-07-22 11:17:32 +02:00
Ricki Hirner 88efd5ab04 Use Apache Commons ContextedException instead of own class 2018-07-22 11:16:35 +02:00
Ricki Hirner b44306eaf2 Rewrite startup strings 2018-07-21 11:08:12 +02:00
Ricki Hirner abb0850889 Version bump to 1.12-beta2 2018-07-17 13:34:11 +02:00
Ricki Hirner 7760bddca7 Collection info: allow copying URL to clipboard 2018-07-17 13:24:57 +02:00
Ricki Hirner 60527e83fe Collection info fragment 2018-07-17 13:24:52 +02:00
Ricki Hirner 54cc9b39e1 Account activity: show "Select collections to synchronize" hint when no collections are selected 2018-07-16 20:05:40 +02:00
Ricki Hirner be9d404e6c Update to okhttp 3.11 2018-07-16 14:01:38 +02:00
Ricki Hirner fd40b5a4e3 Update build tools; enable parallel sync of address books authority 2018-07-15 17:10:57 +02:00
Ricki Hirner abb142d118 Fetch translations from Transifex 2018-07-13 15:21:23 +02:00
Ricki Hirner 7a7a940089 New About activity
* new About activity using AboutLibraries library
* include DAVdroid version and other non-personal information in URL when DAVdroid homepage is opened
  (so that we know what DAVdroid versions are used out there and maybe can provide version-specific help)
2018-07-13 15:21:19 +02:00
Ricki Hirner f0e1e03095 New sync logic with XML streaming
* refactored dav4android to use XML streaming
* refactored collection detection
* refactored sync logic
2018-07-12 00:05:53 +02:00
Ricki Hirner 2358996940 Enable strict mode for debugging; use thread for DB changes in AccountActivity 2018-06-27 12:55:07 +02:00
Ricki Hirner 09b42fc940 Change detection error message 2018-06-24 21:33:59 +02:00
Ricki Hirner 9c796f8226 Rename address book accounts correctly when renaming accounts; drop unparsable fields from vCards 2018-06-20 12:39:35 +02:00
Ricki Hirner 9a635875a1 Version bump to 1.11.5 2018-06-17 16:37:50 +02:00
Ricki Hirner 9d598cead7 move lambda expressions out of parentheses; use CREATOR-named companion objects for Parcelable 2018-06-17 16:37:11 +02:00
Ricki Hirner 2915a1f2b6 Fix lateinit null value in resource detection; update Kotlin and gradle 2018-06-16 15:08:06 +02:00
Ricki Hirner 5316fb42a5 Trigger a full calendar sync when past event time limit is changed in account settings 2018-06-14 09:21:42 +02:00
Ricki Hirner 1c3a5ceb09 Don't show contacts permission notification when no address book is selected for synchronization 2018-06-13 17:23:55 +02:00
Ricki Hirner c34ca0f8e6 Version bump to 1.11.4.1 2018-06-12 11:15:40 +02:00
Ricki Hirner b9f629f6ce Collection sync: don't reset "present remotely" flag after enumerating resources 2018-06-12 00:43:12 +02:00
Ricki Hirner c6e37fcc58 Version bump to 1.11.4 2018-06-08 11:52:16 +02:00
Ricki Hirner cb8c1f45ad Fetch translations from Transifex 2018-06-08 11:52:16 +02:00
Ricki Hirner f7e1b97a66 Update gradle plugin 2018-06-08 11:52:16 +02:00
Ricki Hirner 90a70f39e8 Fix crash when collection is deleted 2018-05-30 10:33:26 +02:00
Ricki Hirner 9226d867c3 Update copyright 2018-05-28 12:04:56 +02:00
Ricki Hirner 216579680d Add maxOccurs to contacts.xml group membership (allows editing of contacts again) 2018-05-28 11:56:47 +02:00
Ricki Hirner fe8b50c76d Fetch translations from Transifex 2018-05-28 10:47:33 +02:00
Ricki Hirner 9ab7b3b105 Add group memberships to contacts.xml so that they can be edited with some Contacts apps 2018-05-28 10:47:04 +02:00
Ricki Hirner b90ec5b64c Version bump to 1.11.4-beta2 2018-05-27 16:28:41 +02:00
Ricki Hirner b197ac7da2 Show HTTP request/response in debug info 2018-05-27 16:28:02 +02:00
Ricki Hirner 9aa58d170f Use US locale for date in User-Agent 2018-05-27 15:34:30 +02:00
Ricki Hirner 553b782339 Fix lateinit problem 2018-05-26 12:42:02 +02:00
Ricki Hirner 8b6376e01d Use HttpUrl whenever possible
* HttpUrl is the preferred class because we use URLs mainly for okhttp
* don't use URI or String for URLs, if possible
* HttpUrl is not Serializable, so use Parcelable for data classes with HttpUrl

X
2018-05-26 12:29:42 +02:00
Ricki Hirner 5da229ed88 Use weak reference for sync adapter thread lock 2018-05-26 12:29:38 +02:00
Ricki Hirner a0bb681f48 Don't show InterruptedIOException; tests 2018-05-25 10:57:03 +02:00
Ricki Hirner 2e3987e6af Version bump to 1.11.4-beta1 2018-05-24 14:14:27 +02:00
Ricki Hirner f72588a691 Update ical4android 2018-05-24 14:14:27 +02:00
Ricki Hirner 56fc2121f8 Integrate adaptive icon 2018-05-24 14:14:27 +02:00
Lokesh Krishna c863d05f3f Adaptive icon 2018-05-24 14:14:27 +02:00
Ricki Hirner f0ff12ebb9 dav4android update (immutable responses) 2018-05-24 14:14:27 +02:00
Ricki Hirner 87cb43b0e6 Fetch translations from Transifex 2018-05-24 14:14:27 +02:00
Ricki Hirner 2e073897b8 Collection sync: handle 403 with valid-sync-token precondition 2018-05-24 14:14:27 +02:00
Ricki Hirner bdc032d1b9 Update build.gradle 2018-05-12 11:27:19 +02:00
Ricki Hirner d07fcdcddc ical4android: reduce size of sent VTIMEZONEs 2018-05-11 23:18:54 +02:00
Ricki Hirner 5f2f151e14 Version bump to 1.11.3 2018-05-10 12:57:00 +02:00
Ricki Hirner 33ddb06272 Enable Collection Synchronization for CalDAV when past time event limit is disabled 2018-05-10 12:55:32 +02:00
Ricki Hirner ef9f13d1d6 Version bump to 1.11.2 2018-05-04 11:01:06 +02:00
Ricki Hirner 6b9520287b Collection sync: mark skipped entries as locally present 2018-05-01 16:34:38 +02:00
Ricki Hirner 3944ab0222 Version bump to 1.11.2-beta1 2018-05-01 10:19:57 +02:00
Ricki Hirner 103459464c Improve collection sync for contacts 2018-04-30 12:42:15 +02:00
Ricki Hirner be2d10278d Collection sync: don't download already available resources 2018-04-30 12:41:27 +02:00
Ricki Hirner f4b864b3d0 Support collection sync (RFC 6578) for contacts 2018-04-30 12:41:21 +02:00
Ricki Hirner a576701ee3 Code cleanup (lint) 2018-04-28 21:23:37 +02:00
Ricki Hirner 40bca54c09 Fix memory leak in vcard4android 2018-04-27 00:54:01 +02:00
Ricki Hirner 88efbd7a00 Add message to local storage error notification 2018-04-26 12:29:16 +02:00
Ricki Hirner 292eb3bb52 Fetch translations from Transifex 2018-04-26 10:53:53 +02:00
Ricki Hirner bfddf92a3b Don't throw exception when content provider doesn't return all results; ignore RemoteException on getting contacts sync state 2018-04-26 10:53:27 +02:00
Ricki Hirner 872bfa2f40 Version bump to 1.11.1 2018-04-26 10:52:30 +02:00
Ricki Hirner 73d9dff1b7 Show and use ical4j and okhttp version numbers when possible 2018-04-25 01:04:13 +02:00
Ricki Hirner fd84ff37e5 Log remote resource info for tasks, too 2018-04-19 08:30:23 +02:00
Ricki Hirner d280ce9d63 Treat InterruptedIOException like IOException 2018-04-13 15:24:03 +02:00
Ricki Hirner 67c7be900a Fetch translations from Transifex 2018-04-13 10:24:33 +02:00
Ricki Hirner c9cd6de476 Version bump to 1.11 2018-04-13 10:19:29 +02:00
350 changed files with 17181 additions and 9089 deletions

No files matched your search

+5 -4
View File
@@ -1,4 +1,4 @@
image: registry.gitlab.com/bitfireat/davdroid:latest
image: registry.gitlab.com/bitfireat/docker-android-emulator:latest
before_script:
- git submodule update --init --recursive
@@ -7,12 +7,13 @@ before_script:
cache:
paths:
- .gradle/
- apk/
test:
tags:
- privileged
script:
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
- ./gradlew check mergeAndroidReports
- start-emulator.sh
- ./gradlew app:check app:connectedCheck
artifacts:
paths:
- app/build/outputs/lint-results-debug.html
+3 -6
View File
@@ -1,12 +1,9 @@
[submodule "dav4android"]
path = dav4android
url = ../dav4android.git
[submodule "ical4android"]
path = ical4android
url = ../ical4android.git
url = https://gitlab.com/bitfireAT/ical4android.git
[submodule "vcard4android"]
path = vcard4android
url = ../vcard4android.git
url = https://gitlab.com/bitfireAT/vcard4android.git
[submodule "cert4android"]
path = cert4android
url = ../cert4android.git
url = https://gitlab.com/bitfireAT/cert4android.git
+47
View File
@@ -0,0 +1,47 @@
Contributing to DAVx⁵
=====================
**Thank you for your interest in contributing to DAVx⁵!**
Because you're reading this, you're probably interested in
contributing to the DAVx⁵ code. [Other ways to contribute:
see here.](https://www.davx5.com/donate#c306)
To contribute:
1. It's good idea to have a look at the [DAVx⁵ Roadmap](https://gitlab.com/bitfireAT/davx5-ose/wikis/Roadmap)
to see whether the change is already planned. Maybe there's even a link to a
corresponding forum thread there.
1. Determine which project the changes shall go to. There's
the DAVx⁵ main project (this repo), and the [related
libraries](README.md).
1. Please post to the [DAVx⁵ development forum](https://www.davx5.com/forums)
before doing actual work (unless you do it only for yourself, of course).
This will help to coordinate activities and you'll also get hints
about where to start and possible pitfalls.
1. Fork the repository.
1. Do the changes in your repository.
1. Submit a pull request to the original project.
1. Post in the forum again (to make sure the pull request is being notified).
Questions, discussion
=====================
We're happy to see questions, discussions etc. in the
[DAVx⁵ development forum](https://www.davx5.com/forums)!
Licensing
=========
All code has to be licensed under the GPL.
We (bitfire.at, initial developers) are also asking you to double-license the
code so that we can also use it for related non-open source projects like
[Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5).
Please find more about this in the Contributor's License Agreement (CLA)
we'll send to you if you want to contribute.
+15 -18
View File
@@ -1,40 +1,37 @@
![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png)
[![build status](https://gitlab.com/bitfireAT/davdroid/badges/master-ose/build.svg)](https://gitlab.com/bitfireAT/davdroid/commits/master-ose)
DAVdroid
DAVx⁵
========
Please see the [DAVdroid Web site](https://www.davdroid.com) for
comprehensive information about DAVdroid.
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
comprehensive information about DAVx⁵.
DAVdroid is licensed under the [GPLv3 License](LICENSE).
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
Help and discussion: [DAVdroid forums](https://www.davdroid.com/forums/)
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums)
**If you want to support DAVdroid, please consider [donating to DAVdroid](https://www.davdroid.com/donate/)
or [purchasing it](https://www.davdroid.com/download/).**
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
or [purchasing it](https://www.davx5.com/download).**
Generated KDoc: https://bitfireAT.gitlab.io/davdroid/dokka/app/
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
Parts of DAVdroid have been outsourced into these libraries:
Parts of DAVx⁵ have been outsourced into these libraries:
* [cert4android](https://gitlab.com/bitfireAT/cert4android) custom certificate management
* [dav4android](https://gitlab.com/bitfireAT/dav4android) WebDAV/CalDav/CardDAV framework
* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) WebDAV/CalDav/CardDAV framework
* [ical4android](https://gitlab.com/bitfireAT/ical4android) iCalendar processing and Calendar Provider access
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) VCard processing and Contacts Provider access
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) vCard processing and Contacts Provider access
USED THIRD-PARTY LIBRARIES
==========================
Those libraries are used by DAVdroid (alphabetically):
Those libraries are used by DAVx⁵ (alphabetically):
* [Ambilwarna](https://github.com/yukuku/ambilwarna) [Apache License, Version 2.0](https://github.com/yukuku/ambilwarna/blob/master/LICENSE)
* [Color Picker](https://github.com/jaredrummler/ColorPicker) [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE)
* [dnsjava](http://www.xbill.org/dnsjava/) [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
* [ez-vcard](https://github.com/mangstadt/ez-vcard) [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
* [iCal4j](https://github.com/ical4j/ical4j) [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
* [okhttp](https://square.github.io/okhttp) [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
* [Project Lombok](http://projectlombok.org/) [MIT License](http://opensource.org/licenses/mit-license.php)
+99 -48
View File
@@ -7,94 +7,145 @@
*/
apply plugin: 'com.android.application'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'org.jetbrains.dokka-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.dokka'
android {
compileSdkVersion 27
buildToolsVersion '27.0.3'
compileSdkVersion 29
buildToolsVersion '29.0.2'
defaultConfig {
applicationId "at.bitfire.davdroid"
versionCode 211
versionCode 301000012
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
buildConfigField "boolean", "customCerts", "true"
minSdkVersion 19 // Android 4.4
targetSdkVersion 27 // Android 8.1
minSdkVersion 21 // Android 5.0
targetSdkVersion 29 // Android 10.0
// when using this, make sure that notification icons are real bitmaps
vectorDrawables.useSupportLibrary = true
buildConfigField "String", "userAgent", "\"DAVx5\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
flavorDimensions "type"
compileOptions {
// enable because ical4android requires desugaring
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures.dataBinding = true
flavorDimensions "distribution"
productFlavors {
standard {
versionName "1.11-rc1-ose"
buildConfigField "boolean", "customCerts", "true"
versionName "3.1-ose"
}
}
buildTypes {
debug {
minifyEnabled false
// minifyEnabled enables R8 and thus desugaring (which is needed for working debug builds)
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-debug.pro'
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro'
shrinkResources true
}
}
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable "OnClick" // doesn't recognize Kotlin onClick methods
disable 'RtlEnabled'
disable 'RtlHardcoded'
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable 'RtlEnabled', 'RtlHardcoded' // RTL not supported yet
disable 'Typos'
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
}
defaultConfig {
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
dokka.configuration {
sourceLink {
url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/"
lineSuffix = "#L"
}
jdkVersion = 8
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/")
packageListUrl = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/package-list")
}
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/")
packageListUrl = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/package-list")
}
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/")
packageListUrl = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/package-list")
}
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/")
packageListUrl = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/package-list")
}
}
}
dependencies {
compile project(':cert4android')
compile project(':dav4android')
compile project(':ical4android')
compile project(':vcard4android')
implementation project(':cert4android')
implementation project(':ical4android')
implementation project(':vcard4android')
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
compile 'com.android.support:appcompat-v7:27.1.0'
compile 'com.android.support:cardview-v7:27.1.0'
compile 'com.android.support:design:27.1.0'
compile 'com.android.support:preference-v14:27.1.0'
implementation 'androidx.appcompat:appcompat:1.2.0-rc01'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta6'
implementation 'androidx.fragment:fragment-ktx:1.2.4'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
implementation 'androidx.preference:preference:1.1.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01'
implementation 'com.google.android:flexbox:1.1.0'
implementation 'com.google.android.material:material:1.2.0-beta01'
compile 'com.github.yukuku:ambilwarna:2.0.1'
def room_version = '2.2.5'
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
compile 'com.squareup.okhttp3:logging-interceptor:3.10.0'
compile 'commons-io:commons-io:2.6'
compile 'dnsjava:dnsjava:2.1.8'
compile 'org.apache.commons:commons-lang3:3.6'
compile 'org.apache.commons:commons-collections4:4.1'
implementation 'com.jaredrummler:colorpicker:1.1.0'
implementation "com.github.AppIntro:AppIntro:${versions.appIntro}"
implementation "com.gitlab.bitfireAT:dav4jvm:${versions.dav4jvm}"
implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}"
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}"
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
implementation 'commons-io:commons-io:2.6'
//noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7
implementation 'dnsjava:dnsjava:2.1.9'
implementation 'org.apache.commons:commons-collections4:4.4'
//noinspection GradleDependency - commons-lang 3.10+ needs Java 8/Android 7
implementation 'org.apache.commons:commons-lang3:3.9'
implementation 'org.apache.commons:commons-text:1.8'
// for tests
androidTestCompile 'com.android.support.test:runner:1.0.1'
androidTestCompile 'com.android.support.test:rules:1.0.1'
androidTestCompile 'junit:junit:4.12'
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.10.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'junit:junit:4.13'
androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
testCompile 'junit:junit:4.12'
testCompile 'com.squareup.okhttp3:mockwebserver:3.10.0'
testImplementation 'junit:junit:4.13'
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
}
+8
View File
@@ -0,0 +1,8 @@
# R8 usage for DAVx⁵:
# shrinking no (only in release builds)
# optimization yes (on by R8 defaults)
# obfuscation no (open-source)
-dontobfuscate
-dontshrink
+25
View File
@@ -0,0 +1,25 @@
# R8 usage for DAVx⁵:
# shrinking yes (only in release builds)
# optimization yes (on by R8 defaults)
# obfuscation no (open-source)
-dontobfuscate
# okhttp
-keepclassmembers class okhttp3.internal.Util.** { *; }
# ez-vcard
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
# ical4j
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
# DAVx + libs
-keep class at.bitfire.** { *; } # all DAVx code is required
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-44
View File
@@ -1,44 +0,0 @@
# ProGuard usage for DAVdroid:
# shrinking yes (main reason for using ProGuard)
# optimization yes
# obfuscation no (DAVdroid is open-source)
# preverification no
-dontobfuscate
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
-optimizationpasses 5
-allowaccessmodification
-dontpreverify
# Kotlin
-dontwarn kotlin.**
# ez-vcard
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
# ical4j: ignore unused dynamic libraries
-dontwarn aQute.**
-dontwarn groovy.** # Groovy-based ContentBuilder not used
-dontwarn javax.cache.** # no JCache support in Android
-dontwarn net.fortuna.ical4j.model.**
-dontwarn org.codehaus.groovy.**
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
# okhttp
-dontwarn okio.**
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.ParametersAreNonnullByDefault
-dontwarn org.conscrypt.**
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
# DAVdroid + libs
-keep class at.bitfire.** { *; } # all DAVdroid code is required
@@ -1,128 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid
import android.support.test.InstrumentationRegistry.getInstrumentation
import at.bitfire.cert4android.CustomCertManager
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockWebServer
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.ArrayUtils.contains
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyStore
import java.security.Principal
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
class CustomTlsSocketFactoryTest {
private lateinit var certMgr: CustomCertManager
private lateinit var factory: CustomTlsSocketFactory
private val server = MockWebServer()
@Before
fun startServer() {
certMgr = CustomCertManager(getInstrumentation().context, false, true)
factory = CustomTlsSocketFactory(null, certMgr)
server.start()
}
@After
fun stopServer() {
server.shutdown()
certMgr.close()
}
@Test
fun testSendClientCertificate() {
var public: X509Certificate? = null
javaClass.classLoader.getResourceAsStream("sample.crt").use {
public = CertificateFactory.getInstance("X509").generateCertificate(it) as? X509Certificate
}
assertNotNull(public)
val keyFactory = KeyFactory.getInstance("RSA")
val private = keyFactory.generatePrivate(PKCS8EncodedKeySpec(readResource("sample.key")))
assertNotNull(private)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val alias = "sample"
keyStore.setKeyEntry(alias, private, null, arrayOf(public))
assertTrue(keyStore.containsAlias(alias))
val trustManagerFactory = TrustManagerFactory.getInstance("X509")
trustManagerFactory.init(null as KeyStore?)
val trustManager = trustManagerFactory.trustManagers.first() as X509TrustManager
val factory = CustomTlsSocketFactory(object: X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
arrayOf(public).takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
private.takeIf { forAlias == alias }
}, trustManager)
/* known client cert test URLs (thanks!):
* - https://prod.idrix.eu/secure/
* - https://server.cryptomix.com/secure/
*/
val client = OkHttpClient.Builder()
.sslSocketFactory(factory, trustManager)
.build()
client.newCall(Request.Builder()
.get()
.url("https://prod.idrix.eu/secure/")
.build()).execute().use { response ->
assertTrue(response.isSuccessful)
assertTrue(response.body()!!.string().contains("CN=User Cert,O=Internet Widgits Pty Ltd,ST=Some-State,C=CA"))
}
}
@Test
fun testUpgradeTLS() {
val s = factory.createSocket(server.hostName, server.port)
assertTrue(s is SSLSocket)
val ssl = s as SSLSocket
assertFalse(contains(ssl.enabledProtocols, "SSLv3"))
assertTrue(contains(ssl.enabledProtocols, "TLSv1"))
assertTrue(contains(ssl.enabledProtocols, "TLSv1.1"))
assertTrue(contains(ssl.enabledProtocols, "TLSv1.2"))
}
private fun readResource(name: String): ByteArray {
this.javaClass.classLoader.getResourceAsStream(name).use {
return IOUtils.toByteArray(it)
}
}
}
@@ -8,12 +8,11 @@
package at.bitfire.davdroid.model
import android.content.ContentValues
import android.support.test.filters.SmallTest
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.ResourceType
import androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.model.ServiceDB.Collections
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
@@ -21,7 +20,7 @@ import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class CollectionInfoTest {
class CollectionTest {
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@@ -39,7 +38,7 @@ class CollectionInfoTest {
@Test
@SmallTest
fun testFromDavResource() {
fun testFromDavResponseAddressBook() {
// r/w address book
server.enqueue(MockResponse()
.setResponseCode(207)
@@ -54,14 +53,24 @@ class CollectionInfoTest {
"</response>" +
"</multistatus>"))
var dav = DavResource(httpClient.okHttpClient, server.url("/"))
dav.propfind(0, ResourceType.NAME)
var info = CollectionInfo(dav)
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type)
assertFalse(info.readOnly)
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
assertTrue(info.privWriteContent)
assertTrue(info.privUnbind)
assertNull(info.supportsVEVENT)
assertNull(info.supportsVTODO)
assertNull(info.supportsVJOURNAL)
assertEquals("My Contacts", info.displayName)
assertEquals("My Contacts Description", info.description)
}
@Test
@SmallTest
fun testFromDavResponseCalendar() {
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
@@ -78,48 +87,48 @@ class CollectionInfoTest {
"</response>" +
"</multistatus>"))
dav = DavResource(httpClient.okHttpClient, server.url("/"))
dav.propfind(0, ResourceType.NAME)
info = CollectionInfo(dav)
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
assertTrue(info.readOnly)
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
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)
assertTrue(info.supportsVEVENT)
assertTrue(info.supportsVTODO)
assertEquals("tzdata", info.timezone)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
fun testFromDB() {
val values = ContentValues()
values.put(Collections.ID, 1)
values.put(Collections.SERVICE_ID, 1)
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name)
values.put(Collections.URL, "http://example.com")
values.put(Collections.READ_ONLY, 1)
values.put(Collections.DISPLAY_NAME, "display name")
values.put(Collections.DESCRIPTION, "description")
values.put(Collections.COLOR, 0xFFFF0000)
values.put(Collections.TIME_ZONE, "tzdata")
values.put(Collections.SUPPORTS_VEVENT, 1)
values.put(Collections.SUPPORTS_VTODO, 1)
values.put(Collections.SYNC, 1)
@SmallTest
fun testFromDavResponseWebcal() {
// Webcal subscription
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
"<response>" +
" <href>/webcal1</href>" +
" <propstat><prop>" +
" <displayname>Sample Subscription</displayname>" +
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
val info = CollectionInfo(values)
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
assertEquals(1.toLong(), info.id)
assertEquals(1.toLong(), info.serviceID)
assertEquals("http://example.com", info.url)
assertTrue(info.readOnly)
assertEquals("display name", info.displayName)
assertEquals("description", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timeZone)
assertTrue(info.supportsVEVENT)
assertTrue(info.supportsVTODO)
assertTrue(info.selected)
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_WEBCAL, info.type)
assertEquals("Sample Subscription", info.displayName)
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
}
}
@@ -0,0 +1,64 @@
package at.bitfire.davdroid.model
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class DaoToolsTest {
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().context
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
@After
fun closeDb() {
db.close()
}
@Test
fun testSyncAll() {
val serviceDao = db.serviceDao()
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
service.id = serviceDao.insertOrReplace(service)
val homeSetDao = db.homeSetDao()
val entry1 = HomeSet(id=1, serviceId=service.id, url= "https://example.com/1".toHttpUrl())
val entry3 = HomeSet(id=3, serviceId=service.id, url= "https://example.com/3".toHttpUrl())
val oldItems = listOf(
entry1,
HomeSet(id=2, serviceId=service.id, url= "https://example.com/2".toHttpUrl()),
entry3
)
homeSetDao.insert(oldItems)
val newItems = mutableMapOf<HttpUrl, HomeSet>()
newItems[entry1.url] = entry1
// no id, because identity is given by the url
val updated = HomeSet(id=0, serviceId=service.id,
url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry")
newItems[updated.url] = updated
val created = HomeSet(id=4, serviceId=service.id, url= "https://example.com/4".toHttpUrl())
newItems[created.url] = created
DaoTools(homeSetDao).syncAll(oldItems, newItems, { it.url })
val afterSync = homeSetDao.getByService(service.id)
assertEquals(afterSync.size, 3)
assertFalse(afterSync.contains(entry3))
assertTrue(afterSync.contains(entry1))
assertTrue(afterSync.contains(updated))
assertTrue(afterSync.contains(created))
}
}
@@ -8,32 +8,31 @@
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.App
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class DefaultsProviderTest {
class DefaultsSettingsProviderTest {
private val provider: Provider = DefaultsProvider()
private val provider: SettingsProvider = DefaultsProvider()
@Test
fun testHas() {
assertEquals(Pair(false, true), provider.has("notExisting"))
assertEquals(Pair(true, true), provider.has(App.OVERRIDE_PROXY))
assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY))
}
@Test
fun testGet() {
assertEquals(Pair("localhost", true), provider.getString(App.OVERRIDE_PROXY_HOST))
assertEquals(Pair(8118, true), provider.getInt(App.OVERRIDE_PROXY_PORT))
assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST))
assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT))
}
@Test
fun testPutRemove() {
assertEquals(Pair(false, true), provider.isWritable(App.OVERRIDE_PROXY))
assertFalse(provider.putBoolean(App.OVERRIDE_PROXY, true))
assertFalse(provider.remove(App.OVERRIDE_PROXY))
assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY))
assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true))
assertFalse(provider.remove(Settings.OVERRIDE_PROXY))
}
}
@@ -8,37 +8,27 @@
package at.bitfire.davdroid.settings
import android.support.test.InstrumentationRegistry
import android.support.test.InstrumentationRegistry.getTargetContext
import at.bitfire.davdroid.App
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import org.junit.After
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SettingsTest {
lateinit var settings: Settings.Stub
lateinit var settings: Settings
@Before
fun init() {
InstrumentationRegistry.getContext().isRestricted
settings = Settings.getInstance(getTargetContext())!!
fun initialize() {
settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
}
@After
fun shutdown() {
settings.close()
}
@Test
fun testHas() {
assertFalse(settings.has("notExisting"))
// provided by DefaultsProvider
assertTrue(settings.has(App.OVERRIDE_PROXY))
assertTrue(settings.has(Settings.OVERRIDE_PROXY))
}
}
@@ -0,0 +1,31 @@
package at.bitfire.davdroid.syncadapter
import android.os.Bundle
import androidx.test.filters.SmallTest
import at.bitfire.davdroid.syncadapter.SyncAdapterService.SyncAdapter
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SyncAdapterServiceTest {
@Test
@SmallTest
fun testPriorityCollections() {
val extras = Bundle()
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "")
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123")
assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,")
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3")
assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray())
}
}
@@ -8,11 +8,11 @@
package at.bitfire.davdroid.ui.setup
import android.support.test.InstrumentationRegistry.getTargetContext
import android.support.test.filters.SmallTest
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.AddressbookHomeSet
import at.bitfire.dav4android.property.ResourceType
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.AddressbookHomeSet
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
@@ -44,18 +44,20 @@ class DavResourceFinderTest {
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
lateinit var loginInfo: LoginInfo
lateinit var loginModel: LoginModel
@Before
fun initServerAndClient() {
server.setDispatcher(TestDispatcher())
server.dispatcher = TestDispatcher()
server.start()
loginInfo = LoginInfo(URI.create("/"), Credentials("mock", "12345"))
finder = DavResourceFinder(getTargetContext(), loginInfo)
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
client = HttpClient.Builder()
.addAuthentication(null, loginInfo.credentials)
.addAuthentication(null, loginModel.credentials!!)
.build()
}
@@ -68,32 +70,25 @@ class DavResourceFinderTest {
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// before dav.propfind(), no info is available
var dav = DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(dav, info)
assertEquals(0, info.collections.size)
assertEquals(0, info.homeSets.size)
}
// recognize home set
dav.propfind(0, AddressbookHomeSet.NAME)
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(dav, info)
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/").uri(), info.homeSets.first())
var info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
// recognize address book
dav = DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
dav.propfind(0, ResourceType.NAME)
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(dav, info)
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/").uri(), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
}
@Test
@@ -117,33 +112,43 @@ class DavResourceFinderTest {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
}
@Test
fun testQueryEmailAddress() {
var info = ServiceInfo()
assertArrayEquals(
arrayOf("email1@example.com", "email2@example.com"),
finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray()
)
assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty())
}
// mock server
class TestDispatcher: Dispatcher() {
override fun dispatch(rq: RecordedRequest): MockResponse {
if (!checkAuth(rq)) {
override fun dispatch(request: RecordedRequest): MockResponse {
if (!checkAuth(request)) {
val authenticate = MockResponse().setResponseCode(401)
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
return authenticate
}
val path = rq.path
val path = request.path!!
if (rq.method.equals("OPTIONS", true)) {
if (request.method.equals("OPTIONS", true)) {
val dav = when {
path.startsWith(PATH_CALDAV) -> "calendar-access"
path.startsWith(PATH_CARDDAV) -> "addressbook"
@@ -154,7 +159,7 @@ class DavResourceFinderTest {
if (dav != null)
response.addHeader("DAV", dav)
return response
} else if (rq.method.equals("PROPFIND", true)) {
} else if (request.method.equals("PROPFIND", true)) {
val props: String?
when (path) {
PATH_CALDAV,
@@ -172,14 +177,21 @@ class DavResourceFinderTest {
" <CARD:addressbook/>" +
"</resourcetype>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
props = "<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
" <href>mailto:email1@example.com</href>" +
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
else -> props = null
}
Logger.log.info("Sending props: $props")
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${rq.path}</href>" +
" <href>${request.path}</href>" +
" <propstat><prop>$props</prop></propstat>" +
"</response>" +
"</multistatus>")
-24
View File
@@ -1,24 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEEzCCAfsCAQEwDQYJKoZIhvcNAQEFBQAwRjELMAkGA1UEBhMCQ0ExEzARBgNV
BAgMClNvbWUtU3RhdGUxEDAOBgNVBAoMB0NBIENlcnQxEDAOBgNVBAMMB0NBIENl
cnQwHhcNMTgwMTEzMjAyOTI5WhcNMTkwMTEzMjAyOTI5WjBZMQswCQYDVQQGEwJD
QTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
cyBQdHkgTHRkMRIwEAYDVQQDDAlVc2VyIENlcnQwggEiMA0GCSqGSIb3DQEBAQUA
A4IBDwAwggEKAoIBAQDqOyHAeG4psE/f6i/eTfwbhn6j7WaFXxZiSOWwpQZmzRrx
MrfkABJCk0X7KNgCaJcmBkG9G1Ri4HfKrxvJFswMXknlq+0ulGBk7oDnZM+pihuX
3D9VCWMMkCqYhLCGADj2zB2mkX4LpcMRi6XoOetKURE/vcIy7rSLAtJM6ZRdftfh
2ZxnautS1Tyujh9Au3NI/+Of80tT/nA+oBJQeT1fB/ga1OQlZP5kjSaA7IPiIbTz
QBO+r898MvqK/lwsvOYnWAp7TY03z+vPfCs0zjijZEl9Wrl0hW6o5db5kU1v5bcr
p87hxFJsGD2HIr2y6kvYfL2hn+h9iANyYdRnUgapAgMBAAEwDQYJKoZIhvcNAQEF
BQADggIBAHANsiJITedXPyp89lVMEmGY3zKtOqgQ3tqjvjlNt2sdPnj7wmZbmrNd
sa90S/UwOn8PzEFOVxYy1BPlljlEjtjmc4OHMcm4P4Zv36uawHilmK8V+zT59gCK
ftB5FP2TLFUFi2X9o8J06d0xJRE77uewN155NV4RmPuP4b/tMmeixoQppHqLqEr5
lgEUnt3Mh1ctmeFQFJR6lJ01hlB0gdpVHIhzrVLTO3uo8ePLJTmxP6tyKl/HXj9F
mpVsKb1kriKwbkGczfw99OUZeUVbTwQOR07r0SrG71B7IuDvxIORnhQc1OUjt7ob
wjdaZauAHxpGBRu+hw9Yqaxchk9Gldy1nEjGyyVCD0FU5taXbl8PhBWEDc4U9tI+
xVNmPpsSuCsbz3Mjd1YIVRGL99vLrKsQcj+TNM+jJKKRKes3ihl+l/0FwG6UuO7L
EvjlUg5hOtYi1D7xuYyMjroGBGh7swYMt6w4eCDbcjzcCkaCi0H2pScM/rLBpDjS
LIoGCvZ1LBdi933/iOj1/8dxGZwY6fEgcyiD2n0xAgYIniLWjEZXOMdIK5FNTNga
Tswanvp+6Noa4oIu/hl/LXvPMsouaWfSEbRe0Dshi3GpLj3YtEHoN9DHB8bn7jy5
34By81GT41m5kq3hWP//x9kSHYSADpbovCbKbElU1qSt6vTVR4nq
-----END CERTIFICATE-----
Binary file not shown.
+35 -35
View File
@@ -1,11 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2013 2015 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
-->
<manifest package="at.bitfire.davdroid"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
@@ -25,13 +18,6 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!--
for writing external log files; permission only required for SDK <= 18 because since then,
writing to app-private directory doesn't require extra permissions
-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
@@ -41,24 +27,27 @@
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.LOCATION -->
<!-- required since Android 8.1 to get the WiFi name (for "sync in Wifi only" feature) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- getting the WiFi name (for "sync in Wifi only") requires
- coarse location (Android 8.1)
- fine location (Android 10) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- ical4android declares task access permissions -->
<application
android:name=".App"
android:allowBackup="true"
android:fullBackupContent="false"
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppThemeExt"
android:theme="@style/AppTheme"
tools:ignore="UnusedAttribute">
<service android:name=".DavService"/>
<service android:name=".settings.Settings"/>
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".ui.AccountsActivity"
android:label="@string/app_name"
@@ -71,14 +60,20 @@
<activity
android:name=".ui.AboutActivity"
android:label="@string/app_name"
android:label="@string/navigation_drawer_about"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"/>
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.setup.LoginActivity"
@@ -90,10 +85,10 @@
</activity>
<activity
android:name=".ui.AccountActivity"
android:parentActivityName=".ui.AccountsActivity">
</activity>
<activity android:name=".ui.AccountSettingsActivity"/>
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:theme="@style/AppTheme.NoActionBar"/>
<activity android:name=".ui.account.SettingsActivity"/>
<activity android:name=".ui.CreateAddressBookActivity"
android:label="@string/create_addressbook"/>
<activity android:name=".ui.CreateCalendarActivity"
@@ -104,18 +99,27 @@
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="true"
android:label="@string/debug_info_title">
<intent-filter>
<action android:name="android.intent.action.BUG_REPORT"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="@string/authority_log_provider"
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority_debug_provider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/log_paths" />
android:resource="@xml/debug_paths" />
</provider>
<!-- account type "DAVdroid" -->
<activity
android:name=".ui.PermissionsActivity"
android:label="@string/app_settings_security_app_permissions"
android:parentActivityName=".ui.AppSettingsActivity" />
<!-- account type "DAVx⁵" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
@@ -130,7 +134,6 @@
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -143,7 +146,6 @@
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -154,7 +156,7 @@
android:resource="@xml/sync_tasks"/>
</service>
<!-- account type "DAVdroid Address book" -->
<!-- account type "DAVx⁵ Address book" -->
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="false">
@@ -175,7 +177,6 @@
<service
android:name=".syncadapter.AddressBooksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -188,7 +189,6 @@
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -1,28 +0,0 @@
package at.bitfire.davdroid.settings;
import at.bitfire.davdroid.settings.ISettingsObserver;
interface ISettings {
void forceReload();
boolean has(String key);
boolean getBoolean(String key, boolean defaultValue);
int getInt(String key, int defaultValue);
long getLong(String key, long defaultValue);
String getString(String key, String defaultValue);
boolean isWritable(String key);
boolean putBoolean(String key, boolean value);
boolean putInt(String key, int value);
boolean putLong(String key, long value);
boolean putString(String key, String value);
boolean remove(String key);
void registerObserver(ISettingsObserver observer);
void unregisterObserver(ISettingsObserver observer);
}
@@ -1,7 +0,0 @@
package at.bitfire.davdroid.settings;
interface ISettingsObserver {
void onSettingsChanged();
}
-153
View File
@@ -1,153 +0,0 @@
<h3>Apache License, Version 2.0, January 2004</h3>
<p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a> </p>
<p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
<p><strong><a name="definitions">1. Definitions</a></strong>.</p>
<p>"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.</p>
<p>"Licensor" shall mean the copyright owner or entity authorized by the
copyright owner that is granting the License.</p>
<p>"Legal Entity" shall mean the union of the acting entity and all other
entities that control, are controlled by, or are under common control with
that entity. For the purposes of this definition, "control" means (i) the
power, direct or indirect, to cause the direction or management of such
entity, whether by contract or otherwise, or (ii) ownership of fifty
percent (50%) or more of the outstanding shares, or (iii) beneficial
ownership of such entity.</p>
<p>"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.</p>
<p>"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation source,
and configuration files.</p>
<p>"Object" form shall mean any form resulting from mechanical transformation
or translation of a Source form, including but not limited to compiled
object code, generated documentation, and conversions to other media types.</p>
<p>"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that
is included in or attached to the work (an example is provided in the
Appendix below).</p>
<p>"Derivative Works" shall mean any work, whether in Source or Object form,
that is based on (or derived from) the Work and for which the editorial
revisions, annotations, elaborations, or other modifications represent, as
a whole, an original work of authorship. For the purposes of this License,
Derivative Works shall not include works that remain separable from, or
merely link (or bind by name) to the interfaces of, the Work and Derivative
Works thereof.</p>
<p>"Contribution" shall mean any work of authorship, including the original
version of the Work and any modifications or additions to that Work or
Derivative Works thereof, that is intentionally submitted to Licensor for
inclusion in the Work by the copyright owner or by an individual or Legal
Entity authorized to submit on behalf of the copyright owner. For the
purposes of this definition, "submitted" means any form of electronic,
verbal, or written communication sent to the Licensor or its
representatives, including but not limited to communication on electronic
mailing lists, source code control systems, and issue tracking systems that
are managed by, or on behalf of, the Licensor for the purpose of discussing
and improving the Work, but excluding communication that is conspicuously
marked or otherwise designated in writing by the copyright owner as "Not a
Contribution."</p>
<p>"Contributor" shall mean Licensor and any individual or Legal Entity on
behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.</p>
<p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
terms and conditions of this License, each Contributor hereby grants to You
a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of, publicly
display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.</p>
<p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
and conditions of this License, each Contributor hereby grants to You a
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, use,
offer to sell, sell, import, and otherwise transfer the Work, where such
license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by
combination of their Contribution(s) with the Work to which such
Contribution(s) was submitted. If You institute patent litigation against
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
that the Work or a Contribution incorporated within the Work constitutes
direct or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate as of the
date such litigation is filed.</p>
<p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
distribute copies of the Work or Derivative Works thereof in any medium,
with or without modifications, and in Source or Object form, provided that
You meet the following conditions:</p>
<p>a. You must give any other recipients of the Work or Derivative Works a
copy of this License; and</p>
<p>b. You must cause any modified files to carry prominent notices stating
that You changed the files; and</p>
<p>c. You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from
the Source form of the Work, excluding those notices that do not pertain to
any part of the Derivative Works; and</p>
<p>d. If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable copy
of the attribution notices contained within such NOTICE file, excluding
those notices that do not pertain to any part of the Derivative Works, in
at least one of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or documentation,
if provided along with the Derivative Works; or, within a display generated
by the Derivative Works, if and wherever such third-party notices normally
appear. The contents of the NOTICE file are for informational purposes only
and do not modify the License. You may add Your own attribution notices
within Derivative Works that You distribute, alongside or as an addendum to
the NOTICE text from the Work, provided that such additional attribution
notices cannot be construed as modifying the License.
<br/>
<br/>
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such
Derivative Works as a whole, provided Your use, reproduction, and
distribution of the Work otherwise complies with the conditions stated in
this License.
</p>
<p><strong><a name="contributions">5. Submission of Contributions</a></strong>. Unless You
explicitly state otherwise, any Contribution intentionally submitted for
inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the
terms of any separate license agreement you may have executed with Licensor
regarding such Contributions.</p>
<p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
permission to use the trade names, trademarks, service marks, or product
names of the Licensor, except as required for reasonable and customary use
in describing the origin of the Work and reproducing the content of the
NOTICE file.</p>
<p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
applicable law or agreed to in writing, Licensor provides the Work (and
each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You
are solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise
of permissions under this License.</p>
<p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
under no legal theory, whether in tort (including negligence), contract, or
otherwise, unless required by applicable law (such as deliberate and
grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a result
of this License or out of the use or inability to use the Work (including
but not limited to damages for loss of goodwill, work stoppage, computer
failure or malfunction, or any and all other commercial damages or losses),
even if such Contributor has been advised of the possibility of such
damages.</p>
<p><strong><a name="additional">9. Accepting Warranty or Additional Liability</a></strong>.
While redistributing the Work or Derivative Works thereof, You may choose
to offer, and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your own behalf
and on Your sole responsibility, not on behalf of any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor
harmless for any liability incurred by, or claims asserted against, such
Contributor by reason of your accepting any such warranty or additional
liability.</p>
<p>END OF TERMS AND CONDITIONS</p>
-28
View File
@@ -1,28 +0,0 @@
<h3>BSD License (3-clause)</h3>
<p>Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:</p>
<p>o Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.</p>
<p>o Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.</p>
<p>o Neither the name of Ben Fortuna nor the names of any other contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.</p>
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
-23
View File
@@ -1,23 +0,0 @@
<h3>BSD License</h3>
<p>Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:</p>
<p>1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.</p>
<p>2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.</p>
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
@@ -2,9 +2,9 @@
<p style="text-align: center;">Version 3, 29 June 2007</p>
<p>Copyright &copy; 2007 Free Software Foundation, Inc.
&lt;<a href="http://fsf.org/">http://fsf.org/</a>&gt;</p><p>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.</p>
&lt;<a href="http://fsf.org/">http://fsf.org/</a>&gt;</p><p>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.</p>
<h3><a name="preamble"></a>Preamble</h3>
@@ -42,16 +42,16 @@ know their rights.</p>
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.</p>
<p>For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
<p>For the developers\' and authors\' protection, the GPL clearly explains
that there is no warranty for this free software. For both users\' and
authors\' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.</p>
<p>Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
protecting users\' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
@@ -77,7 +77,7 @@ modification follow.</p>
<p>&ldquo;Copyright&rdquo; also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.</p>
<p>&ldquo;The Program&rdquo; refers to any copyrightable work licensed under this
License. Each licensee is addressed as &ldquo;you&rdquo;. &ldquo;Licensees&rdquo; and
&ldquo;recipients&rdquo; may be individuals or organizations.</p>
@@ -135,7 +135,7 @@ produce the work, or an object code interpreter used to run it.</p>
<p>The &ldquo;Corresponding Source&rdquo; for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
control those activities. However, it does not include the work\'s
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
@@ -177,7 +177,7 @@ your copyrighted material outside their relationship with you.</p>
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.</p>
<h4><a name="section3"></a>3. Protecting Users' Legal Rights From Anti-Circumvention Law.</h4>
<h4><a name="section3"></a>3. Protecting Users\' Legal Rights From Anti-Circumvention Law.</h4>
<p>No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
@@ -189,13 +189,13 @@ measures.</p>
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
modification of the work as a means of enforcing, against the work\'s
users, your or third parties\' legal rights to forbid circumvention of
technological measures.</p>
<h4><a name="section4"></a>4. Conveying Verbatim Copies.</h4>
<p>You may convey verbatim copies of the Program's source code as you
<p>You may convey verbatim copies of the Program\'s source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
@@ -240,7 +240,7 @@ works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
&ldquo;aggregate&rdquo; if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
used to limit the access or legal rights of the compilation\'s users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.</p>
@@ -462,7 +462,7 @@ organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
licenses to the work the party\'s predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.</p>
@@ -479,9 +479,9 @@ sale, or importing the Program or any portion of it.</p>
<p>A &ldquo;contributor&rdquo; is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's &ldquo;contributor version&rdquo;.</p>
work thus licensed is called the contributor\'s &ldquo;contributor version&rdquo;.</p>
<p>A contributor's &ldquo;essential patent claims&rdquo; are all patent claims
<p>A contributor\'s &ldquo;essential patent claims&rdquo; are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
@@ -492,7 +492,7 @@ patent sublicenses in a manner consistent with the requirements of
this License.</p>
<p>Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
patent license under the contributor\'s essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.</p>
@@ -513,10 +513,10 @@ patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. &ldquo;Knowingly relying&rdquo; means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
covered work in a country, or your recipient\'s use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.</p>
<p>If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
@@ -544,7 +544,7 @@ or that patent license was granted, prior to 28 March 2007.</p>
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.</p>
<h4><a name="section12"></a>12. No Surrender of Others' Freedom.</h4>
<h4><a name="section12"></a>12. No Surrender of Others\' Freedom.</h4>
<p>If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
@@ -584,7 +584,7 @@ GNU General Public License, you may choose any version ever published
by the Free Software Foundation.</p>
<p>If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU General Public License can be used, that proxy\'s
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.</p>
+1
View File
@@ -0,0 +1 @@
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","zagur"],"cs":["pavelb","tomas.odehnal"],"da":["knutztar","mjjzf","Tntdruid_","twikedk"],"de":["anestiskaci","Atalanttore","corppneq","crit12","maxkl","nicolas_git","owncube","TheName","Wyrrrd","YvanM"],"el":["anestiskaci","diamond_gr","KristinaQejvanaj"],"es":["aluaces","Ark74","Elhea","GranPC","jcvielma","plaguna","polkhas","xphnx"],"eu":["cockeredradiation","Osoitz","Thadah"],"fa":["ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","Numb","taranehsaei"],"fi_FI":["raketti","tseipii"],"fr":["AlainR","alkino2","Amadeen","boutil","callmemagnus","chfo","chrcha","Floflr","grenatrad","jokx","Jorg722","Llorc","LoiX07","mathieugfortin","Novick","Poussinou","Thecross","YvanM","ÉricB."],"fr_FR":["chrcha","Llorc","Poussinou"],"gl":["aluaces","pikamoku"],"hu":["jtg"],"it":["Damtux","ed0","FranzMari","noccio","nwandy","rickyroo","technezio"],"ja":["Naofumi"],"nb_NO":["elonus"],"nl":["davtemp","dehart","erikhubers","toonvangerwen","XtremeNova"],"pl":["gsz","mg6","oskarjakiela","TheName","TORminator"],"pt_BR":["amalvarenga","wanderlei.huttel"],"ru":["aigoshin","anm","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+48 -25
View File
@@ -11,40 +11,42 @@ package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.support.v7.app.AppCompatDelegate
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import kotlin.concurrent.thread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.logging.Level
import kotlin.system.exitProcess
class App: Application() {
@Suppress("unused")
class App: Application(), Thread.UncaughtExceptionHandler {
companion object {
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
const val OVERRIDE_PROXY = "override_proxy"
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
fun getLauncherBitmap(context: Context): Bitmap? {
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
context.getDrawable(R.mipmap.ic_launcher)
else
@Suppress("deprecation")
context.resources.getDrawable(R.mipmap.ic_launcher)
val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)
return if (drawableLogo is BitmapDrawable)
drawableLogo.bitmap
else
null
}
fun homepageUrl(context: Context) =
Uri.parse(context.getString(R.string.homepage_url)).buildUpon()
.appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
.appendQueryParameter("pk_kwd", context::class.java.simpleName)
.appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
.build()!!
}
@@ -52,23 +54,44 @@ class App: Application() {
super.onCreate()
Logger.initialize(this)
if (BuildConfig.DEBUG)
// debug builds
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build())
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
// handle uncaught exceptions in non-debug standard flavor
Thread.setDefaultUncaughtExceptionHandler(this)
if (Build.VERSION.SDK_INT <= 21)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
NotificationUtils.createChannels(this)
// don't block UI for some background checks
thread {
CoroutineScope(Dispatchers.Default).launch {
// watch installed/removed apps
val tasksFilter = IntentFilter()
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
tasksFilter.addDataScheme("package")
registerReceiver(PackageChangedReceiver(), tasksFilter)
OpenTasksWatcher(this@App)
// check whether a tasks app is currently installed
PackageChangedReceiver.updateTaskSync(this)
OpenTasksWatcher.updateTaskSync(this@App)
}
}
override fun uncaughtException(t: Thread, e: Throwable) {
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
val intent = Intent(this, DebugInfoActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
startActivity(intent)
exitProcess(1)
}
}
@@ -0,0 +1,12 @@
package at.bitfire.davdroid
import android.content.ContentProviderClient
import android.os.Build
@Suppress("DEPRECATION")
fun ContentProviderClient.closeCompat() {
if (Build.VERSION.SDK_INT >= 24)
close()
else
release()
}
@@ -13,4 +13,18 @@ object Constants {
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
/**
* Context label for [org.apache.commons.lang3.exception.ContextedException].
* Context value is the [at.bitfire.davdroid.resource.LocalResource]
* which is related to the exception cause.
*/
const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource"
/**
* Context label for [org.apache.commons.lang3.exception.ContextedException].
* Context value is the [okhttp3.HttpUrl] of the remote resource
* which is related to the exception cause.
*/
const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource"
}
@@ -1,165 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid
import android.os.Build
import at.bitfire.davdroid.log.Logger
import java.io.IOException
import java.net.InetAddress
import java.net.Socket
import java.security.GeneralSecurityException
import java.util.*
import javax.net.ssl.*
/**
* Custom TLS socket factory with support for
* - enabling/disabling algorithms depending on the Android version,
* - client certificate authentication
*/
class CustomTlsSocketFactory(
keyManager: KeyManager?,
trustManager: X509TrustManager
): SSLSocketFactory() {
private var delegate: SSLSocketFactory
companion object {
// Android 5.0+ (API level 21) provides reasonable default settings
// but it still allows SSLv3
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
var protocols: Array<String>? = null
var cipherSuites: Array<String>? = null
init {
if (Build.VERSION.SDK_INT >= 23) {
// Since Android 6.0 (API level 23),
// - TLSv1.1 and TLSv1.2 is enabled by default
// - SSLv3 is disabled by default
// - all modern ciphers are activated by default
protocols = null
cipherSuites = null
Logger.log.fine("Using device default TLS protocols/ciphers")
} else {
(SSLSocketFactory.getDefault().createSocket() as? SSLSocket)?.use { socket ->
try {
/* set reasonable protocol versions */
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
// - remove all SSL versions (especially SSLv3) because they're insecure now
val _protocols = LinkedList<String>()
for (protocol in socket.supportedProtocols.filterNot { it.contains("SSL", true) })
_protocols += protocol
Logger.log.info("Enabling (only) these TLS protocols: ${_protocols.joinToString(", ")}")
protocols = _protocols.toTypedArray()
/* set up reasonable cipher suites */
val knownCiphers = arrayOf(
// TLS 1.2
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
// maximum interoperability
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"SSL_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
// additionally
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"
)
val availableCiphers = socket.supportedCipherSuites
Logger.log.info("Available cipher suites: ${availableCiphers.joinToString(", ")}")
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus
* disabling ciphers which are enabled by default, but have become unsecure), but for
* the security level of DAVdroid and maximum compatibility, disabling of insecure
* ciphers should be a server-side task */
// for the final set of enabled ciphers, take the ciphers enabled by default, ...
val _cipherSuites = LinkedList<String>()
_cipherSuites.addAll(socket.enabledCipherSuites)
Logger.log.fine("Cipher suites enabled by default: ${_cipherSuites.joinToString(", ")}")
// ... add explicitly allowed ciphers ...
_cipherSuites.addAll(knownCiphers)
// ... and keep only those which are actually available
_cipherSuites.retainAll(availableCiphers)
Logger.log.info("Enabling (only) these TLS ciphers: " + _cipherSuites.joinToString(", "))
cipherSuites = _cipherSuites.toTypedArray()
} catch (e: IOException) {
Logger.log.severe("Couldn't determine default TLS settings")
}
}
}
}
}
init {
try {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
delegate = sslContext.socketFactory
} catch (e: GeneralSecurityException) {
throw IllegalStateException() // system has no TLS
}
}
override fun getDefaultCipherSuites(): Array<String>? = cipherSuites ?: delegate.defaultCipherSuites
override fun getSupportedCipherSuites(): Array<String>? = cipherSuites ?: delegate.supportedCipherSuites
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket {
val ssl = delegate.createSocket(s, host, port, autoClose)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: String, port: Int): Socket {
val ssl = delegate.createSocket(host, port)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
val ssl = delegate.createSocket(host, port, localHost, localPort)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: InetAddress, port: Int): Socket {
val ssl = delegate.createSocket(host, port)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
val ssl = delegate.createSocket(address, port, localAddress, localPort)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
private fun upgradeTLS(ssl: SSLSocket) {
protocols?.let { ssl.enabledProtocols = it }
cipherSuites?.let { ssl.enabledCipherSuites = it }
}
}
@@ -10,38 +10,34 @@ package at.bitfire.davdroid
import android.accounts.Account
import android.app.PendingIntent
import android.app.Service
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.os.Binder
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.property.*
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.room.Transaction
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.model.*
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import org.apache.commons.collections4.iterators.IteratorChain
import org.apache.commons.collections4.iterators.SingletonIterator
import java.io.IOException
import okhttp3.OkHttpClient
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
import kotlin.concurrent.thread
class DavService: Service() {
class DavService: android.app.Service() {
companion object {
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
@@ -52,6 +48,16 @@ class DavService: Service() {
contents://<authority>/<account.type>/<account name>
**/
const val ACTION_FORCE_SYNC = "forceSync"
val DAV_COLLECTION_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
)
}
private val runningRefresh = HashSet<Long>()
@@ -65,15 +71,20 @@ class DavService: Service() {
when (intent.action) {
ACTION_REFRESH_COLLECTIONS ->
if (runningRefresh.add(id)) {
thread { refreshCollections(id) }
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
refreshingStatusListeners.forEach { listener ->
listener.get()?.onDavRefreshStatusChanged(id, true)
}
CoroutineScope(Dispatchers.IO).launch {
refreshCollections(id)
}
}
ACTION_FORCE_SYNC -> {
val authority = intent.data.authority
val uri = intent.data!!
val authority = uri.authority!!
val account = Account(
intent.data.pathSegments[1],
intent.data.pathSegments[0]
uri.pathSegments[1],
uri.pathSegments[0]
)
forceSync(authority, account)
}
@@ -97,9 +108,9 @@ class DavService: Service() {
inner class InfoBinder: Binder() {
fun isRefreshing(id: Long) = runningRefresh.contains(id)
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) {
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
if (callImmediate)
if (callImmediateIfRunning)
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
}
@@ -129,243 +140,240 @@ class DavService: Service() {
ContentResolver.requestSync(account, authority, extras)
}
private fun refreshCollections(service: Long) {
OpenHelper(this@DavService).use { dbHelper ->
val db = dbHelper.writableDatabase
private fun refreshCollections(serviceId: Long) {
val db = AppDatabase.getInstance(this)
val homeSetDao = db.homeSetDao()
val collectionDao = db.collectionDao()
val serviceType by lazy {
db.query(Services._TABLE, arrayOf(Services.SERVICE), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return@lazy cursor.getString(0)
} ?: throw IllegalArgumentException("Service not found")
}
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, getString(R.string.account_type))
val account by lazy {
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return@lazy Account(cursor.getString(0), getString(R.string.account_type))
}
throw IllegalArgumentException("Account not found")
}
val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
val homeSets by lazy {
val homeSets = mutableSetOf<HttpUrl>()
db.query(HomeSets._TABLE, arrayOf(HomeSets.URL), "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext())
HttpUrl.parse(cursor.getString(0))?.let { homeSets += it }
}
homeSets
}
/**
* Checks if the given URL defines home sets and adds them to the home set list.
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
*/
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
val related = mutableSetOf<HttpUrl>()
val collections by lazy {
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
db.query(Collections._TABLE, null, "${Collections.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, values)
values.getAsString(Collections.URL)?.let { url ->
HttpUrl.parse(url)?.let { collections.put(it, CollectionInfo(values)) }
fun findRelated(root: HttpUrl, dav: Response) {
// refresh home sets: calendar-proxy-read/write-for
dav[CalendarProxyReadFor::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
root.resolve(href)?.let { proxyReadFor ->
related += proxyReadFor
}
}
}
collections
}
fun readPrincipal(): HttpUrl? {
db.query(Services._TABLE, arrayOf(Services.PRINCIPAL), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let { return HttpUrl.parse(it) }
}
return null
}
/**
* Checks if the given URL defines home sets and adds them to the home set list.
* @param dav DavResource to check
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun queryHomeSets(dav: DavResource) {
when (serviceType) {
Services.SERVICE_CARDDAV -> {
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
for ((resource, addressbookHomeSet) in dav.findProperties(AddressbookHomeSet::class.java))
for (href in addressbookHomeSet.hrefs)
resource.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
dav[CalendarProxyWriteFor::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
root.resolve(href)?.let { proxyWriteFor ->
related += proxyWriteFor
}
}
Services.SERVICE_CALDAV -> {
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
for ((resource, calendarHomeSet) in dav.findProperties(CalendarHomeSet::class.java))
for (href in calendarHomeSet.hrefs)
resource.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
}
// refresh home sets: direct group memberships
dav[GroupMembership::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
root.resolve(href)?.let { groupMembership ->
related += groupMembership
}
}
}
}
fun saveHomeSets() {
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
for (homeSet in homeSets) {
val values = ContentValues(2)
values.put(HomeSets.SERVICE_ID, service)
values.put(HomeSets.URL, homeSet.toString())
db.insertOrThrow(HomeSets._TABLE, null, values)
}
}
fun saveCollections() {
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
for ((_,collection) in collections) {
val values = collection.toDB()
Logger.log.log(Level.FINE, "Saving collection", values)
values.put(Collections.SERVICE_ID, service)
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
}
try {
Logger.log.info("Refreshing $serviceType collections of service #$service")
Settings.getInstance(this)?.use { settings ->
// create authenticating OkHttpClient (credentials taken from account settings)
HttpClient.Builder(this, settings, AccountSettings(this, settings, account))
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
// refresh home set list (from principal)
readPrincipal()?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
val principal = DavResource(httpClient, principalUrl)
queryHomeSets(principal)
// refresh home sets: calendar-proxy-read/write-for
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor::class.java))
for (href in proxyRead.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
}
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor::class.java))
for (href in proxyWrite.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
}
// refresh home sets: direct group memberships
principal.properties[GroupMembership::class.java]?.let { groupMembership ->
for (href in groupMembership.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
principal.location.resolve(href)?.let { url ->
try {
queryHomeSets(DavResource(httpClient, url))
} catch(e: HttpException) {
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
} catch(e: DavException) {
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
}
val dav = DavResource(client, url)
when (service.type) {
Service.TYPE_CARDDAV ->
try {
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
response[AddressbookHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
}
}
}
if (recurse)
findRelated(dav.location, response)
}
// remember selected collections
val selectedCollections = HashSet<HttpUrl>()
collections.values
.filter { it.selected }
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
// now refresh collections (taken from home sets)
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val homeSetUrl = itHomeSets.next()
Logger.log.fine("Listing home set $homeSetUrl")
val homeSet = DavResource(httpClient, homeSetUrl)
try {
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
val itCollections = IteratorChain<DavResource>(homeSet.members.iterator(), homeSet.related.iterator(), SingletonIterator(homeSet))
while (itCollections.hasNext()) {
val member = itCollections.next()
val info = CollectionInfo(member)
info.confirmed = true
Logger.log.log(Level.FINE, "Found collection", info)
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
collections[member.location] = info
}
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
}
// check/refresh unconfirmed collections
val itCollections = collections.entries.iterator()
while (itCollections.hasNext()) {
val (url, info) = itCollections.next()
if (!info.confirmed)
try {
val collection = DavResource(httpClient, url)
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
val info = CollectionInfo(collection)
info.confirmed = true
// remove unusable collections
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
itCollections.remove()
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
throw e
}
}
// restore selections
for (url in selectedCollections)
collections[url]?.let { it.selected = true }
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e)
else
throw e
}
Service.TYPE_CALDAV -> {
try {
dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
response[CalendarHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
}
}
if (recurse)
findRelated(dav.location, response)
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e)
else
throw e
}
}
}
for (resource in related)
queryHomeSets(client, resource, false)
}
@Transaction
fun saveHomesets() {
DaoTools(homeSetDao).syncAll(
homeSetDao.getByService(serviceId),
homeSets,
{ it.url })
}
@Transaction
fun saveCollections() {
DaoTools(collectionDao).syncAll(
collectionDao.getByService(serviceId),
collections, { it.url }) { new, old ->
new.forceReadOnly = old.forceReadOnly
new.sync = old.sync
}
}
fun saveResults() {
saveHomesets()
saveCollections()
}
try {
Logger.log.info("Refreshing ${service.type} collections of service #$service")
// cancel previous notification
NotificationManagerCompat.from(this)
.cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
HttpClient.Builder(this, AccountSettings(this, account))
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
// refresh home set list (from principal)
service.principal?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
queryHomeSets(httpClient, principalUrl)
}
db.beginTransactionNonExclusive()
try {
saveHomeSets()
saveCollections()
db.setTransactionSuccessful()
} finally {
db.endTransaction()
// now refresh homesets and their member collections
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val homeSet = itHomeSets.next()
Logger.log.fine("Listing home set ${homeSet.key}")
try {
DavResource(httpClient, homeSet.key).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
homeSet.value.displayName = response[DisplayName::class.java]?.displayName
homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
}
// in any case, check whether the response is about a useable collection
val info = Collection.fromDavResponse(response) ?: return@propfind
info.serviceId = serviceId
info.confirmed = true
Logger.log.log(Level.FINE, "Found collection", info)
// remember usable collections
if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type)))
collections[response.href] = info
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
}
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
// check/refresh unconfirmed collections
val itCollections = collections.entries.iterator()
while (itCollections.hasNext()) {
val (url, info) = itCollections.next()
if (!info.confirmed)
try {
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess())
return@propfind
val debugIntent = Intent(this, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.confirmed = true
val notify = NotificationUtils.newBuilder(this)
.setSmallIcon(R.drawable.ic_sync_error_notification)
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
NotificationManagerCompat.from(this)
.notify(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
} finally {
runningRefresh.remove(service)
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
// remove unusable collections
if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source == null))
itCollections.remove()
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
throw e
}
}
}
saveResults()
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
val debugIntent = Intent(this, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
NotificationManagerCompat.from(this)
.notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
} finally {
runningRefresh.remove(serviceId)
refreshingStatusListeners.mapNotNull { it.get() }.forEach {
it.onDavRefreshStatusChanged(serviceId, false)
}
}
}
}
}
@@ -8,11 +8,16 @@
package at.bitfire.davdroid
import android.accounts.Account
import android.annotation.TargetApi
import android.content.ContentResolver
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.davdroid.log.Logger
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import org.xbill.DNS.*
import java.util.*
@@ -22,18 +27,18 @@ import java.util.*
*/
object DavUtils {
@Suppress("FunctionName")
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
val alpha = (colorWithAlpha shr 24) and 0xFF
val color = colorWithAlpha and 0xFFFFFF
return String.format("#%06X%02X", color, alpha)
}
fun lastSegmentOfUrl(url: String): String {
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
fun lastSegmentOfUrl(url: HttpUrl): String {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
val segments = LinkedList<String>(httpUrl.pathSegments())
Collections.reverse(segments)
val segments = LinkedList<String>(url.pathSegments)
segments.reverse()
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
}
@@ -47,14 +52,18 @@ object DavUtils {
get the active DNS servers). */
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
val simpleResolvers = activeLink.dnsServers.map {
Logger.log.fine("Using DNS server ${it.hostAddress}")
val resolver = SimpleResolver()
resolver.setAddress(it)
resolver
}
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
lookup.setResolver(resolver)
if (activeLink != null) {
// get DNS servers of active network link and set them for dnsjava so that it can send SRV queries
val simpleResolvers = activeLink.dnsServers.map {
Logger.log.fine("Using DNS server ${it.hostAddress}")
val resolver = SimpleResolver()
resolver.setAddress(it)
resolver
}
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
lookup.setResolver(resolver)
} else
Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work")
}
}
@@ -81,4 +90,19 @@ object DavUtils {
return paths
}
fun requestSync(context: Context, account: Account) {
val authorities = arrayOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.authority
)
for (authority in authorities) {
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
}
}
}
@@ -12,15 +12,14 @@ import android.content.Context
import android.os.Build
import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4android.BasicDigestAuthHandler
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.ISettings
import okhttp3.Cache
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import okhttp3.*
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
@@ -29,14 +28,11 @@ import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.KeyManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
import javax.net.ssl.*
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
@@ -44,34 +40,48 @@ class HttpClient private constructor(
): AutoCloseable {
companion object {
/** max. size of disk cache (10 MB) */
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
/** [OkHttpClient] singleton to build all clients from */
val sharedClient = OkHttpClient.Builder()
val sharedClient: OkHttpClient = OkHttpClient.Builder()
// set timeouts
.connectTimeout(30, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
.connectionSpecs(listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.COMPATIBLE_TLS
))
// don't allow redirects by default, because it would break PROPFIND handling
.followRedirects(false)
// offer Brotli and gzip compression
.addInterceptor(BrotliInterceptor)
// add User-Agent to every request
.addNetworkInterceptor(UserAgentInterceptor)
.build()!!
.build()
}
override fun close() {
okHttpClient.cache?.close()
certManager?.close()
}
class Builder(
val context: Context? = null,
val settings: ISettings? = null,
accountSettings: AccountSettings? = null,
val logger: java.util.logging.Logger = Logger.log
) {
private var certManager: CustomCertManager? = null
private var certificateAlias: String? = null
private var cache: Cache? = null
private val orig = sharedClient.newBuilder()
@@ -81,39 +91,44 @@ class HttpClient private constructor(
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
message -> logger.finest(message)
val loggingInterceptor = HttpLoggingInterceptor(object: HttpLoggingInterceptor.Logger {
override fun log(message: String) {
logger.finest(message)
}
})
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
orig.addInterceptor(loggingInterceptor)
}
settings?.let {
context?.let {
val settings = Settings.getInstance(context)
// custom proxy support
try {
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) {
val address = InetSocketAddress(
settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT),
settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
settings.getString(Settings.OVERRIDE_PROXY_HOST)
?: Settings.OVERRIDE_PROXY_HOST_DEFAULT,
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
)
val proxy = Proxy(Proxy.Type.HTTP, address)
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy", proxy)
}
} catch(e: Exception) {
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
context?.let {
if (BuildConfig.customCerts)
customCertManager(CustomCertManager(context, true, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
customCertManager(CustomCertManager(context, true /*BuildConfig.customCertsUI*/,
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT)))
}
// use account settings for authentication
accountSettings?.let {
addAuthentication(null, it.credentials())
}
}
// use account settings for authentication
accountSettings?.let {
addAuthentication(null, it.credentials())
}
}
@@ -123,12 +138,12 @@ class HttpClient private constructor(
fun withDiskCache(): Builder {
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
for (dir in arrayOf(context.externalCacheDir, context.cacheDir)) {
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
Logger.log.fine("Using disk cache: $cacheDir")
orig.cache(Cache(cacheDir, 10*1024*1024))
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
break
}
}
@@ -169,18 +184,20 @@ class HttpClient private constructor(
factory.trustManagers.first() as X509TrustManager
}()
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
?: OkHostnameVerifier.INSTANCE
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier)
?: OkHostnameVerifier
var keyManager: KeyManager? = null
try {
certificateAlias?.let { alias ->
// get client certificate and private key
certificateAlias?.let { alias ->
try {
val context = requireNotNull(context)
// get provider certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})")
logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
// create Android KeyStore (performs key operations without revealing secret data to DAVdroid)
// create Android KeyStore (performs key operations without revealing secret data to DAVx5)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
@@ -201,12 +218,21 @@ class HttpClient private constructor(
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
orig.protocols(listOf(Protocol.HTTP_1_1))
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e)
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e)
}
orig.sslSocketFactory(CustomTlsSocketFactory(keyManager, trustManager), trustManager)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
orig.hostnameVerifier(hostnameVerifier)
return HttpClient(orig.build(), certManager)
@@ -216,9 +242,11 @@ class HttpClient private constructor(
private object UserAgentInterceptor: Interceptor {
private val userAgentDate = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US).format(Date(BuildConfig.buildTime))
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
@@ -32,7 +32,7 @@ class MemoryCookieStore: CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(storage) {
for (cookie in cookies)
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie)
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
}
}
@@ -46,7 +46,7 @@ class MemoryCookieStore: CookieJar {
val cookie = iter.value
// remove expired cookies
if (cookie.expiresAt() <= System.currentTimeMillis()) {
if (cookie.expiresAt <= System.currentTimeMillis()) {
iter.remove()
continue
}
@@ -0,0 +1,77 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class OpenTasksWatcher(
context: Context
): PackageChangedReceiver(context) {
companion object {
@WorkerThread
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("App was launched or package was (in)installed; OpenTasks provider now available = $tasksInstalled")
var enabledAnyAccount = false
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
val db = AppDatabase.getInstance(context)
for (service in db.serviceDao().getByType(Service.TYPE_CALDAV)) {
val account = Account(service.accountName, context.getString(R.string.account_type))
try {
val accountSettings = AccountSettings(context, account)
val currentSyncable = ContentResolver.getIsSyncable(account, OpenTasks.authority)
if (tasksInstalled) {
if (currentSyncable <= 0) {
Logger.log.info("Enabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, OpenTasks.authority, 1)
accountSettings.setSyncInterval(OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL)
enabledAnyAccount = true
}
} else if (currentSyncable != 0) {
Logger.log.info("Disabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
}
} catch (e: InvalidAccountException) {
// account which is still mentioned in DB doesn't exist (anymore)
}
}
if (enabledAnyAccount && !PermissionUtils.havePermissions(context, PermissionUtils.TASKS_PERMISSIONS)) {
Logger.log.warning("Tasks sync is now enabled for at least one account, but OpenTasks permissions are not granted")
PermissionUtils.notifyPermissions(context, null)
}
}
}
override fun onReceive(context: Context, intent: Intent) {
CoroutineScope(Dispatchers.Default).launch {
updateTaskSync(context)
}
}
}
@@ -1,64 +1,25 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider
import android.content.IntentFilter
class PackageChangedReceiver: BroadcastReceiver() {
abstract class PackageChangedReceiver(
val context: Context
): BroadcastReceiver(), AutoCloseable {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED)
updateTaskSync(context)
}
companion object {
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
if (tasksInstalled) {
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
}
} else
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
}
}
}
init {
val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
addAction(Intent.ACTION_PACKAGE_CHANGED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
context.registerReceiver(this, filter)
}
}
override fun close() {
context.unregisterReceiver(this)
}
}
@@ -0,0 +1,72 @@
package at.bitfire.davdroid
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.PermissionsActivity
import at.bitfire.ical4android.TaskProvider
object PermissionUtils {
val CONTACT_PERMSSIONS = arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
)
val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR
)
val TASKS_PERMISSIONS = arrayOf(
TaskProvider.PERMISSION_READ_TASKS,
TaskProvider.PERMISSION_WRITE_TASKS
)
/**
* Checks whether at least one of the given permissions is granted.
*
* @param context context to check
* @param permissions array of permissions to check
*
* @return whether at least one of [permissions] is granted
*/
fun haveAnyPermission(context: Context, permissions: Array<String>) =
permissions.any { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
/**
* Checks whether all given permissions are granted.
*
* @param context context to check
* @param permissions array of permissions to check
*
* @return whether all [permissions] are granted
*/
fun havePermissions(context: Context, permissions: Array<String>) =
permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
/**
* Shows a notification about missing permissions.
*
* @param context notification context
* @param intent will be set as content Intent; if null, an Intent to launch PermissionsActivity will be used
*/
fun notifyPermissions(context: Context, intent: Intent?) {
val contentIntent = intent ?: Intent(context, PermissionsActivity::class.java)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(context.getString(R.string.sync_error_permissions))
.setContentText(context.getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context)
.notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
}
}
@@ -8,45 +8,51 @@
package at.bitfire.davdroid.log
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Process
import android.preference.PreferenceManager
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import org.apache.commons.lang3.time.DateFormatUtils
import java.io.File
import java.io.IOException
import java.util.logging.FileHandler
import java.util.logging.Level
object Logger {
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
const val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
val log = java.util.logging.Logger.getLogger("davdroid")!!
private const val LOG_TO_FILE = "log_to_file"
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger("davx5")
private lateinit var context: Application
private lateinit var preferences: SharedPreferences
fun initialize(context: Context) {
fun initialize(appContext: Application) {
context = appContext
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener { _, s ->
if (s == LOG_TO_EXTERNAL_STORAGE)
reinitialize(context.applicationContext)
}
preferences.registerOnSharedPreferenceChangeListener(this)
reinitialize(context.applicationContext)
reinitialize()
}
private fun reinitialize(context: Context) {
val logToFile = preferences.getBoolean(LOG_TO_EXTERNAL_STORAGE, false)
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (key == LOG_TO_FILE) {
log.info("Logging settings changed; re-initializing logger")
reinitialize()
}
}
private fun reinitialize() {
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
log.info("Verbose logging: $logVerbose; to file: $logToFile")
@@ -64,43 +70,67 @@ object Logger {
// log to external file according to preferences
if (logToFile) {
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_storage_notification)
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true)
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.logging_notification_title))
val dir = context.getExternalFilesDir(null)
if (dir != null)
try {
val fileName = File(dir, "davdroid-${Process.myPid()}-${DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss")}.txt").toString()
log.info("Logging to $fileName")
val logDir = debugDir(context) ?: return
val logFile = File(logDir, "davx5-log.txt")
val fileHandler = FileHandler(fileName)
fileHandler.formatter = PlainTextFormatter.DEFAULT
rootLogger.addHandler(fileHandler)
try {
val fileHandler = FileHandler(logFile.toString(), true)
fileHandler.formatter = PlainTextFormatter.DEFAULT
rootLogger.addHandler(fileHandler)
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_EXTERNAL_STORAGE)
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
builder .setContentText(dir.path)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
.setOngoing(true)
builder .setContentText(logDir.path)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(context.getString(R.string.logging_notification_text))
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setOngoing(true)
} catch(e: IOException) {
log.log(Level.SEVERE, "Couldn't create external log file", e)
val message = context.getString(R.string.logging_couldnt_create_file, e.localizedMessage)
builder .setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setCategory(NotificationCompat.CATEGORY_ERROR)
}
else
builder.setContentText(context.getString(R.string.logging_no_external_storage))
// add "Share" action
val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile)
log.fine("Now logging to file: $logFile -> $logFileUri")
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs")
shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
shareIntent.type = "text/plain"
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
val chooserIntent = Intent.createChooser(shareIntent, null)
val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_notify,
context.getString(R.string.logging_notification_send_log),
PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT))
builder.addAction(shareAction.build())
} catch(e: IOException) {
log.log(Level.SEVERE, "Couldn't create log file", e)
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
}
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
} else
} else {
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
// delete old logs
debugDir(context)?.deleteRecursively()
}
}
private fun debugDir(context: Context): File? {
val dir = File(context.filesDir, "debug")
if (dir.exists() && dir.isDirectory)
return dir
if (dir.mkdir())
return dir
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
return null
}
}
@@ -0,0 +1,224 @@
package at.bitfire.davdroid.model
import android.content.Context
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.log.Logger
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
Collection::class
], version = 7)
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
abstract fun serviceDao(): ServiceDao
abstract fun homeSetDao(): HomeSetDao
abstract fun collectionDao(): CollectionDao
companion object {
private var INSTANCE: AppDatabase? = null
@Synchronized
fun getInstance(context: Context): AppDatabase {
INSTANCE?.let { return it }
val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(
Migration1_2,
Migration2_3,
Migration3_4,
Migration4_5,
Migration5_6,
Migration6_7
)
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.build()
INSTANCE = db
return db
}
}
fun dump(sb: StringBuilder) {
val db = openHelper.readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
while (cursorTables.moveToNext()) {
val table = cursorTables.getString(0)
sb.append(table).append("\n")
db.query("SELECT * FROM $table").use { cursor ->
// print columns
val cols = cursor.columnCount
sb.append("\t| ")
for (i in 0 until cols)
sb .append(" ")
.append(cursor.getColumnName(i))
.append(" |")
sb.append("\n")
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ")
for (i in 0 until cols) {
sb.append(" ")
try {
val value = cursor.getString(i)
if (value != null)
sb.append(value
.replace("\r", "<CR>")
.replace("\n", "<LF>"))
else
sb.append("<null>")
} catch (e: SQLiteException) {
sb.append("<unprintable>")
}
sb.append(" |")
}
sb.append("\n")
}
sb.append("----------\n")
}
}
db.endTransaction()
}
}
// migrations
object Migration6_7: 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 Migration5_6: 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 Migration4_5: 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 Migration3_4: Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
}
object Migration2_3: Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// We don't have access to the context in a Room migration now, so
// we will just drop those settings from old DAVx5 versions.
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
try {
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
when (cursor.getString(0)) {
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
}
}
}
db.execSQL("DROP TABLE settings")
} finally {
edit.apply()
}*/
}
}
object Migration1_2: 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"))
}
}
}
@@ -0,0 +1,160 @@
package at.bitfire.davdroid.model
import androidx.room.*
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
],
indices = [
Index("serviceId","type")
]
)
data class Collection(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
var serviceId: Long = 0,
var type: String,
var url: HttpUrl,
var privWriteContent: Boolean = true,
var privUnbind: Boolean = true,
var forceReadOnly: Boolean = false,
var displayName: String? = null,
var description: String? = null,
// CalDAV only
var color: Int? = null,
/** timezone definition (full VTIMEZONE) - not a TZID! **/
var timezone: String? = null,
/** whether the collection supports VEVENT; in case of calendars: null means true */
var supportsVEVENT: Boolean? = null,
/** whether the collection supports VTODO; in case of calendars: null means true */
var supportsVTODO: Boolean? = null,
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
var supportsVJOURNAL: Boolean? = null,
/** Webcal subscription source URL */
var source: HttpUrl? = null,
/** whether this collection has been selected for synchronization */
var sync: Boolean = false
): IdEntity() {
companion object {
const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK"
const val TYPE_CALENDAR = "CALENDAR"
const val TYPE_WEBCAL = "WEBCAL"
/**
* Generates a collection entity from a WebDAV response.
* @param dav WebDAV response
* @return null if the response doesn't represent a collection
*/
fun fromDavResponse(dav: Response): Collection? {
val url = UrlUtils.withTrailingSlash(dav.href)
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
when {
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
else -> null
}
} ?: return null
var privWriteContent = true
var privUnbind = true
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
privWriteContent = privilegeSet.mayWriteContent
privUnbind = privilegeSet.mayUnbind
}
var displayName: String? = null
dav[DisplayName::class.java]?.let {
if (!it.displayName.isNullOrEmpty())
displayName = it.displayName
}
var description: String? = null
var color: Int? = null
var timezone: String? = null
var supportsVEVENT: Boolean? = null
var supportsVTODO: Boolean? = null
var supportsVJOURNAL: Boolean? = null
var source: HttpUrl? = null
when (type) {
TYPE_ADDRESSBOOK -> {
dav[AddressbookDescription::class.java]?.let { description = it.description }
}
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 }
if (type == TYPE_CALENDAR) {
supportsVEVENT = true
supportsVTODO = true
supportsVJOURNAL = true
dav[SupportedCalendarComponentSet::class.java]?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
supportsVJOURNAL = it.supportsJournal
}
} else { // Type.WEBCAL
dav[Source::class.java]?.let {
source = it.hrefs.firstOrNull()?.let { rawHref ->
val href = rawHref
.replace("^webcal://".toRegex(), "http://")
.replace("^webcals://".toRegex(), "https://")
href.toHttpUrlOrNull()
}
}
supportsVEVENT = true
}
}
}
return Collection(
type = type,
url = url,
privWriteContent = privWriteContent,
privUnbind = privUnbind,
displayName = displayName,
description = description,
color = color,
timezone = timezone,
supportsVEVENT = supportsVEVENT,
supportsVTODO = supportsVTODO,
supportsVJOURNAL = supportsVJOURNAL,
source = source
)
}
}
// non-persistent properties
@Ignore
var confirmed: Boolean = false
// calculated properties
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
fun readOnly() = forceReadOnly || !privWriteContent
}
@@ -0,0 +1,43 @@
package at.bitfire.davdroid.model
import androidx.lifecycle.LiveData
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface CollectionDao: SyncableDao<Collection> {
@Query("SELECT * FROM collection WHERE id=:id")
fun get(id: Long): Collection?
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url")
fun getByServiceAndSync(serviceId: Long): List<Collection>
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync")
fun observeHasSyncByService(serviceId: Long): LiveData<Boolean>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url")
fun getSyncCalendars(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url")
fun getSyncTaskLists(serviceId: Long): List<Collection>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(collection: Collection)
@Insert
fun insert(collection: Collection)
}
@@ -1,162 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.model
import android.content.ContentValues
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import java.io.Serializable
data class CollectionInfo(
val url: String,
var id: Long? = null,
var serviceID: Long? = null,
var type: Type? = null,
var readOnly: Boolean = false,
var forceReadOnly: Boolean = false,
var displayName: String? = null,
var description: String? = null,
var color: Int? = null,
var timeZone: String? = null,
var supportsVEVENT: Boolean = false,
var supportsVTODO: Boolean = false,
var selected: Boolean = false,
// subscriptions
var source: String? = null,
// non-persistent properties
var confirmed: Boolean = false
): Serializable {
enum class Type {
ADDRESS_BOOK,
CALENDAR,
WEBCAL // iCalendar subscription
}
companion object {
val DAV_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
)
}
constructor(dav: DavResource): this(dav.location.toString()) {
dav.properties[ResourceType::class.java]?.let { type ->
when {
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL
}
}
dav.properties[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
readOnly = !privilegeSet.mayWriteContent
}
dav.properties[DisplayName::class.java]?.let {
if (!it.displayName.isNullOrEmpty())
displayName = it.displayName
}
when (type) {
Type.ADDRESS_BOOK -> {
dav.properties[AddressbookDescription::class.java]?.let { description = it.description }
}
Type.CALENDAR, Type.WEBCAL -> {
dav.properties[CalendarDescription::class.java]?.let { description = it.description }
dav.properties[CalendarColor::class.java]?.let { color = it.color }
dav.properties[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone }
if (type == Type.CALENDAR) {
supportsVEVENT = true
supportsVTODO = true
dav.properties[SupportedCalendarComponentSet::class.java]?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
}
} else { // Type.WEBCAL
dav.properties[Source::class.java]?.let { source = it.hrefs.firstOrNull() }
supportsVEVENT = true
}
}
}
}
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
id = values.getAsLong(Collections.ID)
serviceID = values.getAsLong(Collections.SERVICE_ID)
type = try {
Type.valueOf(values.getAsString(Collections.TYPE))
} catch (e: Exception) {
null
}
readOnly = values.getAsInteger(Collections.READ_ONLY) != 0
forceReadOnly = values.getAsInteger(Collections.FORCE_READ_ONLY) != 0
displayName = values.getAsString(Collections.DISPLAY_NAME)
description = values.getAsString(Collections.DESCRIPTION)
color = values.getAsInteger(Collections.COLOR)
timeZone = values.getAsString(Collections.TIME_ZONE)
supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false
supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false
source = values.getAsString(Collections.SOURCE)
selected = values.getAsInteger(Collections.SYNC) != 0
}
fun toDB(): ContentValues {
val values = ContentValues()
// Collections.SERVICE_ID is never changed
type?.let { values.put(Collections.TYPE, it.name) }
values.put(Collections.URL, url)
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
values.put(Collections.FORCE_READ_ONLY, if (forceReadOnly) 1 else 0)
values.put(Collections.DISPLAY_NAME, displayName)
values.put(Collections.DESCRIPTION, description)
values.put(Collections.COLOR, color)
values.put(Collections.TIME_ZONE, timeZone)
values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0)
values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0)
values.put(Collections.SOURCE, source)
values.put(Collections.SYNC, if (selected) 1 else 0)
return values
}
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
val i = values.getAsInteger(field)
return if (i == null)
null
else
(i != 0)
}
}
@@ -0,0 +1,17 @@
package at.bitfire.davdroid.model
import androidx.room.TypeConverter
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
@TypeConverter
fun stringToHttpUrl(url: String?): HttpUrl? =
url?.let { it.toHttpUrlOrNull() }
}
@@ -8,13 +8,11 @@
package at.bitfire.davdroid.model
import java.io.Serializable
class Credentials(
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null
): Serializable {
) {
enum class Type {
UsernamePassword,
@@ -0,0 +1,41 @@
package at.bitfire.davdroid.model
import at.bitfire.davdroid.log.Logger
import java.util.logging.Level
class DaoTools<T: IdEntity>(dao: SyncableDao<T>): SyncableDao<T> by dao {
/**
* Synchronizes a list of "old" elements with a list of "new" elements so that the list
* only contain equal elements.
*
* @param allOld list of old elements
* @param allNew map of new elements (stored in key map)
* @param selectKey generates a unique key from the element (will be called on old elements)
* @param prepareNew prepares new elements (can be used to take over properties of old elements)
*/
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) {
Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew))
val remainingNew = allNew.toMutableMap()
allOld.forEach { old ->
val key = selectKey(old)
val matchingNew = remainingNew[key]
if (matchingNew != null) {
// keep this old item, but maybe update it
matchingNew.id = old.id // identity is proven by key
prepareNew(matchingNew, old)
if (matchingNew != old)
update(matchingNew)
// remove from remainingNew
remainingNew -= key
} else {
// this old item is not present anymore, delete it
delete(old)
}
}
insert(remainingNew.values.toList())
}
}
@@ -0,0 +1,28 @@
package at.bitfire.davdroid.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Entity(tableName = "homeset",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
],
indices = [
// index by service; no duplicate URLs per service
Index("serviceId", "url", unique = true)
]
)
data class HomeSet(
@PrimaryKey(autoGenerate = true)
override var id: Long,
var serviceId: Long,
var url: HttpUrl,
var privBind: Boolean = true,
var displayName: String? = null
): IdEntity()
@@ -0,0 +1,21 @@
package at.bitfire.davdroid.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface HomeSetDao: SyncableDao<HomeSet> {
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByService(serviceId: Long): List<HomeSet>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(homeSet: HomeSet): Long
}
@@ -0,0 +1,5 @@
package at.bitfire.davdroid.model
abstract class IdEntity {
abstract var id: Long
}
@@ -0,0 +1,28 @@
package at.bitfire.davdroid.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Entity(tableName = "service",
indices = [
// only one service per type and account
Index("accountName", "type", unique = true)
])
data class Service(
@PrimaryKey(autoGenerate = true)
var id: Long,
var accountName: String,
var type: String,
var principal: HttpUrl?
) {
companion object {
const val TYPE_CALDAV = "caldav"
const val TYPE_CARDDAV = "carddav"
}
}
@@ -1,223 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.model
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteOpenHelper
import android.preference.PreferenceManager
import at.bitfire.davdroid.App
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.StartupDialogFragment
import java.util.logging.Level
class ServiceDB {
object Services {
const val _TABLE = "services"
const val ID = "_id"
const val ACCOUNT_NAME = "accountName"
const val SERVICE = "service"
const val PRINCIPAL = "principal"
// allowed values for SERVICE column
const val SERVICE_CALDAV = "caldav"
const val SERVICE_CARDDAV = "carddav"
}
object HomeSets {
const val _TABLE = "homesets"
const val ID = "_id"
const val SERVICE_ID = "serviceID"
const val URL = "url"
}
object Collections {
const val _TABLE = "collections"
const val ID = "_id"
const val TYPE = "type"
const val SERVICE_ID = "serviceID"
const val URL = "url"
const val READ_ONLY = "readOnly"
const val FORCE_READ_ONLY = "forceReadOnly"
const val DISPLAY_NAME = "displayName"
const val DESCRIPTION = "description"
const val COLOR = "color"
const val TIME_ZONE = "timezone"
const val SUPPORTS_VEVENT = "supportsVEVENT"
const val SUPPORTS_VTODO = "supportsVTODO"
const val SOURCE = "source"
const val SYNC = "sync"
}
companion object {
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
val values = ContentValues(1)
values.put(Services.ACCOUNT_NAME, newName)
db.updateWithOnConflict(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName), SQLiteDatabase.CONFLICT_REPLACE)
}
}
class OpenHelper(
val context: Context
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), AutoCloseable {
companion object {
const val DATABASE_NAME = "services.db"
const val DATABASE_VERSION = 4
}
override fun onConfigure(db: SQLiteDatabase) {
setWriteAheadLoggingEnabled(true)
db.setForeignKeyConstraintsEnabled(true)
}
override fun onCreate(db: SQLiteDatabase) {
Logger.log.info("Creating database " + db.path)
db.execSQL("CREATE TABLE ${Services._TABLE}(" +
"${Services.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
"${Services.ACCOUNT_NAME} TEXT NOT NULL," +
"${Services.SERVICE} TEXT NOT NULL," +
"${Services.PRINCIPAL} TEXT NULL)")
db.execSQL("CREATE UNIQUE INDEX services_account ON ${Services._TABLE} (${Services.ACCOUNT_NAME},${Services.SERVICE})")
db.execSQL("CREATE TABLE ${HomeSets._TABLE}(" +
"${HomeSets.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
"${HomeSets.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
"${HomeSets.URL} TEXT NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON ${HomeSets._TABLE}(${HomeSets.SERVICE_ID},${HomeSets.URL})")
db.execSQL("CREATE TABLE ${Collections._TABLE}(" +
"${Collections.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
"${Collections.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
"${Collections.TYPE} TEXT NOT NULL," +
"${Collections.URL} TEXT NOT NULL," +
"${Collections.READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
"${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
"${Collections.DISPLAY_NAME} TEXT NULL," +
"${Collections.DESCRIPTION} TEXT NULL," +
"${Collections.COLOR} INTEGER NULL," +
"${Collections.TIME_ZONE} TEXT NULL," +
"${Collections.SUPPORTS_VEVENT} INTEGER NULL," +
"${Collections.SUPPORTS_VTODO} INTEGER NULL," +
"${Collections.SOURCE} TEXT NULL," +
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
for (upgradeFrom in oldVersion until newVersion) {
val upgradeTo = upgradeFrom + 1
Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo")
try {
val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java)
upgradeProc.invoke(this, db)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't upgrade database", e)
}
}
}
@Suppress("unused")
private fun upgrade_3_4(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL")
}
@Suppress("unused")
private fun upgrade_2_3(db: SQLiteDatabase) {
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()
}
}
@Suppress("unused")
private fun upgrade_1_2(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL")
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" +
"SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " +
"FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" +
")",
arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK))
}
fun dump(sb: StringBuilder) {
val db = readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
while (cursorTables.moveToNext()) {
val table = cursorTables.getString(0)
sb.append(table).append("\n")
db.query(table, null, null, null, null, null, null).use { cursor ->
// print columns
val cols = cursor.columnCount
sb.append("\t| ")
for (i in 0 until cols)
sb .append(" ")
.append(cursor.getColumnName(i))
.append(" |")
sb.append("\n")
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ")
for (i in 0 until cols) {
sb.append(" ")
try {
val value = cursor.getString(i)
if (value != null)
sb.append(value
.replace("\r", "<CR>")
.replace("\n", "<LF>"))
else
sb.append("<null>")
} catch (e: SQLiteException) {
sb.append("<unprintable>")
}
sb.append(" |")
}
sb.append("\n")
}
sb.append("----------\n")
}
}
db.endTransaction()
}
}
}
}
@@ -0,0 +1,35 @@
package at.bitfire.davdroid.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndType(accountName: String, type: String): Service?
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
fun getIdByAccountAndType(accountName: String, type: String): Long?
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): Service?
@Query("SELECT * FROM service WHERE type=:type")
fun getByType(type: String): List<Service>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(service: Service): Long
@Query("DELETE FROM service")
fun deleteAll()
@Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)")
fun deleteExceptAccounts(accountNames: Array<String>)
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
fun renameAccount(oldName: String, newName: String)
}
@@ -8,40 +8,56 @@
package at.bitfire.davdroid.model
import at.bitfire.davdroid.log.Logger
import java.util.logging.Level
import at.bitfire.dav4jvm.property.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String
val value: String,
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
) {
companion object {
private const val KEY_TYPE = "type"
private const val KEY_VALUE = "value"
private const val KEY_INITIAL_SYNC = "initialSync"
fun fromString(s: String?): SyncState? {
if (s == null)
return null
val pos = s.indexOf(':')
if (pos == -1)
return null
return try {
val json = JSONObject(s)
SyncState(
Type.valueOf(s.substring(0, pos)),
s.substring(pos + 1)
Type.valueOf(json.getString(KEY_TYPE)),
json.getString(KEY_VALUE),
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't restore SyncState", e)
} catch (e: JSONException) {
null
}
}
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
}
enum class Type { CTAG, SYNC_TOKEN }
override fun toString() =
"${type.name}:$value"
override fun toString(): String {
val json = JSONObject()
json.put(KEY_TYPE, type.name)
json.put(KEY_VALUE, value)
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
return json.toString()
}
}
@@ -0,0 +1,18 @@
package at.bitfire.davdroid.model
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Update
interface SyncableDao<T: IdEntity> {
@Insert
fun insert(items: List<T>)
@Update
fun update(item: T)
@Delete
fun delete(item: T)
}
@@ -9,7 +9,6 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.TargetApi
import android.content.*
import android.os.Build
import android.os.Bundle
@@ -22,7 +21,7 @@ import android.util.Base64
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
@@ -31,9 +30,9 @@ import java.util.logging.Level
/**
* A local address book. Requires an own Android account, because Android manages contacts per
* account and there is no such thing as "address books". So, DAVdroid creates a "DAVdroid
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book. These accounts are bound to a
* DAVdroid main account.
* DAVx5 main account.
*/
class LocalAddressBook(
private val context: Context,
@@ -48,13 +47,20 @@ class LocalAddressBook(
const val USER_DATA_URL = "url"
const val USER_DATA_READ_ONLY = "read_only"
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
val accountManager = AccountManager.get(context)
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
val userData = initialUserData(mainAccount, info.url.toString())
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
if (!accountManager.addAccountExplicitly(account, null, userData))
throw IllegalStateException("Couldn't create address book account")
// Android sometimes seems to lose the initial user data, so set it a second time
userData.keySet().forEach { key ->
accountManager.setUserData(account, key, userData.getString(key))
}
val addressBook = LocalAddressBook(context, account, provider)
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
@@ -63,22 +69,28 @@ class LocalAddressBook(
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = !info.privWriteContent || info.forceReadOnly
return addressBook
}
fun findAll(context: Context, provider: ContentProviderClient, mainAccount: Account?) = AccountManager.get(context)
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.mainAccount == mainAccount }
.toList()
fun accountName(mainAccount: Account, info: CollectionInfo): String {
fun accountName(mainAccount: Account, info: Collection): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
DavUtils.lastSegmentOfUrl(info.url)
else
it
})
sb.append(" (${mainAccount.name} $hash)")
return sb.toString()
}
@@ -103,7 +115,10 @@ class LocalAddressBook(
}
override val title = account.name
override val tag: String
get() = "contacts-${account.name}"
override val title = account.name!!
/**
* Whether contact groups ([LocalGroup]) are included in query results
@@ -119,20 +134,22 @@ class LocalAddressBook(
get() {
_mainAccount?.let { return it }
val accountManager = AccountManager.get(context)
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw IllegalStateException("Address book doesn't exist anymore")
AccountManager.get(context).let { accountManager ->
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw IllegalStateException("No main account assigned to address book account")
}
}
set(account) {
val accountManager = AccountManager.get(context)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, account.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, account.type)
set(newMainAccount) {
AccountManager.get(context).let { accountManager ->
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
}
_mainAccount = account
_mainAccount = newMainAccount
}
var url: String
@@ -140,7 +157,7 @@ class LocalAddressBook(
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
var readOnly: Boolean
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
@@ -161,7 +178,7 @@ class LocalAddressBook(
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider.update(groupsSyncUri(), values, "${Groups.DIRTY}=0", null)
number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
}
return number
@@ -169,45 +186,53 @@ class LocalAddressBook(
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider.delete(groupsSyncUri(),
"${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
fun update(info: CollectionInfo) {
fun update(info: Collection) {
val newAccountName = accountName(mainAccount, info)
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, {
try {
// update raw contacts to new account name
provider?.let { provider ->
val values = ContentValues(1)
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
}
} catch (e: RemoteException) {
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
}
}, null)
val future = accountManager.renameAccount(account, newAccountName, null, null)
account = future.result
}
Constants.log.info("Address book read-only? = ${info.readOnly}")
readOnly = info.readOnly || info.forceReadOnly
val nowReadOnly = !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
Constants.log.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)
}
// make sure it will still be synchronized when contacts are updated
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) <= 0)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
fun delete() {
val accountManager = AccountManager.get(context)
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, null, null, null)
else
@@ -236,8 +261,8 @@ class LocalAddressBook(
else
findDeletedContacts()
fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null)
fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", null)
fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null)
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
@@ -248,16 +273,30 @@ class LocalAddressBook(
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
fun findDirtyContacts() = queryContacts("${RawContacts.DIRTY}!=0", null)
fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null)
override fun findDirtyWithoutNameOrUid() =
if (includeGroups)
findDirtyContactsWithoutNameOrUid() + findDirtyGroupsWithoutNameOrUid()
else
findDirtyContactsWithoutNameOrUid()
private fun findDirtyContactsWithoutNameOrUid() = queryContacts(
"${RawContacts.DIRTY} AND (${AndroidContact.COLUMN_FILENAME} IS NULL OR ${AndroidContact.COLUMN_UID} IS NULL)",
null)
private fun findDirtyGroupsWithoutNameOrUid() = queryGroups(
"${Groups.DIRTY} AND (${AndroidGroup.COLUMN_FILENAME} IS NULL OR ${AndroidGroup.COLUMN_UID} IS NULL)",
null)
private fun queryContactsGroups(whereContacts: String?, whereArgsContacts: Array<String>?, whereGroups: String?, whereArgsGroups: Array<String>?): List<LocalAddress> {
val contacts = queryContacts(whereContacts, whereArgsContacts)
return if (includeGroups)
contacts + queryGroups(whereGroups, whereArgsGroups)
else
contacts
override fun forgetETags() {
if (includeGroups) {
val values = ContentValues(1)
values.putNull(AndroidGroup.COLUMN_ETAG)
provider!!.update(groupsSyncUri(), values, null, null)
}
val values = ContentValues(1)
values.putNull(AndroidContact.COLUMN_ETAG)
provider!!.update(rawContactsSyncUri(), values, null, null)
}
@@ -326,7 +365,7 @@ class LocalAddressBook(
val values = ContentValues(1)
values.put(Groups.TITLE, title)
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
return ContentUris.parseId(uri)
}
@@ -14,12 +14,12 @@ import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract
import android.provider.CalendarContract.*
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
@@ -38,7 +38,7 @@ class LocalCalendar private constructor(
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri {
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
@@ -52,23 +52,22 @@ class LocalCalendar private constructor(
return create(account, provider, values)
}
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url)
values.put(Calendars.NAME, info.url.toString())
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (info.readOnly || info.forceReadOnly)
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
else {
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 ->
info.timezone?.let { tzData ->
try {
val timeZone = DateUtils.parseVTimeZone(tzData)
timeZone.timeZoneId?.let { tzId ->
@@ -78,18 +77,22 @@ class LocalCalendar private constructor(
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
values.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}")
values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}")
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
// add base values for Calendars
values.putAll(calendarBaseValues)
return values
}
}
override val tag: String
get() = "events-${account.name}-$id"
override val title: String
get() = displayName ?: id.toString()
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.let { cursor ->
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
@@ -102,30 +105,38 @@ class LocalCalendar private constructor(
}
fun update(info: CollectionInfo, updateColor: Boolean) =
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() =
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
override fun findDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
// get dirty events which are required to have an increased SEQUENCE value
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
val event = localEvent.event!!
val sequence = event.sequence
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (localEvent.weAreOrganizer)
event.sequence = sequence!! + 1
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
try {
val event = requireNotNull(localEvent.event)
val sequence = event.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (localEvent.weAreOrganizer) // increase sequence only if we're the organizer (i.e. not for attendee changes)
event.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
dirty += localEvent
}
return dirty
}
override fun findDirtyWithoutNameOrUid() =
queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND " +
"(${Events._SYNC_ID} IS NULL OR ${Events.UID_2445} IS NULL)", null)
override fun findByName(name: String) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
@@ -134,14 +145,36 @@ class LocalCalendar private constructor(
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
return provider.update(eventsSyncURI(), values,
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL",
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.delete(eventsSyncURI(),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
override fun removeNotDirtyMarked(flags: Int): Int {
var deleted = 0
// list all non-dirty events with the given flags and delete every row + its exceptions
provider.query(eventsSyncURI(), arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
val batch = BatchOperation(provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(eventsSyncURI())
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
))
}
deleted = batch.commit()
}
return deleted
}
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.toString()))
}
fun processDirtyExceptions() {
@@ -150,7 +183,7 @@ class LocalCalendar private constructor(
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
@@ -190,7 +223,7 @@ class LocalCalendar private constructor(
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
@@ -8,31 +8,81 @@
package at.bitfire.davdroid.resource
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.model.SyncState
interface LocalCollection<out T: LocalResource<*>> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
val tag: String
/** collection title (used for user notifications etc.) **/
val title: String
var lastSyncState: SyncState?
/**
* Finds local resources of this collection which have been marked as *deleted* by the user
* or an app acting on their behalf.
*
* @return list of resources marked as *deleted*
*/
fun findDeleted(): List<T>
/**
* Finds local resources of this collection which have been marked as *dirty*, i.e. resources
* which have been modified by the user or an app acting on their behalf.
*
* @return list of resources marked as *dirty*
*/
fun findDirty(): List<T>
/**
* Finds local resources of this collection which do not have a file name and/or UID, but
* need one for synchronization.
*
* For instance, exceptions of recurring events are local resources but do not need their
* own file name/UID because they're sent with the same UID as the main event.
*
* @return list of resources which need file name and UID for synchronization, but don't have both of them
*/
fun findDirtyWithoutNameOrUid(): List<T>
/**
* Finds a local resource of this collection with a given file name. (File names are assigned
* by the sync adapter.)
*
* @param name file name to look for
* @return resource with the given name, or null if none
*/
fun findByName(name: String): T?
/**
* Marks all entries which are not dirty with the given flags only.
* @return number of marked entries
**/
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
* and have an [Events.ORIGINAL_ID] of null.
*
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
*
* @return number of marked entries
*/
fun markNotDirty(flags: Int): Int
/**
* Removes all entries with are not dirty and are marked with exactly the given flags.
* @return number of removed entries
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
* a given flag combination.
*
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
* all entries with exactly this flag will be removed)
*
* @return number of removed entries
*/
fun removeNotDirtyMarked(flags: Int): Int
/**
* Forgets the ETags of all members so that they will be reloaded from the server during sync.
*/
fun forgetETags()
}
@@ -27,7 +27,7 @@ class LocalContact: AndroidContact, LocalAddress {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
}
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
@@ -38,8 +38,6 @@ class LocalContact: AndroidContact, LocalAddress {
private val groupMemberships = HashSet<Long>()
override var flags: Int = 0
private set
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
: super(addressBook, values) {
@@ -95,7 +93,7 @@ class LocalContact: AndroidContact, LocalAddress {
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.flags = flags
@@ -216,7 +214,7 @@ class LocalContact: AndroidContact, LocalAddress {
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
* whether a membership has been deleted/added when a raw contact is dirty.
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
* @throws FileNotFoundException if the current contact can't be found
@@ -239,6 +237,12 @@ class LocalContact: AndroidContact, LocalAddress {
}
// data rows
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
@@ -10,7 +10,6 @@ package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.BuildConfig
import at.bitfire.ical4android.*
@@ -21,12 +20,12 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
companion object {
init {
ICalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
}
const val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
const val COLUMN_FLAGS = CalendarContract.Events.SYNC_DATA2
const val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
const val COLUMN_ETAG = Events.SYNC_DATA1
const val COLUMN_FLAGS = Events.SYNC_DATA2
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
}
override var fileName: String? = null
@@ -53,7 +52,6 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
}
override fun populateEvent(row: ContentValues) {
super.populateEvent(row)
val event = requireNotNull(event)
event.uid = row.getAsString(Events.UID_2445)
@@ -61,6 +59,8 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
super.populateEvent(row)
}
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
@@ -72,9 +72,9 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
builder .withValue(Events.UID_2445, event.uid)
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(CalendarContract.Events.DIRTY, 0)
.withValue(CalendarContract.Events.DELETED, 0)
.withValue(LocalEvent.COLUMN_FLAGS, flags)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
@@ -106,7 +106,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
override fun clearDirty(eTag: String?) {
val values = ContentValues(2)
values.put(CalendarContract.Events.DIRTY, 0)
values.put(Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
calendar.provider.update(eventSyncURI(), values, null, null)
@@ -20,7 +20,6 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.dav4android.Constants
import at.bitfire.vcard4android.*
import java.util.*
@@ -102,11 +101,10 @@ class LocalGroup: AndroidGroup, LocalAddress {
override var flags: Int = 0
private set
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues):
super(addressBook, values) {
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
@@ -118,6 +116,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
override fun contentValues(): ContentValues {
val values = super.contentValues()
values.put(COLUMN_FLAGS, flags)
val members = Parcel.obtain()
try {
@@ -200,7 +199,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(LocalGroup.COLUMN_FLAGS, flags)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags
@@ -28,14 +28,34 @@ interface LocalResource<in TData: Any> {
*/
val id: Long?
/**
* Remote file name for the resource, for instance `mycontact.vcf`.
*/
val fileName: String?
var eTag: String?
val flags: Int
/**
* Generates a new UID and file name and assigns them to this resource. Typically used
* before uploading a resource which has just been created locally.
*/
fun assignNameAndUID()
/**
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
* locally modified resource.
*
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
*/
fun clearDirty(eTag: String?)
/**
* Sets (local) flags of the resource. At the moment, the only allowed values are
* 0 and [FLAG_REMOTELY_PRESENT].
*/
fun updateFlags(flags: Int)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
* @return content URI of the created row (e.g. event URI)
@@ -50,7 +50,6 @@ class LocalTask: AndroidTask, LocalResource<Task> {
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
super.buildTask(builder, update)
val task = requireNotNull(task)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
@@ -9,6 +9,7 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.ContentValues
@@ -17,8 +18,9 @@ import android.net.Uri
import android.os.Build
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.AndroidTaskListFactory
@@ -38,14 +40,18 @@ class LocalTaskList private constructor(
fun tasksProviderAvailable(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
else {
val provider = TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)
provider?.use { return true }
else
try {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use {
return true
}
} catch (e: Exception) {
// couldn't acquire task provider
}
return false
}
}
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
@@ -53,6 +59,7 @@ class LocalTaskList private constructor(
return create(account, provider, values)
}
@SuppressLint("Recycle")
@Throws(Exception::class)
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
var client: ContentProviderClient? = null
@@ -64,16 +71,13 @@ class LocalTaskList private constructor(
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
client?.close()
else
client?.release()
client?.closeCompat()
}
}
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url)
values.put(TaskLists._SYNC_ID, info.url.toString())
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
@@ -84,6 +88,9 @@ class LocalTaskList private constructor(
}
override val tag: String
get() = "tasks-${account.name}-$id"
override val title: String
get() = name ?: id.toString()
@@ -109,25 +116,32 @@ class LocalTaskList private constructor(
}
fun update(info: CollectionInfo, updateColor: Boolean) =
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
val tasks = queryTasks(Tasks._DIRTY, null)
for (localTask in tasks) {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else
task.sequence = sequence + 1
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else // task was modified, increase sequence
task.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
}
return tasks
}
override fun findDirtyWithoutNameOrUid() =
queryTasks("${Tasks._DIRTY} AND (${Tasks._SYNC_ID} IS NULL OR ${Tasks._UID} IS NULL)", null)
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
@@ -142,9 +156,16 @@ class LocalTaskList private constructor(
override fun removeNotDirtyMarked(flags: Int) =
provider.client.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?",
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
}
object Factory: AndroidTaskListFactory<LocalTaskList> {
@@ -5,38 +5,38 @@
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.os.Build
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.os.Bundle
import android.os.Parcel
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
import java.util.*
import java.util.logging.Level
/**
@@ -44,37 +44,46 @@ import java.util.logging.Level
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
*/
@Suppress("FunctionName")
class AccountSettings(
val context: Context,
val settings: ISettings,
val account: Account
) {
companion object {
const val CURRENT_VERSION = 8
const val CURRENT_VERSION = 10
const val KEY_SETTINGS_VERSION = "version"
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val WIFI_ONLY_DEFAULT = false
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
/** Time range limitation to the past [in days]
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
< 0 (-1) no limit
>= 0 entries more than n days in the past won't be synchronized
/** Time range limitation to the past [in days]. Values:
*
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
* - <0 (typically -1): no limit
* - n>0: entries more than n days in the past won't be synchronized
*/
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
/**
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
* Value can be null (no default alarm) or an integer (default alarm shall be created this
* number of minutes before the event/task).
*/
const val KEY_DEFAULT_ALARM = "default_alarm"
/* Whether DAVx5 sets the local calendar color to the value from service DB at every sync
value = null (not existing) true (default)
"0" false */
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/* Whether DAVdroid populates and uses CalendarContract.Colors
/* Whether DAVx5 populates and uses CalendarContract.Colors
value = null (not existing) false (default)
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
@@ -102,9 +111,10 @@ class AccountSettings(
}
}
val accountManager: AccountManager = AccountManager.get(context)
val settings = Settings.getInstance(context)
init {
synchronized(AccountSettings::class.java) {
@@ -159,14 +169,14 @@ class AccountSettings(
}
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
settings.getBoolean(KEY_WIFI_ONLY, false)
settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT
else
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
settings.getString(KEY_WIFI_ONLY_SSIDS, null)
settings.getString(KEY_WIFI_ONLY_SSIDS)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
@@ -177,25 +187,57 @@ class AccountSettings(
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
if (strDays != null) {
val days = Integer.valueOf(strDays)
return if (days < 0) null else days
return if (strDays != null) {
val days = strDays.toInt()
if (days < 0)
null
else
days
} else
return DEFAULT_TIME_RANGE_PAST_DAYS
DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
/**
* Takes the default alarm setting (in this order) from
*
* 1. the local account settings
* 2. the settings provider (unless the value is -1 there).
*
* @return A default reminder shall be created this number of minutes before the start of every
* non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun getDefaultAlarm() =
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 }
/**
* Sets the default alarm value in the local account settings, if the new value differs
* from the value of the settings provider. If the new value is the same as the value of
* the settings provider, the local setting will be deleted, so that the settings provider
* value applies.
*
* @param minBefore The number of minutes a default reminder shall be created before the
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun setDefaultAlarm(minBefore: Int?) =
accountManager.setUserData(account, KEY_DEFAULT_ALARM,
if (minBefore == settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 })
null
else
minBefore?.toString())
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS, false)
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false
else
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
fun setManageCalendarColors(manage: Boolean) =
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
fun getEventColors() = if (settings.has(KEY_EVENT_COLORS))
settings.getBoolean(KEY_EVENT_COLORS, false)
settings.getBoolean(KEY_EVENT_COLORS) ?: false
else
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
fun setEventColors(useColors: Boolean) =
@@ -204,7 +246,7 @@ class AccountSettings(
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = settings.getString(KEY_CONTACT_GROUP_METHOD, null) ?:
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
if (name != null)
try {
@@ -223,7 +265,7 @@ class AccountSettings(
// update from previous account settings
private fun update(baseVersion: Int) {
for (toVersion in baseVersion+1 .. CURRENT_VERSION) {
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
val fromVersion = toVersion-1
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
try {
@@ -238,19 +280,59 @@ class AccountSettings(
}
}
@Suppress("unused")
@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, OpenTasks)?.use { provider ->
val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), 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)
}
@SuppressLint("Recycle")
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account),
AndroidCalendar.calendarBaseValues, null, null)
provider.closeCompat()
}
}
@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 db = AppDatabase.getInstance(context)
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) {
Logger.log.info("Disabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, 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)?.let { provider ->
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
// ETag is now in sync_version instead of sync1
// UID is now in _uid instead of sync2
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), 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 ->
arrayOf(account.type, account.name), null)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
@@ -270,16 +352,14 @@ class AccountSettings(
}
@Suppress("unused")
@SuppressLint("Recycle")
private fun update_6_7() {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
AndroidCalendar.insertColors(provider, account)
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
provider.release()
provider.closeCompat()
}
}
@@ -290,6 +370,7 @@ class AccountSettings(
}
@Suppress("unused")
@SuppressLint("Recycle", "ParcelClassLoader")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
val parcel = Parcel.obtain()
@@ -306,18 +387,16 @@ class AccountSettings(
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()
val url = params.getString("url")
val params = parcel.readBundle()!!
val url = params.getString("url")?.toHttpUrlOrNull()
if (url == null)
Logger.log.info("No address book URL, ignoring account")
else {
// create new address book
val info = CollectionInfo(url)
info.type = CollectionInfo.Type.ADDRESS_BOOK
info.displayName = account.name
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
Logger.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
@@ -341,10 +420,7 @@ class AccountSettings(
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
provider.release()
provider.closeCompat()
}
}
@@ -360,7 +436,7 @@ class AccountSettings(
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context)
OpenTasksWatcher.updateTaskSync(context)
}
@Suppress("unused")
@@ -368,170 +444,6 @@ class AccountSettings(
setGroupMethod(GroupMethod.CATEGORIES)
}
@Suppress("unused")
private fun update_2_3() {
// Don't show a warning for Android updates anymore
accountManager.setUserData(account, "last_android_version", null)
var serviceCardDAV: Long? = null
var serviceCalDAV: Long? = null
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
// CardDAV: migrate address books
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
try {
val addrBook = LocalAddressBook(context, account, client)
val url = addrBook.url
Logger.log.fine("Migrating address book $url")
// insert CardDAV service
val values = ContentValues(3)
values.put(Services.ACCOUNT_NAME, account.name)
values.put(Services.SERVICE, Services.SERVICE_CARDDAV)
serviceCardDAV = db.insert(Services._TABLE, null, values)
// insert address book
values.clear()
values.put(Collections.SERVICE_ID, serviceCardDAV)
values.put(Collections.URL, url)
values.put(Collections.SYNC, 1)
db.insert(Collections._TABLE, null, values)
// insert home set
HttpUrl.parse(url)?.let {
val homeSet = it.resolve("../")
values.clear()
values.put(HomeSets.SERVICE_ID, serviceCardDAV)
values.put(HomeSets.URL, homeSet.toString())
db.insert(HomeSets._TABLE, null, values)
}
} catch (e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e)
} finally {
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
// CalDAV: migrate calendars + task lists
val collections = HashSet<String>()
val homeSets = HashSet<HttpUrl>()
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { client ->
try {
val calendars = AndroidCalendar.find(account, client, LocalCalendar.Factory, null, null)
for (calendar in calendars)
calendar.name?.let { url ->
Logger.log.fine("Migrating calendar $url")
collections.add(url)
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
}
} catch (e: CalendarStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate calendars", e)
} finally {
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
AndroidTaskList.acquireTaskProvider(context)?.use { provider ->
try {
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
for (taskList in taskLists)
taskList.syncId?.let { url ->
Logger.log.fine("Migrating task list $url")
collections.add(url)
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
}
} catch (e: CalendarStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e)
}
}
if (!collections.isEmpty()) {
// insert CalDAV service
val values = ContentValues(3)
values.put(Services.ACCOUNT_NAME, account.name)
values.put(Services.SERVICE, Services.SERVICE_CALDAV)
serviceCalDAV = db.insert(Services._TABLE, null, values)
// insert collections
for (url in collections) {
values.clear()
values.put(Collections.SERVICE_ID, serviceCalDAV)
values.put(Collections.URL, url)
values.put(Collections.SYNC, 1)
db.insert(Collections._TABLE, null, values)
}
// insert home sets
for (homeSet in homeSets) {
values.clear()
values.put(HomeSets.SERVICE_ID, serviceCalDAV)
values.put(HomeSets.URL, homeSet.toString())
db.insert(HomeSets._TABLE, null, values)
}
}
}
// initiate service detection (refresh) to get display names, colors etc.
val refresh = Intent(context, DavService::class.java)
refresh.action = DavService.ACTION_REFRESH_COLLECTIONS
serviceCardDAV?.let {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
context.startService(refresh)
}
serviceCalDAV?.let {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
context.startService(refresh)
}
}
@Suppress("unused")
private fun update_1_2() {
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
*/
// move previous address book info to ContactsContract.SyncState
val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) ?:
throw ContactsStorageException("Couldn't access Contacts provider")
try {
val addr = LocalAddressBook(context, account, provider)
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
val values = ContentValues()
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addr.settings = values
val url = accountManager.getUserData(account, "addressbook_url")
if (!url.isNullOrEmpty())
addr.url = url
accountManager.setUserData(account, "addressbook_url", null)
val cTag = accountManager.getUserData (account, "addressbook_ctag")
if (!cTag.isNullOrEmpty())
addr.lastSyncState = SyncState(SyncState.Type.CTAG, cTag)
accountManager.setUserData(account, "addressbook_ctag", null)
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
@Suppress("deprecation")
provider.release()
}
}
// updates from AccountSettings version 2 and below are not supported anymore
}
@@ -8,32 +8,31 @@
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.App
import android.content.Context
open class DefaultsProvider(
private val allowOverride: Boolean = true
): Provider {
): SettingsProvider {
open val booleanDefaults = mapOf(
Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(App.OVERRIDE_PROXY, false)
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT),
Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT)
)
open val intDefaults = mapOf(
Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT)
)
open val longDefaults = mapOf<String, Long>()
open val stringDefaults = mapOf(
Pair(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT)
)
override fun close() {
override fun forceReload() {
}
override fun forceReload() {
override fun close() {
}
@@ -71,4 +70,9 @@ open class DefaultsProvider(
override fun remove(key: String) = false
class Factory : ISettingsProviderFactory {
override fun getProviders(context: Context) = listOf(DefaultsProvider())
}
}
@@ -0,0 +1,17 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.settings
import android.content.Context
interface ISettingsProviderFactory {
fun getProviders(context: Context): List<SettingsProvider>
}
@@ -8,62 +8,90 @@
package at.bitfire.davdroid.settings
import android.annotation.TargetApi
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.log.Logger
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
class Settings: Service(), Provider.Observer {
class Settings(
appContext: Context
) {
private val providers = LinkedList<Provider>()
private val observers = LinkedList<WeakReference<ISettingsObserver>>()
companion object {
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
override fun onCreate() {
Logger.log.info("Initializing Settings service")
// settings keys and default values
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false
const val OVERRIDE_PROXY = "override_proxy"
const val OVERRIDE_PROXY_DEFAULT = false
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
// always add a defaults provider first
providers.add(DefaultsProvider())
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
private var singleton: Settings? = null
fun getInstance(context: Context): Settings {
singleton?.let { return it }
val newInstance = Settings(context.applicationContext)
singleton = newInstance
return newInstance
}
// always add a provider for local preferences
providers.add(SharedPreferencesProvider(this))
}
override fun onDestroy() {
Logger.log.info("Shutting down Settings service")
providers.forEach { it.close() }
providers.clear()
}
private val providers = LinkedList<SettingsProvider>()
private val observers = LinkedList<WeakReference<OnChangeListener>>()
override fun onTrimMemory(level: Int) {
stopSelf()
init {
synchronized(javaClass) { // ServiceLoader is not thread-safe
val factories = ServiceLoader.load(ISettingsProviderFactory::class.java)
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
factories.forEach { factory ->
providers.addAll(factory.getProviders(appContext))
}
}
}
fun forceReload() {
providers.forEach { it.forceReload() }
providers.forEach {
it.forceReload()
}
onSettingsChanged()
}
override fun onReload() {
observers.forEach {
Handler(Looper.getMainLooper()).post {
it.get()?.onSettingsChanged()
/*** OBSERVERS ***/
fun addOnChangeListener(observer: OnChangeListener) {
synchronized(this) {
observers += WeakReference(observer)
}
}
fun removeOnChangeListener(observer: OnChangeListener) {
synchronized(this) {
observers.removeAll { it.get() == null || it.get() == observer }
}
}
fun onSettingsChanged() {
synchronized(this) {
observers.mapNotNull { it.get() }.forEach {
it.onSettingsChanged()
}
}
}
private fun has(key: String): Boolean {
/*** SETTINGS ACCESS ***/
fun has(key: String): Boolean {
Logger.log.fine("Looking for setting $key")
var result = false
for (provider in providers)
@@ -83,7 +111,7 @@ class Settings: Service(), Provider.Observer {
return result
}
private fun<T> getValue(key: String, reader: (Provider) -> Pair<T?, Boolean>): T? {
private fun<T> getValue(key: String, reader: (SettingsProvider) -> Pair<T?, Boolean>): T? {
Logger.log.fine("Looking up setting $key")
var result: T? = null
for (provider in providers)
@@ -101,16 +129,16 @@ class Settings: Service(), Provider.Observer {
}
fun getBoolean(key: String) =
getValue(key, { provider -> provider.getBoolean(key) })
getValue(key) { provider -> provider.getBoolean(key) }
fun getInt(key: String) =
getValue(key, { provider -> provider.getInt(key) })
getValue(key) { provider -> provider.getInt(key) }
fun getLong(key: String) =
getValue(key, { provider -> provider.getLong(key) })
getValue(key) { provider -> provider.getLong(key) }
fun getString(key: String) =
getValue(key, { provider -> provider.getString(key) })
getValue(key) { provider -> provider.getString(key) }
fun isWritable(key: String): Boolean {
@@ -124,7 +152,7 @@ class Settings: Service(), Provider.Observer {
return false
}
private fun<T> putValue(key: String, value: T?, writer: (Provider) -> Boolean): Boolean {
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean {
Logger.log.fine("Trying to write setting $key = $value")
for (provider in providers) {
val (writable, further) = provider.isWritable(key)
@@ -143,16 +171,16 @@ class Settings: Service(), Provider.Observer {
}
fun putBoolean(key: String, value: Boolean?) =
putValue(key, value, { provider -> provider.putBoolean(key, value) })
putValue(key, value) { provider -> provider.putBoolean(key, value) }
fun putInt(key: String, value: Int?) =
putValue(key, value, { provider -> provider.putInt(key, value) })
putValue(key, value) { provider -> provider.putInt(key, value) }
fun putLong(key: String, value: Long?) =
putValue(key, value, { provider -> provider.putLong(key, value) })
putValue(key, value) { provider -> provider.putLong(key, value) }
fun putString(key: String, value: String?) =
putValue(key, value, { provider -> provider.putString(key, value) })
putValue(key, value) { provider -> provider.putString(key, value) }
fun remove(key: String): Boolean {
var deleted = false
@@ -161,115 +189,13 @@ class Settings: Service(), Provider.Observer {
}
val binder = object: ISettings.Stub() {
override fun forceReload() =
this@Settings.forceReload()
override fun has(key: String) =
this@Settings.has(key)
override fun getBoolean(key: String, defaultValue: Boolean) =
this@Settings.getBoolean(key) ?: defaultValue
override fun getInt(key: String, defaultValue: Int) =
this@Settings.getInt(key) ?: defaultValue
override fun getLong(key: String, defaultValue: Long) =
this@Settings.getLong(key) ?: defaultValue
override fun getString(key: String, defaultValue: String?) =
this@Settings.getString(key) ?: defaultValue
override fun isWritable(key: String) =
this@Settings.isWritable(key)
override fun remove(key: String) =
this@Settings.remove(key)
override fun putBoolean(key: String, value: Boolean) =
this@Settings.putBoolean(key, value)
override fun putString(key: String, value: String?) =
this@Settings.putString(key, value)
override fun putInt(key: String, value: Int) =
this@Settings.putInt(key, value)
override fun putLong(key: String, value: Long) =
this@Settings.putLong(key, value)
override fun registerObserver(observer: ISettingsObserver) {
observers += WeakReference(observer)
}
override fun unregisterObserver(observer: ISettingsObserver) {
observers.removeAll { it.get() == observer }
}
}
override fun onBind(intent: Intent?) = binder
class Stub(
delegate: ISettings,
private val context: Context,
private val serviceConn: ServiceConnection?
): ISettings by delegate, AutoCloseable {
override fun close() {
try {
context.unbindService(serviceConn)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't unbind Settings service", e)
}
}
}
companion object {
fun getInstance(context: Context): Stub? {
if (Looper.getMainLooper().thread == Thread.currentThread())
throw IllegalStateException("Must not be called from main thread")
var service: ISettings? = null
val serviceLock = Object()
val serviceConn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
synchronized(serviceLock) {
service = ISettings.Stub.asInterface(binder)
serviceLock.notify()
}
}
override fun onServiceDisconnected(name: ComponentName) {
service = null
}
}
if (!context.bindService(Intent(context, Settings::class.java), serviceConn, Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT))
return null
synchronized(serviceLock) {
if (service == null)
try {
serviceLock.wait()
} catch(e: InterruptedException) {
}
if (service == null) {
try {
context.unbindService(serviceConn)
} catch (e: IllegalArgumentException) {
}
return null
}
}
return Stub(service!!, context, serviceConn)
}
interface OnChangeListener {
/**
* Will be called when something has changed in a [SettingsProvider].
* Runs in worker thread!
*/
@WorkerThread
fun onSettingsChanged()
}
}
@@ -8,11 +8,10 @@
package at.bitfire.davdroid.settings
import java.io.Closeable
interface Provider: Closeable {
interface SettingsProvider {
fun forceReload()
fun close()
fun has(key: String): Pair<Boolean, Boolean>
@@ -30,9 +29,4 @@ interface Provider: Closeable {
fun remove(key: String): Boolean
interface Observer {
fun onReload()
}
}
@@ -13,11 +13,11 @@ import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.preference.PreferenceManager
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.AppDatabase
class SharedPreferencesProvider(
context: Context
): Provider {
val context: Context
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
companion object {
private const val META_VERSION = "version"
@@ -34,14 +34,21 @@ class SharedPreferencesProvider(
firstCall(context)
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
}
}
override fun close() {
preferences.registerOnSharedPreferenceChangeListener(this)
}
override fun forceReload() {
}
override fun close() {
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
Settings.getInstance(context).onSettingsChanged()
}
override fun has(key: String) =
Pair(preferences.contains(key), true)
@@ -56,44 +63,44 @@ class SharedPreferencesProvider(
}
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
getValue(key, { preferences -> preferences.getBoolean(key, /* will never be used: */ false) })
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
override fun getInt(key: String): Pair<Int?, Boolean> =
getValue(key, { preferences -> preferences.getInt(key, /* will never be used: */ -1) })
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
override fun getLong(key: String): Pair<Long?, Boolean> =
getValue(key, { preferences -> preferences.getLong(key, /* will never be used: */ -1) })
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
override fun getString(key: String): Pair<String?, Boolean> =
getValue(key, { preferences -> preferences.getString(key, /* will never be used: */ null) })
getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) }
override fun isWritable(key: String) =
Pair(true, true)
Pair(first = true, second = true)
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
if (value == null)
return remove(key)
return if (value == null)
remove(key)
else {
Logger.log.fine("Writing setting $key = $value")
val edit = preferences.edit()
writer(edit, value)
edit.apply()
return true
true
}
}
override fun putBoolean(key: String, value: Boolean?) =
putValue(key, value, { editor, v -> editor.putBoolean(key, v) })
putValue(key, value) { editor, v -> editor.putBoolean(key, v) }
override fun putInt(key: String, value: Int?) =
putValue(key, value, { editor, v -> editor.putInt(key, v) })
putValue(key, value) { editor, v -> editor.putInt(key, v) }
override fun putLong(key: String, value: Long?) =
putValue(key, value, { editor, v -> editor.putLong(key, v) })
putValue(key, value) { editor, v -> editor.putLong(key, v) }
override fun putString(key: String, value: String?) =
putValue(key, value, { editor, v -> editor.putString(key, v) })
putValue(key, value) { editor, v -> editor.putString(key, v) }
override fun remove(key: String): Boolean {
Logger.log.fine("Removing setting $key")
@@ -114,7 +121,12 @@ class SharedPreferencesProvider(
edit.apply()
// open ServiceDB to upgrade it and possibly migrate settings
ServiceDB.OpenHelper(context).use { it.readableDatabase }
AppDatabase.getInstance(context)
}
class Factory : ISettingsProviderFactory {
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
}
}
@@ -11,59 +11,57 @@ import android.accounts.*
import android.app.Service
import android.content.Context
import android.content.Intent
import android.database.DatabaseUtils
import android.os.Bundle
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.ui.setup.LoginActivity
import java.util.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.logging.Level
/**
* Account authenticator for the main DAVdroid account type.
* Account authenticator for the main DAVx5 account type.
*
* Gets started when a DAVdroid account is removed, too, so it also watches for account removals
* Gets started when a DAVx5 account is removed, too, so it also watches for account removals
* and contains the corresponding cleanup code.
*/
class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
companion object {
@WorkerThread
@Synchronized
fun cleanupAccounts(context: Context) {
Logger.log.info("Cleaning up orphaned accounts")
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
val accountManager = AccountManager.get(context)
val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type))
.map { it.name }
val sqlAccountNames = LinkedList<String>()
val accountNames = HashSet<String>()
val accountManager = AccountManager.get(context)
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
accountNames += account.name
}
// delete orphaned address book accounts
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, null) }
.forEach {
try {
if (!accountNames.contains(it.mainAccount.name))
it.delete()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
}
// delete orphaned address book accounts
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, null) }
.forEach {
try {
if (!accountNames.contains(it.mainAccount.name))
it.delete()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
}
}
// delete orphaned services in DB
if (sqlAccountNames.isEmpty())
db.delete(ServiceDB.Services._TABLE, null, null)
else
db.delete(ServiceDB.Services._TABLE, "${ServiceDB.Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
}
// delete orphaned services in DB
val db = AppDatabase.getInstance(context)
val serviceDao = db.serviceDao()
if (accountNames.isEmpty())
serviceDao.deleteAll()
else
serviceDao.deleteExceptAccounts(accountNames.toTypedArray())
}
}
@@ -85,11 +83,13 @@ class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
override fun onAccountsUpdated(accounts: Array<out Account>?) {
cleanupAccounts(this)
CoroutineScope(Dispatchers.Default).launch {
cleanupAccounts(this@AccountAuthenticatorService)
}
}
@@ -12,13 +12,14 @@ import android.content.ContentProvider
import android.content.ContentValues
import android.net.Uri
@Suppress("ImplicitNullableNothingType")
class AddressBookProvider: ContentProvider() {
override fun onCreate() = false
override fun insert(p0: Uri?, p1: ContentValues?) = null
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?) = 0
override fun getType(p0: Uri?) = null
override fun insert(p0: Uri, p1: ContentValues?) = null
override fun query(p0: Uri, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
override fun delete(p0: Uri, p1: String?, p2: Array<out String>?) = 0
override fun getType(p0: Uri) = null
}
@@ -7,41 +7,39 @@
*/
package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.database.DatabaseUtils
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.R
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.AccountSettings
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
class AddressBooksSyncAdapterService: SyncAdapterService() {
class AddressBooksSyncAdapterService : SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
class AddressBooksSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return
}
) : SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, settings, account)
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
@@ -50,61 +48,50 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
updateLocalAddressBooks(contactsProvider, account)
val accountManager = AccountManager.get(context)
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
} catch(e: Exception) {
if (updateLocalAddressBooks(account, syncResult))
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
}
Logger.log.info("Address book sync complete")
}
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean {
val db = AppDatabase.getInstance(context)
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getByServiceAndSync(service.id))
remoteAddressBooks[collection.url] = collection
fun remoteAddressBooks(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
if (remoteAddressBooks.isEmpty())
Logger.log.info("No contacts permission, but no address book selected for synchronization")
else
Logger.log.warning("No contacts permission, but address books are selected for synchronization")
return false
}
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
try {
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return false
}
// enumerate remote and local address books
val service = getService()
val remote = remoteAddressBooks(service)
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, provider, account)) {
val url = addressBook.url
val info = remote[url]
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = addressBook.url.toHttpUrl()
val info = remoteAddressBooks[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
@@ -113,20 +100,24 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch(e: Exception) {
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remote -= url
remoteAddressBooks -= url
}
}
// create new local address books
for ((_, info) in remote) {
for ((_, info) in remoteAddressBooks) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, provider, account, info)
LocalAddressBook.create(context, contactsProvider, account, info)
}
} finally {
contactsProvider?.closeCompat()
}
return true
}
}
@@ -1,284 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCollection
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.ConflictException
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.exception.PreconditionFailedException
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.SyncToken
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.ISettings
import okhttp3.HttpUrl
import okhttp3.RequestBody
import java.util.logging.Level
abstract class BaseDavSyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
context: Context,
settings: ISettings,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCollection: CollectionType
): SyncManager<ResourceType, CollectionType>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection), AutoCloseable {
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: RemoteType
override fun close() {
httpClient.close()
}
override fun prepare(): Boolean {
// always re-sync on manual syncs
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
localCollection.lastSyncState = null
return true
}
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks for thread interruption before each request to allow quick sync cancellation.
*/
override fun processLocallyDeleted(): Boolean {
var numDeleted = 0
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
val localList = localCollection.findDeleted()
for (local in localList)
useLocal(local, {
abortIfCancelled()
val fileName = local.fileName
if (fileName != null) {
Logger.log.info("$fileName has been deleted locally -> deleting from server")
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
try {
remote.delete(local.eTag)
numDeleted++
} catch (e: HttpException) {
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
}
})
} else
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
syncResult.stats.numDeletes++
})
Logger.log.info("Removed $numDeleted record(s) from server")
return numDeleted > 0
}
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks for thread interruption before each request to allow quick sync cancellation.
*/
override fun uploadDirty(): Boolean {
var numUploaded = 0
// upload dirty contacts
for (local in localCollection.findDirty())
useLocal(local, {
abortIfCancelled()
if (local.fileName == null) {
Logger.log.fine("Generating file name/UID for local record #${local.id}")
local.assignNameAndUID()
}
val fileName = local.fileName!!
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
// generate entity to upload (VCard, iCal, whatever)
val body = prepareUpload(local)
try {
if (local.eTag == null) {
Logger.log.info("Uploading new record $fileName")
remote.put(body, null, true)
} else {
Logger.log.info("Uploading locally modified record $fileName")
remote.put(body, local.eTag, false)
}
numUploaded++
} catch(e: ConflictException) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
} catch(e: PreconditionFailedException) {
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
}
val newETag = remote.properties[GetETag::class.java]
val eTag: String?
if (newETag != null) {
eTag = newETag.eTag
Logger.log.fine("Received new ETag=$eTag after uploading")
} else {
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
eTag = null
}
local.clearDirty(eTag)
})
})
Logger.log.info("Sent $numUploaded record(s) to server")
return numUploaded > 0
}
override fun syncRequired(): Boolean {
val localState = localCollection.lastSyncState
val remoteState = syncState(false)
Logger.log.info("Local sync state = $localState, remote sync state = $remoteState")
return when {
remoteState?.type == SyncState.Type.SYNC_TOKEN -> {
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
lastKnownToken != remoteState.value
}
remoteState?.type == SyncState.Type.CTAG -> {
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
lastKnownCTag != remoteState.value
}
else ->
true
}
}
override fun syncState(forceRefresh: Boolean) = useRemoteCollection { remote ->
if (forceRefresh)
remote.propfind(0, GetCTag.NAME, SyncToken.NAME)
remote.properties[SyncToken::class.java]?.token?.let {
SyncState(SyncState.Type.SYNC_TOKEN, it)
} ?:
remote.properties[GetCTag::class.java]?.cTag?.let {
SyncState(SyncState.Type.CTAG, it)
}
}
override fun resetPresentRemotely() {
val number = localCollection.markNotDirty(0)
Logger.log.info("Number of local non-dirty entries: $number")
}
override fun compareLocalRemote(syncState: SyncState?, remoteResources: Map<String, DavResource>): RemoteChanges {
/* check which resources are
1. updated remotely -> update
2. added remotely -> update
3. not present remotely anymore -> ignore (because they will be deleted by deleteObsolete()
*/
val changes = RemoteChanges(syncState, false)
for ((name, remote) in remoteResources)
useLocal(localCollection.findByName(name), { local ->
if (local == null) {
Logger.log.info("$name has been added remotely")
changes.updated += remote
} else {
val localETag = local.eTag
val remoteETag = remote.properties[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag")
if (localETag == remoteETag)
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
else {
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
changes.updated += remote
}
// mark as remotely present, so that this resource won't be deleted at the end
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
}
})
return changes
}
override fun listRemoteChanges(state: SyncState?): RemoteChanges {
throw UnsupportedOperationException("Collection sync not implemented yet")
/* TODO
try {
davCollection.reportChanges(
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
false, COLLECTION_SYNC_PAGE_SIZE,
GetETag.NAME)
} catch(e: HttpException) {
if (e.status in arrayOf(500,507))
// some servers don't like the limit, try again without
davCollection.reportChanges(
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
false, null,
GetETag.NAME, GetContentType.NAME)
}
var syncToken: String? = null
davCollection.properties[SyncToken::class.java]?.let {
syncToken = it.token
}
val changes = RemoteChanges(syncToken?.let { SyncState(SyncState.Type.SYNC_TOKEN, it) }, davCollection.furtherResults)
for (member in davCollection.members)
changes.updated += member
for (member in davCollection.removedMembers)
changes.deleted += member.fileName()
Logger.log.log(Level.INFO, "Received list of changed/removed resources", changes)
return changes
*/
}
override fun deleteNotPresentRemotely() {
val removed = localCollection.removeNotDirtyMarked(0)
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
}
override fun postProcess() {
}
protected fun<T: LocalResource<*>?, R> useLocal(local: T, body: (T) -> R): R {
local?.let { currentLocalResource.push(it) }
val result = body(local)
local?.let { currentLocalResource.pop() }
return result
}
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
currentRemoteResource.push(remote)
val result = body(remote)
currentRemoteResource.pop()
return result
}
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
useRemote(davCollection, body)
}
@@ -12,26 +12,30 @@ import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.SyncToken
import at.bitfire.davdroid.AccountSettings
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import net.fortuna.ical4j.model.component.VAlarm
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.time.Duration
import java.util.*
import java.util.logging.Level
@@ -40,25 +44,16 @@ import java.util.logging.Level
*/
class CalendarSyncManager(
context: Context,
settings: ISettings,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCalendar: LocalCalendar
): BaseDavSyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
companion object {
const val MULTIGET_MAX_RESOURCES = 30
}
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCalendar) {
override fun prepare(): Boolean {
if (!super.prepare())
return false
collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false
collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
// if there are dirty exceptions for events, mark their master events as dirty, too
@@ -67,26 +62,39 @@ class CalendarSyncManager(
return true
}
override fun queryCapabilities() {
useRemoteCollection { it.propfind(0, GetCTag.NAME, SyncToken.NAME) }
}
override fun queryCapabilities(): SyncState? =
useRemoteCollection {
var syncState: SyncState? = null
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
syncState = syncState(response)
}
}
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource, {
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState
}
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
SyncAlgorithm.PROPFIND_REPORT
else
SyncAlgorithm.COLLECTION_SYNC
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) {
val event = requireNotNull(resource.event)
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
val os = ByteArrayOutputStream()
event.write(os)
RequestBody.create(
DavCalendar.MIME_ICALENDAR_UTF8,
os.toByteArray()
)
})
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
}
override fun listAllRemote(): Map<String, DavResource> {
override fun listAllRemote(callback: DavResponseCallback) {
// calculate time range limits
var limitStart: Date? = null
accountSettings.getTimeRangePastDays()?.let { pastDays ->
@@ -96,99 +104,81 @@ class CalendarSyncManager(
}
return useRemoteCollection { remote ->
// fetch list of remote VEVENTs and build hash table to index file name
Logger.log.info("Querying events since $limitStart")
remote.calendarQuery("VEVENT", limitStart, null)
val result = LinkedHashMap<String, DavResource>(remote.members.size)
for (iCal in remote.members) {
val fileName = iCal.fileName()
Logger.log.fine("Found remote VEVENT: $fileName")
result[fileName] = iCal
}
result
remote.calendarQuery("VEVENT", limitStart, null, callback)
}
}
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted)
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local event $name")
useLocal(it, { local -> local.delete() })
syncResult.stats.numDeletes++
}
val toDownload = changes.updated.map { it.location }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
val body = remote.get(DavCalendar.MIME_ICALENDAR.toString())
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = remote.properties[GetETag::class.java]?.eTag
?: throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVEvent(remote.fileName(), eTag, reader)
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
useRemoteCollection {
it.multiget(bunch) { response, _ ->
useRemote(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@useRemote
}
})
else {
// multiple contacts, use multi-get
useRemoteCollection { it.multiget(bunch) }
// process multiget results
for (remote in davCollection.members)
useRemote(remote, {
val eTag = remote.properties[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote.properties[CalendarData::class.java]
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without address data")
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
})
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
}
}
abortIfCancelled()
}
}
override fun postProcess() {
}
// helpers
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
val events: List<Event>
try {
events = Event.fromReader(reader)
events = Event.eventsFromReader(reader)
} catch (e: InvalidCalendarException) {
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
if (events.size == 1) {
val newData = events.first()
val event = events.first()
// delete local event, if it exists
useLocal(localCollection.findByName(fileName), { local ->
// set default reminder for non-full-day events, if requested
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
if (defaultAlarmMinBefore != null && !event.isAllDay() && event.alarms.isEmpty()) {
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong()))
Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
event.alarms += alarm
}
// update local event, if it exists
useLocal(localCollection.findByName(fileName)) { local ->
if (local != null) {
Logger.log.info("Updating $fileName in local calendar")
Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event)
local.eTag = eTag
local.update(newData)
local.update(event)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding $fileName to local calendar")
useLocal(LocalEvent(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event)
useLocal(LocalEvent(localCollection, event, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.add()
})
}
syncResult.stats.numInserts++
}
})
}
} else
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_event)
}
@@ -8,18 +8,21 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.database.DatabaseUtils
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.AndroidCalendar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.logging.Level
class CalendarsSyncAdapterService: SyncAdapterService() {
@@ -31,9 +34,9 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, settings, account)
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
@@ -49,9 +52,13 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
updateLocalCalendars(provider, account, accountSettings)
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
val priorityCalendars = priorityCollections(extras)
val calendars = AndroidCalendar
.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
.sortedByDescending { priorityCalendars.contains(it.id) }
for (calendar in calendars) {
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
CalendarSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, calendar).use {
CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use {
it.performSync()
}
}
@@ -62,63 +69,37 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
}
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
val db = AppDatabase.getInstance(context)
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteCalendars(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
arrayOf(service.toString()), null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
remoteCalendars[collection.url] = collection
}
// enumerate remote and local calendars
val service = getService()
val remote = remoteCalendars(service)
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let { url ->
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remote -= url
}
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let {
val url = it.toHttpUrl()
val info = remoteCalendars[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remoteCalendars -= url
}
// create new local calendars
for ((_, info) in remote) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
// create new local calendars
for ((_, info) in remoteCalendars) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
}
@@ -15,10 +15,9 @@ import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.AccountSettings
import java.util.logging.Level
class ContactsSyncAdapterService: SyncAdapterService() {
@@ -34,10 +33,10 @@ class ContactsSyncAdapterService: SyncAdapterService() {
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val addressBook = LocalAddressBook(context, account, provider)
val accountSettings = AccountSettings(context, settings, addressBook.mainAccount)
val accountSettings = AccountSettings(context, addressBook.mainAccount)
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
@@ -55,13 +54,17 @@ class ContactsSyncAdapterService: SyncAdapterService() {
}
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
Logger.log.info("Synchronizing address book: ${addressBook.url}")
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
ContactsSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
it.performSync()
}
} catch(e: Exception) {
@@ -13,27 +13,29 @@ import android.content.*
import android.os.Build
import android.os.Bundle
import android.provider.ContactsContract.Groups
import android.support.v4.app.NotificationCompat
import at.bitfire.dav4android.DavAddressBook
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.AccountSettings
import at.bitfire.dav4jvm.DavAddressBook
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.*
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.GroupMethod
import ezvcard.VCardVersion
import ezvcard.io.CannotParseException
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.*
import java.util.*
import java.util.logging.Level
/**
@@ -56,10 +58,10 @@ import java.util.logging.Level
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVdroid will
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
* DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
@@ -73,7 +75,6 @@ import java.util.logging.Level
*/
class ContactsSyncManager(
context: Context,
settings: ISettings,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
@@ -81,25 +82,24 @@ class ContactsSyncManager(
syncResult: SyncResult,
val provider: ContentProviderClient,
localAddressBook: LocalAddressBook
): BaseDavSyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, extras, authority, syncResult, localAddressBook) {
companion object {
private const val MULTIGET_MAX_RESOURCES = 10
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
private val readOnly = localAddressBook.readOnly
private var numDiscarded = 0
private var hasVCard4 = false
private val groupMethod = accountSettings.getGroupMethod()
/**
* Used to download images which are referenced by URL
*/
private lateinit var resourceDownloader: ResourceDownloader
override fun prepare(): Boolean {
if (!super.prepare())
return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localCollection.verifyDirty()
@@ -110,69 +110,76 @@ class ContactsSyncManager(
}
}
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
resourceDownloader = ResourceDownloader(davCollection.location)
return true
}
override fun queryCapabilities() {
useRemoteCollection { dav ->
dav.propfind(0, SupportedAddressData.NAME, GetCTag.NAME, SyncToken.NAME)
override fun queryCapabilities(): SyncState? {
Logger.log.info("Contact group method: $groupMethod")
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
val properties = dav.properties
properties[SupportedAddressData::class.java]?.let {
hasVCard4 = it.hasVCard4()
return useRemoteCollection {
var syncState: SyncState? = null
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[SupportedAddressData::class.java]?.let { supported ->
hasVCard4 = supported.hasVCard4()
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
}
Logger.log.info("Server advertises VCard/4 support: $hasVCard4")
Logger.log.info("Contact group method: $groupMethod")
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
Logger.log.info("Server supports vCard/4: $hasVCard4")
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState
}
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
override fun syncAlgorithm() = if (hasCollectionSync)
SyncAlgorithm.COLLECTION_SYNC
else
SyncAlgorithm.PROPFIND_REPORT
override fun processLocallyDeleted(): Boolean {
if (readOnly) {
for (group in localCollection.findDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
useLocal(group, { it.resetDeleted() })
numDiscarded++
}
override fun processLocallyDeleted() =
if (readOnly) {
for (group in localCollection.findDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
useLocal(group) { it.resetDeleted() }
}
for (contact in localCollection.findDeletedContacts()) {
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
useLocal(contact, { it.resetDeleted() })
numDiscarded++
}
for (contact in localCollection.findDeletedContacts()) {
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
useLocal(contact) { it.resetDeleted() }
}
if (numDiscarded > 0)
notifyDiscardedChange()
return false
} else
// mirror deletions to remote collection (DELETE)
return super.processLocallyDeleted()
}
false
} else
// mirror deletions to remote collection (DELETE)
super.processLocallyDeleted()
override fun uploadDirty(): Boolean {
if (readOnly) {
for (group in localCollection.findDirtyGroups()) {
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
useLocal(group, { it.clearDirty(null) })
numDiscarded++
useLocal(group) { it.clearDirty(null) }
}
for (contact in localCollection.findDirtyContacts()) {
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
useLocal(contact, { it.clearDirty(null) })
numDiscarded++
useLocal(contact) { it.clearDirty(null) }
}
if (numDiscarded > 0)
notifyDiscardedChange()
} else {
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
@@ -182,16 +189,16 @@ class ContactsSyncManager(
Logger.log.fine("Finally removing group $group")
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty()
useLocal(group, { it.delete() })
useLocal(group) { it.delete() }
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (group in localCollection.findDirtyGroups()) {
Logger.log.fine("Marking members of modified group $group as dirty")
useLocal(group, {
useLocal(group) {
it.markMembersDirty()
it.clearDirty(null)
})
}
}
} else {
/* groups as separate VCards: there are group contacts and individual contacts */
@@ -221,21 +228,7 @@ class ContactsSyncManager(
return super.uploadDirty()
}
private fun notifyDiscardedChange() {
val notification = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_STATUS)
.setSmallIcon(R.drawable.ic_delete_notification)
.setContentTitle(context.getString(R.string.sync_contacts_read_only_address_book))
.setContentText(context.resources.getQuantityString(R.plurals.sync_contacts_local_contact_changes_discarded, numDiscarded, numDiscarded))
.setNumber(numDiscarded)
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setLocalOnly(true)
.build()
notificationManager.notify("discarded_${account.name}", 0, notification)
}
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource, {
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) {
val contact: Contact
if (resource is LocalContact) {
contact = resource.contact!!
@@ -265,83 +258,36 @@ class ContactsSyncManager(
val os = ByteArrayOutputStream()
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
RequestBody.create(
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
os.toByteArray().toRequestBody(
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8
)
})
override fun listAllRemote() = useRemoteCollection { dav ->
// fetch list of remote VCards and build hash table to index file name
dav.propfind(1, ResourceType.NAME, GetETag.NAME)
val result = LinkedHashMap<String, DavResource>(dav.members.size)
for (vCard in dav.members) {
// ignore member collections
var ignore = false
vCard.properties[ResourceType::class.java]?.let { type ->
if (type.types.contains(ResourceType.COLLECTION))
ignore = true
}
if (ignore)
continue
val fileName = vCard.fileName()
Logger.log.fine("Found remote VCard: $fileName")
result[fileName] = vCard
}
result
}
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted)
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local address $name")
useLocal(it, { it.delete() })
syncResult.stats.numDeletes++
override fun listAllRemote(callback: DavResponseCallback) =
useRemoteCollection {
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
}
val toDownload = changes.updated.map { it.location }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
// prepare downloader which may be used to download external resource like contact photos
val downloader = ResourceDownloader(collectionURL)
// download new/updated VCards from server
for (bunch in toDownload.chunked(CalendarSyncManager.MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = remote.properties[GetETag::class.java]?.eTag
?: throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVCard(remote.fileName(), eTag, reader, downloader)
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch")
useRemoteCollection {
it.multiget(bunch, hasVCard4) { response, _ ->
useRemote(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@useRemote
}
})
else {
// multiple contacts, use multi-get
useRemoteCollection { it.multiget(bunch, hasVCard4) }
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
// process multi-get results
for (remote in davCollection.members)
useRemote(remote, {
val eTag = remote.properties[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val addressData = response[AddressData::class.java]
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
val addressData = remote.properties[AddressData::class.java]
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
})
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)
}
}
abortIfCancelled()
}
}
@@ -365,22 +311,30 @@ class ContactsSyncManager(
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
Logger.log.info("Processing CardDAV resource $fileName")
val contacts = Contact.fromReader(reader, downloader)
val contacts = try {
Contact.fromReader(reader, downloader)
} catch (e: CannotParseException) {
Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
if (contacts.isEmpty()) {
Logger.log.warning("Received VCard without data, ignoring")
Logger.log.warning("Received vCard without data, ignoring")
return
} else if (contacts.size > 1)
Logger.log.warning("Received multiple VCards, using first one")
Logger.log.warning("Received multiple vCards, using first one")
val newData = contacts.first()
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
Logger.log.warning("Received group VCard although group method is CATEGORIES. Saving as regular contact")
Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact")
newData.group = false
}
// update local contact, if it exists
useLocal(localCollection.findByName(fileName), {
useLocal(localCollection.findByName(fileName)) {
var local = it
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
@@ -388,12 +342,14 @@ class ContactsSyncManager(
if (local is LocalGroup && newData.group) {
// update group
local.eTag = eTag
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
local.eTag = eTag
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
@@ -407,16 +363,16 @@ class ContactsSyncManager(
if (local == null) {
if (newData.group) {
Logger.log.log(Level.INFO, "Creating local group", newData)
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
it.add()
local = it
})
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
group.add()
local = group
}
} else {
Logger.log.log(Level.INFO, "Creating local contact", newData)
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
it.add()
local = it
})
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
contact.add()
local = contact
}
}
syncResult.stats.numInserts++
}
@@ -438,9 +394,9 @@ class ContactsSyncManager(
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
(local as? LocalContact)?.updateHashCode(null)
})
}
}
@@ -451,20 +407,14 @@ class ContactsSyncManager(
): Contact.Downloader {
override fun download(url: String, accepts: String): ByteArray? {
val httpUrl = HttpUrl.parse(url)
val httpUrl = url.toHttpUrlOrNull()
if (httpUrl == null) {
Logger.log.log(Level.SEVERE, "Invalid external resource URL", url)
return null
}
val host = httpUrl.host()
if (host == null) {
Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url)
return null
}
// authenticate only against a certain host, and only upon request
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
val builder = HttpClient.Builder(context, baseUrl.host, accountSettings.credentials())
// allow redirects
builder.followRedirects(true)
@@ -477,7 +427,7 @@ class ContactsSyncManager(
.build()).execute()
if (response.isSuccessful)
return response.body()?.bytes()
return response.body?.bytes()
else
Logger.log.warning("Couldn't download external resource")
} catch(e: IOException) {
@@ -489,4 +439,7 @@ class ContactsSyncManager(
}
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_contact)
}
@@ -26,7 +26,7 @@ class NullAuthenticatorService: Service() {
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
@@ -10,33 +10,63 @@ package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.app.PendingIntent
import android.app.Service
import android.content.*
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.support.v4.content.ContextCompat
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.R
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.PermissionUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.AccountActivity
import at.bitfire.davdroid.ui.AccountSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import org.apache.commons.collections4.IteratorUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.account.SettingsActivity
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
companion object {
val runningSyncs: MutableSet<Pair<String, Account>> = Collections.synchronizedSet(mutableSetOf<Pair<String, Account>>())
/** Keep a list of running syncs to block multiple calls at the same time,
* like run by some devices. Weak references are used for the case that a thread
* is terminated and the `finally` block which cleans up [runningSyncs] is not
* executed. */
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
/**
* Specifies an list of IDs which are requested to be synchronized before
* the other collections. For instance, if some calendars of a CalDAV
* account are visible in the calendar app and others are hidden, the visible calendars can
* be synchronized first, so that the "Refresh" action in the calendar app is more responsive.
*
* Extra type: String (comma-separated list of IDs)
*
* In case of calendar sync, the extra value is a list of Android calendar IDs.
* In case of task sync, the extra value is an a list of OpenTask task list IDs.
*/
const val SYNC_EXTRAS_PRIORITY_COLLECTIONS = "priority_collections"
/**
* Requests a re-synchronization of all entries. For instance, if this extra is
* set for a calendar sync, all remote events will be listed and checked for remote
* changes again.
*
* Useful if settings which modify the remote resource list (like the CalDAV setting
* "sync events n days in the past") have been changed.
*/
const val SYNC_EXTRAS_RESYNC = "resync"
/**
* Requests a full re-synchronization of all entries. For instance, if this extra is
* set for an address book sync, all contacts will be downloaded again and updated in the
* local storage.
*
* Useful if settings which modify parsing/local behavior have been changed.
*/
const val SYNC_EXTRAS_FULL_RESYNC = "full_resync"
}
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
@@ -48,71 +78,95 @@ abstract class SyncAdapterService: Service() {
context: Context
): AbstractThreadedSyncAdapter(context, false) {
abstract fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
companion object {
fun priorityCollections(extras: Bundle): Set<Long> {
val ids = mutableSetOf<Long>()
extras.getString(SYNC_EXTRAS_PRIORITY_COLLECTIONS)?.let { rawIds ->
for (rawId in rawIds.split(','))
try {
ids += rawId.toLong()
} catch (e: NumberFormatException) {
Logger.log.log(Level.WARNING, "Couldn't parse SYNC_EXTRAS_PRIORITY_COLLECTIONS", e)
}
}
return ids
}
}
abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
// prevent multiple syncs of the same authority to be run for the same account
val currentSync = Pair(authority, account)
if (!runningSyncs.add(currentSync)) {
Logger.log.warning("There's already another $authority sync running for $account, aborting")
return
synchronized(runningSyncs) {
if (runningSyncs.any { it.get() == currentSync }) {
Logger.log.warning("There's already another $authority sync running for $account, aborting")
return
}
runningSyncs += WeakReference(currentSync)
}
// required for ServiceLoader -> ical4j -> ical4android
Thread.currentThread().contextClassLoader = context.classLoader
try {
// required for dav4android (ServiceLoader)
Thread.currentThread().contextClassLoader = context.classLoader
// load app settings
Settings.getInstance(context).use { settings ->
if (settings == null) {
syncResult.databaseError = true
Logger.log.severe("Couldn't connect to Settings service, aborting sync")
return
}
//if (runSync) {
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
sync(settings, account, extras, authority, provider, syncResult)
//}
}
Logger.log.info("Sync for $authority complete")
if (true)
sync(account, extras, authority, provider, syncResult)
} finally {
runningSyncs -= currentSync
synchronized(runningSyncs) {
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
}
}
Logger.log.info("Sync for $currentSync finished")
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
syncResult.databaseError = true
val intent = Intent(context, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
notifyPermissions(intent)
}
override fun onSyncCanceled() {
Logger.log.info("Sync thread cancelled! Interrupting sync")
super.onSyncCanceled()
}
override fun onSyncCanceled(thread: Thread) {
Logger.log.info("Sync thread ${thread.id} cancelled! Interrupting sync")
super.onSyncCanceled(thread)
}
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
if (settings.getSyncWifiOnly()) {
// WiFi required
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetworkInfo
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
// check for connected WiFi network
var wifiAvailable = false
connectivityManager.allNetworks.forEach { network ->
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
wifiAvailable = true
}
}
if (!wifiAvailable) {
Logger.log.info("Not on connected WiFi, stopping")
return false
}
// if execution reaches this point, we're on a connected WiFi
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
// getting the WiFi name requires location permission (and active location services) since Android 8.1
// see https://issuetracker.google.com/issues/70633700
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
val intent = Intent(context, AccountSettingsActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, settings.account)
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
val intent = Intent(context, SettingsActivity::class.java)
intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, settings.account)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
notifyPermissions(intent)
PermissionUtils.notifyPermissions(context, intent)
}
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
@@ -126,18 +180,6 @@ abstract class SyncAdapterService: Service() {
return true
}
private fun notifyPermissions(intent: Intent) {
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_error_notification)
.setContentTitle(context.getString(R.string.sync_error_permissions))
.setContentText(context.getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
}
}
}
@@ -19,39 +19,52 @@ import android.os.Bundle
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.exception.ServiceUnavailableException
import at.bitfire.dav4android.exception.UnauthorizedException
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.DavService
import at.bitfire.davdroid.R
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.dav4jvm.*
import at.bitfire.dav4jvm.exception.*
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.SyncToken
import at.bitfire.davdroid.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.*
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.ui.AccountSettingsActivity
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.account.SettingsActivity
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.MiscUtils
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.UsesThreadContextClassLoader
import at.bitfire.vcard4android.ContactsStorageException
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.lang3.exception.ContextedException
import org.dmfs.tasks.contract.TaskContract
import java.io.IOException
import java.io.InterruptedIOException
import java.net.HttpURLConnection
import java.security.cert.CertificateException
import java.util.*
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import java.util.logging.Level
import javax.net.ssl.SSLHandshakeException
import kotlin.math.min
abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>>(
@Suppress("MemberVisibilityCanBePrivate")
@UsesThreadContextClassLoader
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
val context: Context,
val settings: ISettings,
val account: Account,
val accountSettings: AccountSettings,
val extras: Bundle,
@@ -60,131 +73,191 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
val localCollection: CollectionType
): AutoCloseable {
companion object {
fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) =
manager.cancel(notificationTag(authority, account), NotificationUtils.NOTIFY_SYNC_ERROR)
private fun notificationTag(authority: String, account: Account) =
"$authority-${account.name}".hashCode().toString()
enum class SyncAlgorithm {
PROPFIND_REPORT,
COLLECTION_SYNC
}
companion object {
const val MAX_MULTIGET_RESOURCES = 10
}
init {
// required for ServiceLoader -> ical4j -> ical4android
Ical4Android.checkThreadContextClassLoader()
}
/**
* We use our own dispatcher to make sure that all threads have [Thread.getContextClassLoader] set,
* which is required for dav4jvm and ical4j (because they rely on [ServiceLoader]).
*/
private val workDispatcher = Executors.newFixedThreadPool(
// number of threads = number of CPUs, but max. 4
min(Runtime.getRuntime().availableProcessors(), 4)
).asCoroutineDispatcher()
private val mainAccount = if (localCollection is LocalAddressBook)
localCollection.mainAccount
else
account
protected val notificationManager = NotificationManagerCompat.from(context)
protected val notificationTag = Companion.notificationTag(authority, mainAccount)
protected val notificationTag = localCollection.tag
/** Local resource we're currently operating on. Used for error notifications. **/
protected val currentLocalResource = LinkedList<LocalResource<*>>()
/** Remote resource we're currently operating on. Used for error notifications. **/
protected val currentRemoteResource = LinkedList<DavResource>()
protected val httpClient = HttpClient.Builder(context, accountSettings).build()
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: RemoteType
protected var hasCollectionSync = false
override fun close() {
httpClient.close()
}
fun performSync() {
// dismiss previous error notifications
notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
try {
unwrapExceptions({
Logger.log.info("Preparing synchronization")
if (!prepare()) {
Logger.log.info("No reason to synchronize, aborting")
return
return@unwrapExceptions
}
abortIfCancelled()
Logger.log.info("Querying server capabilities")
queryCapabilities()
abortIfCancelled()
var remoteSyncState = queryCapabilities()
Logger.log.info("Sending local deletes/updates to server")
val modificationsSent =
processLocallyDeleted() ||
val modificationsSent = processLocallyDeleted() ||
uploadDirty()
abortIfCancelled()
if (modificationsSent || syncRequired())
if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) {
Logger.log.info("Forcing re-synchronization of all entries")
// forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC)
localCollection.lastSyncState = null
remoteSyncState = null
// forget sync state of members (→ download all members again and update them locally)
localCollection.forgetETags()
}
if (modificationsSent || syncRequired(remoteSyncState))
when (syncAlgorithm()) {
SyncAlgorithm.PROPFIND_REPORT -> {
Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
resetPresentRemotely()
// get current sync state
val syncState = syncState(modificationsSent)
if (modificationsSent)
remoteSyncState = querySyncState()
// list all entries at now current sync state (which may be the same as or newer than lastSyncState)
Logger.log.info("Listing remote entries")
val remote = listAllRemote()
abortIfCancelled()
Logger.log.info("Comparing local/remote entries")
val changes = compareLocalRemote(syncState, remote)
Logger.log.info("Processing remote changes")
processRemoteChanges(changes)
// list and process all entries at current sync state (which may be the same as or newer than remoteSyncState)
Logger.log.info("Processing remote entries")
syncRemote { callback ->
listAllRemote(callback)
}
Logger.log.info("Deleting entries which are not present remotely anymore")
deleteNotPresentRemotely()
syncResult.stats.numDeletes += deleteNotPresentRemotely()
Logger.log.info("Post-processing")
postProcess()
Logger.log.info("Saving sync state")
localCollection.lastSyncState = changes.state
Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
localCollection.lastSyncState = remoteSyncState
}
SyncAlgorithm.COLLECTION_SYNC -> {
throw UnsupportedOperationException("Collection sync not supported yet")
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
/*val lastSyncState = localCollection.getLastSyncState()?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
val initialSync = lastSyncState == null
if (initialSync)
var initialSync = false
if (syncState == null) {
Logger.log.info("Starting initial sync")
initialSync = true
resetPresentRemotely()
} else if (syncState.initialSync == true) {
Logger.log.info("Continuing initial sync")
initialSync = true
}
var changes = listRemoteChanges(lastSyncState)
var furtherChanges = false
do {
processRemoteChanges(changes)
localCollection.setLastSyncState(changes.state)
Logger.log.info("Listing changes since $syncState")
syncRemote { callback ->
try {
val result = listRemoteChanges(syncState, callback)
syncState = SyncState.fromSyncToken(result.first, initialSync)
furtherChanges = result.second
} catch(e: HttpException) {
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
Logger.log.info("Sync token invalid, performing initial sync")
initialSync = true
resetPresentRemotely()
changes = listRemoteChanges(changes.state)
} while(changes.furtherChanges)
val result = listRemoteChanges(null, callback)
syncState = SyncState.fromSyncToken(result.first, initialSync)
furtherChanges = result.second
} else
throw e
}
}
if (initialSync)
Logger.log.log(Level.INFO, "Saving sync state", syncState)
localCollection.lastSyncState = syncState
Logger.log.info("Server has further changes: $furtherChanges")
} while(furtherChanges)
if (initialSync) {
// initial sync is finished, remove all local resources which have not been listed by server
Logger.log.info("Deleting local resources which are not on server (anymore)")
deleteNotPresentRemotely()
postProcess()*/
// remove initial sync flag
syncState!!.initialSync = false
Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
localCollection.lastSyncState = syncState
}
Logger.log.info("Post-processing")
postProcess()
}
}
else
Logger.log.info("Remote collection didn't change, no reason to sync")
}
// sync was cancelled: re-throw to SyncAdapterService
catch(e: InterruptedException) { throw e }
catch (e: InterruptedIOException) { throw e }
}, { e, local, remote ->
when (e) {
// sync was cancelled: re-throw to SyncAdapterService
is InterruptedException,
is InterruptedIOException ->
throw e
// I/O errors
catch (e: SSLHandshakeException) {
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
// specific I/O errors
is SSLHandshakeException -> {
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
// when a certificate is rejected by cert4android, the cause will be a CertificateException
if (!BuildConfig.customCerts || e.cause !is CertificateException)
notifyException(e)
}
// when a certificate is rejected by cert4android, the cause will be a CertificateException
if (e.cause !is CertificateException)
notifyException(e, local, remote)
}
// specific HTTP errors
catch (e: ServiceUnavailableException) {
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
e.retryAfter?.let { retryAfter ->
// how many seconds to wait? getTime() returns ms, so divide by 1000
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
// specific HTTP errors
is ServiceUnavailableException -> {
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
e.retryAfter?.let { retryAfter ->
// how many seconds to wait? getTime() returns ms, so divide by 1000
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
}
}
// all others
else ->
notifyException(e, local, remote)
}
}
// all others
catch (e: Throwable) { notifyException(e) }
})
}
@@ -193,24 +266,158 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
/**
* Queries the server for synchronization capabilities like specific report types,
* data formats etc.
*
* Should also query and save the initial sync state (e.g. CTag/sync-token).
*
* @return current sync state
*/
protected abstract fun queryCapabilities()
protected abstract fun queryCapabilities(): SyncState?
protected abstract fun processLocallyDeleted(): Boolean
protected abstract fun uploadDirty(): Boolean
/**
* Processes locally deleted entries and forwards them to the server (HTTP `DELETE`).
*
* @return whether resources have been deleted from the server
*/
protected open fun processLocallyDeleted(): Boolean {
var numDeleted = 0
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
val localList = localCollection.findDeleted()
for (local in localList) {
useLocal(local) {
val fileName = local.fileName
if (fileName != null) {
Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})")
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
try {
remote.delete(local.eTag) {}
numDeleted++
} catch (e: HttpException) {
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
}
}
} else
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
syncResult.stats.numDeletes++
}
}
Logger.log.info("Removed $numDeleted record(s) from server")
return numDeleted > 0
}
/**
* Uploads locally modified resources to the server (HTTP `PUT`).
*
* @return whether resources have been uploaded
*/
protected open fun uploadDirty(): Boolean {
var numUploaded = 0
// make sure all resources have file name and UID before uploading them
for (local in localCollection.findDirtyWithoutNameOrUid())
useLocal(local) {
Logger.log.fine("Generating file name/UID for local resource #${local.id}")
local.assignNameAndUID()
}
// upload dirty resources (parallelized)
runBlocking(workDispatcher) {
for (local in localCollection.findDirty())
launch {
useLocal(local) {
val fileName = local.fileName!!
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
// generate entity to upload (VCard, iCal, whatever)
val body = prepareUpload(local)
var eTag: String? = null
val processETag: (response: okhttp3.Response) -> Unit = { response ->
response.header("ETag")?.let { getETag ->
eTag = GetETag(getETag).eTag
}
}
try {
if (local.eTag == null) {
Logger.log.info("Uploading new record $fileName")
remote.put(body, null, true, processETag)
} else {
Logger.log.info("Uploading locally modified record $fileName")
remote.put(body, local.eTag, false, processETag)
}
numUploaded++
} catch(e: ForbiddenException) {
// HTTP 403 Forbidden
// If and only if the upload failed because of missing permissions, treat it like 412.
if (e.errors.contains(Error.NEED_PRIVILEGES))
Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", e)
else
throw e
} catch(e: ConflictException) {
// HTTP 409 Conflict
// We can't interact with the user to resolve the conflict, so we treat 409 like 412.
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
} catch(e: PreconditionFailedException) {
// HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile.
// Ignore this condition so that the resource can be downloaded and reset again.
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
}
if (eTag != null)
Logger.log.fine("Received new ETag=$eTag after uploading")
else
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
local.clearDirty(eTag)
}
}
}
}
Logger.log.info("Sent $numUploaded record(s) to server")
return numUploaded > 0
}
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
/**
* Determines whether a sync is required because there were changes on the server.
* For instance, this method can check the collection's CTag/sync-token.
* For instance, this method can compare the collection's `CTag`/`sync-token` with
* the last known local value.
*
* When local changes have been uploaded ([processLocallyDeleted] and/or
* [uploadDirty] were true), a sync is always required and this method
* will not be evaluated.
* should *not* be evaluated.
*
* @return whether data has been changed on the server = whether running the
* sync algorithm is required
* Will return _true_ if [SyncAdapterService.SYNC_EXTRAS_RESYNC] and/or
* [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
*
* @param state remote sync state to compare local sync state with
*
* @return whether data has been changed on the server, i.e. whether running the
* sync algorithm is required
*/
protected abstract fun syncRequired(): Boolean
protected open fun syncRequired(state: SyncState?): Boolean {
if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_RESYNC) ||
extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC))
return true
val localState = localCollection.lastSyncState
Logger.log.info("Local sync state = $localState, remote sync state = $state")
return when {
state?.type == SyncState.Type.SYNC_TOKEN -> {
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
lastKnownToken != state.value
}
state?.type == SyncState.Type.CTAG -> {
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
lastKnownCTag != state.value
}
else ->
true
}
}
/**
* Determines which sync algorithm to use.
@@ -221,15 +428,6 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
*/
protected abstract fun syncAlgorithm(): SyncAlgorithm
/**
* Returns the current sync state of the remote resource. Keep in mind that
* WebDAV operations are atomic and the sync state might already be obsolete when used.
*
* @param forceRefresh false: result may be taken from a previous request, for instance
* from the [prepare] phase; true: sends a request to determine the current sync state
*/
protected abstract fun syncState(forceRefresh: Boolean): SyncState?
/**
* Marks all local resources which shall be taken into consideration for this
* sync as "synchronizing". Purpose of marking is that resources which have been marked
@@ -237,51 +435,158 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
*
* Used together with [deleteNotPresentRemotely].
*/
protected abstract fun resetPresentRemotely()
protected open fun resetPresentRemotely() {
val number = localCollection.markNotDirty(0)
Logger.log.info("Number of local non-dirty entries: $number")
}
/**
* Lists all remote resources which should be taken into account for synchronization.
* Will be used if incremental synchronization is not available.
* @return Map with resource names (like "mycontact.vcf") as keys and the resources
* Calls a callback to list remote resources. All resources from the returned
* list are downloaded and processed.
*
* @param listRemote function to list remote resources (for instance, all since a certain sync-token)
*/
protected abstract fun listAllRemote(): Map<String, DavResource>
protected open fun syncRemote(listRemote: (DavResponseCallback) -> Unit) {
// thread-safe sync stats
val nInserted = AtomicInteger()
val nUpdated = AtomicInteger()
val nDeleted = AtomicInteger()
val nSkipped = AtomicInteger()
runBlocking(workDispatcher) {
// download queue
val toDownload = LinkedBlockingQueue<HttpUrl>()
fun download(url: HttpUrl?) {
if (url != null)
toDownload += url
if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) {
while (toDownload.size > 0) {
val bunch = LinkedList<HttpUrl>()
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
launch {
downloadRemote(bunch)
}
}
}
}
coroutineScope {
listRemote { response, relation ->
// ignore non-members
if (relation != Response.HrefRelation.MEMBER)
return@listRemote
// ignore collections
if (response[at.bitfire.dav4jvm.property.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.ResourceType.COLLECTION) == true)
return@listRemote
val name = response.hrefName()
if (response.isSuccess()) {
Logger.log.fine("Found remote resource: $name")
launch {
useLocal(localCollection.findByName(name)) { local ->
if (local == null) {
Logger.log.info("$name has been added remotely, queueing download")
download(response.href)
nInserted.incrementAndGet()
} else {
val localETag = local.eTag
val remoteETag = response[GetETag::class.java]?.eTag
?: throw DavException("Server didn't provide ETag")
if (localETag == remoteETag) {
Logger.log.info("$name has not been changed on server (ETag still $remoteETag)")
nSkipped.incrementAndGet()
} else {
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
download(response.href)
nUpdated.incrementAndGet()
}
// mark as remotely present, so that this resource won't be deleted at the end
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
}
}
}
} else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
// collection sync: resource has been deleted on remote server
launch {
useLocal(localCollection.findByName(name)) { local ->
Logger.log.info("$name has been deleted on server, deleting locally")
local?.delete()
nDeleted.incrementAndGet()
}
}
}
}
}
// download remaining resources
download(null)
}
// update sync stats
with(syncResult.stats) {
numInserts += nInserted.get()
numUpdates += nUpdated.get()
numDeletes += nDeleted.get()
numSkippedEntries += nSkipped.get()
}
}
protected abstract fun listAllRemote(callback: DavResponseCallback)
protected open fun listRemoteChanges(syncState: SyncState?, callback: DavResponseCallback): Pair<SyncToken, Boolean> {
var furtherResults = false
val report = davCollection.reportChanges(
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
false, null,
GetETag.NAME) { response, relation ->
when (relation) {
Response.HrefRelation.SELF ->
furtherResults = response.status?.code == 507
Response.HrefRelation.MEMBER ->
callback(response, relation)
else ->
Logger.log.fine("Unexpected sync-collection response: $response")
}
}
var syncToken: SyncToken? = null
report.filterIsInstance(SyncToken::class.java).firstOrNull()?.let {
syncToken = it
}
if (syncToken == null)
throw DavException("Received sync-collection response without sync-token")
return Pair(syncToken!!, furtherResults)
}
/**
* Compares local resources which are marked for synchronization and remote resources by file name and ETag.
* Remote resources
* + which are not present locally
* + whose ETag has changed since the last sync (i.e. remote ETag != locally known last remote ETag)
* will be saved as "updated" in the result.
* Downloads and processes resources, given as a list of URLs. Will be called with a list
* of changed/new remote resources.
*
* Must mark all found remote resources as "present remotely", so that a later execution of
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
* Implementations should not use GET to fetch single resources, but always multi-get, even
* for single resources for these reasons:
*
* @param remoteResources Map of remote resource names and resources
* @return List of updated resources on the server. The "deleted" list remains empty. The sync
* state is taken from [syncState].
* 1. GET can only be used without HTTP compression, because it may change the ETag.
* multi-get sends the ETag in the XML body, so there's no problem with compression.
* 2. Some servers are wrongly configured to suppress the ETag header in the response.
* With multi-get, the ETag is in the XML body, so it won't be affected by that.
* 3. If there are two methods to download resources (GET and multi-get), both methods
* have to be implemented, tested and maintained. Given that multi-get is required
* in any case, it's better to have only one method.
* 4. For users, it's strange behavior when DAVx5 can download multiple remote changes,
* but not a single one (or vice versa). So only one method is more user-friendly.
* 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests.
*/
protected abstract fun compareLocalRemote(syncState: SyncState?, remoteResources: Map<String, DavResource>): RemoteChanges
/**
* Lists remote changes (incremental sync).
*
* Must mark all found remote resources as "present remotely", so that a later execution of
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
*
* @return List of of remote changes together with the sync state after those changes
*/
protected abstract fun listRemoteChanges(state: SyncState?): RemoteChanges
/**
* Processes remote changes:
* + downloads and locally saves remotely updated resources
* + locally deletes remotely deleted resources
* Should call [abortIfCancelled] from time to time, for instance
* after downloading a resource.
* @param changes List of remotely updated and deleted resources
*/
protected abstract fun processRemoteChanges(changes: RemoteChanges)
protected abstract fun downloadRemote(bunch: List<HttpUrl>)
/**
* Locally deletes entries which are
@@ -291,7 +596,11 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
* Used together with [resetPresentRemotely] when a full listing has been received from
* the server to locally delete resources which are not present remotely (anymore).
*/
protected abstract fun deleteNotPresentRemotely()
protected open fun deleteNotPresentRemotely(): Int {
val removed = localCollection.removeNotDirtyMarked(0)
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
return removed
}
/**
* Post-processing of synchronized entries, for instance contact group membership operations.
@@ -299,21 +608,34 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
protected abstract fun postProcess()
/**
* Throws an [InterruptedException] if the current thread has been interrupted,
* most probably because synchronization was cancelled by the user.
* @throws InterruptedException (which will be catched by [performSync])
* */
protected fun abortIfCancelled() {
if (Thread.interrupted())
throw InterruptedException("Sync was cancelled")
// sync helpers
protected fun syncState(dav: Response) =
dav[SyncToken::class.java]?.token?.let {
SyncState(SyncState.Type.SYNC_TOKEN, it)
} ?:
dav[GetCTag::class.java]?.cTag?.let {
SyncState(SyncState.Type.CTAG, it)
}
private fun querySyncState(): SyncState? {
var state: SyncState? = null
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF)
state = syncState(response)
}
return state
}
private fun notifyException(e: Throwable) {
// exception helpers
private fun notifyException(e: Throwable, local: ResourceType?, remote: HttpUrl?) {
val message: String
when (e) {
is IOException -> {
is IOException,
is InterruptedIOException -> {
Logger.log.log(Level.WARNING, "I/O error", e)
message = context.getString(R.string.sync_error_io, e.localizedMessage)
syncResult.stats.numIoExceptions++
@@ -330,7 +652,7 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
}
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
message = context.getString(R.string.sync_error_local_storage)
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
syncResult.databaseError = true
}
else -> {
@@ -343,25 +665,16 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
val contentIntent: Intent
var viewItemAction: NotificationCompat.Action? = null
if (e is UnauthorizedException) {
contentIntent = Intent(context, AccountSettingsActivity::class.java)
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
contentIntent = Intent(context, SettingsActivity::class.java)
contentIntent.putExtra(SettingsActivity.EXTRA_ACCOUNT,
if (authority == ContactsContract.AUTHORITY)
mainAccount
else
account)
} else {
contentIntent = Intent(context, DebugInfoActivity::class.java)
contentIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
contentIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
contentIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
// use current local/remote resource
currentLocalResource.firstOrNull()?.let { local ->
// pass local resource info to debug info
contentIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
// generate "view item" action
contentIntent = buildDebugInfoIntent(e, local, remote)
if (local != null)
viewItemAction = buildViewItemAction(local)
}
currentRemoteResource.firstOrNull()?.let { remote ->
contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.location.toString())
}
}
// to make the PendingIntent unique
@@ -378,7 +691,7 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
}
val builder = NotificationUtils.newBuilder(context, channel)
builder .setSmallIcon(R.drawable.ic_sync_error_notification)
builder .setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(localCollection.title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
@@ -393,6 +706,19 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
}
private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) =
Intent(context, DebugInfoActivity::class.java).apply {
putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
putExtra(DebugInfoActivity.KEY_THROWABLE, e)
// pass current local/remote resource
if (local != null)
putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
if (remote != null)
putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString())
}
private fun buildRetryAction(): NotificationCompat.Action {
val retryIntent = Intent(context, DavService::class.java)
retryIntent.action = DavService.ACTION_FORCE_SYNC
@@ -419,7 +745,7 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT))
}
private fun buildViewItemAction(local: LocalResource<*>): NotificationCompat.Action? {
private fun buildViewItemAction(local: ResourceType): NotificationCompat.Action? {
Logger.log.log(Level.FINE, "Adding view action for local resource", local)
val intent = local.id?.let { id ->
when (local) {
@@ -441,25 +767,106 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
}
enum class SyncPhase(val number: Int) {
PREPARE(0),
QUERY_CAPABILITIES(1)
@Deprecated("Use Kotlin coroutines instead")
fun checkResults(results: MutableCollection<Future<*>>) {
val iter = results.iterator()
while (iter.hasNext()) {
val result = iter.next()
if (result.isDone) {
try {
result.get()
} catch(e: ExecutionException) {
throw e.cause!!
}
iter.remove()
}
}
}
enum class SyncAlgorithm {
PROPFIND_REPORT,
COLLECTION_SYNC
protected fun notifyInvalidResource(e: Throwable, fileName: String) {
val intent = buildDebugInfoIntent(e, null, collectionURL.resolve(fileName))
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_WARNINGS)
builder .setSmallIcon(R.drawable.ic_warning_notify)
.setContentTitle(notifyInvalidResourceTitle())
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
.setSubText(mainAccount.name)
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.priority = NotificationCompat.PRIORITY_LOW
notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build())
}
protected abstract fun notifyInvalidResourceTitle(): String
class RemoteChanges(
val state: SyncState?,
val furtherChanges: Boolean
) {
val deleted = LinkedList<String>()
val updated = LinkedList<DavResource>()
override fun toString() = MiscUtils.reflectionToString(this)
protected fun<T: ResourceType?, R> useLocal(local: T, body: (T) -> R): R {
try {
return body(local)
} catch (e: ContextedException) {
e.addContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
throw e
} catch (e: Throwable) {
if (local != null)
throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
else
throw e
}
}
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
try {
return body(remote)
} catch (e: ContextedException) {
e.addContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
throw e
} catch(e: Throwable) {
throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
}
}
protected fun<T> useRemote(remote: Response, body: (Response) -> T): T {
try {
return body(remote)
} catch (e: ContextedException) {
e.addContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
throw e
} catch (e: Throwable) {
throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
}
}
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
useRemote(davCollection, body)
private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) {
var ex: Throwable? = null
try {
body()
} catch(e: Throwable) {
ex = e
}
var local: ResourceType? = null
var remote: HttpUrl? = null
if (ex is ContextedException) {
@Suppress("UNCHECKED_CAST")
// we want the innermost context value, which is the first one
(ex.getFirstContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE) as? ResourceType)?.let {
if (local == null)
local = it
}
(ex.getFirstContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE) as? HttpUrl)?.let {
if (remote == null)
remote = it
}
ex = ex.cause
}
if (ex != null)
handler(ex, local, remote)
}
}
@@ -8,27 +8,28 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.app.PendingIntent
import android.content.*
import android.content.pm.PackageManager
import android.database.DatabaseUtils
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.davdroid.AccountSettings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.dmfs.tasks.contract.TaskContract
import java.util.logging.Level
@@ -44,10 +45,15 @@ class TasksSyncAdapterService: SyncAdapterService() {
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val taskProvider = TaskProvider.fromProviderClient(context, provider)
val accountSettings = AccountSettings(context, settings, account)
// make sure account can be seen by OpenTasks
if (Build.VERSION.SDK_INT >= 26)
AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE)
val accountSettings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
@@ -57,17 +63,21 @@ class TasksSyncAdapterService: SyncAdapterService() {
updateLocalTaskLists(taskProvider, account, accountSettings)
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
val priorityTaskLists = priorityCollections(extras)
val taskLists = AndroidTaskList
.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
.sortedByDescending { priorityTaskLists.contains(it.id) }
for (taskList in taskLists) {
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
TasksSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, taskList).use {
TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use {
it.performSync()
}
}
} catch (e: TaskProvider.ProviderTooOldException) {
val nm = NotificationManagerCompat.from(context)
val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName)
val notify = NotificationUtils.newBuilder(context)
.setSmallIcon(R.drawable.ic_sync_error_notification)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(context.getString(R.string.sync_error_opentasks_too_old))
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
@@ -95,63 +105,38 @@ class TasksSyncAdapterService: SyncAdapterService() {
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
val db = AppDatabase.getInstance(context)
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
fun getService() =
db.query(Services._TABLE, arrayOf(Services.ID),
"${Services.ACCOUNT_NAME}=? AND ${Services.SERVICE}=?",
arrayOf(account.name, Services.SERVICE_CALDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}",
arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
remoteTaskLists[collection.url] = collection
}
// enumerate remote and local task lists
val service = getService()
val remote = remoteTaskLists(service)
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let { url ->
val info = remote[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remote -= url
}
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let {
val url = it.toHttpUrl()
val info = remoteTaskLists[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteTaskLists -= url
}
// create new local task lists
for ((_,info) in remote) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
// create new local task lists
for ((_,info) in remoteTaskLists) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
}
@@ -12,27 +12,31 @@ import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.dav4android.property.SyncToken
import at.bitfire.davdroid.AccountSettings
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.CalendarData
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.SyncToken
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.Task
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.*
import java.util.logging.Level
/**
@@ -40,115 +44,88 @@ import java.util.logging.Level
*/
class TasksSyncManager(
context: Context,
settings: ISettings,
account: Account,
accountSettings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCollection: LocalTaskList
): BaseDavSyncManager<LocalTask, LocalTaskList, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection) {
companion object {
const val MULTIGET_MAX_RESOURCES = 30
}
): SyncManager<LocalTask, LocalTaskList, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCollection) {
override fun prepare(): Boolean {
if (!super.prepare())
return false
val url = localCollection.syncId ?: return false
collectionURL = HttpUrl.parse(url) ?: return false
collectionURL = (localCollection.syncId ?: return false).toHttpUrlOrNull() ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
return true
}
override fun queryCapabilities() {
useRemoteCollection { it.propfind(0, GetCTag.NAME, SyncToken.NAME) }
}
override fun queryCapabilities() =
useRemoteCollection {
var syncState: SyncState? = null
it.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF)
syncState = syncState(response)
}
syncState
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource, {
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource) {
val task = requireNotNull(resource.task)
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
val os = ByteArrayOutputStream()
task.write(os)
RequestBody.create(
DavCalendar.MIME_ICALENDAR_UTF8,
os.toByteArray()
)
})
override fun listAllRemote() = useRemoteCollection { remote ->
remote.calendarQuery("VTODO", null, null)
val result = LinkedHashMap<String, DavResource>(remote.members.size)
for (vCard in remote.members) {
val fileName = vCard.fileName()
Logger.log.fine("Found remote VTODO: $fileName")
result[fileName] = vCard
}
result
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
}
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted) {
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local task $name")
useLocal(it, { it.delete() })
syncResult.stats.numDeletes++
}
override fun listAllRemote(callback: DavResponseCallback) {
useRemoteCollection { remote ->
Logger.log.info("Querying tasks")
remote.calendarQuery("VTODO", null, null, callback)
}
}
val toDownload = changes.updated.map { it.location }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
val body = remote.get(DavCalendar.MIME_ICALENDAR.toString())
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = remote.properties[GetETag::class.java]?.eTag
?: throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVTodo(remote.fileName(), eTag, reader)
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
// multiple iCalendars, use calendar-multi-get
useRemoteCollection {
it.multiget(bunch) { response, _ ->
useRemote(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@useRemote
}
})
else {
// multiple contacts, use multi-get
davCollection.multiget(bunch)
// process multiget results
for (remote in davCollection.members) {
val eTag = remote.properties[GetETag::class.java]?.eTag
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote.properties[CalendarData::class.java]
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without address data")
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
}
}
abortIfCancelled()
}
}
override fun postProcess() {
val touched = localCollection.touchRelations()
Logger.log.info("Touched $touched relations")
}
// helpers
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
val tasks: List<Task>
try {
tasks = Task.fromReader(reader)
tasks = Task.tasksFromReader(reader)
} catch (e: InvalidCalendarException) {
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
@@ -156,22 +133,25 @@ class TasksSyncManager(
val newData = tasks.first()
// update local task, if it exists
useLocal(localCollection.findByName(fileName), { local ->
useLocal(localCollection.findByName(fileName)) { local ->
if (local != null) {
Logger.log.info("Updating $fileName in local task list")
Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData)
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding $fileName to local task list")
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData)
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.add()
})
}
syncResult.stats.numInserts++
}
})
}
} else
Logger.log.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_task)
}
@@ -1,5 +1,5 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* Copyright © Ricki Hirner (bitfire web engineering) and other contributors.
* 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
@@ -8,208 +8,231 @@
package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.content.Context
import android.app.Application
import android.os.Build
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.v4.app.FragmentPagerAdapter
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import android.support.v7.app.AppCompatActivity
import android.text.Html
import android.text.Spanned
import android.text.util.Linkify
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.text.method.LinkMovementMethod
import android.util.DisplayMetrics
import android.view.*
import androidx.annotation.UiThread
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView
import androidx.core.text.HtmlCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import ezvcard.Ezvcard
import kotlinx.android.synthetic.main.about_component.view.*
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.LibsBuilder
import kotlinx.android.synthetic.main.about.*
import kotlinx.android.synthetic.main.about_languages.*
import kotlinx.android.synthetic.main.about_translation.view.*
import kotlinx.android.synthetic.main.activity_about.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.apache.commons.io.IOUtils
import java.io.IOException
import org.json.JSONObject
import java.text.Collator
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Level
class AboutActivity: AppCompatActivity() {
private class ComponentInfo(
val title: String?,
val version: String?,
val website: String,
val copyright: String,
val licenseInfo: Int?,
val licenseTextFile: String?
)
companion object {
private lateinit var components: Array<ComponentInfo>
const val pixelsHtml = "<font color=\"#fff433\">■</font>" +
"<font color=\"#ffffff\">■</font>" +
"<font color=\"#9b59d0\">■</font>" +
"<font color=\"#000000\">■</font>"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
components = arrayOf(
ComponentInfo(
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
R.string.about_license_info_no_warranty, "gpl-3.0-standalone.html"
), ComponentInfo(
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
), ComponentInfo(
"Apache Commons", null, "http://commons.apache.org/",
"Apache Software Foundation", R.string.about_license_info_no_warranty, "apache2.html"
), ComponentInfo(
"dnsjava", null, "http://dnsjava.org/",
"Brian Wellington", R.string.about_license_info_no_warranty, "bsd.html"
), ComponentInfo(
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
), ComponentInfo(
"ical4j", "2.x", "https://ical4j.github.io/",
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
), ComponentInfo(
"OkHttp", null, "https://square.github.io/okhttp/",
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
)
)
setContentView(R.layout.activity_about)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
viewpager.adapter = TabsAdapter(supportFragmentManager)
tabs.setupWithViewPager(viewpager)
tabs.setupWithViewPager(viewpager, false)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.about_davdroid, menu)
return true
}
fun showWebsite(item: MenuItem) {
UiUtils.launchUri(this, App.homepageUrl(this))
}
private inner class TabsAdapter(
fm: FragmentManager
): FragmentPagerAdapter(fm) {
): FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
override fun getCount() = components.size
override fun getCount() = 3
override fun getPageTitle(position: Int) =
components[position].title ?: getString(R.string.app_name)!!
override fun getPageTitle(position: Int): String =
when (position) {
0 -> getString(R.string.app_name)
1 -> getString(R.string.about_translations)
else -> getString(R.string.about_libraries)
}
override fun getItem(position: Int) = ComponentFragment.instantiate(position)
override fun getItem(position: Int) =
when (position) {
0 -> AppFragment()
1 -> LanguagesFragment()
else -> {
LibsBuilder()
.withLicenseShown(true)
.withAboutIconShown(false)
// https://github.com/mikepenz/AboutLibraries/issues/490
.withLibraryModification("org_brotli__dec", Libs.LibraryFields.LIBRARY_NAME, "Brotli")
.withLibraryModification("org_brotli__dec", Libs.LibraryFields.AUTHOR_NAME, "Google")
.supportFragment()
}
}
}
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
class AppFragment: Fragment() {
companion object {
const val KEY_POSITION = "position"
const val KEY_FILE_NAME = "fileName"
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
inflater.inflate(R.layout.about, container, false)!!
fun instantiate(position: Int): ComponentFragment {
val frag = ComponentFragment()
val args = Bundle(1)
args.putInt(KEY_POSITION, position)
frag.arguments = args
return frag
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
app_name.setText(R.string.app_name)
app_version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
build_time.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH))
pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
if (true /* open-source version */) {
warranty.setText(R.string.about_license_info_no_warranty)
val model = ViewModelProvider(this).get(TextFileModel::class.java)
model.initialize("gplv3.html", true)
model.htmlText.observe(viewLifecycleOwner, Observer { spanned ->
license_text.text = spanned
})
}
}
private val licenseFragmentLoader = ServiceLoader.load(ILicenseFragment::class.java)!!.firstOrNull()
@SuppressLint("SetTextI18n")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val info = (activity as AboutActivity).components[arguments!!.getInt(KEY_POSITION)]
val v = inflater.inflate(R.layout.about_component, container, false)
var title = info.title ?: getString(R.string.app_name)
info.version?.let { title += " ${info.version}" }
v.title.text = title
v.website.autoLinkMask = Linkify.WEB_URLS
v.website.text = info.website
v.copyright.text = "© ${info.copyright}"
if (info.licenseInfo == null && info.licenseTextFile == null) {
// No license text, so this must be the app's tab. Show the license fragment here, if available.
licenseFragmentLoader?.let { factory ->
fragmentManager!!.beginTransaction()
.add(R.id.license_fragment, factory.getFragment())
.commit()
}
v.license_terms.visibility = View.GONE
} else {
// show license info
if (info.licenseInfo == null)
v.license_info.visibility = View.GONE
else
v.license_info.setText(info.licenseInfo)
// load and format license text
if (info.licenseTextFile == null) {
v.license_header.visibility = View.GONE
v.license_text.visibility = View.GONE
} else {
val args = Bundle(1)
args.putString(KEY_FILE_NAME, info.licenseTextFile)
loaderManager.initLoader(0, args, this)
}
}
return v
}
override fun onCreateLoader(id: Int, args: Bundle?) =
LicenseLoader(activity!!, args!!.getString(KEY_FILE_NAME))
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
view?.let { v ->
v.license_text.autoLinkMask = Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS
v.license_text.text = license
}
}
override fun onLoaderReset(loader: Loader<Spanned>) {}
}
class LicenseLoader(
context: Context,
val fileName: String
): AsyncTaskLoader<Spanned>(context) {
class LanguagesFragment: Fragment() {
private var content: Spanned? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
inflater.inflate(R.layout.about_languages, container, false)!!
override fun onStartLoading() {
if (content != null)
deliverResult(content)
else
forceLoad()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val model = ViewModelProvider(this).get(TextFileModel::class.java)
model.initialize("translators.json", false)
model.plainText.observe(viewLifecycleOwner, Observer { json ->
val jsonTranslations = JSONObject(json)
translators.adapter = TranslationsAdapter(jsonTranslations)
})
translators.layoutManager = LinearLayoutManager(requireActivity())
}
override fun loadInBackground(): Spanned? {
Logger.log.fine("Loading license file $fileName")
try {
context.resources.assets.open(fileName).use {
content = Html.fromHtml(IOUtils.toString(it, Charsets.UTF_8))
return content
class Translation(
val language: String,
val translators: Array<String>
)
class TranslationsAdapter(
jsonTranslations: JSONObject
): RecyclerView.Adapter<TranslationsAdapter.ViewHolder>() {
class ViewHolder(val cardView: CardView): RecyclerView.ViewHolder(cardView)
private val translations = LinkedList<Translation>()
init {
for (langCode in jsonTranslations.keys()) {
val jsonTranslators = jsonTranslations.getJSONArray(langCode)
val translators = Array<String>(jsonTranslators.length()) {
idx -> jsonTranslators.getString(idx)
}
val langTag = langCode.replace('_', '-')
val language = Locale.forLanguageTag(langTag).displayName
translations += Translation(language, translators)
}
} catch(e: IOException) {
Logger.log.log(Level.SEVERE, "Couldn't read license file", e)
return null
// sort translations by localized language name
val collator = Collator.getInstance()
translations.sortWith(object: Comparator<Translation> {
override fun compare(o1: Translation, o2: Translation) =
collator.compare(o1.language, o2.language)
})
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val tv = LayoutInflater.from(parent.context).inflate(R.layout.about_translation, parent, false) as CardView
return ViewHolder(tv)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val translation = translations[position]
holder.cardView.apply {
language.text = translation.language
val profiles = translation.translators.map { "<a href='https://www.transifex.com/user/profile/$it'>$it</a>" }
translators.text = HtmlCompat.fromHtml(
context.getString(R.string.about_translations_thanks, profiles.joinToString(", ")),
HtmlCompat.FROM_HTML_MODE_COMPACT)
translators.movementMethod = LinkMovementMethod.getInstance()
}
}
override fun getItemCount() = translations.size
}
}
interface ILicenseFragment {
class TextFileModel(
application: Application
): AndroidViewModel(application) {
fun getFragment(): Fragment
var initialized = false
val htmlText = MutableLiveData<Spanned>()
val plainText = MutableLiveData<String>()
@UiThread
fun initialize(assetName: String, html: Boolean) {
if (initialized) return
initialized = true
viewModelScope.launch(Dispatchers.IO) {
getApplication<Application>().resources.assets.open(assetName).use {
val raw = IOUtils.toString(it, Charsets.UTF_8)
if (html) {
val spanned = HtmlCompat.fromHtml(raw, HtmlCompat.FROM_HTML_MODE_LEGACY)
htmlText.postValue(spanned)
} else
plainText.postValue(raw)
}
}
}
}
@@ -1,759 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Dialog
import android.app.LoaderManager
import android.content.*
import android.content.pm.PackageManager
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.design.widget.Snackbar
import android.support.v4.app.ActivityCompat
import android.support.v4.app.DialogFragment
import android.support.v4.content.ContextCompat
import android.support.v7.app.AlertDialog
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.view.*
import android.widget.*
import at.bitfire.davdroid.DavService
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider
import kotlinx.android.synthetic.main.account_caldav_item.view.*
import kotlinx.android.synthetic.main.activity_account.*
import java.util.*
import java.util.logging.Level
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
companion object {
const val EXTRA_ACCOUNT = "account"
private fun requestSync(context: Context, account: Account) {
val authorities = arrayOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.authority
)
for (authority in authorities) {
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
}
}
}
lateinit var account: Account
private var accountInfo: AccountInfo? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// account may be a DAVdroid address book account -> use main account in this case
account = LocalAddressBook.mainAccount(this,
requireNotNull(intent.getParcelableExtra(EXTRA_ACCOUNT)))
title = account.name
setContentView(R.layout.activity_account)
val icMenu = if (Build.VERSION.SDK_INT >= 21)
getDrawable(R.drawable.ic_menu_light)
else
resources.getDrawable(R.drawable.ic_menu_light)
// CardDAV toolbar
carddav_menu.overflowIcon = icMenu
carddav_menu.inflateMenu(R.menu.carddav_actions)
carddav_menu.setOnMenuItemClickListener(this)
// CalDAV toolbar
caldav_menu.overflowIcon = icMenu
caldav_menu.inflateMenu(R.menu.caldav_actions)
caldav_menu.setOnMenuItemClickListener(this)
// Webcal toolbar
webcal_menu.overflowIcon = icMenu
webcal_menu.inflateMenu(R.menu.webcal_actions)
webcal_menu.setOnMenuItemClickListener(this)
// load CardDAV/CalDAV collections
loaderManager.initLoader(0, null, this)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (grantResults.any { it == PackageManager.PERMISSION_GRANTED })
// we've got additional permissions; try to load everything again
reload()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.activity_account, menu)
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val itemRename = menu.findItem(R.id.rename_account)
// renameAccount is available for API level 21+
itemRename.isVisible = Build.VERSION.SDK_INT >= 21
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.sync_now ->
requestSync()
R.id.settings -> {
val intent = Intent(this, AccountSettingsActivity::class.java)
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
R.id.rename_account ->
RenameAccountFragment.newInstance(account).show(supportFragmentManager, null)
R.id.delete_account -> {
AlertDialog.Builder(this)
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.account_delete_confirmation_title)
.setMessage(R.string.account_delete_confirmation_text)
.setNegativeButton(android.R.string.no, null)
.setPositiveButton(android.R.string.yes, { _, _ ->
deleteAccount()
})
.show()
}
else ->
return super.onOptionsItemSelected(item)
}
return true
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.refresh_address_books ->
accountInfo?.carddav?.let { carddav ->
val intent = Intent(this, DavService::class.java)
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, carddav.id)
startService(intent)
}
R.id.create_address_book -> {
val intent = Intent(this, CreateAddressBookActivity::class.java)
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
R.id.refresh_calendars ->
accountInfo?.caldav?.let { caldav ->
val intent = Intent(this, DavService::class.java)
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, caldav.id)
startService(intent)
}
R.id.create_calendar -> {
val intent = Intent(this, CreateCalendarActivity::class.java)
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
}
return false
}
private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ ->
if (!view.isEnabled)
return@OnItemClickListener
val list = parent as ListView
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
val info = adapter.getItem(position)
val nowChecked = !info.selected
OpenHelper(this).use { dbHelper ->
val db = dbHelper.writableDatabase
db.beginTransactionNonExclusive()
val values = ContentValues(1)
values.put(Collections.SYNC, if (nowChecked) 1 else 0)
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
db.setTransactionSuccessful()
db.endTransaction()
}
info.selected = nowChecked
adapter.notifyDataSetChanged()
}
private val onActionOverflowListener = { anchor: View, info: CollectionInfo ->
val popup = PopupMenu(this, anchor, Gravity.RIGHT)
popup.inflate(R.menu.account_collection_operations)
with(popup.menu.findItem(R.id.force_read_only)) {
if (info.readOnly)
isVisible = false
else
isChecked = info.forceReadOnly
}
popup.setOnMenuItemClickListener({ item ->
when (item.itemId) {
R.id.force_read_only -> {
val nowChecked = !item.isChecked
OpenHelper(this).use { dbHelper ->
val db = dbHelper.writableDatabase
db.beginTransactionNonExclusive()
val values = ContentValues(1)
values.put(Collections.FORCE_READ_ONLY, nowChecked)
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
db.setTransactionSuccessful()
db.endTransaction()
reload()
}
}
R.id.delete_collection ->
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
}
true
})
popup.show()
// long click was handled
true
}
private val webcalOnItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
val info = parent.getItemAtPosition(position) as CollectionInfo
var uri = Uri.parse(info.source)
val nowChecked = !info.selected
if (nowChecked) {
// subscribe to Webcal feed
when {
uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build()
uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build()
}
val intent = Intent(Intent.ACTION_VIEW, uri)
info.displayName?.let { intent.putExtra("title", it) }
info.color?.let { intent.putExtra("color", it) }
if (packageManager.resolveActivity(intent, 0) != null)
startActivity(intent)
else {
val snack = Snackbar.make(parent, R.string.account_no_webcal_handler_found, Snackbar.LENGTH_LONG)
val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid"))
if (packageManager.resolveActivity(installIntent, 0) != null)
snack.setAction(R.string.account_install_icsdroid, {
startActivity(installIntent)
})
snack.show()
}
} else {
// unsubscribe from Webcal feed
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
provider.delete(CalendarContract.Calendars.CONTENT_URI, "${CalendarContract.Calendars.NAME}=?", arrayOf(info.source))
reload()
} finally {
provider.release()
}
}
}
}
/* LOADERS AND LOADED DATA */
class AccountInfo {
var carddav: ServiceInfo? = null
var caldav: ServiceInfo? = null
class ServiceInfo {
var id: Long? = null
var refreshing = false
var hasHomeSets = false
var collections = listOf<CollectionInfo>()
}
}
override fun onCreateLoader(id: Int, args: Bundle?) =
AccountLoader(this, account)
fun reload() {
loaderManager.restartLoader(0, null, this)
}
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
accountInfo = info
carddav.visibility = info?.carddav?.let { carddav ->
carddav_refreshing.visibility = if (carddav.refreshing) View.VISIBLE else View.GONE
address_books.isEnabled = !carddav.refreshing
address_books.alpha = if (carddav.refreshing) 0.5f else 1f
carddav_menu.menu.findItem(R.id.create_address_book).isEnabled = carddav.hasHomeSets
val adapter = AddressBookAdapter(this)
adapter.addAll(carddav.collections)
address_books.adapter = adapter
address_books.onItemClickListener = onItemClickListener
View.VISIBLE
} ?: View.GONE
caldav.visibility = info?.caldav?.let { caldav ->
caldav_refreshing.visibility = if (caldav.refreshing) View.VISIBLE else View.GONE
calendars.isEnabled = !caldav.refreshing
calendars.alpha = if (caldav.refreshing) 0.5f else 1f
caldav_menu.menu.findItem(R.id.create_calendar).isEnabled = caldav.hasHomeSets
val adapter = CalendarAdapter(this)
adapter.addAll(caldav.collections.filter { it.type == CollectionInfo.Type.CALENDAR })
calendars.adapter = adapter
calendars.onItemClickListener = onItemClickListener
View.VISIBLE
} ?: View.GONE
webcal.visibility = info?.caldav?.let {
val collections = it.collections.filter { it.type == CollectionInfo.Type.WEBCAL }
val adapter = CalendarAdapter(this)
adapter.addAll(collections)
webcals.adapter = adapter
webcals.onItemClickListener = webcalOnItemClickListener
if (collections.isNotEmpty())
View.VISIBLE
else
View.GONE
} ?: View.GONE
// ask for permissions
val requiredPermissions = mutableSetOf<String>()
info?.carddav?.let { carddav ->
if (carddav.collections.any { it.type == CollectionInfo.Type.ADDRESS_BOOK }) {
requiredPermissions += Manifest.permission.READ_CONTACTS
requiredPermissions += Manifest.permission.WRITE_CONTACTS
}
}
info?.caldav?.let { caldav ->
if (caldav.collections.any { it.type == CollectionInfo.Type.CALENDAR }) {
requiredPermissions += Manifest.permission.READ_CALENDAR
requiredPermissions += Manifest.permission.WRITE_CALENDAR
if (LocalTaskList.tasksProviderAvailable(this)) {
requiredPermissions += TaskProvider.PERMISSION_READ_TASKS
requiredPermissions += TaskProvider.PERMISSION_WRITE_TASKS
}
}
if (caldav.collections.any { it.type == CollectionInfo.Type.WEBCAL })
requiredPermissions += Manifest.permission.READ_CALENDAR
}
val askPermissions = requiredPermissions.filter { ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
if (askPermissions.isNotEmpty())
ActivityCompat.requestPermissions(this, askPermissions.toTypedArray(), 0)
}
override fun onLoaderReset(loader: Loader<AccountInfo>) {
address_books?.adapter = null
calendars?.adapter = null
}
class AccountLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, SyncStatusObserver {
private var syncStatusListener: Any? = null
private var davServiceConn: ServiceConnection? = null
private var davService: DavService.InfoBinder? = null
override fun onStartLoading() {
// get notified when sync status changes
if (syncStatusListener == null)
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
// bind to DavService to get notified when it's running
if (davServiceConn == null) {
davServiceConn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
// get notified when DavService is running
davService = service as DavService.InfoBinder
service.addRefreshingStatusListener(this@AccountLoader, false)
onContentChanged()
}
override fun onServiceDisconnected(name: ComponentName) {
davService = null
}
}
context.bindService(Intent(context, DavService::class.java), davServiceConn, Context.BIND_AUTO_CREATE)
} else
forceLoad()
}
override fun onReset() {
ContentResolver.removeStatusChangeListener(syncStatusListener)
davService?.removeRefreshingStatusListener(this)
davServiceConn?.let {
context.unbindService(it)
davServiceConn = null
}
}
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
onContentChanged()
override fun onStatusChanged(which: Int) =
onContentChanged()
override fun loadInBackground(): AccountInfo {
val info = AccountInfo()
OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(
Services._TABLE,
arrayOf(Services.ID, Services.SERVICE),
"${Services.ACCOUNT_NAME}=?", arrayOf(account.name),
null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
when (cursor.getString(1)) {
Services.SERVICE_CARDDAV -> {
val carddav = AccountInfo.ServiceInfo()
info.carddav = carddav
carddav.id = id
carddav.refreshing =
davService?.isRefreshing(id) ?: false ||
ContentResolver.isSyncActive(account, context.getString(R.string.address_books_authority))
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.mainAccount)
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch(e: Exception) {
}
}
carddav.hasHomeSets = hasHomeSets(db, id)
carddav.collections = readCollections(db, id)
}
Services.SERVICE_CALDAV -> {
val caldav = AccountInfo.ServiceInfo()
info.caldav = caldav
caldav.id = id
caldav.refreshing =
davService?.isRefreshing(id) ?: false ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
caldav.hasHomeSets = hasHomeSets(db, id)
caldav.collections = readCollections(db, id)
}
}
}
}
}
return info
}
private fun hasHomeSets(db: SQLiteDatabase, service: Long): Boolean {
db.query(ServiceDB.HomeSets._TABLE, null, "${ServiceDB.HomeSets.SERVICE_ID}=?",
arrayOf(service.toString()), null, null, null)?.use { cursor ->
return cursor.count > 0
}
return false
}
private fun readCollections(db: SQLiteDatabase, service: Long): List<CollectionInfo> {
val collections = LinkedList<CollectionInfo>()
db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()),
null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
collections.add(CollectionInfo(values))
}
}
// Webcal: check whether calendar is already subscribed by ICSdroid
// (or any other app that stores the URL in Calendars.NAME)
val webcalCollections = collections.filter { it.type == CollectionInfo.Type.WEBCAL }
if (webcalCollections.isNotEmpty() && ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
for (info in webcalCollections) {
provider.query(CalendarContract.Calendars.CONTENT_URI, null,
"${CalendarContract.Calendars.NAME}=?", arrayOf(info.source), null)?.use { cursor ->
if (cursor.moveToNext())
info.selected = true
}
}
} finally {
provider.release()
}
}
return collections
}
}
/* LIST ADAPTERS */
class AddressBookAdapter(
context: Context
): ArrayAdapter<CollectionInfo>(context, R.layout.account_carddav_item) {
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_carddav_item, parent, false)
val info = getItem(position)
val checked: CheckBox = v.findViewById(R.id.checked)
checked.isChecked = info.selected
var tv: TextView = v.findViewById(R.id.title)
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
tv = v.findViewById(R.id.description)
if (info.description.isNullOrBlank())
tv.visibility = View.GONE
else {
tv.visibility = View.VISIBLE
tv.text = info.description
}
v.findViewById<ImageView>(R.id.read_only).visibility =
if (info.readOnly || info.forceReadOnly) View.VISIBLE else View.GONE
v.findViewById<ImageView>(R.id.action_overflow).setOnClickListener({ view ->
(context as? AccountActivity)?.let {
it.onActionOverflowListener(view, info)
}
})
return v
}
}
class CalendarAdapter(
context: Context
): ArrayAdapter<CollectionInfo>(context, R.layout.account_caldav_item) {
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_caldav_item, parent, false)
val info = getItem(position)
val enabled = info.selected || info.supportsVEVENT || info.supportsVTODO
v.isEnabled = enabled
v.checked.isEnabled = enabled
val checked: CheckBox = v.findViewById(R.id.checked)
checked.isChecked = info.selected
val vColor: View = v.findViewById(R.id.color)
vColor.visibility = info.color?.let {
vColor.setBackgroundColor(it)
View.VISIBLE
} ?: View.INVISIBLE
var tv: TextView = v.findViewById(R.id.title)
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
tv = v.findViewById(R.id.description)
if (info.description.isNullOrBlank())
tv.visibility = View.GONE
else {
tv.visibility = View.VISIBLE
tv.text = info.description
}
v.findViewById<ImageView>(R.id.read_only).visibility =
if (info.readOnly || info.forceReadOnly) View.VISIBLE else View.GONE
v.findViewById<ImageView>(R.id.events).visibility =
if (info.supportsVEVENT) View.VISIBLE else View.GONE
v.findViewById<ImageView>(R.id.tasks).visibility =
if (info.supportsVTODO) View.VISIBLE else View.GONE
val overflow = v.findViewById<ImageView>(R.id.action_overflow)
if (info.type == CollectionInfo.Type.WEBCAL)
overflow.visibility = View.GONE
else
overflow.setOnClickListener({ view ->
(context as? AccountActivity)?.let {
it.onActionOverflowListener(view, info)
}
})
return v
}
}
/* DIALOG FRAGMENTS */
class RenameAccountFragment: DialogFragment() {
companion object {
val ARG_ACCOUNT = "account"
fun newInstance(account: Account): RenameAccountFragment {
val fragment = RenameAccountFragment()
val args = Bundle(1)
args.putParcelable(ARG_ACCOUNT, account)
fragment.arguments = args
return fragment
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val oldAccount: Account = arguments!!.getParcelable(ARG_ACCOUNT)
val editText = EditText(activity)
editText.setText(oldAccount.name)
return AlertDialog.Builder(activity!!)
.setTitle(R.string.account_rename)
.setMessage(R.string.account_rename_new_name)
.setView(editText)
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ ->
val newName = editText.text.toString()
if (newName == oldAccount.name)
return@OnClickListener
val accountManager = AccountManager.get(activity)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
accountManager.renameAccount(oldAccount, newName, { _ ->
Logger.log.info("Updating account name references")
// cancel maybe running synchronization
ContentResolver.cancelSync(oldAccount, null)
for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book)))
ContentResolver.cancelSync(addrBookAccount, null)
// update account name references in database
OpenHelper(activity!!).use { dbHelper ->
ServiceDB.onRenameAccount(dbHelper.writableDatabase, oldAccount.name, newName)
}
// update main account of address book accounts
try {
for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book))) {
val provider = activity!!.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
try {
if (provider != null) {
val addressBook = LocalAddressBook(activity!!, addrBookAccount, provider)
if (oldAccount == addressBook.mainAccount)
addressBook.mainAccount = Account(newName, oldAccount.type)
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider?.close()
else
provider?.release()
}
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
}
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again)
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(activity!!.contentResolver, oldAccount.name, newName)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e)
}
// synchronize again
requestSync(activity!!, Account(newName, oldAccount.type))
}, null)
activity!!.finish()
})
.setNegativeButton(android.R.string.cancel, { _, _ -> })
.create()
}
}
/* USER ACTIONS */
private fun deleteAccount() {
val accountManager = AccountManager.get(this)
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, this, { future ->
try {
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
finish()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
else
accountManager.removeAccount(account, { future ->
try {
if (future.result)
finish()
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
}
private fun requestSync() {
requestSync(this, account)
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
}
}
@@ -11,12 +11,16 @@ package at.bitfire.davdroid.ui
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.ListFragment
import android.app.LoaderManager
import android.content.AsyncTaskLoader
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Loader
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -24,24 +28,41 @@ import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.ListFragment
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.account.AccountActivity
import kotlinx.android.synthetic.main.account_list.*
import kotlinx.android.synthetic.main.account_list_item.view.*
class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>> {
class AccountListFragment: ListFragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
listAdapter = AccountListAdapter(activity)
listAdapter = AccountListAdapter(requireActivity())
val model = ViewModelProvider(this).get(Model::class.java)
model.accounts.observe(viewLifecycleOwner, Observer { accounts ->
val adapter = listAdapter as AccountListAdapter
adapter.clear()
adapter.addAll(*accounts)
})
model.networkAvailable.observe(viewLifecycleOwner, Observer { networkAvailable ->
no_network_info.visibility = if (networkAvailable) View.GONE else View.VISIBLE
})
return inflater.inflate(R.layout.account_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loaderManager.initLoader(0, arguments, this)
listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE
listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
val account = listAdapter.getItem(position) as Account
val account = listAdapter!!.getItem(position) as Account
val intent = Intent(activity, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
@@ -49,66 +70,103 @@ class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<A
}
// loader
override fun onCreateLoader(id: Int, args: Bundle?) =
AccountLoader(activity)
override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {
val adapter = listAdapter as AccountListAdapter
adapter.clear()
adapter.addAll(*accounts)
}
override fun onLoaderReset(loader: Loader<Array<Account>>) {
(listAdapter as AccountListAdapter).clear()
}
class AccountLoader(
context: Context
): AsyncTaskLoader<Array<Account>>(context) {
private val accountManager = AccountManager.get(context)!!
private var listener: OnAccountsUpdateListener? = null
override fun onStartLoading() {
if (listener == null) {
listener = OnAccountsUpdateListener { onContentChanged() }
accountManager.addOnAccountsUpdatedListener(listener, null, false)
}
forceLoad()
}
override fun onReset() {
listener?.let {
try {
accountManager.removeOnAccountsUpdatedListener(it)
} catch(ignored: IllegalArgumentException) {}
listener = null
}
}
override fun loadInBackground(): Array<Account> =
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
}
// list adapter
class AccountListAdapter(
context: Context
): ArrayAdapter<Account>(context, R.layout.account_list_item) {
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val account = getItem(position)
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
val account = getItem(position)!!
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
val v = _v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
v.account_name.text = account.name
return v
}
}
}
class Model(
application: Application
): AndroidViewModel(application), OnAccountsUpdateListener {
val accounts = MutableLiveData<Array<out Account>>()
val networkAvailable = MutableLiveData<Boolean>()
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var networkReceiver: BroadcastReceiver? = null
private val accountManager = AccountManager.get(getApplication())!!
private val connectivityManager = application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
init {
accountManager.addOnAccountsUpdatedListener(this, null, true)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26
networkReceiver = object: BroadcastReceiver() {
init {
update()
}
override fun onReceive(context: Context?, intent: Intent?) = update()
private fun update() {
networkAvailable.postValue(connectivityManager.allNetworkInfo.any { it.isConnected })
}
}
application.registerReceiver(networkReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
} else { // API level >= 26
networkAvailable.postValue(false)
// check for working (e.g. WiFi after captive portal login) Internet connection
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
val callback = object: ConnectivityManager.NetworkCallback() {
val availableNetworks = hashSetOf<Network>()
override fun onAvailable(network: Network) {
availableNetworks += network
update()
}
override fun onLost(network: Network) {
availableNetworks -= network
update()
}
private fun update() {
networkAvailable.postValue(availableNetworks.isNotEmpty())
}
}
connectivityManager.registerNetworkCallback(networkRequest, callback)
networkCallback = callback
}
}
override fun onCleared() {
accountManager.removeOnAccountsUpdatedListener(this)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
networkReceiver?.let {
getApplication<Application>().unregisterReceiver(it)
}
else
networkCallback?.let {
connectivityManager.unregisterNetworkCallback(it)
}
}
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
val context = getApplication<Application>()
accounts.postValue(
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
)
}
}
}
@@ -1,375 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.security.KeyChain
import android.support.v4.app.ActivityCompat
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.app.NavUtils
import android.support.v4.content.ContextCompat
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import android.support.v7.app.AppCompatActivity
import android.support.v7.preference.*
import android.view.MenuItem
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import org.apache.commons.lang3.StringUtils
class AccountSettingsActivity: AppCompatActivity() {
companion object {
const val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
title = getString(R.string.settings_title, account.name)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null)
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras))
.commit()
}
override fun onOptionsItemSelected(item: MenuItem) =
if (item.itemId == android.R.id.home) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
NavUtils.navigateUpTo(this, intent)
true
} else
false
class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<Pair<ISettings, AccountSettings>> {
lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = arguments!!.getParcelable(EXTRA_ACCOUNT)
loaderManager.initLoader(0, arguments, this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.settings_account)
}
override fun onCreateLoader(id: Int, args: Bundle?) =
AccountSettingsLoader(requireActivity(), args!!.getParcelable(EXTRA_ACCOUNT))
override fun onLoadFinished(loader: Loader<Pair<ISettings, AccountSettings>>, result: Pair<ISettings, AccountSettings>?) {
val (settings, accountSettings) = result ?: return
// preference group: authentication
val prefUserName = findPreference("username") as EditTextPreference
val prefPassword = findPreference("password") as EditTextPreference
val prefCertAlias = findPreference("certificate_alias") as Preference
val credentials = accountSettings.credentials()
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
prefUserName.isVisible = true
prefUserName.summary = credentials.userName
prefUserName.text = credentials.userName
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.credentials(Credentials(newValue as String, credentials.password))
loaderManager.restartLoader(0, arguments, this)
false
}
prefPassword.isVisible = true
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.credentials(Credentials(credentials.userName, newValue as String))
loaderManager.restartLoader(0, arguments, this)
false
}
prefCertAlias.isVisible = false
}
Credentials.Type.ClientCertificate -> {
prefUserName.isVisible = false
prefPassword.isVisible = false
prefCertAlias.isVisible = true
prefCertAlias.summary = credentials.certificateAlias
prefCertAlias.setOnPreferenceClickListener {
KeyChain.choosePrivateKeyAlias(activity, { alias ->
accountSettings.credentials(Credentials(certificateAlias = alias))
Handler(Looper.getMainLooper()).post({
loaderManager.restartLoader(0, arguments, this)
})
}, null, null, null, -1, credentials.certificateAlias)
true
}
}
}
// preference group: sync
// those are null if the respective sync type is not available for this account:
val syncIntervalContacts = accountSettings.getSyncInterval(getString(R.string.address_books_authority))
val syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
val syncIntervalTasks = accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
(findPreference("sync_interval_contacts") as ListPreference).let {
if (syncIntervalContacts != null) {
it.isVisible = true
it.value = syncIntervalContacts.toString()
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong())
loaderManager.restartLoader(0, arguments, this)
false
}
} else
it.isVisible = false
}
(findPreference("sync_interval_calendars") as ListPreference).let {
if (syncIntervalCalendars != null) {
it.isVisible = true
it.value = syncIntervalCalendars.toString()
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
loaderManager.restartLoader(0, arguments, this)
false
}
} else
it.isVisible = false
}
(findPreference("sync_interval_tasks") as ListPreference).let {
if (syncIntervalTasks != null) {
it.isVisible = true
it.value = syncIntervalTasks.toString()
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
loaderManager.restartLoader(0, arguments, this)
false
}
} else
it.isVisible = false
}
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
prefWifiOnly.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY)
prefWifiOnly.isChecked = accountSettings.getSyncWifiOnly()
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
accountSettings.setSyncWiFiOnly(wifiOnly as Boolean)
loaderManager.restartLoader(0, arguments, this)
false
}
val prefWifiOnlySSIDs = findPreference("sync_wifi_only_ssids") as EditTextPreference
val onlySSIDs = accountSettings.getSyncWifiOnlySSIDs()?.joinToString(", ")
prefWifiOnlySSIDs.text = onlySSIDs
if (onlySSIDs != null)
prefWifiOnlySSIDs.summary = getString(R.string.settings_sync_wifi_only_ssids_on, onlySSIDs)
else
prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off)
prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct())
loaderManager.restartLoader(0, arguments, this)
false
}
// getting the WiFi name requires location permission (and active location services) since Android 8.1
// see https://issuetracker.google.com/issues/70633700
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
accountSettings.getSyncWifiOnly() && onlySSIDs != null &&
ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0)
// preference group: CardDAV
(findPreference("contact_group_method") as ListPreference).let {
if (syncIntervalContacts != null) {
it.isVisible = true
it.value = accountSettings.getGroupMethod().name
it.summary = it.entry
if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD))
it.isEnabled = false
else {
it.isEnabled = true
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.settings_contact_group_method_change)
.setMessage(R.string.settings_contact_group_method_change_reload_contacts)
.setPositiveButton(android.R.string.ok, { _, _ ->
// change group method
accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
loaderManager.restartLoader(0, arguments, this)
// reload all contacts
val args = Bundle(1)
args.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
ContentResolver.requestSync(account, getString(R.string.address_books_authority), args)
})
.setNegativeButton(android.R.string.cancel, null)
.show()
false
}
}
} else
it.isVisible = false
}
// preference group: CalDAV
(findPreference("time_range_past_days") as EditTextPreference).let {
if (syncIntervalCalendars != null) {
it.isVisible = true
val pastDays = accountSettings.getTimeRangePastDays()
if (pastDays != null) {
it.text = pastDays.toString()
it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
} else {
it.text = null
it.setSummary(R.string.settings_sync_time_range_past_none)
}
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val days = try {
(newValue as String).toInt()
} catch(e: NumberFormatException) {
-1
}
accountSettings.setTimeRangePastDays(if (days < 0) null else days)
loaderManager.restartLoader(0, arguments, this)
false
}
} else
it.isVisible = false
}
(findPreference("manage_calendar_colors") as SwitchPreferenceCompat).let {
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
it.isVisible = true
it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS)
it.isChecked = accountSettings.getManageCalendarColors()
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setManageCalendarColors(newValue as Boolean)
loaderManager.restartLoader(0, arguments, this)
false
}
} else
it.isVisible = false
}
(findPreference("event_colors") as SwitchPreferenceCompat).let {
if (syncIntervalCalendars != null) {
it.isVisible = true
it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS)
it.isChecked = accountSettings.getEventColors()
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
if (newValue as Boolean) {
accountSettings.setEventColors(true)
loaderManager.restartLoader(0, arguments, this)
} else
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.settings_event_colors)
.setMessage(R.string.settings_event_colors_off_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, { _, _ ->
accountSettings.setEventColors(false)
loaderManager.restartLoader(0, arguments, this)
})
.show()
false
}
} else
it.isVisible = false
}
}
override fun onLoaderReset(loader: Loader<Pair<ISettings, AccountSettings>>) {
}
}
class AccountSettingsLoader(
context: Context,
val account: Account
): SettingsLoader<Pair<ISettings, AccountSettings>?>(context), SyncStatusObserver {
private var listenerHandle: Any? = null
override fun onStartLoading() {
super.onStartLoading()
if (listenerHandle == null)
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
}
override fun onReset() {
super.onReset()
listenerHandle?.let {
ContentResolver.removeStatusChangeListener(it)
listenerHandle = null
}
}
override fun loadInBackground(): Pair<ISettings, AccountSettings>? {
settings?.let { settings ->
try {
return Pair(
settings,
AccountSettings(context, settings, account)
)
} catch (e: InvalidAccountException) {
}
}
return null
}
override fun onStatusChanged(which: Int) {
onContentChanged()
}
}
}
@@ -9,87 +9,72 @@
package at.bitfire.davdroid.ui
import android.accounts.AccountManager
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.os.Bundle
import android.support.design.widget.NavigationView
import android.support.design.widget.Snackbar
import android.support.v4.app.LoaderManager
import android.support.v4.content.Loader
import android.support.v4.view.GravityCompat
import android.support.v7.app.ActionBarDrawerToggle
import android.support.v7.app.AppCompatActivity
import android.view.MenuItem
import android.view.View
import at.bitfire.davdroid.App
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.accounts_content.*
import kotlinx.android.synthetic.main.activity_accounts.*
import kotlinx.android.synthetic.main.activity_accounts.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, LoaderManager.LoaderCallbacks<AccountsActivity.Settings>, SyncStatusObserver {
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver {
companion object {
val accountsDrawerHandler = DefaultAccountsDrawerHandler()
private const val fragTagStartup = "startup"
const val REQUEST_INTRO = 0
}
private lateinit var settings: Settings
private var syncStatusSnackbar: Snackbar? = null
private var syncStatusObserver: Any? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_accounts)
settings = Settings.getInstance(this)
if (savedInstanceState == null)
CoroutineScope(Dispatchers.Default).launch {
// use a separate thread to check whether IntroActivity should be shown
if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity)) {
val intro = Intent(this@AccountsActivity, IntroActivity::class.java)
startActivityForResult(intro, REQUEST_INTRO)
}
}
setContentView(R.layout.activity_accounts)
setSupportActionBar(toolbar)
fab.setOnClickListener({
fab.setOnClickListener {
startActivity(Intent(this, LoginActivity::class.java))
})
}
fab.show()
accountsDrawerHandler.initMenu(this, drawer_layout.nav_view.menu)
val toggle = ActionBarDrawerToggle(
this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
drawer_layout.setDrawerListener(toggle)
drawer_layout.addDrawerListener(toggle)
toggle.syncState()
nav_view.setNavigationItemSelectedListener(this)
nav_view.itemIconTintList = null
/* When the DAVdroid main activity is started, start a Settings service that stays in memory
for better performance. The service stops itself when memory is trimmed. */
val settingsIntent = Intent(this, Settings::class.java)
startService(settingsIntent)
val args = Bundle(1)
supportLoaderManager.initLoader(0, args, this)
}
override fun onCreateLoader(code: Int, args: Bundle?) =
SettingsLoader(this)
override fun onLoadFinished(loader: Loader<Settings>, result: Settings?) {
val result = result ?: return
if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) {
val ft = supportFragmentManager.beginTransaction()
StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, fragTagStartup) }
ft.commit()
}
nav_view?.menu?.let {
accountsDrawerHandler.onSettingsChanged(result.settings, it)
}
}
override fun onLoaderReset(loader: Loader<Settings>) {
nav_view?.menu?.let {
accountsDrawerHandler.onSettingsChanged(null, it)
}
}
override fun onResume() {
@@ -117,15 +102,22 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
if (!ContentResolver.getMasterSyncAutomatically()) {
val snackbar = Snackbar
.make(findViewById(R.id.coordinator), R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.accounts_global_sync_enable, {
ContentResolver.setMasterSyncAutomatically(true)
})
.setAction(R.string.accounts_global_sync_enable) {
ContentResolver.setMasterSyncAutomatically(true)
}
syncStatusSnackbar = snackbar
snackbar.show()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_INTRO && resultCode == Activity.RESULT_CANCELED)
finish()
else
super.onActivityResult(requestCode, resultCode, data)
}
override fun onBackPressed() {
if (drawer_layout.isDrawerOpen(GravityCompat.START))
drawer_layout.closeDrawer(GravityCompat.START)
@@ -139,27 +131,4 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
return processed
}
class Settings(
val settings: ISettings
)
class SettingsLoader(
context: Context
): at.bitfire.davdroid.ui.SettingsLoader<Settings>(context) {
override fun loadInBackground(): Settings? {
settings?.let {
val accountManager = AccountManager.get(context)
val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
return Settings(
it
)
}
return null
}
}
}
@@ -8,28 +8,22 @@
package at.bitfire.davdroid.ui
import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.os.Process
import android.support.design.widget.Snackbar
import android.support.v7.app.AppCompatActivity
import android.support.v7.preference.EditTextPreference
import android.support.v7.preference.Preference
import android.support.v7.preference.PreferenceFragmentCompat
import android.support.v7.preference.SwitchPreferenceCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.ISettingsObserver
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment
import at.bitfire.davdroid.ui.intro.OpenSourceFragment
import at.bitfire.davdroid.ui.intro.OpenTasksFragment
import com.google.android.material.snackbar.Snackbar
import java.net.URI
import java.net.URISyntaxException
@@ -52,144 +46,113 @@ class AppSettingsActivity: AppCompatActivity() {
}
class SettingsFragment: PreferenceFragmentCompat() {
val observer = object: ISettingsObserver.Stub() {
override fun onSettingsChanged() {
loadSettings()
}
}
var settings: ISettings? = null
var settingsSvc: ServiceConnection? = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
settings = ISettings.Stub.asInterface(binder)
settings?.registerObserver(observer)
loadSettings()
}
override fun onServiceDisconnected(name: ComponentName) {
settings?.unregisterObserver(observer)
settings = null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!activity!!.bindService(Intent(activity, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE))
settingsSvc = null
}
override fun onDestroy() {
super.onDestroy()
settingsSvc?.let { activity!!.unbindService(it) }
}
class SettingsFragment: PreferenceFragmentCompat(), Settings.OnChangeListener {
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
addPreferencesFromResource(R.xml.settings_app)
loadSettings()
// UI settings
val prefResetHints = findPreference("reset_hints")
prefResetHints.onPreferenceClickListener = Preference.OnPreferenceClickListener {
findPreference<Preference>("notification_settings")!!.apply {
if (Build.VERSION.SDK_INT >= 26)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
})
false
}
else
isVisible = false
}
findPreference<Preference>("reset_hints")!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetHints()
false
}
// security settings
val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES)
prefDistrustSystemCerts.isVisible = BuildConfig.customCerts
prefDistrustSystemCerts.isEnabled = true
val prefResetCertificates = findPreference("reset_certificates")
prefResetCertificates.isVisible = BuildConfig.customCerts
prefResetCertificates.isEnabled = true
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetCertificates()
false
}
arguments?.getString(EXTRA_SCROLL_TO)?.let { scrollToPreference(it) }
}
private fun loadSettings() {
val settings = requireNotNull(settings)
// connection settings
val prefOverrideProxy = findPreference(App.OVERRIDE_PROXY) as SwitchPreferenceCompat
prefOverrideProxy.isChecked = settings.getBoolean(App.OVERRIDE_PROXY, false)
prefOverrideProxy.isEnabled = settings.isWritable(App.OVERRIDE_PROXY)
val prefProxyHost = findPreference(App.OVERRIDE_PROXY_HOST) as EditTextPreference
prefProxyHost.isEnabled = settings.isWritable(App.OVERRIDE_PROXY_HOST)
val proxyHost = settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
prefProxyHost.text = proxyHost
prefProxyHost.summary = proxyHost
prefProxyHost.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val host = newValue as String
try {
URI(null, host, null, null)
settings.putString(App.OVERRIDE_PROXY_HOST, host)
prefProxyHost.summary = host
true
} catch(e: URISyntaxException) {
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
findPreference<Preference>("reset_certificates")!!.apply {
onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetCertificates()
false
}
}
val prefProxyPort = findPreference(App.OVERRIDE_PROXY_PORT) as EditTextPreference
prefProxyHost.isEnabled = settings.isWritable(App.OVERRIDE_PROXY_PORT)
val proxyPort = settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
prefProxyPort.text = proxyPort.toString()
prefProxyPort.summary = proxyPort.toString()
prefProxyPort.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
try {
val port = Integer.parseInt(newValue as String)
if (port in 1..65535) {
settings.putInt(App.OVERRIDE_PROXY_PORT, port)
prefProxyPort.text = port.toString()
prefProxyPort.summary = port.toString()
arguments?.getString(EXTRA_SCROLL_TO)?.let { key ->
scrollToPreference(key)
}
}
private fun loadSettings() {
val settings = Settings.getInstance(requireActivity())
// connection settings
findPreference<SwitchPreferenceCompat>(Settings.OVERRIDE_PROXY)!!.apply {
isChecked = settings.getBoolean(Settings.OVERRIDE_PROXY) ?: Settings.OVERRIDE_PROXY_DEFAULT
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY)
}
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_HOST)!!.apply {
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_HOST)
val proxyHost = settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT
text = proxyHost
summary = proxyHost
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val host = newValue as String
try {
URI(null, host, null, null)
settings.putString(Settings.OVERRIDE_PROXY_HOST, host)
summary = host
true
} else
} catch(e: URISyntaxException) {
Snackbar.make(requireView(), e.localizedMessage, Snackbar.LENGTH_LONG).show()
false
} catch(e: NumberFormatException) {
false
}
}
}
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_PORT)!!.apply {
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_PORT)
val proxyPort = settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
text = proxyPort.toString()
summary = proxyPort.toString()
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
try {
val port = Integer.parseInt(newValue as String)
if (port in 1..65535) {
settings.putInt(Settings.OVERRIDE_PROXY_PORT, port)
text = port.toString()
summary = port.toString()
true
} else
false
} catch(e: NumberFormatException) {
false
}
}
}
// security settings
val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES) as SwitchPreferenceCompat
prefDistrustSystemCerts.isChecked = settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)
// debugging settings
val prefLogToExternalStorage = findPreference(Logger.LOG_TO_EXTERNAL_STORAGE) as SwitchPreferenceCompat
prefLogToExternalStorage.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val context = activity!!
Logger.initialize(context)
// kill a potential :sync process, so that the new logger settings will be used
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.runningAppProcesses.forEach {
if (it.pid != Process.myPid()) {
Logger.log.info("Killing ${it.processName} process, pid = ${it.pid}")
Process.killProcess(it.pid)
}
}
true
}
findPreference<SwitchPreferenceCompat>(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!
.isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT
}
override fun onSettingsChanged() {
loadSettings()
}
private fun resetHints() {
settings?.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS)
settings?.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
settings?.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
settings?.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
val settings = Settings.getInstance(requireActivity())
settings.remove(BatteryOptimizationsFragment.Model.HINT_BATTERY_OPTIMIZATIONS)
settings.remove(BatteryOptimizationsFragment.Model.HINT_AUTOSTART_PERMISSION)
settings.remove(OpenTasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED)
settings.remove(OpenSourceFragment.Model.SETTING_NEXT_DONATION_POPUP)
Snackbar.make(requireView(), R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
}
private fun resetCertificates() {
if (CustomCertManager.resetCertificates(activity!!))
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
if (CustomCertManager.resetCertificates(requireActivity()))
Snackbar.make(requireView(), getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
}
}
@@ -9,43 +9,50 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.LoaderManager
import android.content.AsyncTaskLoader
import android.content.Context
import android.app.Application
import android.content.Intent
import android.content.Loader
import android.os.Bundle
import android.support.v4.app.NavUtils
import android.support.v7.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import kotlinx.android.synthetic.main.activity_create_address_book.*
import okhttp3.HttpUrl
import at.bitfire.davdroid.databinding.ActivityCreateAddressBookBinding
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.ui.account.AccountActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.apache.commons.lang3.StringUtils
import java.util.*
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
class CreateAddressBookActivity: AppCompatActivity() {
companion object {
const val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
private lateinit var model: Model
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setContentView(R.layout.activity_create_address_book)
loaderManager.initLoader(0, intent.extras, this)
model = ViewModelProvider(this).get(Model::class.java)
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
model.initialize(it)
}
val binding = DataBindingUtil.setContentView<ActivityCreateAddressBookBinding>(this, R.layout.activity_create_address_book)
binding.lifecycleOwner = this
binding.model = model
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -56,77 +63,81 @@ class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallba
override fun onOptionsItemSelected(item: MenuItem) =
if (item.itemId == android.R.id.home) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
NavUtils.navigateUpTo(this, intent)
true
} else
false
fun onCreateCollection(item: MenuItem) {
val homeSet = home_sets.selectedItem as String
var ok = true
HttpUrl.parse(homeSet)?.let {
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
info.displayName = display_name.text.toString()
if (info.displayName.isNullOrBlank()) {
display_name.error = getString(R.string.create_collection_display_name_required)
ok = false
}
info.description = StringUtils.trimToNull(description.text.toString())
val args = Bundle()
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV)
if (ok) {
info.type = CollectionInfo.Type.ADDRESS_BOOK
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
}
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
val displayName = model.displayName.value
if (displayName.isNullOrBlank()) {
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
ok = false
} else {
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
model.displayNameError.value = null
}
StringUtils.trimToNull(model.description.value)?.let {
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
}
if (ok) {
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK)
val frag = CreateCollectionFragment()
frag.arguments = args
frag.show(supportFragmentManager, null)
}
}
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
class Model(
application: Application
) : AndroidViewModel(application) {
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
info?.let {
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, it.homeSets)
}
}
var account: Account? = null
override fun onLoaderReset(loader: Loader<AccountInfo>) {
}
val displayName = MutableLiveData<String>()
val displayNameError = MutableLiveData<String>()
class AccountInfo {
val homeSets = LinkedList<String>()
}
val description = MutableLiveData<String>()
class AccountInfoLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(context) {
val homeSets = MutableLiveData<HomeSetAdapter>()
val idxHomeSet = MutableLiveData<Int>()
override fun onStartLoading() = forceLoad()
@MainThread
fun initialize(account: Account) {
if (this.account != null)
return
this.account = account
override fun loadInBackground(): AccountInfo? {
val info = AccountInfo()
ServiceDB.OpenHelper(context).use { dbHelper ->
// find DAV service and home sets
val db = dbHelper.readableDatabase
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null).use { cursor ->
if (!cursor.moveToNext())
return null
val strServiceID = cursor.getString(0)
viewModelScope.launch(Dispatchers.IO) {
// load account info
val adapter = HomeSetAdapter(getApplication())
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
while (c.moveToNext())
info.homeSets += c.getString(0)
}
val db = AppDatabase.getInstance(getApplication())
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service ->
val homeSets = db.homeSetDao().getBindableByService(service.id)
adapter.addAll(homeSets)
}
if (!adapter.isEmpty) {
homeSets.postValue(adapter)
idxHomeSet.postValue(0)
}
}
return info
}
}
}
@@ -9,54 +9,77 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.LoaderManager
import android.content.AsyncTaskLoader
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.Loader
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.support.v4.app.NavUtils
import android.support.v7.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import android.widget.Filter
import androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.databinding.ActivityCreateCalendarBinding
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.ical4android.DateUtils
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import kotlinx.android.synthetic.main.activity_create_calendar.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.fortuna.ical4j.model.Calendar
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
import yuku.ambilwarna.AmbilWarnaDialog
import java.util.*
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
companion object {
const val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
private lateinit var model: Model
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.extras.getParcelable(EXTRA_ACCOUNT)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setContentView(R.layout.activity_create_calendar)
color.setOnClickListener { _ ->
AmbilWarnaDialog(this, (color.background as ColorDrawable).color, true, object: AmbilWarnaDialog.OnAmbilWarnaListener {
override fun onCancel(dialog: AmbilWarnaDialog) {}
override fun onOk(dialog: AmbilWarnaDialog, rgb: Int) =
color.setBackgroundColor(rgb)
}).show()
model = ViewModelProvider(this).get(Model::class.java)
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
model.initialize(it)
}
loaderManager.initLoader(0, null, this)
val binding = DataBindingUtil.setContentView<ActivityCreateCalendarBinding>(this, R.layout.activity_create_calendar)
binding.lifecycleOwner = this
binding.model = model
binding.color.setOnClickListener {
ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setColor((color.background as ColorDrawable).color)
.show(this)
}
binding.timezone.setAdapter(model.timezones)
}
override fun onColorSelected(dialogId: Int, rgb: Int) {
model.color.value = rgb
}
override fun onDialogDismissed(dialogId: Int) {
// color selection dismissed
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -67,105 +90,163 @@ class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks
override fun onOptionsItemSelected(item: MenuItem) =
if (item.itemId == android.R.id.home) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
NavUtils.navigateUpTo(this, intent)
true
} else
false
fun onCreateCollection(item: MenuItem) {
val homeSet = home_sets.selectedItem as String
var ok = true
HttpUrl.parse(homeSet)?.let {
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
info.displayName = display_name.text.toString()
if (info.displayName.isNullOrBlank()) {
display_name.error = getString(R.string.create_collection_display_name_required)
ok = false
}
info.description = StringUtils.trimToNull(description.text.toString())
info.color = (color.background as ColorDrawable).color
val args = Bundle()
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV)
DateUtils.tzRegistry.getTimeZone(time_zone.selectedItem as String)?.let { tz ->
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
val displayName = model.displayName.value
if (displayName.isNullOrBlank()) {
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
ok = false
} else {
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
model.displayNameError.value = null
}
StringUtils.trimToNull(model.description.value)?.let {
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
}
model.color.value?.let {
args.putInt(CreateCollectionFragment.ARG_COLOR, it)
}
val tzId = model.timezone.value
if (tzId.isNullOrBlank()) {
model.timezoneError.value = getString(R.string.create_calendar_time_zone_required)
ok = false
} else {
DateUtils.tzRegistry.getTimeZone(tzId)?.let { tz ->
val cal = Calendar()
cal.components += tz.vTimeZone
info.timeZone = cal.toString()
args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString())
}
model.timezoneError.value = null
}
when (type.checkedRadioButtonId) {
R.id.type_events ->
info.supportsVEVENT = true
R.id.type_tasks ->
info.supportsVTODO = true
R.id.type_events_and_tasks -> {
info.supportsVEVENT = true
info.supportsVTODO = true
val supportsVEVENT = model.supportVEVENT.value ?: false
val supportsVTODO = model.supportVTODO.value ?: false
val supportsVJOURNAL = model.supportVJOURNAL.value ?: false
if (!supportsVEVENT && !supportsVTODO && !supportsVJOURNAL) {
ok = false
model.typeError.value = ""
} else
model.typeError.value = null
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
// only if there's at least one component set not supported; don't include
// information about supported components otherwise (means: everything supported)
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT)
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VTODO, supportsVTODO)
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VJOURNAL, supportsVJOURNAL)
}
if (ok) {
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_CALENDAR)
val frag = CreateCollectionFragment()
frag.arguments = args
frag.show(supportFragmentManager, null)
}
}
class TimeZoneAdapter(
context: Context
): ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, android.R.id.text1) {
val tz: Array<String> = TimeZone.getAvailableIDs()
override fun getFilter(): Filter {
return object: Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filtered = constraint?.let {
tz.filter { it.contains(constraint, true) }
} ?: listOf()
val results = FilterResults()
results.values = filtered
results.count = filtered.size
return results
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
clear()
@Suppress("UNCHECKED_CAST") addAll(results.values as List<String>)
if (results.count >= 0)
notifyDataSetChanged()
else
notifyDataSetInvalidated()
}
}
if (ok) {
info.type = CollectionInfo.Type.CALENDAR
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
}
}
}
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
class Model(
application: Application
): AndroidViewModel(application) {
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
val timeZones = TimeZone.getAvailableIDs()
time_zone.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, timeZones)
var account: Account? = null
// select system time zone
val defaultTimeZone = TimeZone.getDefault().id
for (i in 0 until timeZones.size)
if (timeZones[i] == defaultTimeZone) {
time_zone.setSelection(i)
break
}
val displayName = MutableLiveData<String>()
val displayNameError = MutableLiveData<String>()
info?.let {
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)
}
}
val description = MutableLiveData<String>()
val color = MutableLiveData<Int>()
override fun onLoaderReset(loader: Loader<AccountInfo>) {}
val homeSets = MutableLiveData<HomeSetAdapter>()
val idxHomeSet = MutableLiveData<Int>()
val timezones = TimeZoneAdapter(application)
val timezone = MutableLiveData<String>()
val timezoneError = MutableLiveData<String>()
class AccountInfo {
val homeSets = LinkedList<String>()
}
val typeError = MutableLiveData<String>()
val supportVEVENT = MutableLiveData<Boolean>()
val supportVTODO = MutableLiveData<Boolean>()
val supportVJOURNAL = MutableLiveData<Boolean>()
class AccountInfoLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(context) {
@MainThread
fun initialize(account: Account) {
if (this.account != null)
return
this.account = account
override fun onStartLoading() = forceLoad()
color.value = Constants.DAVDROID_GREEN_RGBA
override fun loadInBackground(): AccountInfo? {
val info = AccountInfo()
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null).use { cursor ->
if (!cursor.moveToNext())
return null
val strServiceID = cursor.getString(0)
timezone.value = TimeZone.getDefault().id
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
while (c.moveToNext())
info.homeSets += c.getString(0)
}
supportVEVENT.value = true
supportVTODO.value = true
supportVJOURNAL.value = true
viewModelScope.launch(Dispatchers.IO) {
// load account info
val adapter = HomeSetAdapter(getApplication())
val db = AppDatabase.getInstance(getApplication())
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service ->
val homeSets = db.homeSetDao().getBindableByService(service.id)
adapter.addAll(homeSets)
}
if (!adapter.isEmpty) {
homeSets.postValue(adapter)
idxHomeSet.postValue(0)
}
}
return info
}
}
}
@@ -9,100 +9,139 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.app.Application
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.XmlUtils
import at.bitfire.davdroid.AccountSettings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.settings.Settings
import okhttp3.HttpUrl
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.settings.AccountSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.IOException
import java.io.StringWriter
import java.util.logging.Level
@Suppress("DEPRECATION")
class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
class CreateCollectionFragment: DialogFragment() {
companion object {
const val ARG_ACCOUNT = "account"
const val ARG_COLLECTION_INFO = "collectionInfo"
const val ARG_SERVICE_TYPE = "serviceType"
fun newInstance(account: Account, info: CollectionInfo): CreateCollectionFragment {
val frag = CreateCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putSerializable(ARG_COLLECTION_INFO, info)
frag.arguments = args
return frag
}
const val ARG_TYPE = "type"
const val ARG_URL = "url"
const val ARG_DISPLAY_NAME = "displayName"
const val ARG_DESCRIPTION = "description"
// CalDAV only
const val ARG_COLOR = "color"
const val ARG_TIMEZONE = "timezone"
const val ARG_SUPPORTS_VEVENT = "supportsVEVENT"
const val ARG_SUPPORTS_VTODO = "supportsVTODO"
const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL"
}
private lateinit var account: Account
private lateinit var info: CollectionInfo
private lateinit var model: Model
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = arguments ?: throw IllegalArgumentException()
val args = requireNotNull(arguments)
account = args.getParcelable(ARG_ACCOUNT)
info = args.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
model = ViewModelProvider(this).get(Model::class.java)
model.account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required")
model.serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required")
loaderManager.initLoader(0, null, this)
}
model.collection = Collection(
type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"),
url = (args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required")).toHttpUrl(),
displayName = args.getString(ARG_DISPLAY_NAME),
description = args.getString(ARG_DESCRIPTION),
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(context)
progress.setTitle(R.string.create_collection_creating)
progress.setMessage(getString(R.string.please_wait))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) },
timezone = args.getString(ARG_TIMEZONE),
supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) },
supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) },
supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) },
sync = true /* by default, sync collections which just have been created */
)
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(requireActivity(), account, info)
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
dismiss()
activity?.let { parent ->
if (exception != null)
requireFragmentManager().beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, account), null)
model.createCollection().observe(this, Observer { exception ->
if (exception == null)
requireActivity().finish()
else {
dismiss()
parentFragmentManager.beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
.commit()
}
})
}
private fun<T> Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? =
if (containsKey(name))
getter(this)
else
parent.finish()
null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val v = inflater.inflate(R.layout.create_collection, container, false)
isCancelable = false
return v
}
class Model(
app: Application
): AndroidViewModel(app) {
lateinit var account: Account
lateinit var serviceType: String
lateinit var collection: Collection
val result = MutableLiveData<Exception>()
fun createCollection(): LiveData<Exception> {
viewModelScope.launch(Dispatchers.IO + NonCancellable) {
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account))
.setForeground(true)
.build().use { httpClient ->
try {
val dav = DavResource(httpClient.okHttpClient, collection.url)
// create collection on remote server
dav.mkCol(generateXml()) {}
// no HTTP error -> create collection locally
val db = AppDatabase.getInstance(getApplication())
db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service ->
collection.serviceId = service.id
db.collectionDao().insert(collection)
}
// post success
result.postValue(null)
} catch (e: Exception) {
// post error
result.postValue(e)
}
}
}
return result
}
}
override fun onLoaderReset(loader: Loader<Exception>) {}
class CreateCollectionLoader(
context: Context,
val account: Account,
val info: CollectionInfo
): AsyncTaskLoader<Exception>(context) {
override fun onStartLoading() = forceLoad()
override fun loadInBackground(): Exception? {
private fun generateXml(): String {
val writer = StringWriter()
try {
val serializer = XmlUtils.newSerializer()
@@ -112,72 +151,80 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
setPrefix("", XmlUtils.NS_WEBDAV)
setPrefix("CAL", XmlUtils.NS_CALDAV)
setPrefix("CARD", XmlUtils.NS_CARDDAV)
startTag(XmlUtils.NS_WEBDAV, "mkcol")
startTag(XmlUtils.NS_WEBDAV, "set")
startTag(XmlUtils.NS_WEBDAV, "prop")
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
startTag(XmlUtils.NS_WEBDAV, "collection")
endTag(XmlUtils.NS_WEBDAV, "collection")
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
startTag(XmlUtils.NS_CARDDAV, "addressbook")
endTag(XmlUtils.NS_CARDDAV, "addressbook")
} else if (info.type == CollectionInfo.Type.CALENDAR) {
startTag(XmlUtils.NS_CALDAV, "calendar")
endTag(XmlUtils.NS_CALDAV, "calendar")
}
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
info.displayName?.let {
startTag(XmlUtils.NS_WEBDAV, "displayname")
text(it)
endTag(XmlUtils.NS_WEBDAV, "displayname")
}
// addressbook-specific properties
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
info.description?.let {
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
text(it)
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
}
}
// calendar-specific properties
if (info.type == CollectionInfo.Type.CALENDAR) {
info.description?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-description")
text(it)
endTag(XmlUtils.NS_CALDAV, "calendar-description")
}
startTag(XmlUtils.NS_WEBDAV, "set")
startTag(XmlUtils.NS_WEBDAV, "prop")
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
startTag(XmlUtils.NS_WEBDAV, "collection")
endTag(XmlUtils.NS_WEBDAV, "collection")
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
startTag(XmlUtils.NS_CARDDAV, "addressbook")
endTag(XmlUtils.NS_CARDDAV, "addressbook")
} else if (collection.type == Collection.TYPE_CALENDAR) {
startTag(XmlUtils.NS_CALDAV, "calendar")
endTag(XmlUtils.NS_CALDAV, "calendar")
}
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
collection.displayName?.let {
startTag(XmlUtils.NS_WEBDAV, "displayname")
text(it)
endTag(XmlUtils.NS_WEBDAV, "displayname")
}
info.color?.let {
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
text(DavUtils.ARGBtoCalDAVColor(it))
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
}
// addressbook-specific properties
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
collection.description?.let {
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
text(it)
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
}
}
info.timeZone?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
cdsect(it)
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
}
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
if (info.supportsVEVENT) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VEVENT")
endTag(XmlUtils.NS_CALDAV, "comp")
}
if (info.supportsVTODO) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VTODO")
endTag(XmlUtils.NS_CALDAV, "comp")
}
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
}
endTag(XmlUtils.NS_WEBDAV, "prop")
endTag(XmlUtils.NS_WEBDAV, "set")
// calendar-specific properties
if (collection.type == Collection.TYPE_CALENDAR) {
collection.description?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-description")
text(it)
endTag(XmlUtils.NS_CALDAV, "calendar-description")
}
collection.color?.let {
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
text(DavUtils.ARGBtoCalDAVColor(it))
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
}
collection.timezone?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
cdsect(it)
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
}
if (collection.supportsVEVENT != null || collection.supportsVTODO != null || collection.supportsVJOURNAL != null) {
// only if there's at least one explicitly supported calendar component set, otherwise don't include the property
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
if (collection.supportsVEVENT != false) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VEVENT")
endTag(XmlUtils.NS_CALDAV, "comp")
}
if (collection.supportsVTODO != false) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VTODO")
endTag(XmlUtils.NS_CALDAV, "comp")
}
if (collection.supportsVJOURNAL != false) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VJOURNAL")
endTag(XmlUtils.NS_CALDAV, "comp")
}
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
}
}
endTag(XmlUtils.NS_WEBDAV, "prop")
endTag(XmlUtils.NS_WEBDAV, "set")
endTag(XmlUtils.NS_WEBDAV, "mkcol")
endDocument()
}
@@ -185,46 +232,9 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e)
}
Settings.getInstance(context)?.use { settings ->
HttpClient.Builder(context, settings, AccountSettings(context, settings, account))
.setForeground(true)
.build().use { httpClient ->
try {
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(info.url)!!)
// create collection on remote server
collection.mkCol(writer.toString())
// now insert collection into database:
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
// 1. find service ID
val serviceType = when (info.type) {
CollectionInfo.Type.ADDRESS_BOOK -> ServiceDB.Services.SERVICE_CARDDAV
CollectionInfo.Type.CALENDAR -> ServiceDB.Services.SERVICE_CALDAV
else -> throw IllegalArgumentException("Collection must be an address book or calendar")
}
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, serviceType), null, null, null).use { c ->
assert(c.moveToNext())
val serviceID = c.getLong(0)
// 2. add collection to service
val values = info.toDB()
values.put(ServiceDB.Collections.SERVICE_ID, serviceID)
db.insert(ServiceDB.Collections._TABLE, null, values)
}
}
} catch(e: Exception) {
return e
}
}
}
return null
return writer.toString()
}
}
}
@@ -11,9 +11,12 @@ package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Application
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
@@ -21,52 +24,62 @@ import android.os.Bundle
import android.os.PowerManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.ContextCompat
import android.support.v4.content.FileProvider
import android.support.v4.content.Loader
import android.support.v7.app.AppCompatActivity
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.davdroid.AccountSettings
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.pm.PackageInfoCompat
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityDebugInfoBinding
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.ical4android.TaskProvider
import kotlinx.android.synthetic.main.activity_debug_info.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.apache.commons.lang3.exception.ExceptionUtils
import org.dmfs.tasks.contract.TaskContract
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Level
class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<String> {
class DebugInfoActivity: AppCompatActivity() {
companion object {
const val KEY_THROWABLE = "throwable"
const val KEY_LOGS = "logs"
const val KEY_ACCOUNT = "account"
const val KEY_AUTHORITY = "authority"
const val KEY_PHASE = "phase"
const val KEY_LOCAL_RESOURCE = "localResource"
const val KEY_REMOTE_RESOURCE = "remoteResource"
}
private var report: String? = null
private lateinit var model: ReportModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_debug_info)
supportLoaderManager.initLoader(0, intent.extras, this)
model = ViewModelProvider(this).get(ReportModel::class.java)
model.initialize(intent.extras)
val binding = DataBindingUtil.setContentView<ActivityDebugInfoBinding>(this, R.layout.activity_debug_info)
binding.model = model
binding.lifecycleOwner = this
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -76,215 +89,260 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
fun onShare(item: MenuItem) {
report?.let {
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.type = "text/plain"
sendIntent.putExtra(Intent.EXTRA_SUBJECT, "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
model.report.value?.let { report ->
val builder = ShareCompat.IntentBuilder.from(this)
.setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
.setText(getString(R.string.debug_info_logs_attached))
.setType("text/plain")
try {
val debugInfoDir = File(cacheDir, "debug-info")
debugInfoDir.mkdir()
val debugInfoDir = File(filesDir, "debug")
if (!(debugInfoDir.exists() && debugInfoDir.isDirectory) && !debugInfoDir.mkdir())
throw IOException("Couldn't create debug directory")
val reportFile = File(debugInfoDir, "debug.txt")
val reportFile = File(debugInfoDir, "davx5-info.txt")
Logger.log.fine("Writing debug info to ${reportFile.absolutePath}")
val writer = FileWriter(reportFile)
writer.write(it)
writer.write(report)
writer.close()
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(this, getString(R.string.authority_log_provider), reportFile))
sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
builder.setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), reportFile))
builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} catch(e: IOException) {
// creating an attachment failed, so send it inline
sendIntent.putExtra(Intent.EXTRA_TEXT, it)
val text = "Couldn't create debug info file: " + Log.getStackTraceString(e) + "\n\n$report"
builder.setText(text)
}
startActivity(Intent.createChooser(sendIntent, null))
builder.startChooser()
}
}
override fun onCreateLoader(id: Int, args: Bundle?) =
ReportLoader(this, args)
class ReportModel(application: Application): AndroidViewModel(application) {
override fun onLoadFinished(loader: Loader<String>, data: String?) {
data?.let {
report = it
text_report.text = it
}
}
private var initialized = false
val report = MutableLiveData<String>()
override fun onLoaderReset(loader: Loader<String>) {}
fun initialize(extras: Bundle?) {
if (initialized)
return
Logger.log.info("Generating debug info report")
initialized = true
class ReportLoader(
context: Context,
val extras: Bundle?
): AsyncTaskLoader<String>(context) {
viewModelScope.launch(Dispatchers.Default) {
val context = getApplication<Application>()
val text = StringBuilder("--- BEGIN DEBUG INFO ---\n")
var result: String? = null
override fun onStartLoading() {
if (result != null)
deliverResult(result)
else
forceLoad()
}
override fun loadInBackground(): String {
Logger.log.info("Building debug info")
val report = StringBuilder("--- BEGIN DEBUG INFO ---\n")
// begin with most specific information
extras?.getInt(KEY_PHASE, -1).takeIf { it != -1 }?.let {
report.append("SYNCHRONIZATION INFO\nSynchronization phase: $it\n")
}
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
report.append("Account name: ${it.name}\n")
}
extras?.getString(KEY_AUTHORITY)?.let {
report.append("Authority: $it\n")
}
// exception details
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
if (throwable is HttpException) {
throwable.request?.let {
report.append("\nHTTP REQUEST:\n$it\n\n")
// begin with most specific information
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
text.append("SYNCHRONIZATION INFO\nAccount name: ${it.name}\n")
}
throwable.response?.let {
report.append("HTTP RESPONSE:\n$it\n")
extras?.getString(KEY_AUTHORITY)?.let {
text.append("Authority: $it\n")
}
}
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
report.append("\nLOCAL RESOURCE:\n$it\n")
}
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
report.append("\nREMOTE RESOURCE:\n$it\n")
}
// exception details
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
if (throwable is HttpException) {
throwable.request?.let { request ->
text.append("\nHTTP REQUEST:\n$request\n")
throwable.requestBody?.let { text.append(it) }
text.append("\n\n")
}
throwable.response?.let { response ->
text.append("HTTP RESPONSE:\n$response\n")
throwable.responseBody?.let { text.append(it) }
text.append("\n\n")
}
}
throwable?.let {
report.append("\nEXCEPTION:\n${Log.getStackTraceString(throwable)}")
}
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
text.append("\nLOCAL RESOURCE:\n$it\n")
}
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
text.append("\nREMOTE RESOURCE:\n$it\n")
}
// logs (for instance, from failed resource detection)
extras?.getString(KEY_LOGS)?.let {
report.append("\nLOGS:\n$it\n")
}
throwable?.let {
// Log.getStackTraceString(e) returns "" in case of UnknownHostException
text.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
}
// software information
try {
val pm = context.packageManager
val installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID) ?: "APK (directly)"
var workaroundInstalled = false
// logs (for instance, from failed resource detection)
extras?.getString(KEY_LOGS)?.let {
text.append("\nLOGS:\n$it\n")
}
// software information
try {
workaroundInstalled = pm.getPackageInfo("${BuildConfig.APPLICATION_ID}.jbworkaround", 0) != null
} catch(e: PackageManager.NameNotFoundException) {
}
val formatter = SimpleDateFormat.getDateInstance()
report.append("\nSOFTWARE INFORMATION\n" +
"Package: ${BuildConfig.APPLICATION_ID}\n" +
"Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) from ${formatter.format(Date(BuildConfig.buildTime))}\n")
.append("Installed from: $installedFrom\n")
.append("JB Workaround installed: ${if (workaroundInstalled) "yes" else "no"}\n\n")
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
}
text.append("\nSOFTWARE INFORMATION\n")
val pm = context.packageManager
val appIDs = mutableSetOf( // we always want info about these packages
BuildConfig.APPLICATION_ID, // DAVx5
"${BuildConfig.APPLICATION_ID}.jbworkaround", // DAVdroid JB Workaround
"org.dmfs.tasks" // OpenTasks
)
// add info about contact, calendar, task provider
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, TaskProvider.ProviderName.OpenTasks.authority))
pm.resolveContentProvider(authority, 0)?.let { appIDs += it.packageName }
// add info about available contact, calendar, task apps
for (uri in arrayOf(ContactsContract.Contacts.CONTENT_URI, CalendarContract.Events.CONTENT_URI, TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority))) {
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, 1))
for (info in pm.queryIntentActivities(viewIntent, 0))
appIDs += info.activityInfo.packageName
}
// connectivity
report.append("CONNECTIVITY (at the moment)\n")
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.activeNetworkInfo?.let { networkInfo ->
val type = when (networkInfo.type) {
ConnectivityManager.TYPE_WIFI -> "WiFi"
ConnectivityManager.TYPE_MOBILE -> "mobile"
else -> "type: ${networkInfo.type}"
for (appID in appIDs)
try {
val info = pm.getPackageInfo(appID, 0)
text .append("* ").append(appID)
.append(" ").append(info.versionName)
.append(" (").append(PackageInfoCompat.getLongVersionCode(info)).append(")")
pm.getInstallerPackageName(appID)?.let { installer ->
text.append(" from ").append(installer)
}
info.applicationInfo?.let { applicationInfo ->
if (!applicationInfo.enabled)
text.append(" disabled!")
if (applicationInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
text.append(" on external storage!")
}
text.append("\n")
} catch(e: PackageManager.NameNotFoundException) {
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
}
report.append("Active connection: $type, ${networkInfo.detailedState}\n")
}
if (Build.VERSION.SDK_INT >= 23)
connectivityManager.defaultProxy?.let { proxy ->
report.append("System default proxy: ${proxy.host}:${proxy.port}")
}
report.append("\n")
report.append("CONFIGURATION\n")
// power saving
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= 23)
report.append("Power saving disabled: ")
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
.append("\n")
// permissions
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS))
report.append(permission).append(" permission: ")
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied")
.append("\n")
// system-wide sync settings
report.append("System-wide synchronization: ")
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
.append("\n")
// main accounts
val accountManager = AccountManager.get(context)
Settings.getInstance(context)?.let { settings ->
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
// connectivity
text.append("\nCONNECTIVITY (at the moment)\n")
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.allNetworks.forEach { network ->
val capabilities = connectivityManager.getNetworkCapabilities(network)
text.append("- $capabilities\n")
}
if (Build.VERSION.SDK_INT >= 23)
connectivityManager.defaultProxy?.let { proxy ->
text.append("System default proxy: ${proxy.host}:${proxy.port}\n")
}
text.append("\n")
text.append("CONFIGURATION\n")
// power saving
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= 23)
text.append("Power saving disabled: ")
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
.append("\n")
// notifications
val nm = NotificationManagerCompat.from(context)
text.append("Notifications (")
text.append(if (nm.areNotificationsEnabled())
"not blocked"
else
"BLOCKED!")
text.append("):\n")
if (Build.VERSION.SDK_INT >= 26) {
val channelsWithoutGroup = nm.notificationChannels.toMutableSet()
for (group in nm.notificationChannelGroups) {
text.append(" [group] ${group.id}")
if (Build.VERSION.SDK_INT >= 28)
text.append(" isBlocked=${group.isBlocked}")
text.append("\n")
for (channel in group.channels) {
text.append(" ${channel.id}: importance=${channel.importance}\n")
channelsWithoutGroup -= channel
}
}
for (channel in channelsWithoutGroup)
text.append(" ${channel.id}: importance=${channel.importance}\n")
}
// permissions
text.append("Permissions:\n")
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS,
Manifest.permission.ACCESS_COARSE_LOCATION)) {
val shortPermission = permission.replace(Regex("^.+\\.permission\\."), "")
text .append(" $shortPermission: ")
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
"granted"
else
"denied")
.append("\n")
}
// system-wide sync settings
text .append("System-wide synchronization: ")
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
.append("\n\n")
// app settings
val settings = Settings(context)
val overrideProxy = settings.getBoolean(Settings.OVERRIDE_PROXY)
text .append("APP SETTINGS\n")
.append("Distrust system certs: ${settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT}\n")
.append("Override system proxy: $overrideProxy\n")
if (overrideProxy == true)
text.append(" Proxy: ${settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT}:${settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT}\n")
text.append("\n")
// main accounts
text.append("ACCOUNTS\n")
val accountManager = AccountManager.get(context)
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
try {
val accountSettings = AccountSettings(context, settings, acct)
report.append("Account: ${acct.name}\n" +
val accountSettings = AccountSettings(context, acct)
text.append("Account: ${acct.name}\n" +
" Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" +
" Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" +
" OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
" WiFi only: ").append(accountSettings.getSyncWifiOnly())
accountSettings.getSyncWifiOnlySSIDs()?.let {
report.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}")
text.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}")
}
report.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}")
text .append("\n getIsSyncable(CalendarContract): ${ContentResolver.getIsSyncable(acct, CalendarContract.AUTHORITY)}")
.append("\n getIsSyncable(OpenTasks): ${ContentResolver.getIsSyncable(acct, TaskProvider.ProviderName.OpenTasks.authority)}")
.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}")
.append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}")
.append("\n Default alarm (min before): ${accountSettings.getDefaultAlarm()}")
.append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}")
.append("\n Use event colors: ${accountSettings.getEventColors()}")
.append("\n")
} catch (e: InvalidAccountException) {
report.append("$acct is invalid (unsupported settings version) or does not exist\n")
text.append("$acct is invalid (unsupported settings version) or does not exist\n")
}
}
}
// address book accounts
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
// address book accounts
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
try {
val addressBook = LocalAddressBook(context, acct, null)
text.append("Address book account: ${acct.name}\n" +
" Main account: ${addressBook.mainAccount}\n" +
" URL: ${addressBook.url}\n" +
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
} catch(e: Exception) {
text.append("$acct is invalid: ${e.message}\n")
}
text.append("\n")
text.append("SQLITE DUMP\n")
AppDatabase.getInstance(context).dump(text)
text.append("\n")
try {
val addressBook = LocalAddressBook(context, acct, null)
report.append("Address book account: ${acct.name}\n" +
" Main account: ${addressBook.mainAccount}\n" +
" URL: ${addressBook.url}\n" +
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
text.append(
"SYSTEM INFORMATION\n" +
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
)
} catch(e: Exception) {
report.append("$acct is invalid: ${e.message}\n")
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
}
report.append("\n")
ServiceDB.OpenHelper(context).use { dbHelper ->
report.append("SQLITE DUMP\n")
dbHelper.dump(report)
report.append("\n")
}
try {
report.append(
"SYSTEM INFORMATION\n" +
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
}
report.append("--- END DEBUG INFO ---\n")
report.toString().let {
result = it
return it
text.append("--- END DEBUG INFO ---\n")
report.postValue(text.toString())
}
}
@@ -295,6 +353,7 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
} else
""
}
}
}
@@ -9,25 +9,28 @@
package at.bitfire.davdroid.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.ISettings
class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
companion object {
private const val BETA_FEEDBACK_URI = "mailto:support@davdroid.com?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
}
override fun onSettingsChanged(settings: ISettings?, menu: Menu) {
override fun initMenu(context: Context, menu: Menu) {
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
menu.findItem(R.id.nav_beta_feedback).isVisible = true
if (/* ose */ true)
menu.findItem(R.id.nav_donate).isVisible = true
}
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
@@ -36,28 +39,31 @@ class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
activity.startActivity(Intent(activity, AboutActivity::class.java))
R.id.nav_app_settings ->
activity.startActivity(Intent(activity, AppSettingsActivity::class.java))
R.id.nav_beta_feedback -> {
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse(BETA_FEEDBACK_URI))
if (activity.packageManager.resolveActivity(intent, 0) != null)
activity.startActivity(intent)
}
R.id.nav_beta_feedback ->
if (!UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO, false))
Toast.makeText(activity, R.string.install_email_client, Toast.LENGTH_LONG).show()
R.id.nav_twitter ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
UiUtils.launchUri(activity,
Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle)))
R.id.nav_website ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))))
UiUtils.launchUri(activity,
App.homepageUrl(activity))
R.id.nav_manual ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("manual/").build()))
UiUtils.launchUri(activity,
App.homepageUrl(activity).buildUpon().appendPath("manual").build())
R.id.nav_faq ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("faq/").build()))
UiUtils.launchUri(activity,
App.homepageUrl(activity).buildUpon().appendPath("faq").build())
R.id.nav_forums ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("forums/").build()))
UiUtils.launchUri(activity,
App.homepageUrl(activity).buildUpon().appendPath("forums").build())
R.id.nav_donate ->
//if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("donate/").build()))
UiUtils.launchUri(activity,
App.homepageUrl(activity).buildUpon().appendPath("donate").build())
R.id.nav_privacy ->
UiUtils.launchUri(activity,
App.homepageUrl(activity).buildUpon().appendPath("privacy").build())
else ->
return false
}
@@ -9,141 +9,131 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.app.Application
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import at.bitfire.dav4android.DavResource
import at.bitfire.davdroid.AccountSettings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.MainThread
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.settings.Settings
import okhttp3.HttpUrl
import at.bitfire.davdroid.databinding.DeleteCollectionBinding
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.settings.AccountSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
@Suppress("DEPRECATION")
class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
class DeleteCollectionFragment: DialogFragment() {
companion object {
const val ARG_ACCOUNT = "account"
const val ARG_COLLECTION_INFO = "collectionInfo"
const val ARG_COLLECTION_ID = "collectionId"
fun newInstance(account: Account, collectionId: Long): DialogFragment {
val frag = DeleteCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putLong(ARG_COLLECTION_ID, collectionId)
frag.arguments = args
return frag
}
}
private lateinit var account: Account
private lateinit var collectionInfo: CollectionInfo
private lateinit var model: DeleteCollectionModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = arguments!!.getParcelable(ARG_ACCOUNT)
collectionInfo = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
loaderManager.initLoader(0, null, this)
model = ViewModelProvider(this).get(DeleteCollectionModel::class.java)
model.initialize(
requireArguments().getParcelable(ARG_ACCOUNT)!!,
requireArguments().getLong(ARG_COLLECTION_ID)
)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(context)
progress.setTitle(R.string.delete_collection_deleting_collection)
progress.setMessage(getString(R.string.please_wait))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false)
binding.lifecycleOwner = this
binding.model = model
binding.ok.setOnClickListener {
isCancelable = false
binding.progress.visibility = View.VISIBLE
binding.controls.visibility = View.GONE
model.deleteCollection().observe(viewLifecycleOwner, Observer { exception ->
if (exception != null)
parentFragmentManager.beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
.commit()
dismiss()
})
}
binding.cancel.setOnClickListener {
dismiss()
}
return binding.root
}
override fun onCreateLoader(id: Int, args: Bundle?) =
DeleteCollectionLoader(activity!!, account, collectionInfo)
class DeleteCollectionModel(
application: Application
): AndroidViewModel(application) {
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
dismiss()
var account: Account? = null
var collectionInfo: Collection? = null
if (exception != null)
requireFragmentManager().beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, account), null)
.commit()
else
(activity as? AccountActivity)?.reload()
}
val db = AppDatabase.getInstance(application)
override fun onLoaderReset(loader: Loader<Exception>) {}
val confirmation = MutableLiveData<Boolean>()
val result = MutableLiveData<Exception>()
@MainThread
fun initialize(account: Account, collectionId: Long) {
if (this.account == null)
this.account = account
class DeleteCollectionLoader(
context: Context,
val account: Account,
val collectionInfo: CollectionInfo
): AsyncTaskLoader<Exception>(context) {
if (collectionInfo == null)
viewModelScope.launch(Dispatchers.IO) {
collectionInfo = db.collectionDao().get(collectionId)
}
}
override fun onStartLoading() = forceLoad()
fun deleteCollection(): LiveData<Exception> {
viewModelScope.launch(Dispatchers.IO + NonCancellable) {
val account = account ?: return@launch
val collectionInfo = collectionInfo ?: return@launch
override fun loadInBackground(): Exception? {
Settings.getInstance(context)?.use { settings ->
HttpClient.Builder(context, settings, AccountSettings(context, settings, account))
val context = getApplication<Application>()
HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true)
.build().use { httpClient ->
try {
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(collectionInfo.url)!!)
try {
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
// delete collection from server
collection.delete(null)
// delete collection from server
collection.delete(null) {}
// delete collection locally
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
db.delete(ServiceDB.Collections._TABLE, "${ServiceDB.Collections.ID}=?", arrayOf(collectionInfo.id.toString()))
// delete collection locally
db.collectionDao().delete(collectionInfo)
// return success
result.postValue(null)
} catch(e: Exception) {
// return error
result.postValue(e)
}
}
} catch(e: Exception) {
return e
}
}
}
return null
}
}
class ConfirmDeleteCollectionFragment: DialogFragment() {
companion object {
fun newInstance(account: Account, collectionInfo: CollectionInfo): DialogFragment {
val frag = ConfirmDeleteCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putSerializable(ARG_COLLECTION_INFO, collectionInfo)
frag.arguments = args
return frag
}
return result
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val collectionInfo = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
val name = if (collectionInfo.displayName.isNullOrBlank())
collectionInfo.url
else
collectionInfo.displayName
return AlertDialog.Builder(activity!!)
.setTitle(R.string.delete_collection_confirm_title)
.setMessage(getString(R.string.delete_collection_confirm_warning, name))
.setPositiveButton(android.R.string.yes, { _, _ ->
val frag = DeleteCollectionFragment()
frag.arguments = arguments
frag.show(fragmentManager, null)
})
.setNegativeButton(android.R.string.no, { _, _ ->
dismiss()
})
.create()
}
}
}
@@ -12,10 +12,10 @@ import android.accounts.Account
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import at.bitfire.dav4android.exception.HttpException
import androidx.fragment.app.DialogFragment
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.io.IOException
class ExceptionInfoFragment: DialogFragment() {
@@ -45,17 +45,17 @@ class ExceptionInfoFragment: DialogFragment() {
else -> R.string.exception
}
val dialog = AlertDialog.Builder(requireActivity())
val dialog = MaterialAlertDialogBuilder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(title)
.setMessage(exception::class.java.name + "\n" + exception.localizedMessage)
.setNegativeButton(R.string.exception_show_details, { _, _ ->
.setNegativeButton(R.string.exception_show_details) { _, _ ->
val intent = Intent(activity, DebugInfoActivity::class.java)
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, exception)
account?.let { intent.putExtra(DebugInfoActivity.KEY_ACCOUNT, it) }
startActivity(intent)
})
.setPositiveButton(android.R.string.ok, { _, _ -> })
}
.setPositiveButton(android.R.string.ok) { _, _ -> }
.create()
isCancelable = false
return dialog
@@ -0,0 +1,42 @@
package at.bitfire.davdroid.ui
import android.content.Context
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import at.bitfire.davdroid.model.HomeSet
class HomeSetAdapter(
context: Context
): ArrayAdapter<HomeSet>(context, android.R.layout.simple_list_item_2, android.R.id.text1) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val data = getItem(position)!!
val v: View
if (!data.displayName.isNullOrBlank()) {
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, null, false)
v.findViewById<TextView>(android.R.id.text1).text = data.displayName
v.findViewById<TextView>(android.R.id.text2).apply {
text = data.url.toString()
setSingleLine()
ellipsize = TextUtils.TruncateAt.START
}
} else {
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, null, false)
v.findViewById<TextView>(android.R.id.text1).apply {
text = data.url.toString()
setSingleLine()
ellipsize = TextUtils.TruncateAt.START
}
}
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) =
getView(position, convertView, parent)
}
@@ -9,13 +9,13 @@
package at.bitfire.davdroid.ui
import android.app.Activity
import android.content.Context
import android.view.Menu
import android.view.MenuItem
import at.bitfire.davdroid.settings.ISettings
interface IAccountsDrawerHandler {
fun onSettingsChanged(settings: ISettings?, menu: Menu)
fun initMenu(context: Context, menu: Menu)
fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean
@@ -14,7 +14,7 @@ import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.support.v4.app.NotificationCompat
import androidx.core.app.NotificationCompat
import at.bitfire.davdroid.App
import at.bitfire.davdroid.R
@@ -24,6 +24,7 @@ object NotificationUtils {
const val NOTIFY_EXTERNAL_FILE_LOGGING = 1
const val NOTIFY_REFRESH_COLLECTIONS = 2
const val NOTIFY_SYNC_ERROR = 10
const val NOTIFY_INVALID_RESOURCE = 11
const val NOTIFY_OPENTASKS = 20
const val NOTIFY_PERMISSIONS = 21
const val NOTIFY_LICENSE = 100
@@ -34,8 +35,8 @@ object NotificationUtils {
private const val CHANNEL_SYNC = "sync"
const val CHANNEL_SYNC_ERRORS = "syncProblems"
const val CHANNEL_SYNC_WARNINGS = "syncWarnings"
const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors"
const val CHANNEL_SYNC_STATUS = "syncStatus"
fun createChannels(context: Context) {
@@ -43,26 +44,29 @@ object NotificationUtils {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val syncChannelGroup = NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))
nm.createNotificationChannelGroup(syncChannelGroup)
val syncChannels = arrayOf(
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT),
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN),
NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_MIN)
)
syncChannels.forEach {
it.group = CHANNEL_SYNC
}
nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync)))
nm.createNotificationChannels(listOf(
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH),
NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT),
*syncChannels
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply {
description = context.getString(R.string.notification_channel_sync_errors_desc)
group = CHANNEL_SYNC
},
NotificationChannel(CHANNEL_SYNC_WARNINGS, context.getString(R.string.notification_channel_sync_warnings), NotificationManager.IMPORTANCE_LOW).apply {
description = context.getString(R.string.notification_channel_sync_warnings_desc)
group = CHANNEL_SYNC
},
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN).apply {
description = context.getString(R.string.notification_channel_sync_io_errors_desc)
group = CHANNEL_SYNC
}
))
}
}
fun newBuilder(context: Context, channel: String = CHANNEL_GENERAL): NotificationCompat.Builder {
fun newBuilder(context: Context, channel: String): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channel)
.setColor(context.resources.getColor(R.color.primaryColor))
@@ -0,0 +1,17 @@
package at.bitfire.davdroid.ui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class PermissionsActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null)
supportFragmentManager.beginTransaction()
.add(android.R.id.content, PermissionsFragment())
.commit()
}
}
@@ -0,0 +1,136 @@
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
import at.bitfire.davdroid.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.PermissionUtils.CONTACT_PERMSSIONS
import at.bitfire.davdroid.PermissionUtils.TASKS_PERMISSIONS
import at.bitfire.davdroid.PermissionUtils.havePermissions
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityPermissionsBinding
import at.bitfire.davdroid.resource.LocalTaskList
class PermissionsFragment: Fragment() {
lateinit var model: Model
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model = ViewModelProvider(this).get(Model::class.java)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = ActivityPermissionsBinding.inflate(inflater, container, false)
binding.lifecycleOwner = viewLifecycleOwner
binding.model = model
binding.text.text = getString(R.string.permissions_text, getString(R.string.app_name))
model.needContactsPermissions.observe(viewLifecycleOwner, Observer { needContacts ->
if (needContacts && model.haveContactsPermissions.value == false)
requestPermissions(CONTACT_PERMSSIONS, 0)
})
model.needCalendarPermissions.observe(viewLifecycleOwner, Observer { needCalendars ->
if (needCalendars && model.haveCalendarPermissions.value == false)
requestPermissions(CALENDAR_PERMISSIONS, 0)
})
model.needTasksPermissions.observe(viewLifecycleOwner, Observer { needTasks ->
if (needTasks == true && model.haveTasksPermissions.value == false)
requestPermissions(TASKS_PERMISSIONS, 0)
})
model.needAllPermissions.observe(viewLifecycleOwner, Observer { needAll ->
if (needAll && model.haveAllPermissions.value == false) {
val all = CONTACT_PERMSSIONS + CALENDAR_PERMISSIONS +
if (model.haveTasksPermissions.value != null) TASKS_PERMISSIONS else emptyArray()
requestPermissions(all, 0)
}
})
binding.appSettings.setOnClickListener {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", BuildConfig.APPLICATION_ID, null))
if (intent.resolveActivity(requireActivity().packageManager) != null)
startActivity(intent)
}
return binding.root
}
override fun onResume() {
super.onResume()
model.checkPermissions()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
model.checkPermissions()
}
class Model(app: Application): AndroidViewModel(app) {
val haveContactsPermissions = MutableLiveData<Boolean>()
val needContactsPermissions = MutableLiveData<Boolean>()
val haveCalendarPermissions = MutableLiveData<Boolean>()
val needCalendarPermissions = MutableLiveData<Boolean>()
val haveTasksPermissions = MutableLiveData<Boolean>()
val needTasksPermissions = MutableLiveData<Boolean>()
val tasksWatcher = object: PackageChangedReceiver(app) {
override fun onReceive(context: Context?, intent: Intent?) {
checkPermissions()
}
}
val haveAllPermissions = MutableLiveData<Boolean>()
val needAllPermissions = MutableLiveData<Boolean>()
init {
checkPermissions()
}
override fun onCleared() {
tasksWatcher.close()
}
fun checkPermissions() {
val contactPermissions = havePermissions(getApplication(), CONTACT_PERMSSIONS)
haveContactsPermissions.value = contactPermissions
needContactsPermissions.value = contactPermissions
val calendarPermissions = havePermissions(getApplication(), CALENDAR_PERMISSIONS)
haveCalendarPermissions.value = calendarPermissions
needCalendarPermissions.value = calendarPermissions
val tasksAvailable = LocalTaskList.tasksProviderAvailable(getApplication())
var tasksPermissions: Boolean? = null
if (tasksAvailable) {
tasksPermissions = havePermissions(getApplication(), TASKS_PERMISSIONS)
haveTasksPermissions.value = tasksPermissions
needTasksPermissions.value = tasksPermissions
} else {
haveTasksPermissions.value = null
needTasksPermissions.value = null
}
val allPermissions = contactPermissions && calendarPermissions && (!tasksAvailable || tasksPermissions == true)
haveAllPermissions.value = allPermissions
needAllPermissions.value = allPermissions
}
}
}
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more