Compare commits

...

856 Commits

Author SHA1 Message Date
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
Ricki Hirner
924ef0243d Fetch translations from Transifex 2018-03-29 14:41:22 +02:00
Ricki Hirner
62d9668081 Version bump to 1.11-rc1 2018-03-29 14:41:00 +02:00
Ricki Hirner
ab6fa7ecbf Update gradle, kotlin, build tools 2018-03-29 14:07:52 +02:00
Ricki Hirner
74b0e5cd31 Fetch translations from Transifex 2018-03-26 09:37:19 +02:00
Ricki Hirner
e2960ab572 Ask for LOCATION_COARSE permission for WiFi name detection on Android 8.1+ 2018-03-26 09:36:14 +02:00
Ricki Hirner
50bd119871 Don't re-schedule non-existent master events of deleted exceptions 2018-03-24 23:14:03 +01:00
Ricki Hirner
c1f3165f5d Improve OpenTasks install message 2018-03-20 12:26:13 +01:00
Ricki Hirner
915cd6ec44 Restrict more method names by interfaces 2018-03-16 13:16:17 +01:00
Ricki Hirner
73e936aeb5 Fix sync bugs 2018-03-15 19:37:04 +01:00
Ricki Hirner
190fc020ae Minor fixes 2018-03-15 18:14:28 +01:00
Ricki Hirner
c40a6bca2f OSE fixes 2018-03-15 17:37:19 +01:00
Ricki Hirner
3129fd909f Use Google repo 2018-03-15 13:11:00 +01:00
Ricki Hirner
31c77a1d57 Themeing, minor refactoring 2018-03-15 13:05:57 +01:00
Ricki Hirner
380562bd28 Add "app auto-start permission" startup dialog for specific vendors 2018-03-15 13:04:38 +01:00
Ricki Hirner
80b1cb55c7 Tests, minor refactoring 2018-03-15 13:01:41 +01:00
Ricki Hirner
59dd66383e Sync error notifications: retry action, lower importance of IOEXceptions 2018-03-15 13:01:19 +01:00
Ricki Hirner
44af04e310 Account activity: remove SnackBar message when a read-only address book is selected 2018-03-15 13:00:11 +01:00
Ricki Hirner
adf45cb569 Show startup fragments only once per AccountActivity lifecycle 2018-03-15 12:59:58 +01:00
Ricki Hirner
ab88020c15 Sync logic fix, theming, ProGuard 2018-03-15 12:58:56 +01:00
Ricki Hirner
e27c6fde08 Update to support library 27.1.0 and use it wherever possible
* Fragment transactions can now be done in onLoadFinished().
2018-03-15 12:57:51 +01:00
Ricki Hirner
3135af78af Tests 2018-03-15 12:44:43 +01:00
Ricki Hirner
9200ec89e1 Further improve notifications 2018-03-15 12:44:23 +01:00
Ricki Hirner
b37a2ad0e0 Remove Project Lombok from About
* not used anymore because of Kotlin
* thanks to Project Lombok!
2018-03-15 12:43:44 +01:00
Ricki Hirner
1df42350cc Remove PermissionsActivity
* asking for permissions is already (and better) done by AccountActivity
2018-03-15 12:43:32 +01:00
Ricki Hirner
3dbd5e3d18 Synchronization error messages / notifications 2018-03-15 12:41:53 +01:00
Ricki Hirner
7fdcb3710b Rewrite sync algorithm, prepare for WebDAV collection sync 2018-03-15 12:40:32 +01:00
Ricki Hirner
2f9f4f1d7b Optimize notification icons
* don't show DAVdroid icon unless there's a strong relationship to DAVdroid itself
2018-03-15 12:39:13 +01:00
Ricki Hirner
9fc3921b32 Account activity: show progress bar at beginning 2018-01-30 14:58:50 +01:00
Ricki Hirner
4f4a22a14e ical4android update 2018-01-27 22:57:34 +01:00
Ricki Hirner
16c44d8ad3 OpenTasks is installed by ical4android tests 2018-01-25 15:11:24 +01:00
Ricki Hirner
6cd2aa783e Prefer AutoCloseable over Closeable 2018-01-25 15:09:57 +01:00
Ricki Hirner
c2e161dac6 lib updates 2018-01-25 15:04:43 +01:00
Ricki Hirner
5a686a9a4c Version bump to 1.10.1.1 2018-01-20 17:11:58 +01:00
Ricki Hirner
7ba98d1c71 Hotfix: don't clear event colors 2018-01-20 17:08:29 +01:00
Ricki Hirner
fee5ab5064 Version bump to 1.10.1 2018-01-20 15:26:15 +01:00
Ricki Hirner
6e1b42e6a4 Fetch translations from Transifex 2018-01-20 15:23:32 +01:00
Ricki Hirner
c1ce953e3e ical4android: work around missing account separation when updating events
* fixes removed eventColor_index fields when there is at least one DAVdroid
  account with enabled calendar colors and at least one account with disabled
  calendar colors
2018-01-20 15:01:05 +01:00
Ricki Hirner
3a6db13df8 Update Kotlin 2018-01-18 16:17:29 +01:00
Ricki Hirner
d1d771b16a Version bump to 1.10 2018-01-18 16:11:54 +01:00
Ricki Hirner
f986d9807e Fetch translations from Transifex 2018-01-18 16:11:14 +01:00
Ricki Hirner
e3f33a5603 Test for client certificates 2018-01-18 16:10:26 +01:00
Ricki Hirner
702888e9bd Login activity: add padding; sync: re-throw Interrupted(IO)Exception 2018-01-15 21:33:00 +01:00
Ricki Hirner
6b41849cae Fetch translations from Transifex 2018-01-15 20:52:13 +01:00
Ricki Hirner
9dfb23b1d2 Remove unnecessary strings 2018-01-15 20:49:47 +01:00
Ricki Hirner
3468473f63 Improve sync error notifications
* refactor checking for cancelled sync
* notify on SSLHandshakeException (except when a certificate was rejected by cert4android)
* show exception cause in debug info
2018-01-15 20:43:07 +01:00
Ricki Hirner
bc63559a24 Login activity
* use TextInputLayout for input fields
* use support library instead of custom EditPassword widget
* improve client certificate UI
2018-01-15 14:00:03 +01:00
Ricki Hirner
3689df1385 Login with client certificates
* setup UI: login with URL and client certificate
* account settings UI: show either username/password or client certificate alias
* AccountSettings: serve credentials in generalized Credentials objects
* HttpClient: use Credentials (instead of username/password) for authentication
* HttpClient: always use CustomTlsSocketFactory
* CustomTlsSocketFactory: support client certificates
2018-01-13 22:56:13 +01:00
Ricki Hirner
dd3b326f66 Version bump to 1.9.10 2018-01-03 12:14:12 +01:00
Ricki Hirner
e2ca9eb7d7 dav4android update 2018-01-02 20:26:56 +01:00
Ricki Hirner
b001be1626 Fetch translations from Transifex 2018-01-02 19:41:53 +01:00
Ricki Hirner
e09714af32 Fix DB upgrade logic 2018-01-02 19:29:42 +01:00
Ricki Hirner
a4a816296b Refactoring 2018-01-01 15:13:57 +01:00
Ricki Hirner
65db706e06 Do contact provider settings only at address book creation (fixes changed contact visibility at sync) 2017-12-31 13:38:49 +01:00
Ricki Hirner
abd0eb533e Version bump to 1.9.9 2017-12-27 13:06:58 +01:00
Ricki Hirner
323cd48a30 Fetch translations from Transifex 2017-12-26 13:08:09 +01:00
Ricki Hirner
07b59fb6be Update for OpenTasks support
* ical4android update
* use uid field for tasks
* require OpenTasks 1.1.8.2
2017-12-26 13:08:04 +01:00
Ricki Hirner
3e2e17bdb2 Fix crash 2017-12-18 09:25:09 +01:00
Ricki Hirner
73c5c9ef64 Fix string resource 2017-12-16 22:09:21 +01:00
Ricki Hirner
12261a23ea Version bump to 1.9.8.1 2017-12-16 22:02:55 +01:00
Ricki Hirner
e19b045494 Fetch translations from Transifex 2017-12-16 22:01:35 +01:00
Ricki Hirner
076962e5ed Specify DNS server for dnsjava explicitly 2017-12-16 21:55:29 +01:00
Ricki Hirner
04306ed919 Version bump to 1.9.8 2017-12-16 18:56:16 +01:00
Ricki Hirner
93abef95f7 Fetch translations from Transifex 2017-12-16 18:54:16 +01:00
Ricki Hirner
54cc3bec9f Refactor Loaders, HttpClient
* improve Loader implementation
* use one shared HttpClient singleton
2017-12-16 18:47:48 +01:00
Ricki Hirner
d4324bcf8e Navigation drawer: add link to manual 2017-12-11 22:58:16 +01:00
Ricki Hirner
eff4438e9f Version bump to 1.9.7 2017-12-11 20:02:30 +01:00
Ricki Hirner
1f7298f947 Don't rely on LOGIN_ACCOUNTS_CHANGED_ACTION 2017-12-11 18:17:28 +01:00
Ricki Hirner
c823eb6efd Fetch translations from Transifex 2017-12-03 15:22:47 +01:00
Ricki Hirner
7e97017c41 Version bump to 1.9.6 2017-12-03 15:18:45 +01:00
Ricki Hirner
5e3b98a676 Target Android 8.1 (Oreo) 2017-11-30 12:58:08 +01:00
Ricki Hirner
8d60ee8d12 Define notification channels 2017-11-30 12:58:07 +01:00
Ricki Hirner
45af16afa6 Version bump to 1.9.5 2017-11-28 12:47:13 +01:00
Ricki Hirner
1b5bb875e0 Cert4android update 2017-11-28 12:46:59 +01:00
Ricki Hirner
09f946d5f4 Process colors for Webcal calendars, too 2017-11-27 17:54:49 +01:00
Ricki Hirner
0342242937 Provide title and color in Webcal intent; ical4android update 2017-11-27 17:25:57 +01:00
Ricki Hirner
46463bc2bc Fetch translations from Transifex 2017-11-25 18:48:51 +01:00
Ricki Hirner
3b13c0ef3c Version bump to 1.9.4 2017-11-25 18:48:14 +01:00
Ricki Hirner
a409273d8d Ask for permissions when account is opened (the permission notification is not always reliable) 2017-11-25 18:47:53 +01:00
Ricki Hirner
270607084e Upgrade libraries 2017-11-25 16:47:50 +01:00
Ricki Hirner
4be57313d7 New collection setting: "force read-only"
* collections in AccountActivity: replace long click by action overflow
2017-11-13 18:11:28 +01:00
Ricki Hirner
4e4cf7648b Version bump to 1.9.3 2017-11-12 17:34:41 +01:00
Ricki Hirner
5d084166f4 Fetch translations from Transifex 2017-11-11 21:25:39 +01:00
Ricki Hirner
173d744c2e Dokka KDoc 2017-11-11 21:18:31 +01:00
Ricki Hirner
d0e0b5d9e6 Raise API level, compatibility, make sync work again
* raise API level to 19 (required by ical4j)
* make HttpClient use Settings to get sync working again
* don't use vector graphics for notification icons (crashes on Android 4.x)
* various fixes and improvements
* library and build tools updates
2017-11-11 20:51:04 +01:00
Ricki Hirner
b2475d2b50 Refactoring
* refactor Settings and HttpClient
* library updates
2017-11-11 20:49:50 +01:00
Ricki Hirner
d0341ef1d0 cert4android: fetch translations 2017-11-04 12:00:19 +01:00
Ricki Hirner
34a55e68b0 Version bump to 1.9.2 2017-11-04 00:46:19 +01:00
Ricki Hirner
9a0cd64f68 SDK/build tools 27 2017-11-04 00:46:19 +01:00
Ricki Hirner
591a4ad423 another 5 minutes wasted life time 2017-10-31 14:01:42 +01:00
Ricki Hirner
7b479d270a Version bump 2017-10-31 13:41:29 +01:00
Ricki Hirner
58c44f6213 Fetch translations from Transifex 2017-10-31 13:38:38 +01:00
Ricki Hirner
b81a0c1eb6 Change tasks icon, add some view descriptions 2017-10-31 13:36:50 +01:00
Ricki Hirner
37a28df7d8 Handle exceptions in settings providers 2017-10-31 13:22:52 +01:00
Ricki Hirner
19796e6669 Fetch translations from Transifex 2017-10-31 09:19:50 +01:00
Ricki Hirner
87719f322b ical4android minor bug fix 2017-10-31 09:19:12 +01:00
Ricki Hirner
d40c2f5c56 lint 2017-10-29 14:57:40 +01:00
Ricki Hirner
48962d1bfa Use vector drawable support library for Android <5 2017-10-29 14:57:38 +01:00
Ricki Hirner
42ba48253f Update build tools, gradle, support library 2017-10-28 00:46:09 +02:00
Ricki Hirner
d55a1aa8b5 Version bump to 1.9.1 2017-10-25 20:43:00 +02:00
Ricki Hirner
dbc4ef9618 Fetch translations from Transifex 2017-10-25 20:37:40 +02:00
Ricki Hirner
3bf9be1443 Prevent multiple syncs to be run at the same time for the same account and authority 2017-10-25 18:40:02 +02:00
Ricki Hirner
e2b5ce7b10 Better handling of contact group method change
* ask for confirmation when group method is changed in account settings activity
* reset address books/force reload when contact group method has changed since last sync
2017-10-25 18:38:50 +02:00
Ricki Hirner
3d537b4472 Settings: add force reload 2017-10-25 15:22:32 +02:00
Ricki Hirner
64c3ca96ce Change homepage to davdroid.com 2017-10-23 14:23:41 +02:00
Ricki Hirner
71f73d66f2 Resource detection: use related replies to gather collection info, too 2017-10-20 13:56:38 +02:00
Ricki Hirner
0e4bb317f5 Version bump to 1.9-ose 2017-10-15 18:14:50 +02:00
Ricki Hirner
583280cf55 Change CI 2017-10-15 17:53:55 +02:00
Ricki Hirner
018f06fd53 Fetch translations from Transifex 2017-10-15 17:43:06 +02:00
Ricki Hirner
bf63605d50 Calendars: allow email reminders for events, too 2017-10-15 17:34:30 +02:00
Ricki Hirner
9643cb7661 StartupDialogFragment: Use activity instead of context 2017-10-13 21:37:00 +02:00
Ricki Hirner
46669dd0e9 README updates 2017-10-13 21:21:50 +02:00
Ricki Hirner
299f50f6d9 Fetch translations from Transifex 2017-10-13 20:43:58 +02:00
Ricki Hirner
af7f5e60bf Move to davdroid-ose repo 2017-10-13 14:55:38 +02:00
Ricki Hirner
d869ecbc0c Minor bug fixes, move beta feedback to navigation drawer 2017-10-13 13:41:14 +02:00
Ricki Hirner
d8536765e1 Managed DAVdroid, settings framework, theming, launcher icons, lib update 2017-10-13 12:54:43 +02:00
Ricki Hirner
9036f3e130 Assign UID/file name for empty contact even when creating in read-only address book (so that it can be deleted later) 2017-09-25 14:41:41 +02:00
Ricki Hirner
ab284c80df cert4android: fetch translations from Transifex 2017-09-25 12:08:37 +02:00
Ricki Hirner
89b324b299 Fetch translations from Transifex 2017-09-25 12:07:10 +02:00
Ricki Hirner
a5f86b13bd Version bump to 1.8.1 2017-09-25 12:03:46 +02:00
Ricki Hirner
3cdfc70692 Optimize read-only contact notifications 2017-09-25 12:03:04 +02:00
Ricki Hirner
29eb1fda9f Fetch translations from Transifex 2017-09-23 21:23:15 +02:00
Ricki Hirner
3c00520a09 Notify on read-only address books
* notify when a read-only address book is selected for synchronization
* notify when local changes have been discarded during sync
2017-09-23 21:12:09 +02:00
Ricki Hirner
d20aa562ad Initial implementation of read-only address books 2017-09-23 21:12:09 +02:00
Ricki Hirner
550612a282 Fetch translations from Transifex 2017-09-20 22:15:09 +02:00
Ricki Hirner
ef496f3986 Version bump to 1.8 2017-09-20 22:14:27 +02:00
Ricki Hirner
f4e7fd957d Reminders: use seconds, too 2017-09-20 22:13:45 +02:00
Ricki Hirner
4f6051a03b Fetch translations from Transifex 2017-09-15 13:29:02 +02:00
Ricki Hirner
670dc5ae9b Abbreviate too long log messages to reduce OOM errors 2017-09-15 13:26:03 +02:00
Ricki Hirner
7d6a052e74 Move strings out of global App context 2017-09-15 13:26:01 +02:00
Ricki Hirner
a7f59ded3f Change options for sync intervals
* remove 5 and 10 minutes because Android now enforces a minimum interval of 15 min
  (which makes sense in terms of battery optimization)
* add 30 min
2017-09-14 18:18:32 +02:00
Ricki Hirner
67646e4ce0 ical4j update 2017-09-14 17:29:58 +02:00
Ricki Hirner
d8346fa24a Fetch translations from Transifex 2017-09-13 17:20:51 +02:00
Ricki Hirner
54a41f02a4 Tests 2017-09-13 17:17:18 +02:00
Ricki Hirner
62d989d40a Webcal support in UI 2017-09-13 17:11:12 +02:00
Ricki Hirner
533abe80a1 database support for subscribed calendars (Webcal feeds)
* resource detection: detect/check subscribed calendars
* database: distinguish between CalDAV calendar and subscribed calendar
2017-09-13 17:09:01 +02:00
Ricki Hirner
4c8fa249cd Version bump to 1.7.3 2017-09-10 20:19:01 +02:00
Ricki Hirner
0551140f90 Fetch translations from Transifex 2017-09-10 20:17:10 +02:00
Ricki Hirner
a0bfe7a784 Make SyncManager Closeable 2017-09-10 20:16:08 +02:00
Ricki Hirner
45d6bf1eae Don't allow calendars which don't support VEVENT and/or VTODO to be selected for sync 2017-09-10 14:04:08 +02:00
Ricki Hirner
856f0bdf53 Upgrade to okhttp/3.9.0 2017-09-10 01:31:19 +02:00
Ricki Hirner
27831a0d4d cert4android: don't keep CustomCertManager in memory all the time
* remove static reference to CustomCertManager (and thus a Context)
* HttpClient now wraps OkHttpClient and is Closeable
2017-09-09 22:34:45 +02:00
Ricki Hirner
39fa8a1a14 Update cert4android 2017-09-09 22:25:42 +02:00
Ricki Hirner
13ca1a7ef9 Fetch cert4android translations from Transifex 2017-09-01 15:13:30 +02:00
Ricki Hirner
71804a59bb Fetch translations from Transifex 2017-09-01 15:12:16 +02:00
Ricki Hirner
37b3ee879d Version bump to 1.7.2 2017-09-01 15:11:46 +02:00
Ricki Hirner
57cb80768d donation popup; ProGuard
* don't show donation popup too often
* ProGuard fixes
2017-08-31 01:05:38 +02:00
Ricki Hirner
b6d378d346 Fetch cert4android translations from Transifex 2017-08-30 21:04:49 +02:00
Ricki Hirner
9ff53fbb1e Fetch translations from Transifex 2017-08-30 21:01:55 +02:00
Ricki Hirner
eac976379a Make event color support optional (opt-in) 2017-08-30 20:22:04 +02:00
Ricki Hirner
d444d95f12 Update to Android support library 26.0.1 2017-08-30 12:32:16 +02:00
Ricki Hirner
af61ac38c8 Optimize logging 2017-08-29 18:07:45 +02:00
Ricki Hirner
2e9f1d2d66 Fetch translations from Transifex 2017-08-22 15:16:54 +02:00
Ricki Hirner
2262685465 Version bump to 1.7.1 2017-08-22 15:16:04 +02:00
Ricki Hirner
4df37759d7 Add donation startup fragment again 2017-08-22 14:58:11 +02:00
Ricki Hirner
fd9e626593 ProGuard: keep ThreeTen; update build tools and Kotlin version 2017-08-22 14:44:38 +02:00
Ricki Hirner
9f890a8c8d Fetch translations from Transifex 2017-08-19 12:16:52 +02:00
Ricki Hirner
1e0a146dc4 Version bump to 1.7 2017-08-19 12:16:26 +02:00
Ricki Hirner
8836bf89a8 Fix migration 2017-08-15 16:11:38 +02:00
Ricki Hirner
dcbbc9a545 Fetch translations from Transifex 2017-08-15 15:52:23 +02:00
Ricki Hirner
319845f5b1 Update Kotlin, vcard4android 2017-08-15 15:51:55 +02:00
Ricki Hirner
dd7f1399f3 Handle conflict when renaming accounts 2017-08-10 17:39:39 +02:00
Ricki Hirner
8de6ebbe8f WiFi restriction: allow multiple SSIDs (comma-separated) 2017-08-08 13:28:04 +02:00
Ricki Hirner
82c5d424a7 Add support for event colors 2017-08-07 22:50:19 +02:00
Ricki Hirner
bdb15e0845 Scroll to external log settings when tapping notification 2017-08-05 15:50:50 +02:00
Ricki Hirner
14b5e66192 Version bump to 1.6.5 (160) 2017-08-05 13:30:02 +02:00
Ricki Hirner
636dd1b86a Fetch lib translations from Transifex 2017-08-05 13:15:10 +02:00
Ricki Hirner
152624a984 Fetch translations from Transifex 2017-08-05 13:13:25 +02:00
Ricki Hirner
3b9e95d4e3 Minor optimizations 2017-08-05 13:12:47 +02:00
Ricki Hirner
e64a5f408c Fix DavResourceFinder 2017-08-03 23:51:39 +02:00
Ricki Hirner
09e53e898d Fix device rotate crash bug, cert4android race condition 2017-08-03 20:47:28 +02:00
Ricki Hirner
d0f879820b Fix custom certificate socket factory 2017-08-02 17:58:57 +02:00
Ricki Hirner
02f9843d47 Remove TextUtils dependency; tests 2017-08-02 17:31:51 +02:00
Ricki Hirner
1c303e43eb Tests 2017-08-02 16:35:28 +02:00
Ricki Hirner
cfc7f96f83 Rewrite last UI classes to Kotlin; allow cancellation of resource detection 2017-08-02 16:28:59 +02:00
Ricki Hirner
47c1e0b8ea Rewrite most UI classes to Kotlin 2017-08-01 15:00:06 +02:00
Ricki Hirner
c5958de17b Fix addAccount authenticator crash 2017-07-31 17:48:18 +02:00
Ricki Hirner
b890c73d04 Rewrite some UI classes to Kotlin 2017-07-31 17:38:28 +02:00
Ricki Hirner
13b749974b Rewrite to Kotlin
* rewrite some UI classes
* move logic from App to CustomCertificates and Logger singletons
2017-07-31 15:55:01 +02:00
Ricki Hirner
f65ac7275e Allow to remove all WiFi SSID restrictions when "sync only on WiFi" is active 2017-07-30 14:55:49 +02:00
Ricki Hirner
0210ba0ad9 cert4android: fetch translations from Transifex 2017-07-27 16:51:08 +02:00
Ricki Hirner
d5a9a65209 Fetch translations from Transifex 2017-07-27 16:49:22 +02:00
Ricki Hirner
b16c7679b8 Version bump to 1.6.4 2017-07-27 16:48:06 +02:00
Ricki Hirner
c4ecc28d66 cert4android, vcard4android updates 2017-07-27 16:47:52 +02:00
Ricki Hirner
b638c2584b Improve debug info 2017-07-27 16:47:52 +02:00
Ricki Hirner
4be6a25eb6 Some minor fixes 2017-07-20 17:26:50 +02:00
Ricki Hirner
797bb78d20 Rewrite LoginCredentials to Kotlin 2017-07-19 22:05:58 +02:00
Ricki Hirner
ee696d233e Fix: SSLSocket doesn't implement Closeable in Android 4.1 2017-07-19 19:49:49 +02:00
Ricki Hirner
a1d2b2ab59 Update copyright 2017-07-19 19:32:46 +02:00
Ricki Hirner
4cb60ca78c Rewrite syncadapter package to Kotlin 2017-07-19 19:11:02 +02:00
Ricki Hirner
e2c47bbe92 Rewrite all remaining classes except syncadapter and ui package 2017-07-16 15:30:58 +02:00
Ricki Hirner
53e333ee22 Rewrite DavService to Kotlin 2017-07-16 01:07:58 +02:00
Ricki Hirner
a3181d35b4 Rewrite resource package to Kotlin 2017-07-14 20:58:29 +02:00
Ricki Hirner
4948471068 Rewrite some classes to Kotlin 2017-07-10 21:09:15 +02:00
Ricki Hirner
ed1acb47c7 Rewrite model package to Kotlin 2017-07-10 01:31:45 +02:00
Ricki Hirner
c73d47b1ec Rewrite log package to Kotlin 2017-07-08 14:31:23 +02:00
Ricki Hirner
b306933f95 Use x86 emulator 2017-07-07 00:14:05 +02:00
Ricki Hirner
dba55d2f2a Fetch translations from Transifex 2017-07-06 12:10:08 +02:00
Ricki Hirner
d20f0986d9 Version bump to 1.6.3 2017-07-06 12:07:27 +02:00
Ricki Hirner
1bc6880dc0 More logging (ical4j) + ProGuard 2017-07-06 12:06:54 +02:00
Ricki Hirner
8d0e4c6a46 Some fixes
* don't acquire contacts provider client for address book management (would require contacts permission check)
* rename task lists: handle all kinds of Exception
* lib updates
2017-07-04 15:01:06 +02:00
Ricki Hirner
1da8543e24 Minor bug/NPE fixes 2017-07-04 15:01:06 +02:00
Ricki Hirner
29792d915a Fetch translations from Transifex 2017-07-03 18:54:12 +02:00
Ricki Hirner
9654a23fd1 Require SDK level 16
In Android < 4.1 (< level 16), android.database.Cursor doesn't implement Closeable,
although it provides close(). Since we now use the Kotlin .use() idiom (which requires
a Closeable) instead of calling close() manually or via Lombok, we need Closeable
Cursors and it's not worth to work around this because Android 4.0.4 is now very old.
2017-07-03 18:46:56 +02:00
Ricki Hirner
4f8031a3a4 Improve preview release startup fragment 2017-07-02 14:42:01 +02:00
Ricki Hirner
eb6fa1ab7f Contacts: use hashCode() hack only for Android 7; problem has been fixed by Android 8 2017-07-02 14:08:04 +02:00
Ricki Hirner
29bf7ad204 vcard4android: Kotlin 2017-06-25 16:06:15 +02:00
Ricki Hirner
18e2a07415 Update gradle plugin 2017-06-21 13:00:08 +02:00
Ricki Hirner
49d1011b4a SDK/build tools level 26 2017-06-21 00:03:44 +02:00
Ricki Hirner
9b6c9b6c03 Version bump to 1.6.2 2017-06-20 15:18:16 +02:00
Ricki Hirner
953ffa6afe Update libs; update Commons Lang to 3.6 2017-06-20 15:17:30 +02:00
Ricki Hirner
e0a0f92c1e Lib updates, okhttp/3.8.1 2017-06-20 15:17:30 +02:00
Ricki Hirner
ce493865e7 ical4android: Kotlin 2017-06-20 15:17:30 +02:00
Ricki Hirner
77c9126be4 Version bump to 1.6.1.1 (151) 2017-06-16 18:57:06 +02:00
Ricki Hirner
9bb56d4f58 Lib updates 2017-06-16 17:54:41 +02:00
Ricki Hirner
908a3be678 Fetch translations from Transifex 2017-06-09 16:33:38 +02:00
Ricki Hirner
75dbbbf4ed Version bump to 1.6.1 2017-06-09 16:33:06 +02:00
Ricki Hirner
2599a15515 dav4android: Kotlin, cert4android: handle InterruptedException 2017-06-09 00:41:34 +02:00
Ricki Hirner
15fe102049 Birthdays without years, again 2017-06-05 14:35:54 +02:00
Ricki Hirner
6f55e51fef Fetch translations from Transifex 2017-06-03 19:27:49 +02:00
Ricki Hirner
1bdb004daf Version bump to 1.6 2017-06-03 19:27:06 +02:00
Ricki Hirner
30e83f45a5 Improve resource detection
* scan unrequested responses for useful auto-detection information
* cert4android: Kotlin
2017-06-03 19:26:43 +02:00
Ricki Hirner
7c8a8ee9fc Support for birthdays without year 2017-06-02 00:38:23 +02:00
Ricki Hirner
8747deeea7 Remove "ical4android" from iCal PRODID (same format as for VCard) 2017-05-14 13:36:49 +02:00
Ricki Hirner
63d2a7d0ef okhttp 3.8.0 ProGuard settings 2017-05-14 13:01:13 +02:00
Ricki Hirner
d89e0a392f Fetch translations from Transifex 2017-05-14 12:18:50 +02:00
Ricki Hirner
bb6ce6a355 Version bump to 1.5.2 2017-05-14 12:18:02 +02:00
Ricki Hirner
f59fd7205d Remove "vcard4android" from VCard PRODID to avoid folding for better compatibility 2017-05-14 12:11:58 +02:00
Ricki Hirner
0dd1fa47b8 Upgrade to okhttp/3.8.0 2017-05-14 11:33:42 +02:00
Ricki Hirner
a067ff03b3 Use UUIDs for newly generated event/task UIDs (RFC 7986 5.3 UID Property) 2017-04-27 13:36:10 +02:00
Ricki Hirner
a652a230c6 Fetch translations from Transifex 2017-04-25 14:11:47 +02:00
Ricki Hirner
c2a616fe52 Version bump to 1.5.1-ose 2017-04-25 14:11:22 +02:00
Ricki Hirner
1ac573d403 Use untranslated User-Agent string
* refactoring: don't use global string variables
2017-04-24 23:00:11 +02:00
Ricki Hirner
80a8ce6f8c Check sync conditions for contacts sync, too 2017-04-23 18:10:01 +02:00
Ricki Hirner
33e45d3ae9 Remove unnecessary InvalidAccountException 2017-04-22 21:37:19 +02:00
Ricki Hirner
f701a63e27 Allow null values for IS_ORGANIZER 2017-04-18 23:54:11 +02:00
Ricki Hirner
5594d2b284 README updates 2017-04-16 21:58:30 +02:00
Ricki Hirner
dcd99f7bca Version bump to 1.5.0.3 2017-04-16 15:51:51 +02:00
Ricki Hirner
df086e7ab5 Fetch translations from Transifex 2017-04-16 15:49:02 +02:00
Ricki Hirner
5b31d06dcf Upgrade libraries 2017-04-16 14:31:50 +02:00
Ricki Hirner
a39564a179 Unify strings
* HTTP client: use app name as User-Agent
* use string resources for homepage URLs
2017-04-11 16:35:29 +02:00
Ricki Hirner
18d8162074 gradle, dav4android updates
* update Android gradle plugin to 2.3.1
* dav4android update: follow redirects on DELETE
2017-04-08 20:26:38 +02:00
Ricki Hirner
27a4c47c35 Version bump to 1.5.0.2 2017-04-02 19:23:38 +02:00
Ricki Hirner
1bed502e67 Fix some inconsistencies 2017-04-02 19:19:38 +02:00
Ricki Hirner
d2dd27f99a Open DAVdroid main activity when add a "DAVdroid Address book" account is added over Settings 2017-03-30 21:23:29 +02:00
Ricki Hirner
2d83ed6be4 Account settings: restart loader after sync interval update
* debug info: add signature
2017-03-29 12:39:59 +02:00
Ricki Hirner
de608312a2 Hotfix: don't crash on empty address book displayName 2017-03-27 11:43:14 +02:00
Ricki Hirner
0b1bd35517 Version bump to 1.5 2017-03-26 19:30:16 +02:00
Ricki Hirner
44fb3713a4 Fetch translations from Transifex 2017-03-26 19:30:13 +02:00
Ricki Hirner
ce2e7e24b1 Remove all references to Constants.ACCOUNT_TYPE (now a to string resource) 2017-03-26 19:13:51 +02:00
Ricki Hirner
fa574aaa8d Improve address book details in debug info 2017-03-26 19:07:26 +02:00
Ricki Hirner
82aa7012d3 Add more debug information
* power saving status
* permissions
* address book accounts
2017-03-25 20:13:07 +01:00
Ricki Hirner
0789134948 Enable SSL_RSA_WITH_3DES_EDE_CBC_SHA for all Android versions
* refactor cipher selection
2017-03-25 20:13:07 +01:00
Ricki Hirner
d4bd96d846 Version bump to 1.5-beta1 2017-03-19 20:44:07 +01:00
Ricki Hirner
038b106b37 Multiple address books, segment 2
* migration from old address book to new address book accounts (5 -> 6)
* support renaming of address book accounts
* change address book accounts properly when the main account is renamed
2017-03-19 20:36:06 +01:00
Ricki Hirner
5a7aeb3eba Support for multiple address books per account
* new account type: "DAVdroid address book", which is assigned to a DAVdroid main account
* stub content provider "Address books", which enumerates all DAVdroid address book accounts and runs contacts sync for them
2017-03-19 20:28:13 +01:00
Ricki Hirner
a33e70162c Unify action bar icon colors 2017-03-12 13:47:52 +01:00
Ricki Hirner
3245b47635 Fix maven 2017-03-03 13:25:51 +01:00
Ricki Hirner
7b965769c9 Update translations from Transifex 2017-03-03 12:52:51 +01:00
Ricki Hirner
91f62614ab Update gradle 2017-03-03 12:51:12 +01:00
Ricki Hirner
c875b4b3ae Version bump to 1.4.1 2017-03-02 23:44:43 +01:00
Ricki Hirner
286ceeea3f Fetch translations from Transifex 2017-03-02 23:44:43 +01:00
Ricki Hirner
8b026d7e16 AccountsActivity: show message when global sync is disabled 2017-03-02 23:44:39 +01:00
Ricki Hirner
2f97830f28 Gradle update to 3.4 2017-02-27 17:39:22 +01:00
Ricki Hirner
75827d89c5 Fetch translations from Transifex 2017-02-17 13:55:08 +01:00
Ricki Hirner
1899c86494 Version bump to 1.4.0.3 2017-02-17 13:54:09 +01:00
Ricki Hirner
4a7b6cf546 Don't use uid2445 column on Android <4.2; alarm ACTION: compare only value (ignore parameters) 2017-02-17 09:55:50 +01:00
Ricki Hirner
0b83c1ad2f Version bump to 1.4.0.2 2017-02-12 19:01:43 +01:00
Ricki Hirner
a9380a8e1b Improve Android 7 workaround behavior in combination with CATEGORIES/VCard4 contact groups 2017-02-12 18:59:41 +01:00
Ricki Hirner
b756a5f54c Retain Events.UID_2445 when preparing events for upload
* move file name/UID generation from SyncManager to LocalContact, LocalEvent, LocalTask
* rename updateFileNameAndUID() to prepareForUpload()
* use random UUID for contacts, UidGenerator with Android device ID for events/tasks
* LocalEvent.prepareForUpload(): use existing UID_2445 if available
2017-02-10 18:06:28 +01:00
Ricki Hirner
762c379c54 Android 7 workaround: update hash after group membership operations 2017-02-10 18:06:28 +01:00
Ricki Hirner
8f8421e65e Version bump to 1.4.0.1 2017-02-06 11:59:06 +01:00
Ricki Hirner
0e67da5718 Android 7 workaround bugfix
* use local version of contact before calculating hash code
* don't stop upload sync if there are deleted contacts
2017-02-06 11:57:51 +01:00
Ricki Hirner
cf8b1e97bd Version bump to 1.4.0 2017-02-05 17:21:09 +01:00
Ricki Hirner
61885b41d9 Use contact hash codes only on Android 7+ (workaround)
vcard4android: don't hash CATEGORIES, more verbose logging
2017-02-05 17:20:49 +01:00
Ricki Hirner
f74cecdc1f Implement checksum to check whether DIRTY contacts have "really" changed
* contact data hash code = hash code of data fields and group memberships
* Before every contact sync, all dirty contacts are checked whether they're
  "really dirty" (= data hash code has changed). If they're not, the DIRTY
  flag is reset. Works around Android 7 behavior of setting contacts to DIRTY
  even if onky meta data has been updated (for instance, lastContacted after
  a call or SMS),
* When an "upload" sync is initiated by notifyChange and there are no
  "really dirty" contacts, the sync is ignored.
* contact upload: clearDirty() saves hash code, too
* contact download: create()/update() saves hash code, too
* debugging: sync flags (extras) are now logged
2017-02-01 19:19:17 +01:00
Ricki Hirner
d8ffd83e1e AccountSettingsActivity: use loader
* use Loader for AccountSettingsActivity sync intervals (fixes Android 7 display "issues")
* SyncManager: allow prepare() to skip synchronization
2017-02-01 01:20:44 +01:00
Ricki Hirner
666233136d Upgrade to okhttp 3.6.0 2017-01-30 22:27:54 +01:00
Ricki Hirner
eae5631d8a Version bump to 1.3.8 2017-01-29 20:11:15 +01:00
Ricki Hirner
dc614bf733 Fetch translations from Transifex (fixes crash in Spanish version) 2017-01-29 19:18:57 +01:00
Ricki Hirner
22c9603b3c Add information about current local and remote resource to debug info 2017-01-29 19:17:53 +01:00
Ricki Hirner
073f9230ba Upgrade to gradle 3.3 2017-01-29 19:17:53 +01:00
Ricki Hirner
3948e83f83 Use isAlwaysSyncable for contacts/calendars again because of buggy Android firmwares 2017-01-09 21:28:28 +01:00
Ricki Hirner
12664652e5 Version bump to 1.3.7 2017-01-08 23:35:41 +01:00
Ricki Hirner
d1162abf4c Change authentication restriction to domains instead of host names 2017-01-08 19:09:34 +01:00
Ricki Hirner
a93fa72d5a Delete local contacts when no CardDAV collection is selected 2017-01-06 15:53:23 +01:00
Ricki Hirner
3c8c3fe9b8 Version bump to 1.3.6 2017-01-01 12:43:47 +01:00
Ricki Hirner
a46f2d118c Fetch translations from Transifex 2017-01-01 12:26:02 +01:00
Ricki Hirner
dc0221dabb Update to ez-vcard 0.10.1
* fix REV and PREF problems
2017-01-01 01:13:03 +01:00
Ricki Hirner
a5e80d2268 Fix permissions notification
* ical4android: remove ORGANIZER from all VEVENT components if there are not attendees
2016-12-31 14:16:18 +01:00
Ricki Hirner
dbd200cb6c AccountSettings version 5: enable/disable OpenTasks by availability (Android 7.1.1 fix)
* better handling of setIsSyncable
2016-12-30 14:29:56 +01:00
Ricki Hirner
aacff632b7 Don't show warning on AccountSettings version updates 2016-12-30 14:28:28 +01:00
Ricki Hirner
e4861b0a68 Update to SDK level 25 2016-12-30 02:58:54 +01:00
Ricki Hirner
8d1b6da197 Change handling of tasks sync when OpenTasks is not installed
* AccountDetailsFragment: at account creation, enable task sync only when OpenTasks is installed
* PackageChangedReceiver: when packages are (un)installed, check for OpenTasks availability and (de)activate task sync for all accounts accordingly
* LocalTaskList: don't cache OpenTasks availability
* sync_*.xml: don't activate sync by default
2016-12-28 22:23:13 +01:00
Ricki Hirner
bf21c1338e Fetch translations from Transifex 2016-12-23 15:54:20 +01:00
Ricki Hirner
9452d6667a Version bump to 1.3.5 2016-12-23 15:51:56 +01:00
Ricki Hirner
6967773690 Address book selection changed: update URL as soon as possible 2016-12-19 18:58:35 +01:00
Ricki Hirner
73b95379fe Update gradle to 3.2.1; ical4android/vcard4android updates 2016-12-18 22:02:44 +01:00
Ricki Hirner
9f89173638 Update okhttp to 3.5.0 2016-12-02 15:01:52 +01:00
Ricki Hirner
77a0201327 Log group assignments more verbosely 2016-11-25 21:40:40 +01:00
Ricki Hirner
21c3f81a8b Rename account: don't crash when content providers are not accessible 2016-11-17 19:59:23 +01:00
Ricki Hirner
035dc4f0b2 Version bump to 1.3.4.1 2016-11-14 18:48:26 +01:00
Ricki Hirner
48fec240cf Fetch translations from Transifex 2016-11-14 18:41:48 +01:00
Ricki Hirner
82a6faa8de Avoid some crashes
* check whether ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATION can be resolved before launching it
* cert4android: don't crash when service can't be bound
2016-11-14 18:39:25 +01:00
Ricki Hirner
a4bd6d6a07 Allow renaming of accounts
* allow renaming of accounts
* always open AccountActivity, even if there are no services (so that users can delete the account from within DAVdroid)
2016-11-14 01:14:46 +01:00
Ricki Hirner
b91743239a Fetch translations from Transifex 2016-11-13 20:34:11 +01:00
Ricki Hirner
611dff15bb Update build tools to 25.0.0, fix WiFiManager leak 2016-11-13 20:22:10 +01:00
Ricki Hirner
cdd56d88fb Fetch translations from Transifex
ical4android: fix for events without dtend/duration
2016-11-06 17:36:59 +01:00
Ricki Hirner
198c53698d Fetch translations from Transifex 2016-11-04 12:02:08 +01:00
Ricki Hirner
3924b72a40 Version bump to 1.3.4
* library updates
2016-11-04 12:01:24 +01:00
Ricki Hirner
6520a4dff8 Add app-wide HTTP proxy setting 2016-10-30 22:21:54 +01:00
Ricki Hirner
9e62c44bef Debug info: send inline on Android <4.1 and when creating an attachment doesn't work 2016-10-22 02:05:58 +02:00
Ricki Hirner
9df01a7477 Version bump to 1.3.3.1 2016-10-21 19:52:55 +02:00
Ricki Hirner
c62eb6cd0e Fetch translations from Transifex 2016-10-21 19:50:09 +02:00
Ricki Hirner
8465df483e Library updates
* dav4android: disable compression for GET requests because it may change the ETag
* better logging for ical4j messages
* tests
2016-10-21 11:08:50 +02:00
Ricki Hirner
2366356922 ProGuard update; signing config 2016-10-18 12:36:41 +02:00
Ricki Hirner
5655aad59a Use string resource for logging file provider authority; vcard4android update 2016-10-17 23:57:53 +02:00
Ricki Hirner
65ddab6ed5 Share debug info: always use attachment
* share debug info: always use attachment (before: send inline if it was small enough)
* use FileProvider for debug info attachment (for Android 7 compatibility)
* dav4android, ical4android fixes
2016-10-17 17:47:27 +02:00
Ricki Hirner
d914addbb2 Add useless ProGuard rule 2016-10-15 16:51:35 +02:00
Ricki Hirner
b9a4489f3c Fetch translations from Transifex 2016-10-14 21:19:39 +02:00
Ricki Hirner
9d3b9c963a Version bump to 1.3.3 2016-10-14 21:00:29 +02:00
Ricki Hirner
72d981f361 dav4android: always use UTF-8 for Basic/Digest auth credentials 2016-10-13 15:23:11 +02:00
Ricki Hirner
6eeb484c56 ical4android: ignore invalid DUE < DTSTART for tasks 2016-10-12 17:03:46 +02:00
Ricki Hirner
cd7332b67c Remove VCard RFC6868 setting (always enabled now; setting not needed for Posteo compatibility anymore) 2016-10-12 16:45:26 +02:00
Ricki Hirner
1a8ff69750 Gitlab CI: install OpenTasks before tests 2016-10-12 13:04:21 +02:00
Ricki Hirner
991221f0ee vcard4android: ez-vcard 0.10.0 2016-10-11 23:36:30 +02:00
Ricki Hirner
ef4267e173 Test adaptions 2016-10-11 00:28:22 +02:00
Ricki Hirner
ba029e3da4 Switch to JUnit4 2016-10-10 21:03:18 +02:00
Ricki Hirner
4270807e2a Add Gitlab CI 2016-10-10 20:18:37 +02:00
Ricki Hirner
8456d078f1 Improve tests 2016-10-07 14:39:21 +02:00
Ricki Hirner
aa693cebb9 Fix NPE in "is refreshing progress bar" 2016-10-07 14:39:18 +02:00
Ricki Hirner
219a7ed38f Version bump to 1.3.2.2 2016-10-05 11:14:41 +02:00
Ricki Hirner
2aa90b4f9c Enable verbose logging of allow loggers (for instance, okhttp) / dav4android update 2016-10-04 23:42:03 +02:00
Ricki Hirner
26f9352411 Android 4.0/4.1 fixes
* require API level 15 for TransactionTooLargeException
* use SQLite WAL only on API level 16+
* various database access, provider access and UI fixes
2016-10-04 16:23:23 +02:00
Ricki Hirner
279c51c4a6 Version bump to 1.3.2 2016-10-03 20:57:14 +02:00
Ricki Hirner
98f4dd4c87 Fetch translations from Transifex 2016-10-03 20:43:01 +02:00
Ricki Hirner
5e1cb21d55 Avoid "no transaction" exception 2016-10-03 20:11:56 +02:00
Ricki Hirner
aa6e43cc94 Minimal layout change 2016-10-03 12:13:59 +02:00
Ricki Hirner
992e83fdb1 Show progress bar when synchronization is active 2016-09-26 23:07:35 +02:00
Ricki Hirner
cb73c1ebf7 Increase SEQUENCE only when we're ORGANIZER 2016-09-26 23:07:35 +02:00
Ricki Hirner
09d8f63515 Query/use CalDAV email address as account name, if available 2016-09-26 23:07:35 +02:00
Ricki Hirner
4f4cf25027 Always increase SEQUENCE 2016-09-26 23:07:35 +02:00
Ricki Hirner
de4e81977a lint: don't keep references to Context in static fields 2016-09-26 23:07:35 +02:00
Ricki Hirner
10bbfcabcb Version bump to 1.3.1
* some cert4android tests
2016-09-18 17:39:07 +02:00
Ricki Hirner
b5d295e57d Import strings from Transifex 2016-09-18 16:50:27 +02:00
Ricki Hirner
49555c34d8 Always use PROPFIND instead of REPORT addressbook-query 2016-09-18 16:43:11 +02:00
Ricki Hirner
9b6903572a README changes 2016-09-02 12:22:45 +02:00
Ricki Hirner
f16d64d636 Fetch translations from Transifex 2016-09-02 12:13:22 +02:00
Ricki Hirner
bceb5b643a lint optimizations
* permissions: declare AUTHENTICATE_ACCOUNTS, GET_ACCOUNTS and MANAGE_ACCOUNTS only until SDK level 22
* minor optimizations and bug fixes
2016-09-02 12:02:42 +02:00
Ricki Hirner
7e783feecf Version bump to 1.3
* vcard4android: fix bug concerning generated formatted postal addresses
2016-09-02 00:55:44 +02:00
Ricki Hirner
f9040f65fe New launcher logo 2016-09-02 00:55:39 +02:00
Ricki Hirner
b73a825ac9 Use cert4android instead of MemorizingTrustManager
* use cert4android instead of MemorizingTrustManager
* new app setting: distrust system certificates
* add network security config to manifest so that user-installed CAs will be accepted in Android 7 again
* update gradle
2016-09-02 00:38:02 +02:00
Ricki Hirner
67104b96fd Accept intent extras for LoginActivity 2016-08-13 23:14:33 +02:00
Ricki Hirner
e2c8fc9662 Fetch translations from Transifex 2016-08-06 00:13:16 +02:00
Ricki Hirner
40e46b721f Fix OpenTasks regression bug
* version bump to 1.2.3
* enable OpenTasks sync on Android <6 again
2016-08-05 23:32:03 +02:00
Ricki Hirner
30e6f44e44 Improve HTTP authentication
* use preemptive Basic auth automatically for HTTPS connections
* cache auth parameters (Basic/Digest)
2016-08-05 23:20:19 +02:00
Ricki Hirner
fd06c00fca Fetch translations from Transifex 2016-08-02 19:30:20 +02:00
Ricki Hirner
307c234f4a Request ignoring battery optimization
* startup dialog: request to ignore battery optimizations
* remove F-Droid donation startup dialog (only useful for davdroid-ose)
* version bump to 1.2.2
2016-08-02 19:30:15 +02:00
Ricki Hirner
d6dda6fbeb Avoid sync error when OpenTasks is not installed 2016-08-01 21:54:56 +02:00
Ricki Hirner
b4cb8234f1 Clean up launcher icon
* clean up launcher icon
* update dependencies
2016-08-01 21:15:55 +02:00
Ricki Hirner
a5fb9fd214 Allow large transactions
* version bump to 1.2.1-ose
* upgrade to okhttp 3.4.1
* ical4android/vcard4android: split oversized transactions
2016-07-27 14:33:06 +02:00
Ricki Hirner
3bbbba15b3 Remove gplay flavour to keep DAVdroid-OSE repo clean
* Remove gplay flavour to keep DAVdroid-OSE repo clean
* update Android gradle plugin to 2.1.2
2016-07-11 13:45:27 +02:00
407 changed files with 23251 additions and 13878 deletions

31
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,31 @@
image: registry.gitlab.com/bitfireat/docker-android-emulator:latest
before_script:
- git submodule update --init --recursive
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
cache:
paths:
- .gradle/
test:
tags:
- privileged
script:
- start-emulator.sh
- ./gradlew app:check app:connectedCheck
artifacts:
paths:
- app/build/outputs/lint-results-debug.html
- app/build/reports
- build/reports
pages:
script:
- ./gradlew app:dokka
- mkdir public && mv app/build/dokka public
artifacts:
paths:
- public
only:
- master-ose

10
.gitmodules vendored
View File

@@ -1,13 +1,9 @@
[submodule "dav4android"]
path = dav4android
url = https://gitlab.com/bitfireAT/dav4android.git
[submodule "ical4android"]
path = ical4android
url = https://gitlab.com/bitfireAT/ical4android.git
[submodule "vcard4android"]
path = vcard4android
url = https://gitlab.com/bitfireAT/vcard4android.git
[submodule "MemorizingTrustManager"]
path = MemorizingTrustManager
url = https://github.com/ge0rg/MemorizingTrustManager
ignore = dirty
[submodule "cert4android"]
path = cert4android
url = https://gitlab.com/bitfireAT/cert4android.git

47
CONTRIBUTING.md Normal file
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.

View File

@@ -1,34 +1,37 @@
![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png)
DAVdroid
DAVx⁵
========
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) 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)
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums)
Parts of DAVdroid have been outsourced into these libraries:
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
or [purchasing it](https://www.davx5.com/download).**
* [dav4android](https://gitlab.com/bitfireAT/dav4android) WebDAV/CalDav/CardDAV framework
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
Parts of DAVx⁵ have been outsourced into these libraries:
* [cert4android](https://gitlab.com/bitfireAT/cert4android) custom certificate management
* [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
[![Flattr this!](https://api.flattr.com/button/flattr-badge-large.png)](https://flattr.com/submit/auto?user_id=bitfire&url=https://davdroid.bitfire.at&title=DAVdroid&category=software)
* [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):
* [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://code.google.com/p/ez-vcard/) [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
* [iCal4j](http://ical4j.sourceforge.net/) [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
* [MemorizingTrustManager](https://github.com/ge0rg/MemorizingTrustManager) [MIT License](https://raw.githubusercontent.com/ge0rg/MemorizingTrustManager/master/LICENSE.txt)
* [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)
* [SLF4J](http://www.slf4j.org/) [MIT License](http://www.slf4j.org/license.html)
* [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)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2016 Ricki Hirner (bitfire web engineering).
* Copyright (c) Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
@@ -7,27 +7,44 @@
*/
apply plugin: 'com.android.application'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.dokka'
android {
compileSdkVersion 23
buildToolsVersion "23.0.3"
compileSdkVersion 29
buildToolsVersion '29.0.2'
defaultConfig {
applicationId "at.bitfire.davdroid"
minSdkVersion 14
targetSdkVersion 23
versionCode 109
versionCode 300000004
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
buildConfigField "boolean", "customCerts", "true"
minSdkVersion 21 // Android 5.0
targetSdkVersion 29 // Android 10.0
buildConfigField "String", "okhttpVersion", "\"${versions.okhttp}\""
buildConfigField "String", "userAgent", "\"DAVx5\""
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
dataBinding.enabled = true
flavorDimensions "distribution"
productFlavors {
standard {
versionName "1.2"
}
gplay {
versionName "1.2-gplay"
versionName "3.0-ose2"
}
}
@@ -43,46 +60,92 @@ android {
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'GradleDependency'
disable 'GradleDynamicVersion'
disable 'IconColors'
disable 'IconLauncherShape'
disable 'IconMissingDensityFolder'
disable 'ImpliedQuantity'
disable 'MissingTranslation'
disable 'MissingQuantity'
disable 'Recycle' // doesn't understand Lombok's @Cleanup
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable 'RtlEnabled'
disable 'RtlHardcoded'
disable 'Typos'
}
packagingOptions {
exclude 'LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
}
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
dokka.configuration {
sourceLink {
url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/"
lineSuffix = "#L"
}
jdkVersion = 7
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(':dav4android')
compile project(':ical4android')
compile project(':vcard4android')
implementation project(':cert4android')
implementation project(':ical4android')
implementation project(':vcard4android')
compile 'com.android.support:appcompat-v7:23.+'
compile 'com.android.support:cardview-v7:23.+'
compile 'com.android.support:design:23.+'
compile 'com.android.support:preference-v14:23.+'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}"
compile 'com.github.yukuku:ambilwarna:2.0.1'
compile project(':MemorizingTrustManager')
implementation 'androidx.appcompat:appcompat:1.2.0-beta01'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta4'
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-alpha06'
compile 'dnsjava:dnsjava:2.1.7'
compile 'org.apache.commons:commons-lang3:3.4'
compile 'org.apache.commons:commons-collections4:4.1'
provided 'org.projectlombok:lombok:1.16.8'
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"
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
testCompile 'junit:junit:4.12'
testCompile 'com.squareup.okhttp3:mockwebserver:3.3.1'
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.3.1'
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}"
testImplementation 'junit:junit:4.13'
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
}

View File

@@ -1,9 +1,9 @@
# ProGuard usage for DAVdroid:
# shrinking yes (main reason for using ProGuard)
# optimization yes
# obfuscation no (DAVdroid is open-source)
# preverification no
# ProGuard usage for DAVx⁵:
# shrinking yes (main reason for using ProGuard)
# optimization yes
# obfuscation no (DAVx⁵ is open-source)
# preverification no
-dontobfuscate
@@ -13,29 +13,29 @@
-dontpreverify
# 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
-dontwarn sun.misc.Perf
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
-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 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.commons.logging.** # Commons logging is not available
-dontwarn net.fortuna.ical4j.model.** # ignore warnings from Groovy dependency
-keep class net.fortuna.ical4j.model.** { *; } # keep all model classes (properties/factories, created at runtime)
# okhttp
-dontwarn java.nio.file.** # not available on Android
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
# MemorizingTrustManager
-dontwarn de.duenndns.ssl.MemorizingTrustManager
-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)
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
-dontwarn sun.net.spi.nameservice.** # not available on Android
# DAVdroid + libs
-keep class at.bitfire.** { *; } # all DAVdroid code is required
# 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);
}

1
app/src/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
espressoTest

View File

@@ -0,0 +1,74 @@
/*
* 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 okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class HttpClientTest {
lateinit var server: MockWebServer
lateinit var httpClient: HttpClient
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
server = MockWebServer()
server.start(30000)
}
@After
fun tearDown() {
server.shutdown()
httpClient.close()
}
@Test
fun testCookies() {
val url = server.url("/test")
// set cookie for root path (/) and /test path in first response
server.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("Set-Cookie", "cookie1=1; path=/")
.addHeader("Set-Cookie", "cookie2=2")
.setBody("Cookie set"))
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertNull(server.takeRequest().getHeader("Cookie"))
// cookie should be sent with second request
// second response lets first cookie expire and overwrites second cookie
server.enqueue(MockResponse()
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
.addHeader("Set-Cookie", "cookie2=2a")
.setResponseCode(200))
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
server.enqueue(MockResponse()
.setResponseCode(200))
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
}
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import android.os.Build;
import android.test.InstrumentationTestCase;
import java.io.IOException;
import java.net.Socket;
import javax.net.ssl.SSLSocket;
import de.duenndns.ssl.MemorizingTrustManager;
import okhttp3.mockwebserver.MockWebServer;
public class SSLSocketFactoryCompatTest extends InstrumentationTestCase {
SSLSocketFactoryCompat factory;
MockWebServer server = new MockWebServer();
@Override
protected void setUp() throws Exception {
factory = new SSLSocketFactoryCompat(new MemorizingTrustManager(getInstrumentation().getTargetContext().getApplicationContext()));
server.start();
}
@Override
protected void tearDown() throws Exception {
server.shutdown();
}
public void testUpgradeTLS() throws IOException {
Socket s = factory.createSocket(server.getHostName(), server.getPort());
assertTrue(s instanceof SSLSocket);
SSLSocket ssl = (SSLSocket)s;
assertFalse(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "SSLv3"));
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1"));
if (Build.VERSION.SDK_INT >= 16) {
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1.1"));
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1.2"));
}
}
}

View File

@@ -1,110 +0,0 @@
/*
* Copyright © 2013 2016 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 junit.framework.TestCase;
import java.io.IOException;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
public class CollectionInfoTest extends TestCase {
MockWebServer server = new MockWebServer();
public void testFromDavResource() throws IOException, HttpException, DavException {
// r/w address book
server.enqueue(new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
DavResource dav = new DavResource(HttpClient.create(), server.url("/"));
dav.propfind(0, ResourceType.NAME);
CollectionInfo info = CollectionInfo.fromDavResource(dav);
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type);
assertFalse(info.readOnly);
assertEquals("My Contacts", info.displayName);
assertEquals("My Contacts Description", info.description);
// read-only calendar, no display name
server.enqueue(new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
dav = new DavResource(HttpClient.create(), server.url("/"));
dav.propfind(0, ResourceType.NAME);
info = CollectionInfo.fromDavResource(dav);
assertEquals(CollectionInfo.Type.CALENDAR, info.type);
assertTrue(info.readOnly);
assertNull(info.displayName);
assertEquals("My Calendar", info.description);
assertEquals(0xFFFF0000, (int)info.color);
assertEquals("tzdata", info.timeZone);
assertTrue(info.supportsVEVENT);
assertTrue(info.supportsVTODO);
}
public void testFromDB() {
ContentValues values = new ContentValues();
values.put(Collections.ID, 1);
values.put(Collections.SERVICE_ID, 1);
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);
CollectionInfo info = CollectionInfo.fromDB(values);
assertEquals(1, info.id);
assertEquals(1, (long)info.serviceID);
assertEquals("http://example.com", info.url);
assertTrue(info.readOnly);
assertEquals("display name", info.displayName);
assertEquals("description", info.description);
assertEquals(0xFFFF0000, (int)info.color);
assertEquals("tzdata", info.timeZone);
assertTrue(info.supportsVEVENT);
assertTrue(info.supportsVTODO);
assertTrue(info.selected);
}
}

View File

@@ -0,0 +1,134 @@
/*
* 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 androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class CollectionTest {
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
}
@After
fun shutDown() {
httpClient.close()
}
@Test
@SmallTest
fun testFromDavResponseAddressBook() {
// r/w address book
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
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)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
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!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
@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>"))
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)
}
}

View File

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

View File

@@ -0,0 +1,38 @@
/*
* 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 org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class DefaultsSettingsProviderTest {
private val provider: SettingsProvider = DefaultsProvider()
@Test
fun testHas() {
assertEquals(Pair(false, true), provider.has("notExisting"))
assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY))
}
@Test
fun testGet() {
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(Settings.OVERRIDE_PROXY))
assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true))
assertFalse(provider.remove(Settings.OVERRIDE_PROXY))
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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 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
@Before
fun initialize() {
settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
}
@Test
fun testHas() {
assertFalse(settings.has("notExisting"))
// provided by DefaultsProvider
assertTrue(settings.has(Settings.OVERRIDE_PROXY))
}
}

View File

@@ -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())
}
}

View File

@@ -1,189 +0,0 @@
/*
* Copyright © 2013 2016 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.setup;
import android.test.InstrumentationTestCase;
import java.io.IOException;
import java.net.URI;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressbookHomeSet;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.ui.setup.DavResourceFinder;
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
import at.bitfire.davdroid.ui.setup.LoginCredentials;
import okhttp3.OkHttpClient;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
public class DavResourceFinderTest extends InstrumentationTestCase {
MockWebServer server = new MockWebServer();
DavResourceFinder finder;
OkHttpClient client;
LoginCredentials credentials;
private static final String
PATH_NO_DAV = "/nodav",
PATH_CALDAV = "/caldav",
PATH_CARDDAV = "/carddav",
PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav",
SUBPATH_PRINCIPAL = "/principal",
SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks",
SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts";
@Override
protected void setUp() throws Exception {
server.setDispatcher(new TestDispatcher());
server.start();
credentials = new LoginCredentials(URI.create("/"), "mock", "12345", true);
finder = new DavResourceFinder(getInstrumentation().getContext(), credentials);
client = HttpClient.create();
client = HttpClient.addAuthentication(client, credentials.userName, credentials.password, credentials.authPreemptive);
}
@Override
protected void tearDown() throws Exception {
server.shutdown();
}
public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException {
ServiceInfo info;
// before dav.propfind(), no info is available
DavResource dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.collections.size());
assertEquals(0, info.homeSets.size());
// recognize home set
dav.propfind(0, AddressbookHomeSet.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.collections.size());
assertEquals(1, info.homeSets.size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.homeSets.iterator().next());
// recognize address book
dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
dav.propfind(0, ResourceType.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(1, info.collections.size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.collections.keySet().iterator().next());
assertEquals(0, info.homeSets.size());
}
public void testProvidesService() throws IOException {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV));
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV));
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV));
}
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
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(),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
);
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
}
// mock server
public class TestDispatcher extends Dispatcher {
@Override
public MockResponse dispatch(RecordedRequest rq) throws InterruptedException {
if (!checkAuth(rq))
return new MockResponse().setResponseCode(401);
String path = rq.getPath();
if ("OPTIONS".equalsIgnoreCase(rq.getMethod())) {
String dav = null;
if (path.startsWith(PATH_CALDAV))
dav = "calendar-access";
else if (path.startsWith(PATH_CARDDAV))
dav = "addressbook";
else if (path.startsWith(PATH_CALDAV_AND_CARDDAV))
dav = "calendar-access, addressbook";
MockResponse response = new MockResponse().setResponseCode(200);
if (dav != null)
response.addHeader("DAV", dav);
return response;
} else if ("PROPFIND".equalsIgnoreCase(rq.getMethod())) {
String props = null;
switch (path) {
case PATH_CALDAV:
case PATH_CARDDAV:
props = "<current-user-principal><href>" + path + SUBPATH_PRINCIPAL + "</href></current-user-principal>";
break;
case PATH_CARDDAV + SUBPATH_PRINCIPAL:
props = "<CARD:addressbook-home-set>" +
" <href>" + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "</href>" +
"</CARD:addressbook-home-set>";
break;
case PATH_CARDDAV + SUBPATH_ADDRESSBOOK:
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>";
break;
}
App.log.info("Sending props: " + props);
return new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>" + rq.getPath() + "</href>" +
" <propstat><prop>" + props + "</prop></propstat>" +
"</response>" +
"</multistatus>");
}
return new MockResponse().setResponseCode(404);
}
private boolean checkAuth(RecordedRequest rq) {
return "Basic bW9jazoxMjM0NQ==".equals(rq.getHeader("Authorization"));
}
}
}

View File

@@ -0,0 +1,191 @@
/*
* 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.setup
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
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.URI
class DavResourceFinderTest {
companion object {
private const val PATH_NO_DAV = "/nodav"
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
}
val server = MockWebServer()
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
lateinit var loginModel: LoginModel
@Before
fun initServerAndClient() {
server.dispatcher = TestDispatcher()
server.start()
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
client = HttpClient.Builder()
.addAuthentication(null, loginModel.credentials!!)
.build()
}
@After
fun stopServer() {
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
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
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
fun testProvidesService() {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
}
@Test
fun testGetCurrentUserPrincipal() {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
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),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
}
// mock server
class TestDispatcher: Dispatcher() {
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 = request.path!!
if (request.method.equals("OPTIONS", true)) {
val dav = when {
path.startsWith(PATH_CALDAV) -> "calendar-access"
path.startsWith(PATH_CARDDAV) -> "addressbook"
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
else -> null
}
val response = MockResponse().setResponseCode(200)
if (dav != null)
response.addHeader("DAV", dav)
return response
} else if (request.method.equals("PROPFIND", true)) {
val props: String?
when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
props = "<CARD:addressbook-home-set>" +
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>"
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'>" +
"<response>" +
" <href>${request.path}</href>" +
" <propstat><prop>$props</prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
private fun checkAuth(rq: RecordedRequest) =
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
}
}

View File

@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright © 2013 2016 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
-->
<menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_about"
android:icon="@drawable/ic_info_dark"
android:title="@string/navigation_drawer_about"/>
<item
android:id="@+id/nav_app_settings"
android:icon="@drawable/ic_settings_dark"
android:title="@string/navigation_drawer_settings"/>
<item android:title="@string/navigation_drawer_news_updates">
<menu>
<item
android:id="@+id/nav_twitter"
android:icon="@drawable/twitter"
android:title="\@davdroidapp"
tools:ignore="HardcodedText"/>
</menu>
</item>
<item android:title="@string/navigation_drawer_external_links">
<menu>
<item
android:id="@+id/nav_website"
android:icon="@drawable/ic_home_dark"
android:title="@string/navigation_drawer_website"/>
<item
android:id="@+id/nav_faq"
android:icon="@drawable/ic_help_dark"
android:title="@string/navigation_drawer_faq"/>
<item
android:id="@+id/nav_forums"
android:icon="@drawable/ic_forum_dark"
android:title="@string/navigation_drawer_forums"/>
<item
android:id="@+id/nav_donate"
android:icon="@drawable/ic_attach_money_dark"
android:title="(entry disabled)"
android:visible="false"
tools:ignore="HardcodedText"/>
</menu>
</item>
</menu>

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"
@@ -15,28 +8,15 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- legacy permissions -->
<uses-permission
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
android:maxSdkVersion="22"
tools:ignore="UnusedAttribute"/>
<!--
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"
tools:ignore="UnusedAttribute"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18"
tools:ignore="UnusedAttribute"/>
<!-- account management permissions not required for own accounts since API level 22 -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
@@ -46,101 +26,28 @@
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.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:icon="@drawable/ic_launcher"
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
tools:ignore="UnusedAttribute">
<receiver
android:name=".App$ReinitLoggingReceiver"
android:exported="false"
android:process=":sync">
<intent-filter>
<action android:name="at.bitfire.davdroid.REINIT_LOGGER"/>
</intent-filter>
</receiver>
<receiver
android:name=".AccountSettings$AppUpdatedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_REPLACED" />
<data android:scheme="package" android:path="at.bitfire.davdroid" />
</intent-filter>
</receiver>
<service android:name=".DavService"/>
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks"/>
</service>
<service
android:name=".DavService"
android:enabled="true">
</service>
<receiver
android:name=".AccountsChangedReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
</intent-filter>
</receiver>
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name=".ui.AccountsActivity"
android:label="@string/app_name"
@@ -153,18 +60,19 @@
<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"/>
<activity android:name=".ui.PermissionsActivity"
android:label="@string/permissions_title"
android:parentActivityName=".ui.AccountsActivity"/>
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
</intent-filter>
</activity>
<activity
android:name=".ui.setup.LoginActivity"
@@ -176,10 +84,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"
@@ -187,14 +95,105 @@
<activity
android:name=".ui.DebugInfoActivity"
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="true"
android:label="@string/debug_info_title">
<intent-filter>
<action android:name="android.intent.action.BUG_REPORT"/>
</intent-filter>
</activity>
<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/debug_paths" />
</provider>
<!-- MemorizingTrustManager -->
<activity
android:name="de.duenndns.ssl.MemorizingActivity"
android:theme="@android:style/Theme.Holo.Light.Dialog.NoActionBar"/>
<!-- account type "DAVx⁵" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks"/>
</service>
<!-- account type "DAVx⁵ Address book" -->
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator_address_book"/>
</service>
<provider
android:authorities="@string/address_books_authority"
android:exported="false"
android:label="@string/address_books_authority_title"
android:name=".syncadapter.AddressBookProvider"
android:multiprocess="false"/>
<service
android:name=".syncadapter.AddressBooksSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_address_books"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts"/>
</service>
</application>

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>

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>

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>

View File

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

View File

@@ -1,7 +0,0 @@
<h3>The MIT License (MIT)</h3>
<p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>
<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>

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","maxkl","nicolas_git","owncube","TheName","Wyrrrd","YvanM"],"el":["anestiskaci","diamond_gr"],"es":["aluaces","Ark74","Elhea","GranPC","jcvielma","plaguna","polkhas","xphnx"],"eu":["cockeredradiation","Osoitz"],"fa":["ahangarha","amiraliakbari","maryambehzi","mtashackori","Numb","taranehsaei"],"fi_FI":["raketti","tseipii"],"fr":["AlainR","alkino2","Amadeen","boutil","callmemagnus","chfo","chrcha","Floflr","grenatrad","Jorg722","Llorc","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","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"]}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,464 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.PeriodicSync;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import lombok.Cleanup;
import okhttp3.HttpUrl;
public class AccountSettings {
private final static int CURRENT_VERSION = 4;
private final static String
KEY_SETTINGS_VERSION = "version",
KEY_USERNAME = "user_name",
KEY_AUTH_PREEMPTIVE = "auth_preemptive",
KEY_WIFI_ONLY = "wifi_only", // sync on WiFi only (default: false)
KEY_WIFI_ONLY_SSID = "wifi_only_ssid"; // restrict sync to specific WiFi SSID
/** Whether to use RFC 6868 for VCards
* value = null (not existing) use RFC6868-style encoding (default value)
* "0" don't use RFC 6868-style encoding
*/
private final static String KEY_VCARD_RFC6868 = "vcard_rfc6868";
/** 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
*/
private final static String KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days";
private final static int DEFAULT_TIME_RANGE_PAST_DAYS = 90;
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
value = null (not existing) true (default)
"0" false */
private final static String KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors";
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
private final static String KEY_CONTACT_GROUP_METHOD = "contact_group_method";
public final static long SYNC_INTERVAL_MANUALLY = -1;
final Context context;
final AccountManager accountManager;
final Account account;
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public AccountSettings(@NonNull Context context, @NonNull Account account) throws InvalidAccountException {
this.context = context;
this.account = account;
accountManager = AccountManager.get(context);
synchronized(AccountSettings.class) {
String versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION);
if (versionStr == null)
throw new InvalidAccountException(account);
int version = 0;
try {
version = Integer.parseInt(versionStr);
} catch (NumberFormatException ignored) {
}
App.log.info("Account " + account.name + " has version " + version + ", current version: " + CURRENT_VERSION);
if (version < CURRENT_VERSION) {
Bitmap bitmapLogo = null;
Drawable drawableLogo = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP ?
context.getDrawable(R.drawable.ic_launcher) :
context.getResources().getDrawable(R.drawable.ic_launcher);
if (drawableLogo instanceof BitmapDrawable)
bitmapLogo = ((BitmapDrawable)drawableLogo).getBitmap();
Notification notify = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_new_releases_light)
.setLargeIcon(bitmapLogo)
.setContentTitle(context.getString(R.string.settings_version_update))
.setContentText(context.getString(R.string.settings_version_update_settings_updated))
.setSubText(context.getString(R.string.settings_version_update_install_hint))
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(context.getString(R.string.settings_version_update_settings_updated)))
.setCategory(NotificationCompat.CATEGORY_SYSTEM)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(PendingIntent.getActivity(context, 0,
new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("faq/entry/davdroid-not-working-after-update/").build()),
PendingIntent.FLAG_CANCEL_CURRENT))
.setLocalOnly(true)
.build();
NotificationManager nm = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
nm.notify(Constants.NOTIFICATION_ACCOUNT_SETTINGS_UPDATED, notify);
update(version);
}
}
}
public static Bundle initialUserData(String userName, boolean preemptive) {
Bundle bundle = new Bundle();
bundle.putString(KEY_SETTINGS_VERSION, String.valueOf(CURRENT_VERSION));
bundle.putString(KEY_USERNAME, userName);
bundle.putString(KEY_AUTH_PREEMPTIVE, Boolean.toString(preemptive));
return bundle;
}
// authentication settings
public String username() { return accountManager.getUserData(account, KEY_USERNAME); }
public void username(@NonNull String userName) { accountManager.setUserData(account, KEY_USERNAME, userName); }
public String password() { return accountManager.getPassword(account); }
public void password(@NonNull String password) { accountManager.setPassword(account, password); }
public boolean preemptiveAuth() { return Boolean.parseBoolean(accountManager.getUserData(account, KEY_AUTH_PREEMPTIVE)); }
public void preemptiveAuth(boolean preemptive) { accountManager.setUserData(account, KEY_AUTH_PREEMPTIVE, Boolean.toString(preemptive)); }
// sync. settings
public Long getSyncInterval(@NonNull String authority) {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null;
if (ContentResolver.getSyncAutomatically(account, authority)) {
List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(account, authority);
if (syncs.isEmpty())
return SYNC_INTERVAL_MANUALLY;
else
return syncs.get(0).period;
} else
return SYNC_INTERVAL_MANUALLY;
}
public void setSyncInterval(@NonNull String authority, long seconds) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, authority, false);
} else {
ContentResolver.setSyncAutomatically(account, authority, true);
ContentResolver.addPeriodicSync(account, authority, new Bundle(), seconds);
}
}
public boolean getSyncWifiOnly() {
return accountManager.getUserData(account, KEY_WIFI_ONLY) != null;
}
public void setSyncWiFiOnly(boolean wiFiOnly) {
accountManager.setUserData(account, KEY_WIFI_ONLY, wiFiOnly ? "1" : null);
}
@Nullable
public String getSyncWifiOnlySSID() {
return accountManager.getUserData(account, KEY_WIFI_ONLY_SSID);
}
public void setSyncWifiOnlySSID(String ssid) {
accountManager.setUserData(account, KEY_WIFI_ONLY_SSID, ssid);
}
// CardDAV settings
public boolean getVCardRFC6868() {
return accountManager.getUserData(account, KEY_VCARD_RFC6868) == null;
}
public void setVCardRFC6868(boolean use) {
accountManager.setUserData(account, KEY_VCARD_RFC6868, use ? null : "0");
}
// CalDAV settings
@Nullable
public Integer getTimeRangePastDays() {
String strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS);
if (strDays != null) {
int days = Integer.valueOf(strDays);
return days < 0 ? null : days;
} else
return DEFAULT_TIME_RANGE_PAST_DAYS;
}
public void setTimeRangePastDays(@Nullable Integer days) {
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, String.valueOf(days == null ? -1 : days));
}
public boolean getManageCalendarColors() {
return accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null;
}
public void setManageCalendarColors(boolean manage) {
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, manage ? null : "0");
}
// CardDAV settings
@NonNull
public GroupMethod getGroupMethod() {
final String name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD);
return name != null ?
GroupMethod.valueOf(name) :
GroupMethod.GROUP_VCARDS;
}
public void setGroupMethod(@NonNull GroupMethod method) {
final String name = method == GroupMethod.GROUP_VCARDS ? null : method.name();
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, name);
}
// update from previous account settings
private void update(int fromVersion) {
for (int toVersion = fromVersion + 1; toVersion <= CURRENT_VERSION; toVersion++) {
App.log.info("Updating account " + account.name + " from version " + fromVersion + " to " + toVersion);
try {
Method updateProc = getClass().getDeclaredMethod("update_" + fromVersion + "_" + toVersion);
updateProc.invoke(this);
accountManager.setUserData(account, KEY_SETTINGS_VERSION, String.valueOf(toVersion));
} catch (Exception e) {
App.log.log(Level.SEVERE, "Couldn't update account settings", e);
}
fromVersion = toVersion;
}
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_1_2() throws ContactsStorageException {
/* - 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
@Cleanup("release") ContentProviderClient provider = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
if (provider == null)
throw new ContactsStorageException("Couldn't access Contacts provider");
LocalAddressBook addr = new LocalAddressBook(account, provider);
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
ContentValues values = new ContentValues();
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
addr.updateSettings(values);
String url = accountManager.getUserData(account, "addressbook_url");
if (!TextUtils.isEmpty(url))
addr.setURL(url);
accountManager.setUserData(account, "addressbook_url", null);
String cTag = accountManager.getUserData(account, "addressbook_ctag");
if (!TextUtils.isEmpty(cTag))
addr.setCTag(cTag);
accountManager.setUserData(account, "addressbook_ctag", null);
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_2_3() {
// Don't show a warning for Android updates anymore
accountManager.setUserData(account, "last_android_version", null);
Long serviceCardDAV = null, serviceCalDAV = null;
ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(context);
try {
SQLiteDatabase db = dbHelper.getWritableDatabase();
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
// CardDAV: migrate address books
ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
if (client != null)
try {
LocalAddressBook addrBook = new LocalAddressBook(account, client);
String url = addrBook.getURL();
if (url != null) {
App.log.fine("Migrating address book " + url);
// insert CardDAV service
ContentValues values = new ContentValues();
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 homeSet = HttpUrl.parse(url).resolve("../");
values.clear();
values.put(HomeSets.SERVICE_ID, serviceCardDAV);
values.put(HomeSets.URL, homeSet.toString());
db.insert(HomeSets._TABLE, null, values);
}
} catch (ContactsStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate address book", e);
} finally {
client.release();
}
// CalDAV: migrate calendars + task lists
Set<String> collections = new HashSet<>();
Set<HttpUrl> homeSets = new HashSet<>();
client = context.getContentResolver().acquireContentProviderClient(CalendarContract.AUTHORITY);
if (client != null)
try {
LocalCalendar calendars[] = (LocalCalendar[])LocalCalendar.find(account, client, LocalCalendar.Factory.INSTANCE, null, null);
for (LocalCalendar calendar : calendars) {
String url = calendar.getName();
App.log.fine("Migrating calendar " + url);
collections.add(url);
homeSets.add(HttpUrl.parse(url).resolve("../"));
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate calendars", e);
} finally {
client.release();
}
TaskProvider provider = LocalTaskList.acquireTaskProvider(context.getContentResolver());
if (provider != null)
try {
LocalTaskList[] taskLists = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
for (LocalTaskList taskList : taskLists) {
String url = taskList.getSyncId();
App.log.fine("Migrating task list " + url);
collections.add(url);
homeSets.add(HttpUrl.parse(url).resolve("../"));
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate task lists", e);
} finally {
provider.close();
}
if (!collections.isEmpty()) {
// insert CalDAV service
ContentValues values = new ContentValues();
values.put(Services.ACCOUNT_NAME, account.name);
values.put(Services.SERVICE, Services.SERVICE_CALDAV);
serviceCalDAV = db.insert(Services._TABLE, null, values);
// insert collections
for (String url : 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 (HttpUrl homeSet : homeSets) {
values.clear();
values.put(HomeSets.SERVICE_ID, serviceCalDAV);
values.put(HomeSets.URL, homeSet.toString());
db.insert(HomeSets._TABLE, null, values);
}
}
} finally {
dbHelper.close();
}
// initiate service detection (refresh) to get display names, colors etc.
Intent refresh = new Intent(context, DavService.class);
refresh.setAction(DavService.ACTION_REFRESH_COLLECTIONS);
if (serviceCardDAV != null) {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCardDAV);
context.startService(refresh);
}
if (serviceCalDAV != null) {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCalDAV);
context.startService(refresh);
}
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES);
}
public static class AppUpdatedReceiver extends BroadcastReceiver {
@Override
@SuppressLint("UnsafeProtectedBroadcastReceiver")
public void onReceive(Context context, Intent intent) {
App.log.info("DAVdroid was updated, checking for AccountSettings version");
// peek into AccountSettings to initiate a possible migration
AccountManager accountManager = AccountManager.get(context);
for (Account account : accountManager.getAccountsByType(Constants.ACCOUNT_TYPE))
try {
App.log.info("Checking account " + account.name);
new AccountSettings(context, account);
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't check for updated account settings", e);
}
}
}
}

View File

@@ -1,43 +0,0 @@
/*
* Copyright © 2013 2016 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.OnAccountsUpdateListener;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import java.util.LinkedList;
import java.util.List;
public class AccountsChangedReceiver extends BroadcastReceiver {
protected static final List<OnAccountsUpdateListener> listeners = new LinkedList<>();
@Override
public void onReceive(Context context, Intent intent) {
Intent serviceIntent = new Intent(context, DavService.class);
serviceIntent.setAction(DavService.ACTION_ACCOUNTS_UPDATED);
context.startService(serviceIntent);
for (OnAccountsUpdateListener listener : listeners)
listener.onAccountsUpdated(null);
}
public static void registerListener(OnAccountsUpdateListener listener, boolean callImmediately) {
listeners.add(listener);
if (callImmediately)
listener.onAccountsUpdated(null);
}
public static void unregisterListener(OnAccountsUpdateListener listener) {
listeners.remove(listener);
}
}

View File

@@ -1,145 +0,0 @@
/*
* Copyright © 2013 2016 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.app.Application;
import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.BitmapDrawable;
import android.support.v7.app.NotificationCompat;
import android.util.Log;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.io.File;
import java.io.IOException;
import java.util.logging.FileHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.net.ssl.HostnameVerifier;
import at.bitfire.davdroid.log.LogcatHandler;
import at.bitfire.davdroid.log.PlainTextFormatter;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.Settings;
import de.duenndns.ssl.MemorizingTrustManager;
import lombok.Cleanup;
import lombok.Getter;
import okhttp3.internal.tls.OkHostnameVerifier;
public class App extends Application {
public static final String FLAVOR_GOOGLE_PLAY = "gplay";
public static final String LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage";
@Getter
private static MemorizingTrustManager memorizingTrustManager;
@Getter
private static SSLSocketFactoryCompat sslSocketFactoryCompat;
@Getter
private static HostnameVerifier hostnameVerifier;
public final static Logger log = Logger.getLogger("davdroid");
static {
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android");
}
@Override
public void onCreate() {
super.onCreate();
// initialize MemorizingTrustManager
memorizingTrustManager = new MemorizingTrustManager(this);
sslSocketFactoryCompat = new SSLSocketFactoryCompat(memorizingTrustManager);
hostnameVerifier = memorizingTrustManager.wrapHostnameVerifier(OkHostnameVerifier.INSTANCE);
// initializer logger
reinitLogger();
}
public void reinitLogger() {
// don't use Android default logging, we have our own handlers
log.setUseParentHandlers(false);
@Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(this);
Settings settings = new Settings(dbHelper.getReadableDatabase());
boolean logToFile = settings.getBoolean(LOG_TO_EXTERNAL_STORAGE, false),
logVerbose = logToFile || Log.isLoggable(log.getName(), Log.DEBUG);
// set logging level according to preferences
log.setLevel(logVerbose ? Level.ALL : Level.INFO);
// remove all handlers
for (Handler handler : log.getHandlers())
log.removeHandler(handler);
// add logcat handler
log.addHandler(LogcatHandler.INSTANCE);
NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
// log to external file according to preferences
if (logToFile) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
.setLargeIcon(((BitmapDrawable)getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
.setContentTitle(getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true);
File dir = getExternalFilesDir(null);
if (dir != null)
try {
String fileName = new File(dir, "davdroid-" + android.os.Process.myPid() + "-" +
DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss") + ".txt").toString();
log.info("Logging to " + fileName);
FileHandler fileHandler = new FileHandler(fileName);
fileHandler.setFormatter(PlainTextFormatter.DEFAULT);
log.addHandler(fileHandler);
builder .setContentText(dir.getPath())
.setSubText(getString(R.string.logging_to_external_storage_warning))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(getString(R.string.logging_to_external_storage, dir.getPath())))
.setOngoing(true);
} catch (IOException e) {
log.log(Level.SEVERE, "Couldn't create external log file", e);
builder .setContentText(getString(R.string.logging_couldnt_create_file, e.getLocalizedMessage()))
.setCategory(NotificationCompat.CATEGORY_ERROR);
}
else
builder.setContentText(getString(R.string.logging_no_external_storage));
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build());
} else
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING);
}
public static class ReinitLoggingReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
log.info("Received broadcast: re-initializing logger");
App app = (App)context.getApplicationContext();
app.reinitLogger();
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* 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.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.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 java.util.logging.Level
import kotlin.concurrent.thread
import kotlin.system.exitProcess
@Suppress("unused")
class App: Application(), Thread.UncaughtExceptionHandler {
companion object {
fun getLauncherBitmap(context: Context): Bitmap? {
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()!!
}
override fun onCreate() {
super.onCreate()
Logger.initialize(this)
if (BuildConfig.DEBUG)
// debug builds
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build())
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
// handle uncaught exceptions in non-debug standard flavor
Thread.setDefaultUncaughtExceptionHandler(this)
if (Build.VERSION.SDK_INT <= 21)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
NotificationUtils.createChannels(this)
// don't block UI for some background checks
thread {
// watch installed/removed apps
val tasksFilter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
registerReceiver(PackageChangedReceiver(), tasksFilter)
// check whether a tasks app is currently installed
PackageChangedReceiver.updateTaskSync(this)
}
}
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)
}
}

View File

@@ -1,33 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import java.lang.reflect.Array;
public class ArrayUtils {
@SuppressWarnings("unchecked")
public static <T> T[][] partition(T[] bigArray, int max) {
int nItems = bigArray.length;
int nPartArrays = (nItems + max-1)/max;
T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0);
// nItems is now the number of remaining items
for (int i = 0; nItems > 0; i++) {
int n = (nItems < max) ? nItems : max;
partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n);
System.arraycopy(bigArray, i*max, partArrays[i], 0, n);
nItems -= n;
}
return partArrays;
}
}

View File

@@ -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()
}

View File

@@ -1,29 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import android.net.Uri;
public class Constants {
public static final String
ACCOUNT_TYPE = "bitfire.at.davdroid";
// notification IDs
public final static int
NOTIFICATION_ACCOUNT_SETTINGS_UPDATED = 0,
NOTIFICATION_EXTERNAL_FILE_LOGGING = 1,
NOTIFICATION_REFRESH_COLLECTIONS = 2,
NOTIFICATION_CONTACTS_SYNC = 10,
NOTIFICATION_CALENDAR_SYNC = 11,
NOTIFICATION_TASK_SYNC = 12,
NOTIFICATION_PERMISSIONS = 20;
public static final Uri webUri = Uri.parse("https://davdroid.bitfire.at/?pk_campaign=davdroid-app");
}

View File

@@ -0,0 +1,30 @@
/*
* 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
object Constants {
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
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"
}

View File

@@ -1,427 +0,0 @@
/*
* Copyright © 2013 2016 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.accounts.AccountManager;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ContentValues;
import android.content.Intent;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.drawable.BitmapDrawable;
import android.os.Binder;
import android.os.IBinder;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import org.apache.commons.collections4.iterators.IteratorChain;
import org.apache.commons.collections4.iterators.SingletonIterator;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
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.AddressbookHomeSet;
import at.bitfire.dav4android.property.CalendarHomeSet;
import at.bitfire.dav4android.property.CalendarProxyReadFor;
import at.bitfire.dav4android.property.CalendarProxyWriteFor;
import at.bitfire.dav4android.property.GroupMembership;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
import at.bitfire.davdroid.model.ServiceDB.OpenHelper;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import lombok.Cleanup;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
public class DavService extends Service {
public static final String
ACTION_ACCOUNTS_UPDATED = "accountsUpdated",
ACTION_REFRESH_COLLECTIONS = "refreshCollections",
EXTRA_DAV_SERVICE_ID = "davServiceID";
private final IBinder binder = new InfoBinder();
private final Set<Long> runningRefresh = new HashSet<>();
private final List<WeakReference<RefreshingStatusListener>> refreshingStatusListeners = new LinkedList<>();
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
String action = intent.getAction();
long id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1);
switch (action) {
case ACTION_ACCOUNTS_UPDATED:
cleanupAccounts();
break;
case ACTION_REFRESH_COLLECTIONS:
if (runningRefresh.add(id)) {
new Thread(new RefreshCollections(id)).start();
for (WeakReference<RefreshingStatusListener> ref : refreshingStatusListeners) {
RefreshingStatusListener listener = ref.get();
if (listener != null)
listener.onDavRefreshStatusChanged(id, true);
}
}
break;
}
}
return START_NOT_STICKY;
}
/* BOUND SERVICE PART
for communicating with the activities
*/
@Override
public IBinder onBind(Intent intent) {
return binder;
}
public interface RefreshingStatusListener {
void onDavRefreshStatusChanged(long id, boolean refreshing);
}
public class InfoBinder extends Binder {
public boolean isRefreshing(long id) {
return runningRefresh.contains(id);
}
public void addRefreshingStatusListener(@NonNull RefreshingStatusListener listener, boolean callImmediate) {
refreshingStatusListeners.add(new WeakReference<>(listener));
if (callImmediate)
for (long id : runningRefresh)
listener.onDavRefreshStatusChanged(id, true);
}
public void removeRefreshingStatusListener(@NonNull RefreshingStatusListener listener) {
for (Iterator<WeakReference<RefreshingStatusListener>> iterator = refreshingStatusListeners.iterator(); iterator.hasNext(); ) {
RefreshingStatusListener item = iterator.next().get();
if (listener.equals(item))
iterator.remove();
}
}
}
/* ACTION RUNNABLES
which actually do the work
*/
void cleanupAccounts() {
App.log.info("Cleaning up orphaned accounts");
final OpenHelper dbHelper = new OpenHelper(this);
try {
SQLiteDatabase db = dbHelper.getWritableDatabase();
List<String> sqlAccountNames = new LinkedList<>();
AccountManager am = AccountManager.get(this);
for (Account account : am.getAccountsByType(Constants.ACCOUNT_TYPE))
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name));
if (sqlAccountNames.isEmpty())
db.delete(Services._TABLE, null, null);
else
db.delete(Services._TABLE, Services.ACCOUNT_NAME + " NOT IN (" + TextUtils.join(",", sqlAccountNames) + ")", null);
} finally {
dbHelper.close();
}
}
private class RefreshCollections implements Runnable {
final long service;
final OpenHelper dbHelper;
SQLiteDatabase db;
RefreshCollections(long davServiceId) {
this.service = davServiceId;
dbHelper = new OpenHelper(DavService.this);
}
@Override
public void run() {
Account account = null;
try {
db = dbHelper.getWritableDatabase();
String serviceType = serviceType();
App.log.info("Refreshing " + serviceType + " collections of service #" + service);
// get account
account = account();
// create authenticating OkHttpClient (credentials taken from account settings)
OkHttpClient httpClient = HttpClient.create(DavService.this, account);
// refresh home sets: principal
Set<HttpUrl> homeSets = readHomeSets();
HttpUrl principal = readPrincipal();
if (principal != null) {
App.log.fine("Querying principal for home sets");
DavResource dav = new DavResource(httpClient, principal);
queryHomeSets(serviceType, dav, homeSets);
// refresh home sets: calendar-proxy-read/write-for
CalendarProxyReadFor proxyRead = (CalendarProxyReadFor)dav.properties.get(CalendarProxyReadFor.NAME);
if (proxyRead != null)
for (String href : proxyRead.principals) {
App.log.fine("Principal is a read-only proxy for " + href + ", checking for home sets");
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
}
CalendarProxyWriteFor proxyWrite = (CalendarProxyWriteFor)dav.properties.get(CalendarProxyWriteFor.NAME);
if (proxyWrite != null)
for (String href : proxyWrite.principals) {
App.log.fine("Principal is a read-write proxy for " + href + ", checking for home sets");
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
}
// refresh home sets: direct group memberships
GroupMembership groupMembership = (GroupMembership)dav.properties.get(GroupMembership.NAME);
if (groupMembership != null)
for (String href : groupMembership.hrefs) {
App.log.fine("Principal is member of group " + href + ", checking for home sets");
DavResource group = new DavResource(httpClient, dav.location.resolve(href));
try {
queryHomeSets(serviceType, group, homeSets);
} catch(HttpException|DavException e) {
App.log.log(Level.WARNING, "Couldn't query member group ", e);
}
}
}
// now refresh collections (taken from home sets)
Map<HttpUrl, CollectionInfo> collections = readCollections();
// (remember selections before)
Set<HttpUrl> selectedCollections = new HashSet<>();
for (CollectionInfo info : collections.values())
if (info.selected)
selectedCollections.add(HttpUrl.parse(info.url));
for (Iterator<HttpUrl> itHomeSets = homeSets.iterator(); itHomeSets.hasNext(); ) {
HttpUrl homeSet = itHomeSets.next();
App.log.fine("Listing home set " + homeSet);
DavResource dav = new DavResource(httpClient, homeSet);
try {
dav.propfind(1, CollectionInfo.DAV_PROPERTIES);
IteratorChain<DavResource> itCollections = new IteratorChain<>(dav.members.iterator(), new SingletonIterator(dav));
while (itCollections.hasNext()) {
DavResource member = itCollections.next();
CollectionInfo info = CollectionInfo.fromDavResource(member);
info.confirmed = true;
App.log.log(Level.FINE, "Found collection", info);
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type == CollectionInfo.Type.CALENDAR))
collections.put(member.location, info);
}
} catch(HttpException e) {
if (e.status == 403 || e.status == 404 || e.status == 410)
// delete home set only if it was not accessible (40x)
itHomeSets.remove();
}
}
// check/refresh unconfirmed collections
for (Iterator<Map.Entry<HttpUrl, CollectionInfo>> iterator = collections.entrySet().iterator(); iterator.hasNext(); ) {
Map.Entry<HttpUrl, CollectionInfo> entry = iterator.next();
HttpUrl url = entry.getKey();
CollectionInfo info = entry.getValue();
if (!info.confirmed)
try {
DavResource dav = new DavResource(httpClient, url);
dav.propfind(0, CollectionInfo.DAV_PROPERTIES);
info = CollectionInfo.fromDavResource(dav);
info.confirmed = true;
// remove unusable collections
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type != CollectionInfo.Type.CALENDAR))
iterator.remove();
} catch(HttpException e) {
if (e.status == 403 || e.status == 404 || e.status == 410)
// delete collection only if it was not accessible (40x)
iterator.remove();
else
throw e;
}
}
// restore selections
for (HttpUrl url : selectedCollections) {
CollectionInfo info = collections.get(url);
if (info != null)
info.selected = true;
}
try {
db.beginTransactionNonExclusive();
saveHomeSets(homeSets);
saveCollections(collections.values());
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
} catch(InvalidAccountException e) {
App.log.log(Level.SEVERE, "Invalid account", e);
} catch(IOException|HttpException|DavException e) {
App.log.log(Level.SEVERE, "Couldn't refresh collection list", e);
Intent debugIntent = new Intent(DavService.this, DebugInfoActivity.class);
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e);
if (account != null)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
Notification notify = new NotificationCompat.Builder(DavService.this)
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(((BitmapDrawable)getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(DavService.this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.build();
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify);
} finally {
dbHelper.close();
runningRefresh.remove(service);
for (WeakReference<RefreshingStatusListener> ref : refreshingStatusListeners) {
RefreshingStatusListener listener = ref.get();
if (listener != null)
listener.onDavRefreshStatusChanged(service, false);
}
}
}
/**
* Checks if the given URL defines home sets and adds them to the home set list.
* @param serviceType CalDAV/CardDAV (calendar home set / addressbook home set)
* @param dav DavResource to check
* @param homeSets set where found home set URLs will be put into
*/
private void queryHomeSets(String serviceType, DavResource dav, Set<HttpUrl> homeSets) throws IOException, HttpException, DavException {
if (Services.SERVICE_CARDDAV.equals(serviceType)) {
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME);
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)dav.properties.get(AddressbookHomeSet.NAME);
if (addressbookHomeSet != null)
for (String href : addressbookHomeSet.hrefs)
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
} else if (Services.SERVICE_CALDAV.equals(serviceType)) {
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME);
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)dav.properties.get(CalendarHomeSet.NAME);
if (calendarHomeSet != null)
for (String href : calendarHomeSet.hrefs)
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
}
}
@NonNull
private Account account() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext()) {
return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE);
} else
throw new IllegalArgumentException("Service not found");
}
@NonNull
private String serviceType() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.SERVICE }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext())
return cursor.getString(0);
else
throw new IllegalArgumentException("Service not found");
}
@Nullable
private HttpUrl readPrincipal() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.PRINCIPAL }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext()) {
String principal = cursor.getString(0);
if (principal != null)
return HttpUrl.parse(cursor.getString(0));
}
return null;
}
@NonNull
private Set<HttpUrl> readHomeSets() {
Set<HttpUrl> homeSets = new LinkedHashSet<>();
@Cleanup Cursor cursor = db.query(HomeSets._TABLE, new String[] { HomeSets.URL }, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext())
homeSets.add(HttpUrl.parse(cursor.getString(0)));
return homeSets;
}
private void saveHomeSets(Set<HttpUrl> homeSets) {
db.delete(HomeSets._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
for (HttpUrl homeSet : homeSets) {
ContentValues values = new ContentValues(1);
values.put(HomeSets.SERVICE_ID, service);
values.put(HomeSets.URL, homeSet.toString());
db.insertOrThrow(HomeSets._TABLE, null, values);
}
}
@NonNull
private Map<HttpUrl, CollectionInfo> readCollections() {
Map<HttpUrl, CollectionInfo> collections = new LinkedHashMap<>();
@Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", new String[]{String.valueOf(service)}, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
collections.put(HttpUrl.parse(values.getAsString(Collections.URL)), CollectionInfo.fromDB(values));
}
return collections;
}
private void saveCollections(Iterable<CollectionInfo> collections) {
db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
for (CollectionInfo collection : collections) {
ContentValues values = collection.toDB();
App.log.log(Level.FINE, "Saving collection", values);
values.put(Collections.SERVICE_ID, service);
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}
}
}

View File

@@ -0,0 +1,375 @@
/*
* 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.app.PendingIntent
import android.content.ContentResolver
import android.content.Intent
import android.os.Binder
import android.os.Bundle
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.*
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 okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
import kotlin.concurrent.thread
class DavService: android.app.Service() {
companion object {
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
const val EXTRA_DAV_SERVICE_ID = "davServiceID"
/** Initialize a forced synchronization. Expects intent data
to be an URI of this format:
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>()
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
when (intent.action) {
ACTION_REFRESH_COLLECTIONS ->
if (runningRefresh.add(id)) {
refreshingStatusListeners.forEach { listener ->
listener.get()?.onDavRefreshStatusChanged(id, true)
}
thread { refreshCollections(id) }
}
ACTION_FORCE_SYNC -> {
val uri = intent.data!!
val authority = uri.authority!!
val account = Account(
uri.pathSegments[1],
uri.pathSegments[0]
)
forceSync(authority, account)
}
}
}
return START_NOT_STICKY
}
/* BOUND SERVICE PART
for communicating with the activities
*/
interface RefreshingStatusListener {
fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean)
}
private val binder = InfoBinder()
inner class InfoBinder: Binder() {
fun isRefreshing(id: Long) = runningRefresh.contains(id)
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
if (callImmediateIfRunning)
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
}
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
val iter = refreshingStatusListeners.iterator()
while (iter.hasNext()) {
val item = iter.next().get()
if (listener == item)
iter.remove()
}
}
}
override fun onBind(intent: Intent?) = binder
/* ACTION RUNNABLES
which actually do the work
*/
private fun forceSync(authority: String, account: Account) {
Logger.log.info("Forcing $authority synchronization of $account")
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)
}
private fun refreshCollections(serviceId: Long) {
val db = AppDatabase.getInstance(this)
val homeSetDao = db.homeSetDao()
val collectionDao = db.collectionDao()
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, getString(R.string.account_type))
val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
/**
* 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>()
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
}
}
}
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
}
}
}
// 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
}
}
}
}
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)
}
} 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)
}
// 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()
}
}
// 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 collection = Collection.fromDavResponse(response) ?: return@propfind
collection.confirmed = true
// 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)
}
}
}
}

View File

@@ -1,41 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import android.support.annotation.NonNull;
import org.apache.commons.lang3.StringUtils;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import okhttp3.HttpUrl;
public class DavUtils {
public static String ARGBtoCalDAVColor(int colorWithAlpha) {
byte alpha = (byte)(colorWithAlpha >> 24);
int color = colorWithAlpha & 0xFFFFFF;
return String.format("#%06X%02X", color, alpha);
}
public static String lastSegmentOfUrl(@NonNull String url) {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
List<String> segments = new LinkedList<>(HttpUrl.parse(url).pathSegments());
Collections.reverse(segments);
for (String segment : segments)
if (!StringUtils.isEmpty(segment))
return segment;
return "/";
}
}

View File

@@ -0,0 +1,108 @@
/*
* 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.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.*
/**
* Some WebDAV and related network utility methods
*/
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: HttpUrl): String {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
val segments = LinkedList<String>(url.pathSegments)
segments.reverse()
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
}
fun prepareLookup(context: Context, lookup: Lookup) {
@TargetApi(Build.VERSION_CODES.O)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
The current version of dnsjava relies on these properties to find the default name servers,
so we have to add the servers explicitly (fortunately, there's an Android API to
get the active DNS servers). */
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
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")
}
}
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
val srvRecords = records?.filterIsInstance(SRVRecord::class.java)
srvRecords?.let {
if (it.size > 1)
Logger.log.warning("Multiple SRV records not supported yet; using first one")
return it.firstOrNull()
}
return null
}
fun pathsFromTXTRecords(records: Array<Record>?): List<String> {
val paths = LinkedList<String>()
records?.filterIsInstance(TXTRecord::class.java)?.forEach { txt ->
@Suppress("UNCHECKED_CAST")
for (segment in txt.strings as List<String>)
if (segment.startsWith("path=")) {
paths.add(segment.substring(5))
break
}
}
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)
}
}
}

View File

@@ -1,157 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import android.accounts.Account;
import android.content.Context;
import android.os.Build;
import android.support.annotation.NonNull;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import at.bitfire.dav4android.BasicDigestAuthenticator;
import lombok.RequiredArgsConstructor;
import okhttp3.Credentials;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.logging.HttpLoggingInterceptor;
public class HttpClient {
private static final OkHttpClient client = new OkHttpClient();
private static final UserAgentInterceptor userAgentInterceptor = new UserAgentInterceptor();
private static final String userAgent;
static {
String date = new SimpleDateFormat("yyyy/MM/dd", Locale.US).format(new Date(BuildConfig.buildTime));
userAgent = "DAVdroid/" + BuildConfig.VERSION_NAME + " (" + date + "; dav4android; okhttp3) Android/" + Build.VERSION.RELEASE;
}
private HttpClient() {
}
public static OkHttpClient create(@NonNull Context context, @NonNull Account account, @NonNull final Logger logger) throws InvalidAccountException {
OkHttpClient.Builder builder = defaultBuilder(logger);
// use account settings for authentication and logging
AccountSettings settings = new AccountSettings(context, account);
if (settings.preemptiveAuth())
builder.addNetworkInterceptor(new PreemptiveAuthenticationInterceptor(settings.username(), settings.password()));
else
builder.authenticator(new BasicDigestAuthenticator(null, settings.username(), settings.password()));
return builder.build();
}
public static OkHttpClient create(@NonNull Logger logger) {
return defaultBuilder(logger).build();
}
public static OkHttpClient create(@NonNull Context context, @NonNull Account account) throws InvalidAccountException {
return create(context, account, App.log);
}
public static OkHttpClient create() {
return create(App.log);
}
private static OkHttpClient.Builder defaultBuilder(@NonNull final Logger logger) {
OkHttpClient.Builder builder = client.newBuilder();
// use MemorizingTrustManager to manage self-signed certificates
if (App.getSslSocketFactoryCompat() != null)
builder.sslSocketFactory(App.getSslSocketFactoryCompat(), App.getMemorizingTrustManager());
if (App.getHostnameVerifier() != null)
builder.hostnameVerifier(App.getHostnameVerifier());
// set timeouts
builder.connectTimeout(30, TimeUnit.SECONDS);
builder.writeTimeout(30, TimeUnit.SECONDS);
builder.readTimeout(120, TimeUnit.SECONDS);
// don't allow redirects, because it would break PROPFIND handling
builder.followRedirects(false);
// add User-Agent to every request
builder.addNetworkInterceptor(userAgentInterceptor);
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
builder.cookieJar(new MemoryCookieStore());
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
@Override
public void log(String message) {
logger.finest(message);
}
});
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
builder.addInterceptor(loggingInterceptor);
}
return builder;
}
private static OkHttpClient.Builder addAuthentication(@NonNull OkHttpClient.Builder builder, @NonNull String username, @NonNull String password, boolean preemptive) {
if (preemptive)
builder.addNetworkInterceptor(new PreemptiveAuthenticationInterceptor(username, password));
else
builder.authenticator(new BasicDigestAuthenticator(null, username, password));
return builder;
}
public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String username, @NonNull String password, boolean preemptive) {
OkHttpClient.Builder builder = client.newBuilder();
addAuthentication(builder, username, password, preemptive);
return builder.build();
}
public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String host, @NonNull String username, @NonNull String password) {
return client.newBuilder()
.authenticator(new BasicDigestAuthenticator(host, username, password))
.build();
}
static class UserAgentInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Locale locale = Locale.getDefault();
Request request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", locale.getLanguage() + "-" + locale.getCountry() + ", " + locale.getLanguage() + ";q=0.7, *;q=0.5")
.build();
return chain.proceed(request);
}
}
@RequiredArgsConstructor
static class PreemptiveAuthenticationInterceptor implements Interceptor {
final String username, password;
@Override
public Response intercept(Chain chain) throws IOException {
App.log.fine("Adding basic authorization header for user " + username);
Request request = chain.request().newBuilder()
.header("Authorization", Credentials.basic(username, password))
.build();
return chain.proceed(request);
}
}
}

View File

@@ -0,0 +1,263 @@
/*
* 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.content.Context
import android.os.Build
import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
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.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
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.*
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
private val certManager: CustomCertManager?
): 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 = OkHttpClient.Builder()
// set timeouts
.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()
}
override fun close() {
okHttpClient.cache?.close()
certManager?.close()
}
class Builder(
val context: Context? = 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()
init {
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
orig.cookieJar(MemoryCookieStore())
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor(object: HttpLoggingInterceptor.Logger {
override fun log(message: String) {
logger.finest(message)
}
})
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
orig.addInterceptor(loggingInterceptor)
}
context?.let {
val settings = Settings.getInstance(context)
// custom proxy support
try {
if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) {
val address = InetSocketAddress(
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) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
//if (BuildConfig.customCerts)
customCertManager(CustomCertManager(context, true,
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT)))
}
// use account settings for authentication
accountSettings?.let {
addAuthentication(null, it.credentials())
}
}
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
addAuthentication(host, credentials)
}
fun withDiskCache(): Builder {
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
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, DISK_CACHE_MAX_SIZE))
break
}
}
return this
}
fun followRedirects(follow: Boolean): Builder {
orig.followRedirects(follow)
return this
}
fun customCertManager(manager: CustomCertManager) {
certManager = manager
}
fun setForeground(foreground: Boolean): Builder {
certManager?.appInForeground = foreground
return this
}
fun addAuthentication(host: String?, credentials: Credentials): Builder {
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
Credentials.Type.ClientCertificate -> {
certificateAlias = credentials.certificateAlias
}
}
return this
}
fun build(): HttpClient {
val trustManager = certManager ?: {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}()
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier)
?: OkHostnameVerifier
var keyManager: KeyManager? = null
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 provider certificate $alias for authentication (chain length: ${certs.size})")
// create Android KeyStore (performs key operations without revealing secret data to DAVx5)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// create KeyManager
keyManager = object: X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
orig.protocols(listOf(Protocol.HTTP_1_1))
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e)
}
}
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)
}
}
private object UserAgentInterceptor: Interceptor {
// 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/${BuildConfig.okhttpVersion}) Android/${Build.VERSION.RELEASE}"
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}
}

View File

@@ -1,19 +0,0 @@
/*
* Copyright © 2013 2016 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;
public class InvalidAccountException extends Exception {
public InvalidAccountException(Account account) {
super("Invalid account: " + account);
}
}

View File

@@ -0,0 +1,13 @@
/*
* 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
class InvalidAccountException(account: Account): Exception("Invalid account: $account")

View File

@@ -1,69 +0,0 @@
/*
* Copyright © 2013 2016 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 org.apache.commons.collections4.MapIterator;
import org.apache.commons.collections4.keyvalue.MultiKey;
import org.apache.commons.collections4.map.HashedMap;
import org.apache.commons.collections4.map.MultiKeyMap;
import java.util.LinkedList;
import java.util.List;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.HttpUrl;
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
public class MemoryCookieStore implements CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
protected final MultiKeyMap<String, Cookie> storage = MultiKeyMap.multiKeyMap(new HashedMap<MultiKey<? extends String>, Cookie>());
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
synchronized(storage) {
for (Cookie cookie : cookies)
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie);
}
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
List<Cookie> cookies = new LinkedList<>();
synchronized(storage) {
MapIterator<MultiKey<? extends String>, Cookie> iter = storage.mapIterator();
while (iter.hasNext()) {
iter.next();
Cookie cookie = iter.getValue();
// remove expired cookies
if (cookie.expiresAt() <= System.currentTimeMillis()) {
iter.remove();
continue;
}
// add applicable cookies
if (cookie.matches(url))
cookies.add(cookie);
}
}
return cookies;
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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 okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import org.apache.commons.collections4.keyvalue.MultiKey
import org.apache.commons.collections4.map.HashedMap
import org.apache.commons.collections4.map.MultiKeyMap
import java.util.*
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
class MemoryCookieStore: CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(storage) {
for (cookie in cookies)
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookies = LinkedList<Cookie>()
synchronized(storage) {
val iter = storage.mapIterator()
while (iter.hasNext()) {
iter.next()
val cookie = iter.value
// remove expired cookies
if (cookie.expiresAt <= System.currentTimeMillis()) {
iter.remove()
continue
}
// add applicable cookies
if (cookie.matches(url))
cookies += cookie
}
}
return cookies
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Bundle
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.ical4android.TaskProvider.ProviderName.OpenTasks
import kotlin.concurrent.thread
class PackageChangedReceiver: BroadcastReceiver() {
companion object {
@WorkerThread
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("Tasks provider available = $tasksInstalled")
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
val db = AppDatabase.getInstance(context)
db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service ->
val account = Account(service.accountName, context.getString(R.string.account_type))
if (tasksInstalled) {
if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) {
ContentResolver.setIsSyncable(account, OpenTasks.authority, 1)
ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
}
} else
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
}
}
}
override fun onReceive(context: Context, intent: Intent) {
thread {
updateTaskSync(context)
}
}
}

View File

@@ -1,171 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid;
import android.os.Build;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.X509TrustManager;
import de.duenndns.ssl.MemorizingTrustManager;
import lombok.Cleanup;
public class SSLSocketFactoryCompat extends SSLSocketFactory {
private SSLSocketFactory delegate;
// Android 5.0+ (API level21) provides reasonable default settings
// but it still allows SSLv3
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
static String protocols[] = null, cipherSuites[] = null;
static {
try {
@Cleanup SSLSocket socket = (SSLSocket)SSLSocketFactory.getDefault().createSocket();
if (socket != null) {
/* 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
List<String> protocols = new LinkedList<>();
for (String protocol : socket.getSupportedProtocols())
if (!protocol.toUpperCase(Locale.US).contains("SSL"))
protocols.add(protocol);
App.log.info("Setting allowed TLS protocols: " + TextUtils.join(", ", protocols));
SSLSocketFactoryCompat.protocols = protocols.toArray(new String[protocols.size()]);
/* set up reasonable cipher suites */
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// choose known secure cipher suites
List<String> allowedCiphers = Arrays.asList(
// 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",
"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");
List<String> availableCiphers = Arrays.asList(socket.getSupportedCipherSuites());
App.log.info("Available cipher suites: " + TextUtils.join(", ", availableCiphers));
App.log.info("Cipher suites enabled by default: " + TextUtils.join(", ", socket.getEnabledCipherSuites()));
// take all allowed ciphers that are available and put them into preferredCiphers
HashSet<String> preferredCiphers = new HashSet<>(allowedCiphers);
preferredCiphers.retainAll(availableCiphers);
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus disabling
* ciphers which are enabled by default, but have become unsecure), but I guess for
* the security level of DAVdroid and maximum compatibility, disabling of insecure
* ciphers should be a server-side task */
// add preferred ciphers to enabled ciphers
HashSet<String> enabledCiphers = preferredCiphers;
enabledCiphers.addAll(new HashSet<>(Arrays.asList(socket.getEnabledCipherSuites())));
App.log.info("Enabling (only) those TLS ciphers: " + TextUtils.join(", ", enabledCiphers));
SSLSocketFactoryCompat.cipherSuites = enabledCiphers.toArray(new String[enabledCiphers.size()]);
}
}
} catch (IOException e) {
App.log.severe("Couldn't determine default TLS settings");
}
}
public SSLSocketFactoryCompat(@NonNull MemorizingTrustManager mtm) {
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new X509TrustManager[] { mtm }, null);
delegate = sslContext.getSocketFactory();
} catch (GeneralSecurityException e) {
throw new AssertionError(); // The system has no TLS. Just give up.
}
}
private void upgradeTLS(SSLSocket ssl) {
if (protocols != null)
ssl.setEnabledProtocols(protocols);
if (cipherSuites != null)
ssl.setEnabledCipherSuites(cipherSuites);
}
@Override
public String[] getDefaultCipherSuites() {
return cipherSuites;
}
@Override
public String[] getSupportedCipherSuites() {
return cipherSuites;
}
@Override
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
Socket ssl = delegate.createSocket(s, host, port, autoClose);
if (ssl instanceof SSLSocket)
upgradeTLS((SSLSocket)ssl);
return ssl;
}
@Override
public Socket createSocket(String host, int port) throws IOException {
Socket ssl = delegate.createSocket(host, port);
if (ssl instanceof SSLSocket)
upgradeTLS((SSLSocket)ssl);
return ssl;
}
@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
Socket ssl = delegate.createSocket(host, port, localHost, localPort);
if (ssl instanceof SSLSocket)
upgradeTLS((SSLSocket)ssl);
return ssl;
}
@Override
public Socket createSocket(InetAddress host, int port) throws IOException {
Socket ssl = delegate.createSocket(host, port);
if (ssl instanceof SSLSocket)
upgradeTLS((SSLSocket)ssl);
return ssl;
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
Socket ssl = delegate.createSocket(address, port, localAddress, localPort);
if (ssl instanceof SSLSocket)
upgradeTLS((SSLSocket)ssl);
return ssl;
}
}

View File

@@ -1,59 +0,0 @@
/*
* Copyright © 2013 2016 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.log;
import android.util.Log;
import org.apache.commons.lang3.math.NumberUtils;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
public class LogcatHandler extends Handler {
private static final int MAX_LINE_LENGTH = 3000;
public static final LogcatHandler INSTANCE = new LogcatHandler();
private LogcatHandler() {
super();
setFormatter(PlainTextFormatter.LOGCAT);
setLevel(Level.ALL);
}
@Override
public void publish(LogRecord r) {
String text = getFormatter().format(r);
int level = r.getLevel().intValue();
int end = text.length();
for (int pos = 0; pos < end; pos += MAX_LINE_LENGTH) {
String line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end));
if (level >= Level.SEVERE.intValue())
Log.e(r.getLoggerName(), line);
else if (level >= Level.WARNING.intValue())
Log.w(r.getLoggerName(), line);
else if (level >= Level.CONFIG.intValue())
Log.i(r.getLoggerName(), line);
else if (level >= Level.FINER.intValue())
Log.d(r.getLoggerName(), line);
else
Log.v(r.getLoggerName(), line);
}
}
@Override
public void flush() {
}
@Override
public void close() {
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.log
import android.util.Log
import org.apache.commons.lang3.math.NumberUtils
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
object LogcatHandler: Handler() {
private const val MAX_LINE_LENGTH = 3000
init {
formatter = PlainTextFormatter.LOGCAT
level = Level.ALL
}
override fun publish(r: LogRecord) {
val text = formatter.format(r)
val level = r.level.intValue()
val end = text.length
var pos = 0
while (pos < end) {
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
when {
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
else -> Log.v(r.loggerName, line)
}
pos += MAX_LINE_LENGTH
}
}
override fun flush() {}
override fun close() {}
}

View File

@@ -0,0 +1,137 @@
/*
* 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.log
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
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 java.io.File
import java.io.IOException
import java.util.logging.FileHandler
import java.util.logging.Level
@SuppressLint("StaticFieldLeak") // we'll only keep an app context
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
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: Context
private lateinit var preferences: SharedPreferences
fun initialize(someContext: Context) {
context = someContext.applicationContext
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener(this)
reinitialize()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (key == LOG_TO_FILE) {
log.info("Logging settings changed; re-initializing logger")
reinitialize()
}
}
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")
// set logging level according to preferences
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
// remove all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.logging_notification_title))
val logDir = debugDir(context) ?: return
val logFile = File(logDir, "davx5-log.txt")
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_FILE)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
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)
// 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 {
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
}
}

View File

@@ -1,61 +0,0 @@
/*
* Copyright © 2013 2016 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.log;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.util.logging.Formatter;
import java.util.logging.LogRecord;
public class PlainTextFormatter extends Formatter {
public final static PlainTextFormatter
LOGCAT = new PlainTextFormatter(true),
DEFAULT = new PlainTextFormatter(false);
private final boolean logcat;
private PlainTextFormatter(boolean onLogcat) {
this.logcat = onLogcat;
}
@Override
@SuppressWarnings("ThrowableResultOfMethodCallIgnored")
public String format(LogRecord r) {
StringBuilder builder = new StringBuilder();
if (!logcat)
builder .append(DateFormatUtils.format(r.getMillis(), "yyyy-MM-dd HH:mm:ss"))
.append(" ").append(r.getThreadID()).append(" ");
builder.append(String.format("[%s] %s", shortClassName(r.getSourceClassName()), r.getMessage()));
if (r.getThrown() != null)
builder .append("\nEXCEPTION ")
.append(ExceptionUtils.getStackTrace(r.getThrown()));
if (r.getParameters() != null) {
int idx = 1;
for (Object param : r.getParameters())
builder.append("\n\tPARAMETER #").append(idx++).append(" = ").append(param);
}
if (!logcat)
builder.append("\n");
return builder.toString();
}
private String shortClassName(String className) {
String s = StringUtils.replace(className, "at.bitfire.davdroid.", "");
return StringUtils.replace(s, "at.bitfire.", "");
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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.log
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import java.util.logging.Formatter
import java.util.logging.LogRecord
class PlainTextFormatter private constructor(
private val logcat: Boolean
): Formatter() {
companion object {
val LOGCAT = PlainTextFormatter(true)
val DEFAULT = PlainTextFormatter(false)
const val MAX_MESSAGE_LENGTH = 20000
}
override fun format(r: LogRecord): String {
val builder = StringBuilder()
if (!logcat)
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss"))
.append(" ").append(r.threadID).append(" ")
val className = shortClassName(r.sourceClassName)
if (className != r.loggerName)
builder.append("[").append(className).append("] ")
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
r.thrown?.let {
builder .append("\nEXCEPTION ")
.append(ExceptionUtils.getStackTrace(it))
}
r.parameters?.let {
for ((idx, param) in it.withIndex())
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
}
if (!logcat)
builder.append("\n")
return builder.toString()
}
private fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
.replace(Regex("\\$.*$"), "")
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright © 2013 2016 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.log;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
public class StringHandler extends Handler {
StringBuilder builder = new StringBuilder();
public StringHandler() {
super();
setFormatter(PlainTextFormatter.DEFAULT);
}
@Override
public void publish(LogRecord record) {
builder.append(getFormatter().format(record));
}
@Override
public void flush() {
}
@Override
public void close() {
}
@Override
public String toString() {
return builder.toString();
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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.log
import java.util.logging.Handler
import java.util.logging.LogRecord
class StringHandler: Handler() {
val builder = StringBuilder()
init {
formatter = PlainTextFormatter.DEFAULT
}
override fun publish(record: LogRecord) {
builder.append(formatter.format(record))
}
override fun flush() {}
override fun close() {}
override fun toString() = builder.toString()
}

View File

@@ -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"))
}
}
}

View File

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

View File

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

View File

@@ -1,163 +0,0 @@
/*
* Copyright © 2013 2016 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 org.apache.commons.lang3.StringUtils;
import java.io.Serializable;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.Property;
import at.bitfire.dav4android.property.AddressbookDescription;
import at.bitfire.dav4android.property.CalendarColor;
import at.bitfire.dav4android.property.CalendarDescription;
import at.bitfire.dav4android.property.CalendarTimezone;
import at.bitfire.dav4android.property.CurrentUserPrivilegeSet;
import at.bitfire.dav4android.property.DisplayName;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.dav4android.property.SupportedCalendarComponentSet;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import lombok.ToString;
@ToString
public class CollectionInfo implements Serializable {
public long id;
public Long serviceID;
public enum Type {
ADDRESS_BOOK,
CALENDAR
}
public Type type;
public String url;
public boolean readOnly;
public String displayName, description;
public Integer color;
public String timeZone;
public Boolean supportsVEVENT;
public Boolean supportsVTODO;
public boolean selected;
// non-persistent properties
public boolean confirmed;
public static final Property.Name[] DAV_PROPERTIES = {
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME
};
public static CollectionInfo fromDavResource(DavResource dav) {
CollectionInfo info = new CollectionInfo();
info.url = dav.location.toString();
ResourceType type = (ResourceType)dav.properties.get(ResourceType.NAME);
if (type != null) {
if (type.types.contains(ResourceType.ADDRESSBOOK))
info.type = Type.ADDRESS_BOOK;
else if (type.types.contains(ResourceType.CALENDAR))
info.type = Type.CALENDAR;
}
info.readOnly = false;
CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME);
if (privilegeSet != null)
info.readOnly = !privilegeSet.mayWriteContent;
DisplayName displayName = (DisplayName)dav.properties.get(DisplayName.NAME);
if (displayName != null && !StringUtils.isEmpty(displayName.displayName))
info.displayName = displayName.displayName;
if (info.type == Type.ADDRESS_BOOK) {
AddressbookDescription addressbookDescription = (AddressbookDescription)dav.properties.get(AddressbookDescription.NAME);
if (addressbookDescription != null)
info.description = addressbookDescription.description;
} else if (info.type == Type.CALENDAR) {
CalendarDescription calendarDescription = (CalendarDescription)dav.properties.get(CalendarDescription.NAME);
if (calendarDescription != null)
info.description = calendarDescription.description;
CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME);
if (calendarColor != null)
info.color = calendarColor.color;
CalendarTimezone timeZone = (CalendarTimezone)dav.properties.get(CalendarTimezone.NAME);
if (timeZone != null)
info.timeZone = timeZone.vTimeZone;
info.supportsVEVENT = info.supportsVTODO = true;
SupportedCalendarComponentSet supportedCalendarComponentSet = (SupportedCalendarComponentSet)dav.properties.get(SupportedCalendarComponentSet.NAME);
if (supportedCalendarComponentSet != null) {
info.supportsVEVENT = supportedCalendarComponentSet.supportsEvents;
info.supportsVTODO = supportedCalendarComponentSet.supportsTasks;
}
}
return info;
}
public static CollectionInfo fromDB(ContentValues values) {
CollectionInfo info = new CollectionInfo();
info.id = values.getAsLong(Collections.ID);
info.serviceID = values.getAsLong(Collections.SERVICE_ID);
info.url = values.getAsString(Collections.URL);
info.readOnly = values.getAsInteger(Collections.READ_ONLY) != 0;
info.displayName = values.getAsString(Collections.DISPLAY_NAME);
info.description = values.getAsString(Collections.DESCRIPTION);
info.color = values.getAsInteger(Collections.COLOR);
info.timeZone = values.getAsString(Collections.TIME_ZONE);
info.supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT);
info.supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO);
info.selected = values.getAsInteger(Collections.SYNC) != 0;
return info;
}
public ContentValues toDB() {
ContentValues values = new ContentValues();
// Collections.SERVICE_ID is never changed
values.put(Collections.URL, url);
values.put(Collections.READ_ONLY, readOnly ? 1 : 0);
values.put(Collections.DISPLAY_NAME, displayName);
values.put(Collections.DESCRIPTION, description);
values.put(Collections.COLOR, color);
values.put(Collections.TIME_ZONE, timeZone);
if (supportsVEVENT != null)
values.put(Collections.SUPPORTS_VEVENT, supportsVEVENT ? 1 : 0);
if (supportsVTODO != null)
values.put(Collections.SUPPORTS_VTODO, supportsVTODO ? 1 : 0);
values.put(Collections.SYNC, selected ? 1 : 0);
return values;
}
private static Boolean getAsBooleanOrNull(ContentValues values, String field) {
Integer i = values.getAsInteger(field);
return (i == null) ? null : (i != 0);
}
}

View File

@@ -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() }
}

View File

@@ -0,0 +1,38 @@
/*
* 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
class Credentials(
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null
) {
enum class Type {
UsernamePassword,
ClientCertificate
}
val type: Type
init {
type = when {
!certificateAlias.isNullOrEmpty() ->
Type.ClientCertificate
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
Type.UsernamePassword
else ->
throw IllegalArgumentException("Either username/password or certificate alias must be set")
}
}
override fun toString() =
"Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
}

View File

@@ -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())
}
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package at.bitfire.davdroid.model
abstract class IdEntity {
abstract var id: Long
}

View File

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

View File

@@ -1,186 +0,0 @@
/*
* Copyright © 2013 2016 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.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Build;
import at.bitfire.davdroid.App;
import lombok.Cleanup;
public class ServiceDB {
public static class Settings {
public static final String
_TABLE = "settings",
NAME = "setting",
VALUE = "value";
}
public static class Services {
public static final String
_TABLE = "services",
ID = "_id",
ACCOUNT_NAME = "accountName",
SERVICE = "service",
PRINCIPAL = "principal";
// allowed values for SERVICE column
public static final String
SERVICE_CALDAV = "caldav",
SERVICE_CARDDAV = "carddav";
}
public static class HomeSets {
public static final String
_TABLE = "homesets",
ID = "_id",
SERVICE_ID = "serviceID",
URL = "url";
}
public static class Collections {
public static final String
_TABLE = "collections",
ID = "_id",
SERVICE_ID = "serviceID",
URL = "url",
READ_ONLY = "readOnly",
DISPLAY_NAME = "displayName",
DESCRIPTION = "description",
COLOR = "color",
TIME_ZONE = "timezone",
SUPPORTS_VEVENT = "supportsVEVENT",
SUPPORTS_VTODO = "supportsVTODO",
SYNC = "sync";
}
public static class OpenHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "services.db";
private static final int DATABASE_VERSION = 1;
public OpenHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
setWriteAheadLoggingEnabled(true);
}
@Override
public void onOpen(SQLiteDatabase db) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
db.setForeignKeyConstraintsEnabled(true);
else {
if (!db.enableWriteAheadLogging())
App.log.warning("Couldn't enable write-ahead logging");
db.execSQL("PRAGMA foreign_keys=ON;");
}
}
@Override
public void onCreate(SQLiteDatabase db) {
App.log.info("Creating database " + db.getPath());
db.execSQL("CREATE TABLE " + Settings._TABLE + "(" +
Settings.NAME + " TEXT NOT NULL," +
Settings.VALUE + " TEXT NOT NULL" +
")");
db.execSQL("CREATE UNIQUE INDEX settings_name ON " + Settings._TABLE + " (" + Settings.NAME + ")");
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.URL + " TEXT NOT NULL," +
Collections.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.SYNC + " INTEGER DEFAULT 0 NOT NULL" +
")");
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON " + Collections._TABLE + "(" + Collections.SERVICE_ID + "," + Collections.URL + ")");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
}
public void dump(StringBuilder sb) {
SQLiteDatabase db = getReadableDatabase();
db.beginTransactionNonExclusive();
// iterate through all tables
@Cleanup Cursor cursorTables = db.query("sqlite_master", new String[] { "name" }, "type='table'", null, null, null, null);
while (cursorTables.moveToNext()) {
String table = cursorTables.getString(0);
sb.append(table).append("\n");
@Cleanup Cursor cursor = db.query(table, null, null, null, null, null, null);
// print columns
int cols = cursor.getColumnCount();
sb.append("\t| ");
for (int i = 0; i < cols; i++) {
sb.append(" ");
sb.append(cursor.getColumnName(i));
sb.append(" |");
}
sb.append("\n");
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ");
for (int i = 0; i < cols; i++) {
sb.append(" ");
try {
String value = cursor.getString(i);
if (value != null)
sb.append(value
.replace("\r", "<CR>")
.replace("\n", "<LF>"));
else
sb.append("<null>");
} catch (SQLiteException e) {
sb.append("<unprintable>");
}
sb.append(" |");
}
sb.append("\n");
}
sb.append("----------\n");
}
db.endTransaction();
}
}
}

View File

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

View File

@@ -1,45 +0,0 @@
/*
* Copyright © 2013 2016 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.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import lombok.Cleanup;
public class Settings {
final SQLiteDatabase db;
public Settings(SQLiteDatabase db) {
this.db = db;
}
public boolean getBoolean(String name, boolean defaultValue) {
@Cleanup Cursor cursor = db.query(ServiceDB.Settings._TABLE, new String[] { ServiceDB.Settings.VALUE },
ServiceDB.Settings.NAME + "=?", new String[] { name }, null, null, null);
if (cursor.moveToNext())
return cursor.getInt(0) != 0;
else
return defaultValue;
}
public void putBoolean(String name, boolean value) {
ContentValues values = new ContentValues(2);
values.put(ServiceDB.Settings.NAME, name);
values.put(ServiceDB.Settings.VALUE, value ? 1 : 0);
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
public void remove(String name) {
db.delete(ServiceDB.Settings._TABLE, ServiceDB.Settings.NAME + "=?", new String[] { name });
}
}

View File

@@ -0,0 +1,63 @@
/*
* 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 at.bitfire.dav4jvm.property.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
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
return try {
val json = JSONObject(s)
SyncState(
Type.valueOf(json.getString(KEY_TYPE)),
json.getString(KEY_VALUE),
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
)
} 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(): 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()
}
}

View File

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

View File

@@ -1,22 +0,0 @@
/*
* Copyright © 2013 2016 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.provider.ContactsContract.RawContacts;
public class UnknownProperties {
public static final String CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties";
public static final String
MIMETYPE = RawContacts.Data.MIMETYPE,
RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID,
UNKNOWN_PROPERTIES = RawContacts.Data.DATA1;
}

View File

@@ -0,0 +1,21 @@
/*
* 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.provider.ContactsContract.RawContacts
object UnknownProperties {
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
const val MIMETYPE = RawContacts.Data.MIMETYPE
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
}

View File

@@ -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.resource
import at.bitfire.vcard4android.Contact
interface LocalAddress: LocalResource<Contact> {
fun resetDeleted()
}

View File

@@ -1,229 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.RemoteException;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.support.annotation.NonNull;
import java.io.FileNotFoundException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class LocalAddressBook extends AndroidAddressBook implements LocalCollection {
protected static final String
SYNC_STATE_CTAG = "ctag",
SYNC_STATE_URL = "url";
private final Bundle syncState = new Bundle();
/**
* Whether contact groups (LocalGroup resources) are included in query results for
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
* {@link #getWithoutFileName()}.
*/
public boolean includeGroups = true;
public LocalAddressBook(Account account, ContentProviderClient provider) {
super(account, provider, LocalGroup.Factory.INSTANCE, LocalContact.Factory.INSTANCE);
}
public LocalContact findContactByUID(String uid) throws ContactsStorageException, FileNotFoundException {
LocalContact[] contacts = (LocalContact[])queryContacts(LocalContact.COLUMN_UID + "=?", new String[] { uid });
if (contacts.length == 0)
throw new FileNotFoundException();
return contacts[0];
}
@Override
public LocalResource[] getAll() throws ContactsStorageException {
List<LocalResource> all = new LinkedList<>();
Collections.addAll(all, (LocalResource[])queryContacts(null, null));
if (includeGroups)
Collections.addAll(all, (LocalResource[])queryGroups(null, null));
return all.toArray(new LocalResource[all.size()]);
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
*/
@Override
public LocalResource[] getDeleted() throws ContactsStorageException {
List<LocalResource> deleted = new LinkedList<>();
Collections.addAll(deleted, getDeletedContacts());
if (includeGroups)
Collections.addAll(deleted, getDeletedGroups());
return deleted.toArray(new LocalResource[deleted.size()]);
}
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
*/
@Override
public LocalResource[] getDirty() throws ContactsStorageException {
List<LocalResource> dirty = new LinkedList<>();
Collections.addAll(dirty, getDirtyContacts());
if (includeGroups)
Collections.addAll(dirty, getDirtyGroups());
return dirty.toArray(new LocalResource[dirty.size()]);
}
/**
* Returns an array of local contacts which don't have a file name yet.
*/
@Override
public LocalResource[] getWithoutFileName() throws ContactsStorageException {
List<LocalResource> nameless = new LinkedList<>();
Collections.addAll(nameless, (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null));
if (includeGroups)
Collections.addAll(nameless, (LocalGroup[])queryGroups(AndroidGroup.COLUMN_FILENAME + " IS NULL", null));
return nameless.toArray(new LocalResource[nameless.size()]);
}
public void deleteAll() throws ContactsStorageException {
try {
provider.delete(syncAdapterURI(RawContacts.CONTENT_URI), null, null);
provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't delete all local contacts and groups", e);
}
}
public LocalContact[] getDeletedContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DELETED + "!= 0", null);
}
public LocalContact[] getDirtyContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0", null);
}
public LocalGroup[] getDeletedGroups() throws ContactsStorageException {
return (LocalGroup[])queryGroups(Groups.DELETED + "!= 0", null);
}
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0", null);
}
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws ContactsStorageException on contact provider errors
*/
public long findOrCreateGroup(@NonNull String title) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID },
Groups.TITLE + "=?", new String[] { title },
null);
if (cursor != null && cursor.moveToNext())
return cursor.getLong(0);
ContentValues values = new ContentValues();
values.put(Groups.TITLE, title);
Uri uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values);
return ContentUris.parseId(uri);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find local contact group", e);
}
}
public void removeEmptyGroups() throws ContactsStorageException {
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
for (LocalGroup group : (LocalGroup[])queryGroups(null, null))
if (group.getMembers().length == 0)
group.delete();
}
public void removeGroups() throws ContactsStorageException {
try {
provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't remove all groups", e);
}
}
// SYNC STATE
@SuppressWarnings("Recycle")
protected void readSyncState() throws ContactsStorageException {
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
byte[] raw = getSyncState();
syncState.clear();
if (raw != null) {
parcel.unmarshall(raw, 0, raw.length);
parcel.setDataPosition(0);
syncState.putAll(parcel.readBundle());
}
}
@SuppressWarnings("Recycle")
protected void writeSyncState() throws ContactsStorageException {
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
parcel.writeBundle(syncState);
setSyncState(parcel.marshall());
}
public String getURL() throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
return syncState.getString(SYNC_STATE_URL);
}
}
public void setURL(String url) throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
syncState.putString(SYNC_STATE_URL, url);
writeSyncState();
}
}
@Override
public String getCTag() throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
return syncState.getString(SYNC_STATE_CTAG);
}
}
@Override
public void setCTag(String cTag) throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
syncState.putString(SYNC_STATE_CTAG, cTag);
writeSyncState();
}
}
}

View File

@@ -0,0 +1,380 @@
/*
* 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.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.os.Build
import android.os.Bundle
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.util.Base64
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
import java.util.*
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, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book. These accounts are bound to a
* DAVx5 main account.
*/
class LocalAddressBook(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
companion object {
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
const val USER_DATA_URL = "url"
const val USER_DATA_READ_ONLY = "read_only"
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))
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")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
// Android < 7 seems to lose the initial user data sometimes, so set it a second time
// https://forums.bitfire.at/post/11644
userData.keySet().forEach { key ->
accountManager.setUserData(account, key, userData.getString(key))
}
val addressBook = LocalAddressBook(context, account, provider)
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = !info.privWriteContent || info.forceReadOnly
return addressBook
}
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: Collection): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(info.displayName.let {
if (it.isNullOrEmpty())
DavUtils.lastSegmentOfUrl(info.url)
else
it
})
sb.append(" (${mainAccount.name} $hash)")
return sb.toString()
}
fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
bundle.putString(USER_DATA_URL, url)
return bundle
}
fun mainAccount(context: Context, account: Account): Account =
if (account.type == context.getString(R.string.account_type_address_book)) {
val manager = AccountManager.get(context)
Account(
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME),
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
)
} else
account
}
override val tag: String
get() = "contacts-${account.name}"
override val title = account.name!!
/**
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
*
* For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
*/
var includeGroups = true
private var _mainAccount: Account? = null
var mainAccount: Account
get() {
_mainAccount?.let { return it }
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(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 = newMainAccount
}
var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
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)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
set(state) {
syncState = state?.toString()?.toByteArray()
}
/* operations on the collection (address book) itself */
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
}
return number
}
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider.delete(groupsSyncUri(),
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
fun update(info: Collection) {
val newAccountName = accountName(mainAccount, info)
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, null, null)
account = future.result
}
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)
}
fun delete() {
val accountManager = AccountManager.get(context)
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, null, null, null)
else
accountManager.removeAccount(account, null, null)
}
/* operations on members (contacts/groups) */
override fun findByName(name: String): LocalAddress? {
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
return if (includeGroups)
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
else
result
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
* @throws RemoteException on content provider errors
*/
override fun findDeleted() =
if (includeGroups)
findDeletedContacts() + findDeletedGroups()
else
findDeletedContacts()
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).
* @throws RemoteException on content provider errors
*/
override fun findDirty() =
if (includeGroups)
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
fun findDirtyGroups() = queryGroups(Groups.DIRTY, 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)
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)
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
* @throws RemoteException on content provider errors
*/
fun verifyDirty(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
var reallyDirty = 0
for (contact in findDirtyContacts()) {
val lastHash = contact.getLastHashCode()
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
}
if (includeGroups)
reallyDirty += findDirtyGroups().size
return reallyDirty
}
fun getByGroupMembership(groupID: Long): List<LocalContact> {
val ids = HashSet<Long>()
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
null)?.use { cursor ->
while (cursor.moveToNext())
ids += cursor.getLong(0)
}
return ids.map { findContactByID(it) }
}
/* special group operations */
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws RemoteException on content provider errors
*/
fun findOrCreateGroup(title: String): Long {
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getLong(0)
}
val values = ContentValues(1)
values.put(Groups.TITLE, title)
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
return ContentUris.parseId(uri)
}
fun removeEmptyGroups() {
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
Logger.log.log(Level.FINE, "Deleting group", group)
group.delete()
}
}
}

View File

@@ -1,266 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.annotation.TargetApi;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import net.fortuna.ical4j.model.component.VTimeZone;
import org.apache.commons.lang3.StringUtils;
import java.io.FileNotFoundException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.ical4android.AndroidCalendar;
import at.bitfire.ical4android.AndroidCalendarFactory;
import at.bitfire.ical4android.BatchOperation;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.DateUtils;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class LocalCalendar extends AndroidCalendar implements LocalCollection {
public static final int defaultColor = 0xFF8bc34a; // light green 500
public static final String COLUMN_CTAG = Calendars.CAL_SYNC1;
protected static final int
DIRTY_INCREASE_SEQUENCE = 1,
DIRTY_DONT_INCREASE_SEQUENCE = 2;
static String[] BASE_INFO_COLUMNS = new String[] {
Events._ID,
Events._SYNC_ID,
LocalEvent.COLUMN_ETAG
};
@Override
protected String[] eventBaseInfoColumns() {
return BASE_INFO_COLUMNS;
}
protected LocalCalendar(Account account, ContentProviderClient provider, long id) {
super(account, provider, LocalEvent.Factory.INSTANCE, id);
}
public static Uri create(@NonNull Account account, @NonNull ContentProviderClient provider, @NonNull CollectionInfo info) throws CalendarStorageException {
ContentValues values = valuesFromCollectionInfo(info, true);
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name);
values.put(Calendars.ACCOUNT_TYPE, account.type);
values.put(Calendars.OWNER_ACCOUNT, account.name);
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1);
values.put(Calendars.SYNC_EVENTS, 1);
return create(account, provider, values);
}
public void update(CollectionInfo info, boolean updateColor) throws CalendarStorageException {
update(valuesFromCollectionInfo(info, updateColor));
}
@TargetApi(15)
private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) {
ContentValues values = new ContentValues();
values.put(Calendars.NAME, info.url);
values.put(Calendars.CALENDAR_DISPLAY_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url));
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor);
if (info.readOnly)
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ);
else {
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);
}
if (!TextUtils.isEmpty(info.timeZone)) {
VTimeZone timeZone = DateUtils.parseVTimeZone(info.timeZone);
if (timeZone != null && timeZone.getTimeZoneId() != null)
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(timeZone.getTimeZoneId().getValue()));
}
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
if (Build.VERSION.SDK_INT >= 15) {
values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(new int[] { Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY }, ","));
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(new int[] { CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE }, ", "));
}
return values;
}
@Override
public LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException {
return (LocalEvent[])queryEvents(Events.ORIGINAL_ID + " IS NULL", null);
}
@Override
public LocalEvent[] getDeleted() throws CalendarStorageException {
return (LocalEvent[])queryEvents(Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NULL", null);
}
@Override
public LocalEvent[] getWithoutFileName() throws CalendarStorageException {
return (LocalEvent[])queryEvents(Events._SYNC_ID + " IS NULL AND " + Events.ORIGINAL_ID + " IS NULL", null);
}
@Override
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
List<LocalResource> dirty = new LinkedList<>();
// get dirty events which are not required to have an increased SEQUENCE value
Collections.addAll(dirty, (LocalEvent[])queryEvents(Events.DIRTY + "=" + DIRTY_DONT_INCREASE_SEQUENCE + " AND " + Events.ORIGINAL_ID + " IS NULL", null));
// get dirty events which are required to have an increased SEQUENCE value
for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "=" + DIRTY_INCREASE_SEQUENCE + " AND " + Events.ORIGINAL_ID + " IS NULL", null)) {
if (event.getEvent().sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
event.getEvent().sequence = 0;
else
event.getEvent().sequence++;
dirty.add(event);
}
return dirty.toArray(new LocalResource[dirty.size()]);
}
@Override
@SuppressWarnings("Recycle")
public String getCTag() throws CalendarStorageException {
try {
@Cleanup Cursor cursor = provider.query(calendarSyncURI(), new String[] { COLUMN_CTAG }, null, null, null);
if (cursor != null && cursor.moveToNext())
return cursor.getString(0);
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
}
return null;
}
@Override
public void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException {
try {
ContentValues values = new ContentValues(1);
values.put(COLUMN_CTAG, cTag);
provider.update(calendarSyncURI(), values, null, null);
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
}
}
@SuppressWarnings("Recycle")
public void processDirtyExceptions() throws CalendarStorageException {
// process deleted exceptions
App.log.info("Processing deleted exceptions");
try {
@Cleanup Cursor cursor = provider.query(
syncAdapterURI(Events.CONTENT_URI),
new String[] { Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE },
Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null);
while (cursor != null && cursor.moveToNext()) {
App.log.fine("Found deleted exception, removing; then re-schuling original event");
long id = cursor.getLong(0), // can't be null (by definition)
originalID = cursor.getLong(1); // can't be null (by query)
// get original event's SEQUENCE
@Cleanup Cursor cursor2 = provider.query(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
new String[] { LocalEvent.COLUMN_SEQUENCE },
null, null, null);
int originalSequence = (cursor2 == null || cursor2.isNull(0)) ? 0 : cursor2.getInt(0);
BatchOperation batch = new BatchOperation(provider);
// re-schedule original event and set it to DIRTY
batch.enqueue(ContentProviderOperation.newUpdate(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, DIRTY_INCREASE_SEQUENCE)
.build());
// remove exception
batch.enqueue(ContentProviderOperation.newDelete(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))).build());
batch.commit();
}
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't process locally modified exception", e);
}
// process dirty exceptions
App.log.info("Processing dirty exceptions");
try {
@Cleanup Cursor cursor = provider.query(
syncAdapterURI(Events.CONTENT_URI),
new String[] { Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE },
Events.DIRTY + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null);
while (cursor != null && cursor.moveToNext()) {
App.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule");
long id = cursor.getLong(0), // can't be null (by definition)
originalID = cursor.getLong(1); // can't be null (by query)
int sequence = cursor.isNull(2) ? 0 : cursor.getInt(2);
BatchOperation batch = new BatchOperation(provider);
// original event to DIRTY
batch.enqueue(ContentProviderOperation.newUpdate(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(Events.DIRTY, DIRTY_DONT_INCREASE_SEQUENCE)
.build());
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(ContentProviderOperation.newUpdate(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
.build());
batch.commit();
}
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't process locally modified exception", e);
}
}
public static class Factory implements AndroidCalendarFactory {
public static final Factory INSTANCE = new Factory();
@Override
public AndroidCalendar newInstance(Account account, ContentProviderClient provider, long id) {
return new LocalCalendar(account, provider, id);
}
@Override
public AndroidCalendar[] newArray(int size) {
return new LocalCalendar[size];
}
}
}

View File

@@ -0,0 +1,255 @@
/*
* 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.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DateUtils
import java.util.*
import java.util.logging.Level
class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
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.
values.put(Calendars.ACCOUNT_NAME, account.name)
values.put(Calendars.ACCOUNT_TYPE, account.type)
values.put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
return create(account, provider, values)
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url.toString())
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
if (info.privWriteContent && !info.forceReadOnly) {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
} else
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
info.timezone?.let { tzData ->
try {
val timeZone = DateUtils.parseVTimeZone(tzData)
timeZone.timeZoneId?.let { tzId ->
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
}
} catch(e: IllegalArgumentException) {
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
// 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)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
set(state) {
val values = ContentValues(1)
values.put(COLUMN_SYNC_STATE, state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() =
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} 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
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()
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
return provider.update(eventsSyncURI(), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.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() {
// process deleted exceptions
Logger.log.info("Processing deleted exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val batch = BatchOperation(provider)
// get original event's SEQUENCE
provider.query(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
// re-schedule original event and set it to DIRTY
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
))
}
}
// completely remove deleted exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
}
// process dirty exceptions
Logger.log.info("Processing dirty exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
val batch = BatchOperation(provider)
// original event to DIRTY
batch.enqueue(BatchOperation.Operation (
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(Events.DIRTY, 1)
))
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(BatchOperation.Operation (
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
))
batch.commit()
}
}
}
object Factory: AndroidCalendarFactory<LocalCalendar> {
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
LocalCalendar(account, provider, id)
}
}

View File

@@ -1,26 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import java.io.FileNotFoundException;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
public interface LocalCollection {
LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException;
LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException;
LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException, FileNotFoundException;
LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException;
String getCTag() throws CalendarStorageException, ContactsStorageException;
void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException;
}

View File

@@ -0,0 +1,88 @@
/*
* 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.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?
/**
* 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 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()
}

View File

@@ -1,196 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.RawContacts.Data;
import android.support.annotation.NonNull;
import java.io.FileNotFoundException;
import java.util.HashSet;
import java.util.Set;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.model.UnknownProperties;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidContactFactory;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.Ezvcard;
public class LocalContact extends AndroidContact implements LocalResource {
static {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION;
}
protected final Set<Long>
cachedGroupMemberships = new HashSet<>(),
groupMemberships = new HashSet<>();
protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id, fileName, eTag);
}
public LocalContact(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
super(addressBook, contact, fileName, eTag);
}
public void clearDirty(String eTag) throws ContactsStorageException {
try {
ContentValues values = new ContentValues(1);
values.put(ContactsContract.RawContacts.DIRTY, 0);
values.put(COLUMN_ETAG, eTag);
addressBook.provider.update(rawContactSyncURI(), values, null, null);
this.eTag = eTag;
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't clear dirty flag", e);
}
}
public void updateFileNameAndUID(String uid) throws ContactsStorageException {
try {
String newFileName = uid + ".vcf";
ContentValues values = new ContentValues(2);
values.put(COLUMN_FILENAME, newFileName);
values.put(COLUMN_UID, uid);
addressBook.provider.update(rawContactSyncURI(), values, null, null);
fileName = newFileName;
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't update UID", e);
}
}
@Override
protected void populateData(String mimeType, ContentValues row) {
switch (mimeType) {
case CachedGroupMembership.CONTENT_ITEM_TYPE:
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID));
break;
case GroupMembership.CONTENT_ITEM_TYPE:
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID));
break;
case UnknownProperties.CONTENT_ITEM_TYPE:
contact.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES);
break;
}
}
@Override
protected void insertDataRows(BatchOperation batch) throws ContactsStorageException {
super.insertDataRows(batch);
if (contact.unknownProperties != null) {
ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
if (id == null)
builder.withValueBackReference(UnknownProperties.RAW_CONTACT_ID, 0);
else
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id);
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, contact.unknownProperties);
batch.enqueue(builder.build());
}
}
public void addToGroup(BatchOperation batch, long groupID) {
assertID();
batch.enqueue(ContentProviderOperation
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
.build()
);
batch.enqueue(ContentProviderOperation
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
.build()
);
}
public void removeGroupMemberships(BatchOperation batch) {
assertID();
batch.enqueue(ContentProviderOperation
.newDelete(dataSyncURI())
.withSelection(
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
new String[] { String.valueOf(id), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE }
)
.withYieldAllowed(true)
.build()
);
}
/**
* 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
* 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 ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getCachedGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return cachedGroupMemberships;
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return groupMemberships;
}
// factory
static class Factory extends AndroidContactFactory {
static final Factory INSTANCE = new Factory();
@Override
public LocalContact newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
return new LocalContact(addressBook, id, fileName, eTag);
}
@Override
public LocalContact newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
return new LocalContact(addressBook, contact, fileName, eTag);
}
@Override
public LocalContact[] newArray(int size) {
return new LocalContact[size];
}
}
}

View File

@@ -0,0 +1,253 @@
/*
* 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.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.os.Build
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.UnknownProperties
import at.bitfire.vcard4android.*
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.*
class LocalContact: AndroidContact, LocalAddress {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
}
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
private val cachedGroupMemberships = HashSet<Long>()
private val groupMemberships = HashSet<Long>()
override var flags: Int = 0
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
fileName = newFileName
}
override fun clearDirty(eTag: String?) {
val values = ContentValues(3)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
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 hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.eTag = eTag
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.flags = flags
}
override fun populateData(mimeType: String, row: ContentValues) {
when (mimeType) {
CachedGroupMembership.CONTENT_ITEM_TYPE ->
cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID)
GroupMembership.CONTENT_ITEM_TYPE ->
groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID)
UnknownProperties.CONTENT_ITEM_TYPE ->
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
}
}
override fun insertDataRows(batch: BatchOperation) {
super.insertDataRows(batch)
contact!!.unknownProperties?.let { unknownProperties ->
val op: BatchOperation.Operation
val builder = ContentProviderOperation.newInsert(dataSyncURI())
if (id == null)
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
else {
op = BatchOperation.Operation(builder)
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
}
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
batch.enqueue(op)
}
}
/**
* Calculates a hash code from the contact's data (VCard) and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
* @return hash code of contact data (including group memberships)
*/
internal fun dataHashCode(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
// reset contact so that getContact() reads from database
contact = null
// groupMemberships is filled by getContact()
val dataHash = contact!!.hashCode()
val groupHash = groupMemberships.hashCode()
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
return dataHash xor groupHash
}
fun updateHashCode(batch: BatchOperation?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
val values = ContentValues(1)
val hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
values.put(COLUMN_HASHCODE, hashCode)
if (batch == null)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
else {
val builder = ContentProviderOperation
.newUpdate(rawContactSyncURI())
.withValues(values)
batch.enqueue(BatchOperation.Operation(builder))
}
}
fun getLastHashCode(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
}
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
))
groupMemberships += groupID
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
))
cachedGroupMemberships += groupID
}
fun removeGroupMemberships(batch: BatchOperation) {
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(dataSyncURI())
.withSelection(
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
)
.withYieldAllowed(true)
))
groupMemberships.clear()
cachedGroupMemberships.clear()
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* 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
* @throws RemoteException on contacts provider errors
*/
fun getCachedGroupMemberships(): Set<Long> {
contact
return cachedGroupMemberships
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
*/
fun getGroupMemberships(): Set<Long> {
contact
return groupMemberships
}
// data rows
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
LocalContact(addressBook, values)
}
}

View File

@@ -1,146 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.annotation.TargetApi;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.os.Build;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Events;
import android.support.annotation.NonNull;
import net.fortuna.ical4j.model.property.ProdId;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.ical4android.AndroidCalendar;
import at.bitfire.ical4android.AndroidEvent;
import at.bitfire.ical4android.AndroidEventFactory;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.Event;
import lombok.Getter;
import lombok.Setter;
@TargetApi(17)
public class LocalEvent extends AndroidEvent implements LocalResource {
static {
Event.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x");
}
static final String COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1,
COLUMN_UID = Build.VERSION.SDK_INT >= 17 ? Events.UID_2445 : Events.SYNC_DATA2,
COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3;
@Getter protected String fileName;
@Getter @Setter protected String eTag;
public LocalEvent(@NonNull AndroidCalendar calendar, Event event, String fileName, String eTag) {
super(calendar, event);
this.fileName = fileName;
this.eTag = eTag;
}
protected LocalEvent(@NonNull AndroidCalendar calendar, long id, ContentValues baseInfo) {
super(calendar, id, baseInfo);
if (baseInfo != null) {
fileName = baseInfo.getAsString(Events._SYNC_ID);
eTag = baseInfo.getAsString(COLUMN_ETAG);
}
}
/* process LocalEvent-specific fields */
@Override
protected void populateEvent(ContentValues values) {
super.populateEvent(values);
fileName = values.getAsString(Events._SYNC_ID);
eTag = values.getAsString(COLUMN_ETAG);
event.uid = values.getAsString(COLUMN_UID);
event.sequence = values.getAsInteger(COLUMN_SEQUENCE);
}
@Override
protected void buildEvent(Event recurrence, ContentProviderOperation.Builder builder) {
super.buildEvent(recurrence, builder);
boolean buildException = recurrence != null;
Event eventToBuild = buildException ? recurrence : event;
builder .withValue(COLUMN_UID, event.uid)
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(CalendarContract.Events.DIRTY, 0)
.withValue(CalendarContract.Events.DELETED, 0);
if (buildException)
builder.withValue(Events.ORIGINAL_SYNC_ID, fileName);
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag);
}
/* custom queries */
public void updateFileNameAndUID(String uid) throws CalendarStorageException {
try {
String newFileName = uid + ".ics";
ContentValues values = new ContentValues(2);
values.put(Events._SYNC_ID, newFileName);
values.put(COLUMN_UID, uid);
calendar.provider.update(eventSyncURI(), values, null, null);
fileName = newFileName;
if (event != null)
event.uid = uid;
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't update UID", e);
}
}
@Override
public void clearDirty(String eTag) throws CalendarStorageException {
try {
ContentValues values = new ContentValues(2);
values.put(CalendarContract.Events.DIRTY, 0);
values.put(COLUMN_ETAG, eTag);
if (event != null)
values.put(COLUMN_SEQUENCE, event.sequence);
calendar.provider.update(eventSyncURI(), values, null, null);
this.eTag = eTag;
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't update UID", e);
}
}
static class Factory implements AndroidEventFactory {
static final Factory INSTANCE = new Factory();
@Override
public AndroidEvent newInstance(AndroidCalendar calendar, long id, ContentValues baseInfo) {
return new LocalEvent(calendar, id, baseInfo);
}
@Override
public AndroidEvent newInstance(AndroidCalendar calendar, Event event) {
return new LocalEvent(calendar, event, null, null);
}
@Override
public AndroidEvent[] newArray(int size) {
return new LocalEvent[size];
}
}
}

View File

@@ -0,0 +1,130 @@
/*
* 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.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.BuildConfig
import at.bitfire.ical4android.*
import net.fortuna.ical4j.model.property.ProdId
import java.util.*
class LocalEvent: AndroidEvent, LocalResource<Event> {
companion object {
init {
ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion)
}
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
private set
override var eTag: String? = null
override var flags: Int = 0
private set
var weAreOrganizer = true
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
override fun populateEvent(row: ContentValues) {
super.populateEvent(row)
val event = requireNotNull(event)
event.uid = row.getAsString(Events.UID_2445)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
}
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
super.buildEvent(recurrence, builder)
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(Events.UID_2445, event.uid)
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
}
override fun assignNameAndUID() {
var uid: String? = null
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0)
}
if (uid == null)
uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName)
values.put(Events.UID_2445, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName
event!!.uid = uid
}
override fun clearDirty(eTag: String?) {
val values = ContentValues(2)
values.put(Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
calendar.provider.update(eventSyncURI(), values, null, null)
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
}

View File

@@ -1,253 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.os.Parcel;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.RawContacts.Data;
import android.provider.ContactsContract.RawContactsEntity;
import org.apache.commons.lang3.ArrayUtils;
import java.io.FileNotFoundException;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.Constants;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.AndroidGroupFactory;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
import lombok.ToString;
@ToString(callSuper=true)
public class LocalGroup extends AndroidGroup implements LocalResource {
/** marshalled list of member UIDs, as sent by server */
public static final String COLUMN_PENDING_MEMBERS = Groups.SYNC3;
public LocalGroup(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id, fileName, eTag);
}
public LocalGroup(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
super(addressBook, contact, fileName, eTag);
}
@Override
public void clearDirty(String eTag) throws ContactsStorageException {
assertID();
ContentValues values = new ContentValues(2);
values.put(Groups.DIRTY, 0);
values.put(COLUMN_ETAG, this.eTag = eTag);
update(values);
// update cached group memberships
BatchOperation batch = new BatchOperation(addressBook.provider);
// delete cached group memberships
batch.enqueue(ContentProviderOperation
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
new String[] { CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) }
).build()
);
// insert updated cached group memberships
for (long member : getMembers())
batch.enqueue(ContentProviderOperation
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
.withYieldAllowed(true)
.build()
);
batch.commit();
}
@Override
public void updateFileNameAndUID(String uid) throws ContactsStorageException {
String newFileName = uid + ".vcf";
ContentValues values = new ContentValues(2);
values.put(COLUMN_FILENAME, newFileName);
values.put(COLUMN_UID, uid);
update(values);
fileName = newFileName;
}
@Override
protected ContentValues contentValues() {
ContentValues values = super.contentValues();
@Cleanup("recycle") Parcel members = Parcel.obtain();
members.writeStringList(contact.members);
values.put(COLUMN_PENDING_MEMBERS, members.marshall());
return values;
}
/**
* Marks all members of the current group as dirty.
*/
public void markMembersDirty() throws ContactsStorageException {
assertID();
BatchOperation batch = new BatchOperation(addressBook.provider);
for (long member : getMembers())
batch.enqueue(ContentProviderOperation
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
.withYieldAllowed(true)
.build()
);
batch.commit();
}
/**
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
* are (if possible) applied, keeping cached memberships in sync.
* @param addressBook address book to take groups from
* @throws ContactsStorageException on contact provider errors
*/
public static void applyPendingMemberships(LocalAddressBook addressBook) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = addressBook.provider.query(
addressBook.syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID, COLUMN_PENDING_MEMBERS },
COLUMN_PENDING_MEMBERS + " IS NOT NULL", new String[] {},
null
);
BatchOperation batch = new BatchOperation(addressBook.provider);
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(0);
Constants.log.fine("Assigning members to group " + id);
// delete all memberships and cached memberships for this group
batch.enqueue(ContentProviderOperation
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
"(" + GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?) OR (" +
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?)",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id), CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) })
.withYieldAllowed(true)
.build()
);
// extract list of member UIDs
List<String> members = new LinkedList<>();
byte[] raw = cursor.getBlob(1);
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
parcel.unmarshall(raw, 0, raw.length);
parcel.setDataPosition(0);
parcel.readStringList(members);
// insert memberships
for (String uid : members) {
Constants.log.fine("Assigning member: " + uid);
try {
LocalContact member = addressBook.findContactByUID(uid);
member.addToGroup(batch, id);
} catch(FileNotFoundException e) {
Constants.log.log(Level.WARNING, "Group member not found: " + uid, e);
}
}
// remove pending memberships
batch.enqueue(ContentProviderOperation
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.withYieldAllowed(true)
.build()
);
batch.commit();
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't get pending memberships", e);
}
}
// helpers
private void assertID() {
if (id == null)
throw new IllegalStateException("Group has not been saved yet");
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws ContactsStorageException on contact provider errors
*/
protected long[] getMembers() throws ContactsStorageException {
assertID();
List<Long> members = new LinkedList<>();
try {
@Cleanup Cursor cursor = addressBook.provider.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
new String[] { Data.RAW_CONTACT_ID },
GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) },
null
);
while (cursor != null && cursor.moveToNext())
members.add(cursor.getLong(0));
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't list group members", e);
}
return ArrayUtils.toPrimitive(members.toArray(new Long[members.size()]));
}
// factory
static class Factory extends AndroidGroupFactory {
static final Factory INSTANCE = new Factory();
@Override
public LocalGroup newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
return new LocalGroup(addressBook, id, fileName, eTag);
}
@Override
public LocalGroup newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
return new LocalGroup(addressBook, contact, fileName, eTag);
}
@Override
public LocalGroup[] newArray(int size) {
return new LocalGroup[size];
}
}
}

View File

@@ -0,0 +1,246 @@
/*
* 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.resource
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Parcel
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.dav4jvm.Constants
import at.bitfire.vcard4android.*
import java.util.*
class LocalGroup: AndroidGroup, LocalAddress {
companion object {
const val COLUMN_FLAGS = Groups.SYNC4
/** marshaled list of member UIDs, as sent by server */
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
/**
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
* are (if possible) applied, keeping cached memberships in sync.
* @param addressBook address book to take groups from
*/
fun applyPendingMemberships(addressBook: LocalAddressBook) {
addressBook.provider!!.query(
addressBook.groupsSyncUri(),
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
null
)?.use { cursor ->
val batch = BatchOperation(addressBook.provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
Constants.log.fine("Assigning members to group $id")
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
// delete all memberships and cached memberships for this group
for (contact in addressBook.getByGroupMembership(id)) {
contact.removeGroupMemberships(batch)
changeContactIDs += contact.id!!
}
// extract list of member UIDs
val members = LinkedList<String>()
val raw = cursor.getBlob(1)
val parcel = Parcel.obtain()
try {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
parcel.readStringList(members)
} finally {
parcel.recycle()
}
// insert memberships
for (uid in members) {
Constants.log.fine("Assigning member: $uid")
addressBook.findContactByUID(uid)?.let { member ->
member.addToGroup(batch, id)
changeContactIDs += member.id!!
} ?: Constants.log.warning("Group member not found: $uid")
}
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
changeContactIDs
.map { addressBook.findContactByID(it) }
.forEach { it.updateHashCode(batch) }
// remove pending memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.withYieldAllowed(true)
))
batch.commit()
}
}
}
}
override var flags: Int = 0
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
override fun contentValues(): ContentValues {
val values = super.contentValues()
values.put(COLUMN_FLAGS, flags)
val members = Parcel.obtain()
try {
members.writeStringList(contact!!.members)
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
} finally {
members.recycle()
}
return values
}
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
update(values)
fileName = newFileName
}
override fun clearDirty(eTag: String?) {
val id = requireNotNull(id)
val values = ContentValues(2)
values.put(Groups.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
this.eTag = eTag
update(values)
// update cached group memberships
val batch = BatchOperation(addressBook.provider!!)
// delete cached group memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
)
))
// insert updated cached group memberships
for (member in getMembers())
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
.withYieldAllowed(true)
))
batch.commit()
}
/**
* Marks all members of the current group as dirty.
*/
fun markMembersDirty() {
val batch = BatchOperation(addressBook.provider!!)
for (member in getMembers())
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
.withYieldAllowed(true)
))
batch.commit()
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(Groups.DELETED, 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags
}
// helpers
private fun groupSyncUri(): Uri {
val id = requireNotNull(id)
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws RemoteException on contact provider errors
*/
internal fun getMembers(): List<Long> {
val id = requireNotNull(id)
val members = LinkedList<Long>()
addressBook.provider!!.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(Data.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
null
)?.use { cursor ->
while (cursor.moveToNext())
members += cursor.getLong(0)
}
return members
}
// factory
object Factory: AndroidGroupFactory<LocalGroup> {
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
LocalGroup(addressBook, values)
}
}

View File

@@ -1,26 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
public interface LocalResource {
Long getId();
String getFileName();
String getETag();
int delete() throws CalendarStorageException, ContactsStorageException;
void updateFileNameAndUID(String uuid) throws CalendarStorageException, ContactsStorageException;
void clearDirty(String eTag) throws CalendarStorageException, ContactsStorageException;
}

View File

@@ -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.resource
import android.net.Uri
interface LocalResource<in TData: Any> {
companion object {
/**
* Resource is present on remote server. This flag is used to identify resources
* which are not present on the remote server anymore and can be deleted at the end
* of the synchronization.
*/
const val FLAG_REMOTELY_PRESENT = 1
}
/**
* Unique ID which identifies the resource in the local storage. May be null if the
* resource has not been saved yet.
*/
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)
*/
fun add(): Uri
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData): Uri
/**
* Deletes the data object from the content provider.
* @return number of affected rows
*/
fun delete(): Int
}

View File

@@ -1,138 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.os.RemoteException;
import android.provider.CalendarContract.Events;
import android.support.annotation.NonNull;
import net.fortuna.ical4j.model.property.ProdId;
import org.dmfs.provider.tasks.TaskContract.Tasks;
import java.io.FileNotFoundException;
import java.text.ParseException;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.ical4android.AndroidTask;
import at.bitfire.ical4android.AndroidTaskFactory;
import at.bitfire.ical4android.AndroidTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.Task;
import lombok.Getter;
import lombok.Setter;
public class LocalTask extends AndroidTask implements LocalResource {
static {
Task.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x");
}
static final String COLUMN_ETAG = Tasks.SYNC1,
COLUMN_UID = Tasks.SYNC2,
COLUMN_SEQUENCE = Tasks.SYNC3;
@Getter protected String fileName;
@Getter @Setter protected String eTag;
public LocalTask(@NonNull AndroidTaskList taskList, Task task, String fileName, String eTag) {
super(taskList, task);
this.fileName = fileName;
this.eTag = eTag;
}
protected LocalTask(@NonNull AndroidTaskList taskList, long id, ContentValues baseInfo) {
super(taskList, id);
if (baseInfo != null) {
fileName = baseInfo.getAsString(Events._SYNC_ID);
eTag = baseInfo.getAsString(COLUMN_ETAG);
}
}
/* process LocalTask-specific fields */
@Override
protected void populateTask(ContentValues values) throws FileNotFoundException, RemoteException, ParseException {
super.populateTask(values);
fileName = values.getAsString(Events._SYNC_ID);
eTag = values.getAsString(COLUMN_ETAG);
task.uid = values.getAsString(COLUMN_UID);
task.sequence = values.getAsInteger(COLUMN_SEQUENCE);
}
@Override
protected void buildTask(ContentProviderOperation.Builder builder, boolean update) {
super.buildTask(builder, update);
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_UID, task.uid)
.withValue(COLUMN_SEQUENCE, task.sequence)
.withValue(COLUMN_ETAG, eTag);
}
/* custom queries */
public void updateFileNameAndUID(String uid) throws CalendarStorageException {
try {
String newFileName = uid + ".ics";
ContentValues values = new ContentValues(2);
values.put(Tasks._SYNC_ID, newFileName);
values.put(COLUMN_UID, uid);
taskList.provider.client.update(taskSyncURI(), values, null, null);
fileName = newFileName;
if (task != null)
task.uid = uid;
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't update UID", e);
}
}
@Override
public void clearDirty(String eTag) throws CalendarStorageException {
try {
ContentValues values = new ContentValues(2);
values.put(Tasks._DIRTY, 0);
values.put(COLUMN_ETAG, eTag);
if (task != null)
values.put(COLUMN_SEQUENCE, task.sequence);
taskList.provider.client.update(taskSyncURI(), values, null, null);
this.eTag = eTag;
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e);
}
}
static class Factory implements AndroidTaskFactory {
static final Factory INSTANCE = new Factory();
@Override
public LocalTask newInstance(AndroidTaskList taskList, long id, ContentValues baseInfo) {
return new LocalTask(taskList, id, baseInfo);
}
@Override
public LocalTask newInstance(AndroidTaskList taskList, Task task) {
return new LocalTask(taskList, task, null, null);
}
@Override
public LocalTask[] newArray(int size) {
return new LocalTask[size];
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* 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.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import at.bitfire.ical4android.AndroidTask
import at.bitfire.ical4android.AndroidTaskFactory
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.Task
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.*
class LocalTask: AndroidTask, LocalResource<Task> {
companion object {
const val COLUMN_ETAG = Tasks.SYNC1
const val COLUMN_FLAGS = Tasks.SYNC2
}
override var fileName: String? = null
override var eTag: String? = null
override var flags = 0
private set
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(taskList: AndroidTaskList<*>, values: ContentValues): super(taskList) {
id = values.getAsLong(Tasks._ID)
fileName = values.getAsString(Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
/* process LocalTask-specific fields */
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
super.buildTask(builder, update)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_FLAGS, flags)
}
/* custom queries */
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
val values = ContentValues(2)
values.put(Tasks._SYNC_ID, newFileName)
values.put(Tasks._UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = newFileName
task!!.uid = uid
}
override fun clearDirty(eTag: String?) {
val values = ContentValues(3)
values.put(Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null)
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
taskList.provider.client.update(taskSyncURI(), values, null, null)
}
this.flags = flags
}
object Factory: AndroidTaskFactory<LocalTask> {
override fun fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
}
}

View File

@@ -1,167 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import org.dmfs.provider.tasks.TaskContract.TaskLists;
import org.dmfs.provider.tasks.TaskContract.Tasks;
import java.io.FileNotFoundException;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.ical4android.AndroidTaskList;
import at.bitfire.ical4android.AndroidTaskListFactory;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
public class LocalTaskList extends AndroidTaskList implements LocalCollection {
public static final int defaultColor = 0xFFC3EA6E; // "DAVdroid green"
public static final String COLUMN_CTAG = TaskLists.SYNC_VERSION;
static String[] BASE_INFO_COLUMNS = new String[] {
Tasks._ID,
Tasks._SYNC_ID,
LocalTask.COLUMN_ETAG
};
// can be cached, because after installing OpenTasks, you have to re-install DAVdroid anyway
private static Boolean tasksProviderAvailable;
@Override
protected String[] taskBaseInfoColumns() {
return BASE_INFO_COLUMNS;
}
protected LocalTaskList(Account account, TaskProvider provider, long id) {
super(account, provider, LocalTask.Factory.INSTANCE, id);
}
public static Uri create(Account account, TaskProvider provider, CollectionInfo info) throws CalendarStorageException {
ContentValues values = valuesFromCollectionInfo(info, true);
values.put(TaskLists.OWNER, account.name);
values.put(TaskLists.SYNC_ENABLED, 1);
values.put(TaskLists.VISIBLE, 1);
return create(account, provider, values);
}
public void update(CollectionInfo info, boolean updateColor) throws CalendarStorageException {
update(valuesFromCollectionInfo(info, updateColor));
}
private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) {
ContentValues values = new ContentValues();
values.put(TaskLists._SYNC_ID, info.url);
values.put(TaskLists.LIST_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url));
if (withColor)
values.put(TaskLists.LIST_COLOR, info.color != null ? info.color : defaultColor);
return values;
}
@Override
public LocalTask[] getAll() throws CalendarStorageException {
return (LocalTask[])queryTasks(null, null);
}
@Override
public LocalTask[] getDeleted() throws CalendarStorageException {
return (LocalTask[])queryTasks(Tasks._DELETED + "!=0", null);
}
@Override
public LocalTask[] getWithoutFileName() throws CalendarStorageException {
return (LocalTask[])queryTasks(Tasks._SYNC_ID + " IS NULL", null);
}
@Override
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
LocalTask[] tasks = (LocalTask[])queryTasks(Tasks._DIRTY + "!=0", null);
if (tasks != null)
for (LocalTask task : tasks) {
if (task.getTask().sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.getTask().sequence = 0;
else
task.getTask().sequence++;
}
return tasks;
}
@Override
@SuppressWarnings("Recycle")
public String getCTag() throws CalendarStorageException {
try {
@Cleanup Cursor cursor = provider.client.query(taskListSyncUri(), new String[] { COLUMN_CTAG }, null, null, null);
if (cursor != null && cursor.moveToNext())
return cursor.getString(0);
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
}
return null;
}
@Override
public void setCTag(String cTag) throws CalendarStorageException {
try {
ContentValues values = new ContentValues(1);
values.put(COLUMN_CTAG, cTag);
provider.client.update(taskListSyncUri(), values, null, null);
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
}
}
// helpers
public static boolean tasksProviderAvailable(@NonNull Context context) {
if (tasksProviderAvailable != null)
return tasksProviderAvailable;
else {
if (Build.VERSION.SDK_INT >= 23)
return context.getPackageManager().resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null;
else {
@Cleanup TaskProvider provider = TaskProvider.acquire(context.getContentResolver(), TaskProvider.ProviderName.OpenTasks);
return tasksProviderAvailable = (provider != null);
}
}
}
public static class Factory implements AndroidTaskListFactory {
public static final Factory INSTANCE = new Factory();
@Override
public AndroidTaskList newInstance(Account account, TaskProvider provider, long id) {
return new LocalTaskList(account, provider, id);
}
@Override
public AndroidTaskList[] newArray(int size) {
return new LocalTaskList[size];
}
}
}

View File

@@ -0,0 +1,173 @@
/*
* 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.resource
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
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.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.AndroidTaskListFactory
import at.bitfire.ical4android.TaskProvider
import org.dmfs.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
class LocalTaskList private constructor(
account: Account,
provider: TaskProvider,
id: Long
): AndroidTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
companion object {
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
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: Collection): Uri {
val values = valuesFromCollectionInfo(info, true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
values.put(TaskLists.VISIBLE, 1)
return create(account, provider, values)
}
@SuppressLint("Recycle")
@Throws(Exception::class)
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
var client: ContentProviderClient? = null
try {
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
client?.use {
val values = ContentValues(1)
values.put(Tasks.ACCOUNT_NAME, newName)
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
}
} finally {
client?.closeCompat()
}
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url.toString())
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
return values
}
}
override val tag: String
get() = "tasks-${account.name}-$id"
override val title: String
get() = name ?: id.toString()
override var lastSyncState: SyncState?
get() {
try {
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
provider.client.update(taskListSyncUri(), values, null, null)
}
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
override fun findDirty(): List<LocalTask> {
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
}
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()
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalTask.COLUMN_FLAGS, flags)
return provider.client.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.client.delete(tasksSyncUri(),
"${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> {
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
LocalTaskList(account, provider, id)
}
}

View File

@@ -0,0 +1,450 @@
/*
* 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.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.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.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.ical4android.AndroidCalendar
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.logging.Level
/**
* Manages settings of an account.
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
*/
@Suppress("FunctionName")
class AccountSettings(
val context: Context,
val account: Account
) {
companion object {
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]. 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 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 DAVx5 populates and uses CalendarContract.Colors
value = null (not existing) false (default)
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
const val SYNC_INTERVAL_MANUALLY = -1L
fun initialUserData(credentials: Credentials): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
when (credentials.type) {
Credentials.Type.UsernamePassword ->
bundle.putString(KEY_USERNAME, credentials.userName)
Credentials.Type.ClientCertificate ->
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
return bundle
}
}
val accountManager: AccountManager = AccountManager.get(context)
val settings = Settings.getInstance(context)
init {
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
}
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION)
update(version)
}
}
// authentication settings
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
)
fun credentials(credentials: Credentials) {
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
accountManager.setPassword(account, credentials.password)
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
// sync. settings
fun getSyncInterval(authority: String): Long? {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null
return if (ContentResolver.getSyncAutomatically(account, authority))
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
else
SYNC_INTERVAL_MANUALLY
}
fun setSyncInterval(authority: String, seconds: Long) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, authority, false)
} else {
ContentResolver.setSyncAutomatically(account, authority, true)
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
}
}
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
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)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
// CalDAV settings
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
return if (strDays != null) {
val days = strDays.toInt()
if (days < 0)
null
else
days
} else
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
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
else
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
fun setEventColors(useColors: Boolean) =
accountManager.setUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
if (name != null)
try {
return GroupMethod.valueOf(name)
}
catch (e: IllegalArgumentException) {
}
return GroupMethod.GROUP_VCARDS
}
fun setGroupMethod(method: GroupMethod) {
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
}
// update from previous account settings
private fun update(baseVersion: Int) {
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
val fromVersion = toVersion-1
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
try {
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
updateProc.invoke(this)
Logger.log.info("Account version update successful")
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
}
}
}
@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, 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 ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
val uid = cursor.getString(2)
val values = ContentValues(4)
values.put(TaskContract.Tasks._UID, uid)
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
values.putNull(TaskContract.Tasks.SYNC1)
values.putNull(TaskContract.Tasks.SYNC2)
Logger.log.log(Level.FINER, "Updating task $id", values)
provider.client.update(
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
values, null, null)
}
}
}
}
@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 {
provider.closeCompat()
}
}
// update allowed WiFi settings key
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID)
accountManager.setUserData(account, "wifi_only_ssid", null)
}
@Suppress("unused")
@SuppressLint("Recycle", "ParcelClassLoader")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
val parcel = Parcel.obtain()
try {
// don't run syncs during the migration
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
ContentResolver.cancelSync(account, null)
// get previous address book settings (including URL)
val raw = ContactsContract.SyncState.get(provider, account)
if (raw == null)
Logger.log.info("No contacts sync state, ignoring account")
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
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 = 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.toString())))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
Logger.log.info("Moving contacts from $account to $addressBookAccount")
val newAccount = ContentValues(2)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
newAccount,
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
arrayOf(account.name, account.type))
Logger.log.info("$affected contacts moved to new address book")
}
ContactsContract.SyncState.set(provider, account, null)
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
provider.closeCompat()
}
}
// update version number so that further syncs don't repeat the migration
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
}
/* Android 7.1.1 OpenTasks fix */
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context)
}
@Suppress("unused")
private fun update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES)
}
// updates from AccountSettings version 2 and below are not supported anymore
}

View File

@@ -0,0 +1,78 @@
/*
* 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
open class DefaultsProvider(
private val allowOverride: Boolean = true
): SettingsProvider {
open val booleanDefaults = mapOf(
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT),
Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT)
)
open val intDefaults = mapOf(
Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT)
)
open val longDefaults = mapOf<String, Long>()
open val stringDefaults = mapOf(
Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT)
)
override fun forceReload() {
}
override fun close() {
}
private fun hasKey(key: String) =
booleanDefaults.containsKey(key) ||
intDefaults.containsKey(key) ||
longDefaults.containsKey(key) ||
stringDefaults.containsKey(key)
override fun has(key: String): Pair<Boolean, Boolean> {
val has = hasKey(key)
return Pair(has, allowOverride || !has)
}
override fun getBoolean(key: String) =
Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key))
override fun getInt(key: String) =
Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key))
override fun getLong(key: String) =
Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key))
override fun getString(key: String) =
Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key))
override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key))
override fun putBoolean(key: String, value: Boolean?) = false
override fun putInt(key: String, value: Int?) = false
override fun putLong(key: String, value: Long?) = false
override fun putString(key: String, value: String?) = false
override fun remove(key: String) = false
class Factory : ISettingsProviderFactory {
override fun getProviders(context: Context) = listOf(DefaultsProvider())
}
}

View File

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

View File

@@ -0,0 +1,199 @@
/*
* 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
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(
appContext: Context
) {
companion object {
// 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"
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
}
}
private val providers = LinkedList<SettingsProvider>()
private val observers = LinkedList<WeakReference<OnChangeListener>>()
init {
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()
}
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()
}
}
}
/*** SETTINGS ACCESS ***/
fun has(key: String): Boolean {
Logger.log.fine("Looking for setting $key")
var result = false
for (provider in providers)
try {
val (value, further) = provider.has(key)
Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further")
if (value) {
result = true
break
}
if (!further)
break
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e)
}
Logger.log.fine("Looking for setting $key -> $result")
return result
}
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)
try {
val (value, further) = reader(provider)
Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further")
value?.let { result = it }
if (!further)
break
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
}
Logger.log.fine("Looked up setting $key -> $result")
return result
}
fun getBoolean(key: String) =
getValue(key) { provider -> provider.getBoolean(key) }
fun getInt(key: String) =
getValue(key) { provider -> provider.getInt(key) }
fun getLong(key: String) =
getValue(key) { provider -> provider.getLong(key) }
fun getString(key: String) =
getValue(key) { provider -> provider.getString(key) }
fun isWritable(key: String): Boolean {
for (provider in providers) {
val (value, further) = provider.isWritable(key)
if (value)
return true
if (!further)
return false
}
return false
}
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)
Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further")
if (writable)
return try {
writer(provider)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e)
false
}
if (!further)
return false
}
return false
}
fun putBoolean(key: String, value: Boolean?) =
putValue(key, value) { provider -> provider.putBoolean(key, value) }
fun putInt(key: String, value: Int?) =
putValue(key, value) { provider -> provider.putInt(key, value) }
fun putLong(key: String, value: Long?) =
putValue(key, value) { provider -> provider.putLong(key, value) }
fun putString(key: String, value: String?) =
putValue(key, value) { provider -> provider.putString(key, value) }
fun remove(key: String): Boolean {
var deleted = false
providers.forEach { deleted = deleted || it.remove(key) }
return deleted
}
interface OnChangeListener {
/**
* Will be called when something has changed in a [SettingsProvider].
* Runs in worker thread!
*/
@WorkerThread
fun onSettingsChanged()
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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
interface SettingsProvider {
fun forceReload()
fun close()
fun has(key: String): Pair<Boolean, Boolean>
fun getBoolean(key: String): Pair<Boolean?, Boolean>
fun getInt(key: String): Pair<Int?, Boolean>
fun getLong(key: String): Pair<Long?, Boolean>
fun getString(key: String): Pair<String?, Boolean>
fun isWritable(key: String): Pair<Boolean, Boolean>
fun putBoolean(key: String, value: Boolean?): Boolean
fun putInt(key: String, value: Int?): Boolean
fun putLong(key: String, value: Long?): Boolean
fun putString(key: String, value: String?): Boolean
fun remove(key: String): Boolean
}

View File

@@ -0,0 +1,132 @@
/*
* 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
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.AppDatabase
class SharedPreferencesProvider(
val context: Context
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
companion object {
private const val META_VERSION = "version"
private const val CURRENT_VERSION = 0
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
init {
val meta = context.getSharedPreferences("meta", MODE_PRIVATE)
val version = meta.getInt(META_VERSION, -1)
if (version == -1) {
// first call, check whether to migrate from SQLite database (DAVdroid <1.9)
firstCall(context)
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
}
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)
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): Pair<T?, Boolean> {
if (preferences.contains(key))
return Pair(
try { reader(preferences) } catch(e: ClassCastException) { null },
true)
return Pair(null, true)
}
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
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) }
override fun getLong(key: String): Pair<Long?, Boolean> =
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) }
override fun isWritable(key: String) =
Pair(first = true, second = true)
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
return if (value == null)
remove(key)
else {
Logger.log.fine("Writing setting $key = $value")
val edit = preferences.edit()
writer(edit, value)
edit.apply()
true
}
}
override fun putBoolean(key: String, value: Boolean?) =
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) }
override fun putLong(key: String, value: Long?) =
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) }
override fun remove(key: String): Boolean {
Logger.log.fine("Removing setting $key")
preferences.edit()
.remove(key)
.apply()
return true
}
private fun firstCall(context: Context) {
// remove possible artifacts from DAVdroid <1.9
val edit = preferences.edit()
edit.remove("override_proxy")
edit.remove("proxy_host")
edit.remove("proxy_port")
edit.remove("log_to_external_storage")
edit.apply()
// open ServiceDB to upgrade it and possibly migrate settings
AppDatabase.getInstance(context)
}
class Factory : ISettingsProviderFactory {
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
}
}

View File

@@ -1,88 +0,0 @@
/*
* Copyright © 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
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import at.bitfire.davdroid.ui.setup.LoginActivity;
public class AccountAuthenticatorService extends Service {
private static AccountAuthenticator accountAuthenticator;
private AccountAuthenticator getAuthenticator() {
if (accountAuthenticator != null)
return accountAuthenticator;
return accountAuthenticator = new AccountAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT))
return getAuthenticator().getIBinder();
return null;
}
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
final Context context;
public AccountAuthenticator(Context context) {
super(context);
this.context = context;
}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
String[] requiredFeatures, Bundle options) throws NetworkErrorException {
Intent intent = new Intent(context, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
return null;
}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public String getAuthTokenLabel(String authTokenType) {
return null;
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
return null;
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* 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.*
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.ui.setup.LoginActivity
import java.util.logging.Level
import kotlin.concurrent.thread
/**
* Account authenticator for the main DAVx5 account type.
*
* 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
fun cleanupAccounts(context: Context) {
Logger.log.info("Cleaning up orphaned accounts")
val accountManager = AccountManager.get(context)
val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type))
.map { it.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 services in DB
val db = AppDatabase.getInstance(context)
val serviceDao = db.serviceDao()
if (accountNames.isEmpty())
serviceDao.deleteAll()
else
serviceDao.deleteExceptAccounts(accountNames.toTypedArray())
}
}
private lateinit var accountManager: AccountManager
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountManager = AccountManager.get(this)
accountManager.addOnAccountsUpdatedListener(this, null, true)
accountAuthenticator = AccountAuthenticator(this)
}
override fun onDestroy() {
super.onDestroy()
accountManager.removeOnAccountsUpdatedListener(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
override fun onAccountsUpdated(accounts: Array<out Account>?) {
thread {
cleanupAccounts(this)
}
}
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
val intent = Intent(context, LoginActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle(1)
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}

View File

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

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