Compare commits

...

201 Commits

Author SHA1 Message Date
Ricki Hirner
a283cbbae5 Add account info when creating calendars
* add ACCOUNT_NAME and ACCOUNT_TYPE when creating calendars
* close TaskProvider when checking for its presence
* when TaskProvider is not available/accessible, explicitly disallow task sync at account creation
  to prevent further crashes
* try to handle OutOfMemoryError
* version 1.0_2
2016-03-30 16:11:44 +02:00
Ricki Hirner
bb95a25b91 Fix NPE for synchronization while CalDAV/CardDAV services is not available in DB 2016-03-30 16:07:41 +02:00
Ricki Hirner
f1ccd01708 Fetch translations from Transifex
* version 1.0_1 for commercial stores
2016-03-29 15:39:10 +02:00
Ricki Hirner
c498225064 Resource detection: fix NPE 2016-03-29 15:19:53 +02:00
Ricki Hirner
879b137cfc Version bump to 1.0
* fetch translations from Transifex
2016-03-27 15:55:05 +02:00
Ricki Hirner
84379f7ee1 Verbose logging of resource detection
* enable verbose logging of resource detection
* dav4android: prevent leaking connections
2016-03-25 16:27:43 +01:00
Ricki Hirner
a594fd3d14 Handle invalid accounts where accounts are used
* add InvalidAccountException for invalid (=not existent/invalid settings version) accounts
* handle invalid accounts properly
* HttpClient: add constructors without Account when authentication is not needed
* drop upgrade compatibility for accounts without version (version<1)
2016-03-25 15:23:54 +01:00
Ricki Hirner
100b78a6a4 Version bump to 1.0-rc1
* fix migration bug (doesn't set read-only flag)
* unify progress dialogs
* improve debug info report styling
2016-03-24 21:03:10 +01:00
Ricki Hirner
758711acb2 Import translations from Transifex 2016-03-24 19:32:45 +01:00
Ricki Hirner
c90b6075db Re-initialize logger in :sync process, too (IPC using broadcast)
* re-initialize logger in :sync process after changing the settings (IPC using broadcast)
* move settings from SharedPreferences (which is not multi-process-safe) to ServiceDB
* logger: show exception details
* settings: show debug info
2016-03-24 19:10:30 +01:00
Ricki Hirner
7109915e6e Minor refactoring (lint) 2016-03-24 17:43:35 +01:00
Ricki Hirner
e8cf9fd5ab Implement AboutActivity (license information) 2016-03-24 13:48:43 +01:00
Ricki Hirner
3a49815220 Show notifications on refresh errors
* show notifications on DAV service refresh errors
* add Twitter to navigation drawer
2016-03-23 22:01:52 +01:00
Ricki Hirner
96881bd986 Improve resource detection
* honour calendar-proxy-read/write-for property
* ignore errors when quering member groups for home sets
* remove home sets and collections from the service database not only on 404, but 403, 404 and 410
* fix crash bug when <displayname> was defined, but empty
2016-03-23 14:46:13 +01:00
Ricki Hirner
c08a0bdc43 Respect read-only flag of collections
* handle read-only information properly
* don't show (clear-text) password in account settings
2016-03-23 12:30:49 +01:00
Ricki Hirner
773b2ee992 SSLSocketFactoryCompat: fix typo 2016-03-23 11:30:42 +01:00
Ricki Hirner
c2181c55d3 Translation fix 2016-03-20 21:03:25 +01:00
Ricki Hirner
8449684dd2 Version bump to 1.0-beta1
* fetch translations from Transifex
* minor changes (lint/strings)
2016-03-20 18:31:11 +01:00
Ricki Hirner
28e7c91658 Initiate DAV service refresh after migration
* initiate DAV service refresh after migration
* minor refactoring of sync adapter classes
* minor UI changes
2016-03-20 17:41:05 +01:00
Ricki Hirner
51867c5f3f Notification for external file logging
* Show notificatin when external file logging is active
* Use column name constants for ServiceDB access
2016-03-20 11:41:08 +01:00
Ricki Hirner
1786b73ac6 Provide settings migration v0.9 -> v1.0 2016-03-19 22:30:07 +01:00
Ricki Hirner
1df3ddbe74 Startup dialogs
* add startup dialogs (F-Droid: donations, Play Store: DRM bug, OpenTasks not installed)
* allow to reset hints/startup dialogs
* AccountSettings: fragment as inner class of activity
2016-03-19 11:22:30 +01:00
Ricki Hirner
5ee8d76b34 Add SQLite dump to debug report 2016-03-18 19:02:27 +01:00
Ricki Hirner
5723225475 App settings UI 2016-03-18 17:24:46 +01:00
Ricki Hirner
f73f6ca43c Account management: Create address book (similar to create calendar) 2016-03-18 15:40:05 +01:00
Ricki Hirner
753c4b05a5 Allow time-range filtering of events (to the past)
* add account setting + GUI: restrict time range in the past
* add support for restricted time range VEVENT synchronization
* fix bug in handling changed exceptions of recurring events
2016-03-16 18:23:52 +01:00
Ricki Hirner
2e34fa686d Minor refactoring 2016-02-24 23:21:25 +01:00
Ricki Hirner
a735564bc1 Use java.util.logging instead of sl4fj 2016-02-24 23:08:19 +01:00
Ricki Hirner
552f6b6936 Refactoring
* move AccountSettings up to package context
* HttpClient: take authentication from AccountSettings in the constructor
* App: provide global instance of MemorizingTrustManager
* App: provide global Java logger, optionally with verbose and external file logging
* LoginCredentials: moved from inner-class into setup package
2016-02-24 15:56:30 +01:00
Ricki Hirner
50f7006e59 Refactoring
* make DavResourceFinder.Configuration really serializable
2016-02-24 12:29:07 +01:00
Ricki Hirner
6ac5fe0204 Show debug info on management errors 2016-02-23 23:10:44 +01:00
Ricki Hirner
19bfe5c5f2 Create/delete calendars 2016-02-23 18:42:50 +01:00
Ricki Hirner
212cd8ddb0 Proof of concept: create remote address books, delete remote collections 2016-02-22 14:33:55 +01:00
Ricki Hirner
c30195d9ba AccountActivity changes
* CalDAV/CardDAV resource list views now always contain all elements without scrolling (NonScrollingListView)
* synchronization action in activity now overriddes system sync settings
2016-02-21 14:15:55 +01:00
Ricki Hirner
3ca063416e Fix crash bug caused by leaking OnAccountsUpdateListener 2016-02-19 14:15:32 +01:00
Ricki Hirner
940d622402 Upgrade to okhttp/3.1.2 + tests 2016-02-19 13:16:34 +01:00
Ricki Hirner
814abc60ed Service detection, account settings
* service detection: detect group memberships and query them for homesets
* account settings
* request account synchronization
2016-01-23 18:44:40 +01:00
Ricki Hirner
220ba4b151 Improved service detection + GUI
* DavService: query group-membership principals for home sets, too
* working collection selection
* contacts sync according to selected address book
2016-01-23 00:04:48 +01:00
Ricki Hirner
777e124b54 Selectable calendars 2016-01-20 21:12:37 +01:00
Ricki Hirner
f32493986b Update local calendars according to ServiceDB at sync 2016-01-20 15:22:58 +01:00
Ricki Hirner
5025a61cd1 Update local task lists according to ServiceDB at sync 2016-01-20 15:01:17 +01:00
Ricki Hirner
89a516bfd1 DavService: refresh collections 2016-01-20 00:39:10 +01:00
Ricki Hirner
af71ed8bc5 Collections refresh 2016-01-19 20:04:25 +01:00
Ricki Hirner
fc29988dc6 Add DavService for long-running operations 2016-01-19 13:51:52 +01:00
Ricki Hirner
77c947da14 Add account details activity (AccountActivity) 2016-01-18 14:59:19 +01:00
Ricki Hirner
ff901ce91f Service database
* HttpClient: authentication that is limited to a host name is never preemptive
* DavResourceFinder: service configuration == null means that this service is not available
* new SQLite database for CalDAV/CardDAV services
* added AccountDetailsFragment, which asks for account name and then finishes account creation
* updated AccountListFragment
2016-01-17 17:10:30 +01:00
Ricki Hirner
85a6b68a56 Rewrite initial configuration detection
* HttpClient: add Accept-Language header
* HttpClient: fix MemoryCookieStore NullPointerException
* DavResourceFinder: check for home sets, too
2016-01-17 00:34:26 +01:00
Ricki Hirner
89050d88c6 Upgrade to okhttp3 2016-01-16 21:34:41 +01:00
Ricki Hirner
ba0350c83d New initial server configuration detection
* separate initial server configuration (= principal and/or a certain collection) detection from collection refresh (to be done)
* GUI: LoginActivity
2016-01-16 00:53:05 +01:00
Ricki Hirner
515969c4b8 Initial changes for new GUI 2016-01-15 01:07:56 +01:00
Ricki Hirner
9a8d29e774 Append trailing slashes to Web URLs; okhttp upgrade 2016-01-08 17:57:51 +01:00
Ricki Hirner
2880b05b5d README updates 2016-01-04 01:43:53 +01:00
Ricki Hirner
d6cff63f2d Version bump to 0.9.1.3 2016-01-03 01:47:32 +01:00
Ricki Hirner
be6aa1b6a2 Upgrade to okhttp/2.7.1 2016-01-02 10:59:52 +01:00
Ricki Hirner
9ec4a4015d Increase timeout values
* increase timeout values because some servers are known to be very slow
2015-12-06 13:45:15 +01:00
Ricki Hirner
9dbc32d30b BuildConfig: use build time instead of current time for timestamp 2015-11-27 14:04:24 +01:00
Ricki Hirner
b63fc70cfb README changes; fix EXDATE bug
* ical4android: process EXDATEs when there are no explicit exceptions
2015-11-27 12:31:15 +01:00
Ricki Hirner
0142e63257 Show open-source information when MainActivity is created 2015-11-24 18:36:25 +01:00
Ricki Hirner
aaa7d71ae3 Version bump to 0.9.1.2
* debug info: send report inline up to 8000 characters, as attachment otherwise
* ical4android: fix bug which locally deleted tasks by mistake
2015-11-24 17:59:47 +01:00
Ricki Hirner
4adf3001ac New upstream libraries, task sync bug fix
* use OkHttp 2.6.0, slf4j-android 1.7.13, and ez-vcard 0.9.8
* ical4android: don't delete all tasks instead of single one
2015-11-23 09:07:35 +01:00
Ricki Hirner
5ccdafa074 ContactsSyncManager: URL fix
* ContactsSyncManager: don't try to download external resources which do not have a valid URL
2015-11-20 10:12:48 +01:00
Ricki Hirner
fce2b85991 Increase version code……………………………………………………………………………. 2015-11-16 13:24:44 +01:00
Ricki Hirner
e5ebf10dc0 Version 0.9.1.1
* resource detection: ignore 404 errors when trying context paths
* work around crash when edit field is changed while there is no acitivity (???!)
* dav4android: fix calendar-multiget request
2015-11-16 13:15:57 +01:00
Ricki Hirner
0f0acd62a3 Optimize soft keyboard handling, make resource detection dialog not cancelable 2015-11-09 11:31:40 +01:00
Ricki Hirner
2414b42867 Add basic support for cookies
* add basic support for cookies (doesn't work for URLs with ports: https://code.google.com/p/android/issues/detail?id=193475)
* MemorizingTrustManager: log reason for inaccessible key store files
2015-11-08 18:51:19 +01:00
Ricki Hirner
243dac9952 Merge branch 'master' of https://gitlab.com/bitfireAT/davdroid 2015-11-07 22:03:54 +01:00
Ricki Hirner
12248b8bb9 Version 0.9.1-beta1
* CalendarSyncManager/TaskSyncManager: only set calendar name and color on sync when data is available
* DavResourceFinder: test getCurrentUserPrincipal
* dav4android: use java.util.ServiceLoader, resilience against multi-status with <propstat> without <status> + test
* ical4android: always set HAS_ATTENDEE_DATA to 1
* vcard4android: small fixes
* merge translations from Transifex
2015-11-07 22:03:19 +01:00
Ricki Hirner
d872bd06e5 * CalendarSyncManager/TaskSyncManager: only set calendar name and color on sync when data is available
* DavResourceFinder: test getCurrentUserPrincipal
* dav4android: use java.util.ServiceLoader, resilience against multi-status with <propstat> without <status> + test
* ical4android: always set HAS_ATTENDEE_DATA to 1
* vcard4android: small fixes
* merge translations from Transifex
2015-11-07 21:09:59 +01:00
Ricki Hirner
065aa3fc84 Version bump to 0.9.1
* filter ":" and "/" from external log file names
2015-11-07 15:35:22 +01:00
Ricki Hirner
20ee4e03f3 Various improvements
* ContactsSyncManager: gracefully handle photo URLs without host name
* MainActivity: cache installer package name
* dav4android: use java.util.ServiceLoader to load DAV property factories
2015-11-07 15:18:23 +01:00
Ricki Hirner
241e15404f Amend DebugInfoActivity
* write report to temporary file in external cache dir before sending
* don't delete the report file onActivityResult (because services like the email service may access it asynchronously)
* don't show label of installer (just the package name), because some use ambiguous strings like "App Store" etc.
* show sync. settings for all accounts again
2015-11-07 14:10:17 +01:00
Ricki Hirner
4a00ba647d Fix crash bug when external log file can't be created 2015-10-28 14:10:03 +01:00
Ricki Hirner
8d00814eaf Update .gitmodules to publically accessible URLs 2015-10-24 12:08:51 +02:00
Ricki Hirner
c665744c31 Version 0.9.0.4
* ical4android: treat empty-string task location and URL as null values
* vcard4android: ignore raw contact data without MIMETYPE
* gracefully ignore when server doesn't sent Content-Type in GET responses
* merge translations from Transifex
2015-10-24 00:36:22 +02:00
Ricki Hirner
2ef278c336 vcard4android: ignore raw contact data rows without MIMETYPE 2015-10-23 02:28:23 +02:00
Ricki Hirner
34de8431ae Fallback to PROPFIND when REPORT addressbook-query returns 400, 403, 500 or 501
* increase max. log line length to 80 characters
2015-10-21 14:40:03 +02:00
Ricki Hirner
9d19d9757c Merge translations from Transifex and bump version to 0.9.0.3 2015-10-21 02:15:10 +02:00
Ricki Hirner
81d13576e8 Minor bug fixes and improvements
* Contacts sync: if REPORT addressbook-query doesn't work, don't ignore other exceptions than HTTP 40x errors
* dav4android: Digest auth improvements (e.g. for OS X Calendar Server)
* vcard4android: better support for exotic IMPP handles and names
2015-10-21 02:06:29 +02:00
Ricki Hirner
6f429328ef Version bump to 0.9.0.2 2015-10-20 13:28:34 +02:00
Ricki Hirner
6465d83da4 Better handling of contacts without N/FN 2015-10-20 13:23:01 +02:00
Ricki Hirner
0f5f39a9fe Lower target SDK to 22 (pre-M) to fixes crashes on Android 6; new permissions model will be implemented later 2015-10-20 12:56:48 +02:00
Ricki Hirner
3e2459c85c 2 bug fixes
* ical4android: enumerate (=synchronize) all task lists and not only the first one
* fix crash bugs when activating external logging without external storage
2015-10-20 12:04:31 +02:00
Ricki Hirner
8f52bf160e Version bump to 0.9.0.1
* with minor fixes
2015-10-19 19:04:01 +02:00
Ricki Hirner
661276450c SSLSocketFactoryCompatTest 2015-10-19 16:55:01 +02:00
Ricki Hirner
c93a89348e Handle event/task sequence == null (meaning it was created locally and not sequence has yet been assigned) 2015-10-19 16:44:37 +02:00
Ricki Hirner
93464ccf8c Enable TLSv1.1 and TLSv1.2 (if available) for Android <5 again 2015-10-19 15:16:44 +02:00
Ricki Hirner
3646a561c6 Remove Robohydra (obsoleted by okhttp-mockwebserver) 2015-10-19 13:18:21 +02:00
Ricki Hirner
da9410c1b5 Fix lint warnings, don't require external storage permission for SDK >18 2015-10-19 11:57:43 +02:00
Ricki Hirner
82f80fed1c Resource detection fixes
* check TXT records for <service>._tcp.domain.tld instead of domain.tld
* duplicate log to ADB for successful resource detection
2015-10-19 01:12:06 +02:00
Ricki Hirner
94770fb0c8 Version 0.9 ready!
* fix lint warnings
* line-break too long messages of network trace logs
* DebugInfoActivity "send": attach log file instead if sending it as plain text
* revert to ez-vcard 0.9.6 because of https://github.com/mangstadt/ez-vcard/issues/33
* German translations
2015-10-19 00:19:29 +02:00
Ricki Hirner
9ddcec5624 Changed source strings 2015-10-18 19:36:03 +02:00
Ricki Hirner
4b5cb30762 Log resource detection results to viewable string
* new StringLogger
* DavResourceFinder: log to StringLogger; if no collections are found, logs can be views
* DebugInfoActivity: show passed logs
* script to fetch translations from Transifex
* increase version to 0.9-beta2
2015-10-18 17:30:26 +02:00
Ricki Hirner
58f05986c9 Synchronization logging to external file
* use ExternalFileLogger to log synchronization, if enabled in Settings
* new settings: log to external file / log verbose
* DavResource: check for well-known even if service type of user-given URL can't be determined
* remove oblsete testing assets
2015-10-18 16:20:26 +02:00
Ricki Hirner
dd50f10c58 Merge translations from Transifex 2015-10-17 22:42:45 +02:00
Ricki Hirner
d3c1688407 Improve DavResourceFinder
* check whether user-given URL actually provides CalDAV/CardDAV before trusting the current-user-principal
  as there may be different principals for CalDAV and CardDAV (if both services are completely separated)
2015-10-17 19:13:16 +02:00
Ricki Hirner
80231dd44b Sync manager optimization
* allow cancellation of synchronization within appropriate time
* sync error notification: use loader, show all accounts, show whether JB Workaround is installed, reorder
2015-10-17 11:33:35 +02:00
Ricki Hirner
4ecca76a95 Group support (VCard 3 CATEGORIES) with vcard4android
* VCard 3-style group support (CATEGORIES)
* sync error notification improvements
* some tests
2015-10-16 23:06:35 +02:00
Ricki Hirner
410a04dc11 Support Basic and Digest auth 2015-10-16 19:30:50 +02:00
Ricki Hirner
7fc01503d5 New collection/service discovery: CalDAV+CardDAV 2015-10-16 12:40:44 +02:00
Ricki Hirner
18542adb2c New resource detection
* new resource detection: only CalDAV yet
2015-10-16 03:27:56 +02:00
Ricki Hirner
e34abf291e Improve error/account settings notifications
* move address book settings from account user data to ContactsContract.SyncState
* remove "VCard4 capable?" setting (as it's detected at every sync)
* show user notification when updating settings version or when Android version was increased
* improve stack trace in DebugInfoActivity
* get rid of Guava (use Commons again)
2015-10-15 15:36:55 +02:00
Ricki Hirner
20bc5af4a3 Resource detection, bug fixes
* resource detection is subject to change yet
* don't use UID_2445 for Android <= 4.1
* more useful sync error notification messages
* handle 401 Unauthorized and show account info when notification is tapped
2015-10-15 13:46:19 +02:00
Ricki Hirner
f344bd3c28 Tasks with new sync logic 2015-10-15 00:49:15 +02:00
Ricki Hirner
419d732195 Process recurring events, exceptions etc. 2015-10-14 21:45:19 +02:00
Ricki Hirner
0c819c842b Basic implementation of calendar sync. with common SyncManager 2015-10-14 18:20:51 +02:00
Ricki Hirner
d348f54deb Remove legacy calendar/task/WebDAV code 2015-10-14 13:38:18 +02:00
Ricki Hirner
c2e9b27831 New DebugInfoActivity
* DebugInfoActivity shows and allows to share sync exceptions
* log sync phase
2015-10-14 12:23:02 +02:00
Ricki Hirner
808958a69b README changes 2015-10-13 11:27:33 +02:00
Ricki Hirner
bd77a5be63 Integrate MemorizingTrustManager by Georg Lukas 2015-10-13 02:34:24 +02:00
Ricki Hirner
ab34def8b0 Contacts sync logic
* download external resources (contact images)
* improve ETag handling
* contacts: set UNGROUPED_VISIBLE to 1
2015-10-12 14:16:26 +02:00
Ricki Hirner
d024cdb495 Contact synchronization logic
* use VERSION_CODE and buildTime from BuildConfig
* new HTTP User-Agent, VCard PRODID values
* contact sync: store CTag in SyncState
* sync logic: upload contacts, check CTag, multiget
2015-10-12 01:59:05 +02:00
Ricki Hirner
4f7f3b851a New sync logic for ContactsSyncAdapter, using dav4android and vcard4android 2015-10-11 22:34:03 +02:00
Ricki Hirner
7f4b4855a0 First implementation of CardDAV sync with dav4android and vcard4android
* try to get rid of Apache Commons
2015-10-10 23:30:38 +02:00
Ricki Hirner
bc2d1ba96d Resource detection with dav4android
* handle authentication (only Basic auth yet)
* rewrite DavResourceFinder to use dav4android
2015-10-10 15:47:44 +02:00
Ricki Hirner
0bc1a8178a First use of dav4android for resource detection
* replaced Apache httplib by gradle version because it will be removed completely anyway
2015-10-10 02:15:53 +02:00
Ricki Hirner
d0b928a93d Make well-known URLs work again when user enters an initial context path 2015-09-22 12:19:39 +02:00
Ricki Hirner
b0163e16cd Merge branch 'french_translations' of https://github.com/callmemagnus/davdroid 2015-09-15 17:53:23 +02:00
Ricki Hirner
98899ab27b Fix UI crash bug 2015-09-15 17:37:36 +02:00
Magnus Anderssen
e4e1053f77 Added missing french translations 2015-09-06 21:22:43 +02:00
rfc2822
bcd2e8d4da Merge pull request #628 from gjtoth/master
Hungarian translation updated.
2015-09-06 13:16:02 +02:00
Ricki Hirner
f7700ba8aa Update README 2015-09-05 01:35:02 +02:00
Ricki Hirner
a198309df5 Version update to 0.8.4.1
* minor (crash) bug fixes
* updated translations
2015-08-31 16:31:12 +02:00
Gábor J.Tóth
c1a26fbbb7 Hungarian translation updated. 2015-08-28 17:46:24 +02:00
Ricki Hirner
5bf3aad575 Version bump to 0.8.4 2015-08-25 22:06:33 +02:00
Ricki Hirner
97ae121331 Exception handling, verbose TLS logs
* handle IllegalArgumentException in Tasks provider (show LocalStorageException notification) (closes #601)
* add more verbose TLS cipher logs (see #608)
2015-08-25 22:04:45 +02:00
Ricki Hirner
31f5be01b4 ical4j update, clean up XML requests
* ical4j update to 2.0-beta1 (fixes #509, fixes #606)
* only run sync adapters in :sync process, set thread context class loaders appropriately
* remove "class" attribute from XML requests (fixes #615)
2015-08-25 21:18:29 +02:00
Ricki Hirner
d7fff8a760 Handle attendees and reminders for exceptions of recurring events 2015-08-10 11:54:05 +02:00
Ricki Hirner
faeb3b7dd0 Refactoring
* VEvent: don't set LAST-MODIFIED to sync time (should be last modification time which is not available)
* ignore 403 Forbidden when uploading (can happen on certain scheduling conditions)
2015-08-10 00:33:26 +02:00
Ricki Hirner
fc1874af85 Remove unnecessary getters/setters
* remove getters/setters for protected fields when they're only accessed from package scope
* version bump to 0.8.3
2015-08-09 20:02:37 +02:00
Ricki Hirner
be80b6fde8 Improve ATTENDEE/ORGANIZER handling 2015-08-08 15:39:58 +02:00
Ricki Hirner
072c763dec Process Content-Type character set information (fixes #594) 2015-08-06 15:57:06 +02:00
Ricki Hirner
6ad74c79f0 Improve event exception handling (always convert RECURRENCE-ID DATE-TIME to DATE when master event is all-day) 2015-08-06 14:11:39 +02:00
Ricki Hirner
01d1b1a6c0 Send used VTIMEZONEs with VTODOs 2015-08-03 16:54:10 +02:00
Ricki Hirner
1c461e9d13 Refactoring
* WebDavResource: properties in separate subclass
* improve time zone handling
* always provide task list color
2015-08-03 15:53:19 +02:00
Ricki Hirner
5ec4dbb9e7 Send charset information with MIME type when uploading VCard/3.0 resources 2015-08-02 16:35:02 +02:00
Ricki Hirner
3225a4bbc1 Detect VCard/4 support per sync, too 2015-08-02 16:24:00 +02:00
rfc2822
ece6be0f9d Merge pull request #593 from svetlemodry/master
Czech translation update
2015-08-02 14:47:04 +02:00
Jaroslav Lichtblau
40c6643b41 Czech translation update
for davdroid
2015-08-02 12:33:41 +02:00
Ricki Hirner
b3afe48179 Added uninstall warning in "Install Tasks app" string (fixes #589) 2015-08-02 09:29:28 +02:00
Ricki Hirner
abf04e14d2 Update collection properties (name, color) on every sync 2015-08-02 08:57:03 +02:00
Ricki Hirner
5b7947034a Convert RDate/ExDate properties <-> Android RDATE/EXDATE strings more precisely (+ tests) 2015-08-01 13:25:35 +02:00
Ricki Hirner
26d9f7284a Version bump to 0.8.2 2015-07-29 21:59:36 +02:00
Ricki Hirner
7c1b787410 VEVENT exceptions always get master UID
* make sure that VEVENT exceptions always the the UID of the master event (fixes #523)
2015-07-28 18:19:41 +02:00
Ricki Hirner
41bae221f0 Asset downloader: send credentials when URI authority is the same, even if the default port is explicitly given 2015-07-28 16:23:15 +02:00
Ricki Hirner
243483a957 Improved iCal generation
* move shared code to new iCalendar class
* generate UIDs and file names with "_" instead of "@" to reduce encoding problems (closes #585)
* tasks: validate "start date" and "completed at" time zones
2015-07-28 15:29:54 +02:00
Ricki Hirner
9d76d57af8 Fix problem of recent commit with deleting local records 2015-07-28 15:04:57 +02:00
Ricki Hirner
44bdd4d0ed Merge branch 'master' of github.com:bitfireAT/davdroid 2015-07-28 14:48:32 +02:00
rfc2822
40bffb78b0 Merge pull request #580 from oskarjakiela/master
Add Polish translation
2015-07-28 14:48:02 +02:00
Oskar S. Jakieła
5951414b25 Add Polish translation 2015-07-20 21:12:53 +02:00
Ricki Hirner
dcd86c7d86 Small refactoring 2015-07-18 15:15:36 +02:00
Ricki Hirner
92966a5c57 Null-pointer check for SIP address types (fixes #506) 2015-07-18 00:52:44 +02:00
Ricki Hirner
ad733ebff1 Handle 409 Conflict status codes (fixes #563) 2015-07-07 00:25:49 +02:00
Ricki Hirner
59088086fd Version bump to 0.8.1
* use slf4j-android as it's required by ical4j/2
* disable i18n lint warnings
* retain ServerInfo when activity is re-created (fixes #543)
2015-07-06 23:48:35 +02:00
Ricki Hirner
0b56d2a966 Add trailing slash to sample URL (closes #522) 2015-07-06 00:27:00 +02:00
Ricki Hirner
ed2a0419ad Specify encoding details of member names passed to WebDavResource (fixes #482) 2015-07-05 23:51:53 +02:00
rfc2822
c6950b1c16 Merge pull request #504 from svetlemodry/master
Czech translation for davdroid
2015-07-05 22:18:06 +02:00
Ricki Hirner
a796a1e9b3 Library updates
* use ical4j/2.0.x instead of 1.0.x (thanks @benfortuna)
* use Apache Commons 3.x instead of 2.x
* code optimizations
2015-06-14 20:35:28 +02:00
Ricki Hirner
c8cfbd6b07 Check for null values of Events.ORIGINAL_ALL_DAY (should fix #551)
* fix indentation
2015-06-14 12:39:36 +02:00
Ricki Hirner
654af1eec5 Check for null values in relations (should fix #547) 2015-06-14 12:07:04 +02:00
Ricki Hirner
534953fe4c Check for null values in StructuredPostal.TYPE (should fix #549) 2015-06-14 12:00:34 +02:00
Ricki Hirner
18c08bc9dd Don't disable per-session cookie management + test (closes #525) 2015-06-12 00:41:21 +02:00
Ricki Hirner
81d7813614 Set _DIRTY=0 for new tasks explicitly (fixes #524) 2015-06-11 23:58:03 +02:00
rfc2822
b7f0a9efa9 Merge pull request #519 from pejakm/srup
Update Serbian translation
2015-05-27 23:46:29 +02:00
Mladen Pejaković
915ed7199b Update Serbian translation 2015-05-27 23:20:36 +02:00
Ricki Hirner
2665f6c4e6 Add missing files (fixes #517) 2015-05-27 15:51:31 +02:00
rfc2822
13ec5a93ae Merge pull request #518 from phy25/values-zh-CN
Translations in zh-rcn for v0.8.0
2015-05-27 15:07:23 +02:00
phy25
a160d56643 Translations in zh-rcn for v0.7. 2015-05-27 20:22:27 +08:00
Ricki Hirner
c3f7c1b97e Extra icon for tasks in "Select collections" fragment 2015-05-27 12:04:06 +02:00
Ricki Hirner
bc7e58232e Version bump to 0.8.0
* update to Lombok 1.16.4 and dnsjava 2.1.7
* optimize imports and copyrights
* delete Note data class (will be implemented later)
2015-05-27 11:21:31 +02:00
Ricki Hirner
f3e83922f7 Version bump to 0.8.0-beta3
* don't offer to install Tasks from InstallAppsFragment, show instructions instead
* fix crash when displaying "todo lists" heading in "selection collections" fragment (fixes #512, fixes #513)
* use Android SDK build tools v22.0.1
2015-05-27 10:13:03 +02:00
Ricki Hirner
af011a65db Sync tasks
* remove VJOURNAL/notes sync (will be implemented later)
* setup: add "install Tasks app" fragment
* version bump to 0.8.0-beta1
* use Tasks instead of Mirakel
* handle task list colors
* allow independent selection of calendar/task sync for the same CalDAV calendar
* minor refactoring (don't use return value of Builder)
* handle more task fields and time zones
* sync interval setting for tasks
2015-05-25 19:54:16 +02:00
Ricki Hirner
aa7e582bc9 Sync notes and tasks 2015-05-22 03:06:30 +02:00
Jaroslav Lichtblau
03517584f2 Czech translation for davdroid
updated
2015-05-17 11:48:40 +02:00
Ricki Hirner
5f3c6045d8 Implement remote filters to fetch only CalDAV resources with useful components (VEVENT for now) 2015-05-15 23:35:27 +02:00
Ricki Hirner
cd513683f5 Version bump to 0.7.7
* SettingsActivity: up navigation
* tests
2015-05-15 14:47:03 +02:00
Ricki Hirner
011dd15c98 Handle Android "INTEGER (boolean)" values which are read as CharSequences correctly (fixes #503) 2015-05-15 02:31:53 +02:00
rfc2822
8fc86dd12c Merge pull request #498 from dehart/master
Added dutch translation file
2015-05-12 18:42:59 +02:00
Michael de Hart
4e690a02ad Added dutch translation file 2015-05-12 14:56:29 +02:00
Ricki Hirner
a3ebd72321 Version bump to 0.7.6
* additional test
* minor code optimizations
2015-05-09 13:29:28 +02:00
Ricki Hirner
87df8f880d Process multiple RDATE/EXDATE values (see #340, see #495) 2015-05-09 11:55:35 +02:00
Ricki Hirner
97633c5204 EXDATE processing
* don't ignore the time zone of EXDATEs (fixes #495)
2015-05-08 17:46:10 +02:00
Ricki Hirner
33958ab548 Better reminder (VALARM) handling
* handle WEEKS in duration correctly (fixes #398)
* handle positive and negative TRIGGER duration values correctly
2015-05-03 22:44:15 +02:00
Ricki Hirner
f19d528739 Use EntityEvent to populate entities from local DB 2015-05-03 21:56:31 +02:00
Ricki Hirner
365e04154b Use RawContactsEntity to query raw contact data 2015-05-03 17:09:20 +02:00
Ricki Hirner
c707b1eb9d RDATE processing
* don't ignore the time zone of RDATEs (see #340)
2015-05-02 11:39:25 +02:00
Ricki Hirner
5e9fe92520 New target SDK: API level 22 (Android 5.1) 2015-05-02 10:55:31 +02:00
Ricki Hirner
f1eabb6227 Support relations the VCard 4.0 way (closes #278) 2015-05-01 02:47:08 +02:00
Ricki Hirner
b5c99265c3 Version bump to 0.7.5
* account settings: show whether CardDAV server supports VCard 4.0
* CardDAV GET: ask for VCard 3.0 or VCard 4.0 (preferred) contacts
* CardDAV multiget: ask for VCard 4.0 contacts if the server supports it
* CardDAV PUT: send VCard 4.0 contacts if the server supports it
* import Apache httpclient-android rev. 1652769 correctly (hopefully fixes #491)
2015-05-01 00:36:12 +02:00
Ricki Hirner
f738f74dea Use source version of apache-httpclient
We can't use a repository version because there's no release yet which
contains rev. 1652769. However, this revision is necessary to get SNI with Android <4.2, too.
To avoid packaging a pre-compiled jar, this source lib has been added as a subproject:

apache-httpclient, branch 4.3.5-android, revision 1652769

Fixes #491.
2015-04-30 14:48:28 +02:00
Ricki Hirner
fb33767e57 Process <status> in multi-get responses without <propstat> (see #475) 2015-04-29 20:04:56 +02:00
Ricki Hirner
8afc55dff3 Merge branch 'master' of github.com:bitfireAT/davdroid 2015-04-29 00:25:49 +02:00
Ricki Hirner
9a63dd4693 Version bump to 0.7.3 2015-04-29 00:25:07 +02:00
Ricki Hirner
2683012ec3 Test parsing recurring events with exceptions
* adapted tests
* use org.apache.httpcomponents:httpclient-android:4.3.5.1 again because it seems to contain all necessary fixes
2015-04-29 00:21:15 +02:00
rfc2822
a79a59f409 Merge pull request #490 from FabianoK/master
Portugues Brazilian translation
2015-04-28 22:29:49 +02:00
Fabiano Sardenberg Kuss - COCOE/COASC/COSAM
81eabf5961 Portugues Brazilian translation 2015-04-28 17:16:52 -03:00
Ricki Hirner
495cdf7c7e Synchronize exceptions of recurring events to the Calendar storage (server to client)
* Event class finds and processes exceptions of recurring events
* workaround for iCloud and other services that provide RECURRENCE-ID as DATETIME even if the original event is an all-day event
* VEvents are generated with all time zone definitions (including time zone definitions of exceptions)
2015-04-28 13:50:06 +02:00
Ricki Hirner
f6eee6c910 Handle dirty flag of exceptions of recurring events
* mark events as dirty when its exceptions are dirty/deleted
* when (mass-)deleting events, delete corresponding exceptions too
2015-04-28 11:08:22 +02:00
Ricki Hirner
a405d07baf Sync recurring event exceptions to CalDAV server
* added SQL filter possibility to generic LocalCollection
* added exceptions of recurring events to Event
* process exceptions of recurring events in LocalCalendar
2015-04-28 01:36:01 +02:00
rfc2822
2696e64a83 Merge pull request #486 from astalavister/master
Russain language added
2015-04-25 21:36:16 +02:00
astalavister
6d6835c3b7 Russian language translation v. 0.7 2015-04-24 12:41:53 +08:00
astalavister
0eb6a56ef1 Russian language translation v. 0.7 2015-04-24 12:32:01 +08:00
310 changed files with 18618 additions and 11005 deletions

6
.gitignore vendored
View File

@@ -84,3 +84,9 @@ build/
# Ignore Gradle GUI config
gradle-app.setting
### external libs ###
.svn
# Javadoc
javadoc/

13
.gitmodules vendored Normal file
View File

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

View File

@@ -1,58 +0,0 @@
DAVdroid is free and open-source software, licensed under the [GPLv3 License](COPYING).
If you like our project, please contribute to it.
# How to contribute
## Reporting issues
An issue might be a bug, an enhancement request or something in between. If you think you
have found a bug or if you want to request some enhancement, please:
1. Read the [Configuration](http://davdroid.bitfire.at/configuration) and [FAQ](http://davdroid.bitfire.at/faq/)
pages carefully. The most common issues/usage challenges are explained there.
2. Search the Web for the problem, maybe ask competent friends or in forums.
3. Browse through the [open issues](https://github.com/rfc2822/davdroid/issues). You can
also search the issues in the search field on top of the page. Please have a look
into the closed issues, too, because many requests have already been handled (and can't/won't
be fixed, for instance).
4. **[Fetch verbose logs](https://github.com/rfc2822/davdroid/wiki/How-to-view-the-logs) and prepare
them. Remove `Authorization: Basic xxxxxx` headers and other private data.** Extracting the
logs may be cumbersome work in the first time, but it's absolutely necessary in order to
handle your issue.
5. [Create a new issue](https://github.com/rfc2822/davdroid/issues/new), containing
* a useful summary of the problem ("Crash when syncing contacts with large photos" instead of "CRASH PLEASE HELP"),
* your DAVdroid version and source ("DAVdroid 0.5.10 from F-Droid"),
* your Android version and device model ("Samsung Galaxy S2 running Android 4.4.2 (CyanogenMod 11-20140504-SNAPSHOT-M6-i9100)"),
* your CalDAV/CardDAV server software, version and hosting information ("OwnCloud 6, hosted on virtual server"),
* a problem description, including **instructions on how to reproduce the problem** (we need to
reproduce the problem before we can fix it!),
* **verbose logs including the network traffic** (see step before). Enquote the logs with three backticks ```
before and after, or post them onto http://gist.github.com and provide a link.
## Pull requests
We're very happy about pull requests for
* source code,
* documentation,
* translation (strings).
However, if you want to contribute source code, please talk with us in the
corresponding issue before because will only merge pull requests that
* match our product goals,
* have the necessary code quality,
* don't interfere with other near-term future development.
However, feel free to fork the repository and do your changes anyway
(that's why it's open-source). Just don't expect your strategic changes to be
merged if there's no consensus in the issue before.
## Donations
If you want to support this project, please also consider [donating to DAVdroid](http://davdroid.bitfire.at/donate)
or [purchasing it in one of the commercial stores](http://davdroid.bitfire.at/download).

View File

View File

@@ -1,21 +1,34 @@
DAVDROID
DAVdroid
========
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) for
detailled information about DAVdroid.
comprehensive information about DAVdroid.
DAVdroid is licensed under the [GPLv3 License](COPYING).
DAVdroid is licensed under the [GPLv3 License](LICENSE).
Twitter: [@davdroidapp](https://twitter.com/davdroidapp)
News and updates: [@davdroidapp](https://twitter.com/davdroidapp)
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
Parts of DAVdroid have been outsourced into these libraries:
* [dav4android](https://gitlab.com/bitfireAT/dav4android) 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)
USED THIRD-PARTY LIBRARIES
==========================
* [Apache HttpClient](http://hc.apache.org) (Android port) [Apache License](http://www.apache.org/licenses/)
* [iCal4j](http://ical4j.sourceforge.net/) [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
Those libraries are used by DAVdroid (alphabetically):
* [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)
* [Simple XML Serialization](http://simple.sourceforge.net/) [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)
* [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)
* [dnsjava](http://www.xbill.org/dnsjava/) [BSD license](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
* [SLF4J](http://www.slf4j.org/) [MIT License](http://www.slf4j.org/license.html)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* Copyright (c) 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
@@ -9,13 +9,18 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 21
buildToolsVersion '21.1.2'
compileSdkVersion 23
buildToolsVersion "23.0.3"
defaultConfig {
applicationId "at.bitfire.davdroid"
minSdkVersion 14
targetSdkVersion 21
targetSdkVersion 22
versionCode 92
versionName "1.0.2"
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
}
buildTypes {
@@ -27,11 +32,23 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
}
}
lintOptions {
abortOnError false
}
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'GradleDependency'
disable 'GradleDynamicVersion'
disable 'IconColors'
disable 'IconLauncherShape'
disable 'IconMissingDensityFolder'
disable 'MissingTranslation'
disable 'OldTargetApi' // Android 6 permission model not implemented yet
disable 'Recycle' // doesn't understand Lombok's @Cleanup
disable 'RtlEnabled'
disable 'RtlHardcoded'
disable 'Typos'
}
packagingOptions {
exclude 'LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE.txt'
}
@@ -42,31 +59,21 @@ configurations.all {
}
dependencies {
// Apache Commons
compile 'commons-lang:commons-lang:2.6'
compile 'commons-io:commons-io:2.4'
// Lombok for useful @helpers
provided 'org.projectlombok:lombok:1.14.8'
// ical4j for parsing/generating iCalendars
compile 'org.mnode.ical4j:ical4j:1.0.6'
// ez-vcard for parsing/generating VCards
compile('com.googlecode.ez-vcard:ez-vcard:0.9.6') {
// hCard functionality not needed
exclude group: 'org.jsoup', module: 'jsoup'
exclude group: 'org.freemarker', module: 'freemarker'
// jCard functionality not needed
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-core'
}
// dnsjava for querying SRV/TXT records
compile 'dnsjava:dnsjava:2.1.6'
// HttpClient 4.3, Android flavour for WebDAV operations
// we have to use our own patched build of 4.3.5.2-SNAPSHOT to avoid
// https://issues.apache.org/jira/browse/HTTPCLIENT-1591
compile files('lib/httpclient-android-4.3.5.2-davdroid1.jar')
// compile 'org.apache.httpcomponents:httpclient-android:4.3.5.2-SNAPSHOT'
// SimpleXML for parsing and generating WebDAV messages
compile('org.simpleframework:simple-xml:2.7.1') {
exclude group: 'stax', module: 'stax-api'
exclude group: 'xpp3', module: 'xpp3'
}
provided 'org.projectlombok:lombok:1.16.6'
compile project(':dav4android')
compile project(':ical4android')
compile 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-v7:23.+'
compile 'com.github.yukuku:ambilwarna:2.0.1'
compile project(':MemorizingTrustManager')
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.2.0'
compile 'dnsjava:dnsjava:2.1.7'
compile 'org.apache.commons:commons-lang3:3.4'
}

View File

Binary file not shown.

View File

@@ -7,12 +7,6 @@
-dontobfuscate
# SimpleXML
-keep class org.simpleframework.** { *; } # keep all interfaces etc. to allow reflection
-dontwarn com.bea.xml.stream.** # StAX API not used
-dontwarn javax.xml.stream.**
# ez-vcard
-dontwarn com.fasterxml.jackson.** # Jackson JSON Processor (for jCards) not used
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
@@ -21,14 +15,22 @@
-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 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
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
# DAVdroid
-keep class at.bitfire.davdroid.** { *; } # all DAVdroid code is required
# DAVdroid + libs
-keep class at.bitfire.** { *; } # all DAVdroid code is required

View File

@@ -1,11 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
BEGIN:VEVENT
UID:all-day-0sec@example.com
DTSTAMP:20140101T000000Z
DTSTART;VALUE=DATE:19970714
DTEND;VALUE=DATE:19970714
SUMMARY:0 Sec Event
END:VEVENT
END:VCALENDAR

View File

@@ -1,11 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
BEGIN:VEVENT
UID:all-day-10days@example.com
DTSTAMP:20140101T000000Z
DTSTART;VALUE=DATE:19970714
DTEND;VALUE=DATE:19970724
SUMMARY:All-Day 10 Days
END:VEVENT
END:VCALENDAR

View File

@@ -1,11 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
BEGIN:VEVENT
UID:all-day-1day@example.com
DTSTAMP:20140101T000000Z
DTSTART;VALUE=DATE:19970714
DTEND;VALUE=DATE:19970714
SUMMARY:All-Day 1 Day
END:VEVENT
END:VCALENDAR

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,11 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
BEGIN:VEVENT
UID:event-on-that-day@example.com
DTSTAMP:19970714T170000Z
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
DTSTART;VALUE=DATE:19970714
SUMMARY:Bastille Day Party
END:VEVENT
END:VCALENDAR

View File

@@ -1,9 +0,0 @@
BEGIN:VCARD
VERSION:3.0
UID:2de59c6cc9
PRODID:-//ownCloud//NONSGML Contacts 0.2.5//EN
REV:2013-12-08T00:04:30+00:00
FN:test mctest
N:mctest;test;;;
IMPP;TYPE=WORK;X-SERVICE-TYPE=jabber:test-without-valid-scheme@test.tld
END:VCARD

View File

@@ -1,5 +0,0 @@
BEGIN:VCARD
VERSION:3.0
FN:VCard with invalid unknown properties
X-UNKNOWN@PROPERTY:MUST-NOT_CONTAIN?OTHER*LETTERS;
END:VCARD

View File

@@ -1,16 +0,0 @@
BEGIN:VCARD
VERSION:3.0
N:Gump;Forrest;Mr.
FN:Forrest Gump
ORG:Bubba Gump Shrimp Co.
TITLE:Shrimp Man
PHOTO;VALUE=URL;TYPE=PNG:http://192.168.0.11:3000/assets/davdroid-logo-192.png
TEL;TYPE=WORK,VOICE:(111) 555-1212
TEL;TYPE=HOME,VOICE:(404) 555-1212
ADR;TYPE=WORK:;;100 Waters Edge;Baytown;LA;30314;United States of America
LABEL;TYPE=WORK:100 Waters Edge\nBaytown, LA 30314\nUnited States of America
ADR;TYPE=HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America
LABEL;TYPE=HOME:42 Plantation St.\nBaytown, LA 30314\nUnited States of America
EMAIL;TYPE=PREF,INTERNET:forrestgump@example.com
REV:2008-04-24T19:52:43Z
END:VCARD

View File

Binary file not shown.

View File

@@ -1,14 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:Blabla
BEGIN:VEVENT
CLASS:PUBLIC
CREATED;VALUE=DATE-TIME:20131008T205713
LAST-MODIFIED;VALUE=DATE-TIME:20131008T205740
SUMMARY:online Anmeldung
DESCRIPTION:http://www.tgbornheim.de/index.php?sessionid=&page=&id=&sportce
ntergroup=&day=6
UID:b99c41704b
DTSTART;VALUE=DATE-TIME;TZID=Europe/Berlin:20131019T060000
END:VEVENT
END:VCALENDAR

View File

@@ -1,16 +0,0 @@
BEGIN:VCARD
VERSION:3.0
N:Gump;Forrest
FN:Forrest Gump
ORG:Bubba Gump Shrimp Co.
TITLE:Shrimp Man
PHOTO;VALUE=URL;TYPE=GIF:http://www.example.com/dir_photos/my_photo.gif
TEL;TYPE=WORK,VOICE:(111) 555-1212
TEL;TYPE=HOME,VOICE:(404) 555-1212
ADR;TYPE=WORK:;;100 Waters Edge;Baytown;LA;30314;United States of America
LABEL;TYPE=WORK:100 Waters Edge\nBaytown, LA 30314\nUnited States of America
ADR;TYPE=HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America
LABEL;TYPE=HOME:42 Plantation St.\nBaytown, LA 30314\nUnited States of America
EMAIL;TYPE=PREF,INTERNET:forrestgump@example.com
REV:2008-04-24T19:52:43Z
END:VCARD

View File

@@ -1,33 +0,0 @@
BEGIN:VCALENDAR
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
VERSION:2.0
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:/freeassociation.sourceforge.net/Tzfile/Europe/Vienna
X-LIC-LOCATION:Europe/Vienna
BEGIN:STANDARD
TZNAME:CET
DTSTART:19701027T030000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
DTSTART:19700331T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:c252087c-7354-4722-aea9-0e7d86c01a25
DTSTAMP:20130926T151211Z
SUMMARY:Test-Ereignis im schönen Wien
DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Vienna:20131009T170000
DTEND;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Vienna:20131009T180000
X-RADICALE-NAME:97929342-291a-434e-bf1a-fa1749bf99d0.ics
X-EVOLUTION-CALDAV-HREF:/radicale/rfc2822/default.ics/97929342-291a-434e-bf1a-fa1749bf99d0.ics
X-EVOLUTION-CALDAV-ETAG:\"-3264224243575339985\"
END:VEVENT
END:VCALENDAR

View File

@@ -1,15 +1,15 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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.util.Arrays;
import junit.framework.TestCase;
import at.bitfire.davdroid.ArrayUtils;
import java.util.Arrays;
public class ArrayUtilsTest extends TestCase {

View File

@@ -1,62 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid;
import java.io.IOException;
import java.io.InputStream;
import lombok.Cleanup;
import net.fortuna.ical4j.data.ParserException;
import android.content.res.AssetManager;
import android.test.InstrumentationTestCase;
import at.bitfire.davdroid.resource.Contact;
import ezvcard.property.Impp;
public class ContactTest extends InstrumentationTestCase {
AssetManager assetMgr;
public void setUp() {
assetMgr = getInstrumentation().getContext().getResources().getAssets();
}
public void testIMPP() throws IOException {
Contact c = parseVCard("impp.vcf");
assertEquals("test mctest", c.getDisplayName());
Impp jabber = c.getImpps().get(0);
assertNull(jabber.getProtocol());
assertEquals("test-without-valid-scheme@test.tld", jabber.getHandle());
}
public void testParseVcard3() throws IOException, ParserException {
Contact c = parseVCard("vcard3-sample1.vcf");
assertEquals("Forrest Gump", c.getDisplayName());
assertEquals("Forrest", c.getGivenName());
assertEquals("Gump", c.getFamilyName());
assertEquals(2, c.getPhoneNumbers().size());
assertEquals("(111) 555-1212", c.getPhoneNumbers().get(0).getText());
assertEquals(1, c.getEmails().size());
assertEquals("forrestgump@example.com", c.getEmails().get(0).getValue());
assertFalse(c.isStarred());
}
private Contact parseVCard(String fileName) throws IOException {
@Cleanup InputStream in = assetMgr.open(fileName, AssetManager.ACCESS_STREAMING);
Contact c = new Contact(fileName, null);
c.parseEntity(in, null);
return c;
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.test.InstrumentationTestCase;
import java.io.IOException;
import java.net.URISyntaxException;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
public class HttpClientTest extends InstrumentationTestCase {
MockWebServer server;
OkHttpClient httpClient;
@Override
public void setUp() throws IOException {
httpClient = HttpClient.create();
server = new MockWebServer();
server.start();
}
@Override
public void tearDown() throws IOException {
server.shutdown();
}
public void testCookies() throws IOException, InterruptedException, URISyntaxException {
HttpUrl url = server.url("/");
// set cookie in first response
server.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Set-Cookie", "theme=light; path=/")
.setBody("Cookie set"));
httpClient.newCall(new Request.Builder()
.get().url(url)
.build()).execute();
assertNull(server.takeRequest().getHeader("Cookie"));
// cookie should be sent with second request
server.enqueue(new MockResponse()
.setResponseCode(200));
httpClient.newCall(new Request.Builder()
.get().url(url)
.build()).execute();
//assertEquals("theme=light", server.takeRequest().getHeader("Cookie"));
// doesn't work for URLs with ports, see https://code.google.com/p/android/issues/detail?id=193475
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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 junit.framework.TestCase;
import java.io.IOException;
import java.net.Socket;
import javax.net.ssl.SSLSocket;
import de.duenndns.ssl.MemorizingTrustManager;
import okhttp3.OkHttpClient;
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,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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
@@ -8,14 +8,14 @@
package at.bitfire.davdroid;
import android.util.Log;
import java.net.URI;
import java.net.URISyntaxException;
import android.util.Log;
public class TestConstants {
public static final String ROBOHYDRA_BASE = "http://192.168.0.11:3000/";
public static URI roboHydra;
static {
try {

View File

@@ -1,71 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid;
import java.net.URI;
import junit.framework.TestCase;
import at.bitfire.davdroid.URIUtils;
public class URLUtilsTest extends TestCase {
/* RFC 1738 p17 HTTP URLs:
hpath = hsegment *[ "/" hsegment ]
hsegment = *[ uchar | ";" | ":" | "@" | "&" | "=" ]
uchar = unreserved | escape
unreserved = alpha | digit | safe | extra
alpha = lowalpha | hialpha
lowalpha = ...
hialpha = ...
digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" |
"8" | "9"
safe = "$" | "-" | "_" | "." | "+"
extra = "!" | "*" | "'" | "(" | ")" | ","
escape = "%" hex hex
*/
public void testEnsureTrailingSlash() throws Exception {
assertEquals("/test/", URIUtils.ensureTrailingSlash("/test"));
assertEquals("/test/", URIUtils.ensureTrailingSlash("/test/"));
String withoutSlash = "http://www.test.example/dav/collection",
withSlash = withoutSlash + "/";
assertEquals(new URI(withSlash), URIUtils.ensureTrailingSlash(new URI(withoutSlash)));
assertEquals(new URI(withSlash), URIUtils.ensureTrailingSlash(new URI(withSlash)));
}
public void testParseURI() throws Exception {
// don't escape valid characters
String validPath = "/;:@&=$-_.+!*'(),";
assertEquals(new URI("https://www.test.example:123" + validPath), URIUtils.parseURI("https://www.test.example:123" + validPath, false));
assertEquals(new URI(validPath), URIUtils.parseURI(validPath, true));
// keep literal IPv6 addresses (only in host name)
assertEquals(new URI("https://[1:2::1]/"), URIUtils.parseURI("https://[1:2::1]/", false));
// "~" as home directory (valid)
assertEquals(new URI("http://www.test.example/~user1/"), URIUtils.parseURI("http://www.test.example/~user1/", false));
assertEquals(new URI("/~user1/"), URIUtils.parseURI("/%7euser1/", true));
// "@" in path names (valid)
assertEquals(new URI("http://www.test.example/user@server.com/"), URIUtils.parseURI("http://www.test.example/user@server.com/", false));
assertEquals(new URI("/user@server.com/"), URIUtils.parseURI("/user%40server.com/", true));
assertEquals(new URI("user@server.com"), URIUtils.parseURI("user%40server.com", true));
// ":" in path names (valid)
assertEquals(new URI("http://www.test.example/my:cal.ics"), URIUtils.parseURI("http://www.test.example/my:cal.ics", false));
assertEquals(new URI("/my:cal.ics"), URIUtils.parseURI("/my%3Acal.ics", true));
assertEquals(new URI(null, null, "my:cal.ics", null, null), URIUtils.parseURI("my%3Acal.ics", true));
// common invalid path names
assertEquals(new URI(null, null, "my cal.ics", null, null), URIUtils.parseURI("my cal.ics", true));
assertEquals(new URI(null, null, "{1234}.vcf", null, null), URIUtils.parseURI("{1234}.vcf", true));
}
}

View File

@@ -1,82 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.HttpException;
import ezvcard.property.Email;
import ezvcard.property.Telephone;
import lombok.Cleanup;
import android.content.res.AssetManager;
import android.test.InstrumentationTestCase;
import org.apache.commons.io.IOUtils;
import at.bitfire.davdroid.resource.Contact;
import at.bitfire.davdroid.resource.InvalidResourceException;
public class ContactTest extends InstrumentationTestCase {
AssetManager assetMgr;
public void setUp() throws IOException, InvalidResourceException {
assetMgr = getInstrumentation().getContext().getResources().getAssets();
}
public void testReferenceVCard() throws IOException, InvalidResourceException {
Contact c = parseVCF("reference.vcf");
assertEquals("Gump", c.getFamilyName());
assertEquals("Forrest", c.getGivenName());
assertEquals("Forrest Gump", c.getDisplayName());
assertEquals("Bubba Gump Shrimp Co.", c.getOrganization().getValues().get(0));
assertEquals("Shrimp Man", c.getJobTitle());
Telephone phone1 = c.getPhoneNumbers().get(0);
assertEquals("(111) 555-1212", phone1.getText());
assertEquals("WORK", phone1.getParameters("TYPE").get(0));
assertEquals("VOICE", phone1.getParameters("TYPE").get(1));
Telephone phone2 = c.getPhoneNumbers().get(1);
assertEquals("(404) 555-1212", phone2.getText());
assertEquals("HOME", phone2.getParameters("TYPE").get(0));
assertEquals("VOICE", phone2.getParameters("TYPE").get(1));
Email email = c.getEmails().get(0);
assertEquals("forrestgump@example.com", email.getValue());
assertEquals("PREF", email.getParameters("TYPE").get(0));
assertEquals("INTERNET", email.getParameters("TYPE").get(1));
@Cleanup InputStream photoStream = assetMgr.open("davdroid-logo-192.png", AssetManager.ACCESS_STREAMING);
byte[] expectedPhoto = IOUtils.toByteArray(photoStream);
assertTrue(Arrays.equals(c.getPhoto(), expectedPhoto));
}
public void testParseInvalidUnknownProperties() throws IOException, InvalidResourceException {
Contact c = parseVCF("invalid-unknown-properties.vcf");
assertEquals("VCard with invalid unknown properties", c.getDisplayName());
assertNull(c.getUnknownProperties());
}
protected Contact parseVCF(String fname) throws IOException, InvalidResourceException {
@Cleanup InputStream in = assetMgr.open(fname, AssetManager.ACCESS_STREAMING);
Contact c = new Contact(fname, null);
c.parseEntity(in, new Resource.AssetDownloader() {
@Override
public byte[] download(URI uri) throws URISyntaxException, IOException, HttpException, DavException {
return IOUtils.toByteArray(uri);
}
});
return c;
}
}

View File

@@ -0,0 +1,90 @@
package at.bitfire.davdroid.resource;
import android.test.InstrumentationTestCase;
import java.io.IOException;
import java.net.URI;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.davdroid.ui.setup.DavResourceFinder;
import at.bitfire.davdroid.ui.setup.LoginCredentials;
import okhttp3.HttpUrl;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
public class DavResourceFinderTest extends InstrumentationTestCase {
MockWebServer server = new MockWebServer();
@Override
protected void setUp() throws Exception {
server.start();
}
@Override
protected void tearDown() throws Exception {
server.shutdown();
}
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
HttpUrl url = server.url("/dav");
LoginCredentials credentials = new LoginCredentials(url.uri(), "admin", "12345", true);
DavResourceFinder finder = new DavResourceFinder(getInstrumentation().getTargetContext().getApplicationContext(), credentials);
// positive test case
server.enqueue(new MockResponse() // PROPFIND response
.setResponseCode(207)
.setHeader("Content-Type", "application/xml;charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <current-user-principal><href>/principals/myself</href></current-user-principal>" +
" </prop>" +
" <status>HTTP/1.0 200 OK</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"));
server.enqueue(new MockResponse() // OPTIONS response
.setResponseCode(200)
.setHeader("DAV", "addressbook"));
URI principal = finder.getCurrentUserPrincipal(url, DavResourceFinder.Service.CARDDAV);
assertEquals(url.resolve("/principals/myself").uri(), principal);
// negative test case: no current-user-principal
server.enqueue(new MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml;charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>HTTP/1.0 200 OK</status>" +
" </response>" +
"</multistatus>"));
assertNull(finder.getCurrentUserPrincipal(url, DavResourceFinder.Service.CARDDAV));
// negative test case: requested service not available
server.enqueue(new MockResponse() // PROPFIND response
.setResponseCode(207)
.setHeader("Content-Type", "application/xml;charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <current-user-principal><href>/principals/myself</href></current-user-principal>" +
" </prop>" +
" <status>HTTP/1.0 200 OK</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"));
server.enqueue(new MockResponse() // OPTIONS response
.setResponseCode(200)
.setHeader("DAV", "addressbook"));
assertNull(finder.getCurrentUserPrincipal(url, DavResourceFinder.Service.CALDAV));
}
}

View File

@@ -1,131 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import java.io.IOException;
import java.io.InputStream;
import lombok.Cleanup;
import net.fortuna.ical4j.data.ParserException;
import android.content.res.AssetManager;
import android.test.InstrumentationTestCase;
import android.text.format.Time;
import at.bitfire.davdroid.resource.Event;
import at.bitfire.davdroid.resource.InvalidResourceException;
public class EventTest extends InstrumentationTestCase {
AssetManager assetMgr;
Event eOnThatDay, eAllDay1Day, eAllDay10Days, eAllDay0Sec;
public void setUp() throws IOException, InvalidResourceException {
assetMgr = getInstrumentation().getContext().getResources().getAssets();
eOnThatDay = parseCalendar("event-on-that-day.ics");
eAllDay1Day = parseCalendar("all-day-1day.ics");
eAllDay10Days = parseCalendar("all-day-10days.ics");
eAllDay0Sec = parseCalendar("all-day-0sec.ics");
//assertEquals("Test-Ereignis im schönen Wien", e.getSummary());
}
public void testStartEndTimes() throws IOException, ParserException, InvalidResourceException {
// event with start+end date-time
Event eViennaEvolution = parseCalendar("vienna-evolution.ics");
assertEquals(1381330800000L, eViennaEvolution.getDtStartInMillis());
assertEquals("Europe/Vienna", eViennaEvolution.getDtStartTzID());
assertEquals(1381334400000L, eViennaEvolution.getDtEndInMillis());
assertEquals("Europe/Vienna", eViennaEvolution.getDtEndTzID());
}
public void testStartEndTimesAllDay() throws IOException, ParserException {
// event with start date only
assertEquals(868838400000L, eOnThatDay.getDtStartInMillis());
assertEquals(Time.TIMEZONE_UTC, eOnThatDay.getDtStartTzID());
// DTEND missing in VEVENT, must have been set to DTSTART+1 day
assertEquals(868838400000L + 86400000, eOnThatDay.getDtEndInMillis());
assertEquals(Time.TIMEZONE_UTC, eOnThatDay.getDtEndTzID());
// event with start+end date for all-day event (one day)
assertEquals(868838400000L, eAllDay1Day.getDtStartInMillis());
assertEquals(Time.TIMEZONE_UTC, eAllDay1Day.getDtStartTzID());
assertEquals(868838400000L + 86400000, eAllDay1Day.getDtEndInMillis());
assertEquals(Time.TIMEZONE_UTC, eAllDay1Day.getDtEndTzID());
// event with start+end date for all-day event (ten days)
assertEquals(868838400000L, eAllDay10Days.getDtStartInMillis());
assertEquals(Time.TIMEZONE_UTC, eAllDay10Days.getDtStartTzID());
assertEquals(868838400000L + 10*86400000, eAllDay10Days.getDtEndInMillis());
assertEquals(Time.TIMEZONE_UTC, eAllDay10Days.getDtEndTzID());
// event with start+end date on some day (invalid 0 sec-event)
assertEquals(868838400000L, eAllDay0Sec.getDtStartInMillis());
assertEquals(Time.TIMEZONE_UTC, eAllDay0Sec.getDtStartTzID());
// DTEND invalid in VEVENT, must have been set to DTSTART+1 day
assertEquals(868838400000L + 86400000, eAllDay0Sec.getDtEndInMillis());
assertEquals(Time.TIMEZONE_UTC, eAllDay0Sec.getDtEndTzID());
}
public void testTimezoneDefToTzId() {
// test valid definition
final String VTIMEZONE_SAMPLE = // taken from RFC 4791, 5.2.2. CALDAV:calendar-timezone Property
"BEGIN:VCALENDAR\n" +
"PRODID:-//Example Corp.//CalDAV Client//EN\n" +
"VERSION:2.0\n" +
"BEGIN:VTIMEZONE\n" +
"TZID:US-Eastern\n" +
"LAST-MODIFIED:19870101T000000Z\n" +
"BEGIN:STANDARD\n" +
"DTSTART:19671029T020000\n" +
"RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10\n" +
"TZOFFSETFROM:-0400\n" +
"TZOFFSETTO:-0500\n" +
"TZNAME:Eastern Standard Time (US &amp; Canada)\n" +
"END:STANDARD\n" +
"BEGIN:DAYLIGHT\n" +
"DTSTART:19870405T020000\n" +
"RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\n" +
"TZOFFSETFROM:-0500\n" +
"TZOFFSETTO:-0400\n" +
"TZNAME:Eastern Daylight Time (US &amp; Canada)\n" +
"END:DAYLIGHT\n" +
"END:VTIMEZONE\n" +
"END:VCALENDAR";
assertEquals("US-Eastern", Event.TimezoneDefToTzId(VTIMEZONE_SAMPLE));
// test null value
try {
Event.TimezoneDefToTzId(null);
fail();
} catch(IllegalArgumentException e) {
assert(true);
}
// test invalid time zone
try {
Event.TimezoneDefToTzId("/* invalid content */");
fail();
} catch(IllegalArgumentException e) {
assert(true);
}
}
public void testUnfolding() throws IOException, InvalidResourceException {
Event e = parseCalendar("two-line-description-without-crlf.ics");
assertEquals("http://www.tgbornheim.de/index.php?sessionid=&page=&id=&sportcentergroup=&day=6", e.getDescription());
}
protected Event parseCalendar(String fname) throws IOException, InvalidResourceException {
@Cleanup InputStream in = assetMgr.open(fname, AssetManager.ACCESS_STREAMING);
Event e = new Event(fname, null);
e.parseEntity(in, null);
return e;
}
}

View File

@@ -1,154 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import java.util.Calendar;
import lombok.Cleanup;
import android.accounts.Account;
import android.annotation.TargetApi;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
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.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.test.InstrumentationTestCase;
import android.util.Log;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalStorageException;
public class LocalCalendarTest extends InstrumentationTestCase {
private static final String
TAG = "davroid.test",
calendarName = "DAVdroid_Test";
ContentProviderClient providerClient;
Account testAccount = new Account(calendarName, CalendarContract.ACCOUNT_TYPE_LOCAL);
LocalCalendar testCalendar;
// helpers
private Uri syncAdapterURI(Uri uri) {
return uri.buildUpon()
.appendQueryParameter(Calendars.ACCOUNT_NAME, calendarName)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true").
build();
}
private long insertNewEvent() throws LocalStorageException, RemoteException {
ContentValues values = new ContentValues();
values.put(Events.CALENDAR_ID, testCalendar.getId());
values.put(Events.TITLE, "Test Event");
values.put(Events.ALL_DAY, 0);
values.put(Events.DTSTART, Calendar.getInstance().getTimeInMillis());
values.put(Events.DTEND, Calendar.getInstance().getTimeInMillis());
values.put(Events.EVENT_TIMEZONE, "UTC");
values.put(Events.DIRTY, 1);
return ContentUris.parseId(providerClient.insert(syncAdapterURI(Events.CONTENT_URI), values));
}
private void deleteEvent(long id) throws RemoteException {
providerClient.delete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)), null, null);
}
// initialization
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1)
protected void setUp() throws Exception {
ContentResolver resolver = getInstrumentation().getContext().getContentResolver();
providerClient = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY);
long id;
@Cleanup Cursor cursor = providerClient.query(Calendars.CONTENT_URI,
new String[]{Calendars._ID},
Calendars.ACCOUNT_TYPE + "=? AND " + Calendars.NAME + "=?",
new String[]{ CalendarContract.ACCOUNT_TYPE_LOCAL, calendarName },
null);
if (cursor != null && cursor.moveToNext()) {
// found local test calendar
id = cursor.getLong(0);
Log.d(TAG, "Found test calendar with ID " + id);
} else {
// no local test calendar found, create
ContentValues values = new ContentValues();
values.put(Calendars.ACCOUNT_NAME, testAccount.name);
values.put(Calendars.ACCOUNT_TYPE, testAccount.type);
values.put(Calendars.NAME, calendarName);
values.put(Calendars.CALENDAR_DISPLAY_NAME, calendarName);
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER);
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
values.put(Calendars.SYNC_EVENTS, 0);
values.put(Calendars.VISIBLE, 1);
if (android.os.Build.VERSION.SDK_INT >= 15) {
values.put(Calendars.ALLOWED_AVAILABILITY, Events.AVAILABILITY_BUSY + "," + Events.AVAILABILITY_FREE + "," + Events.AVAILABILITY_TENTATIVE);
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, Attendees.TYPE_NONE + "," + Attendees.TYPE_OPTIONAL + "," + Attendees.TYPE_REQUIRED + "," + Attendees.TYPE_RESOURCE);
}
Uri calendarURI = providerClient.insert(syncAdapterURI(Calendars.CONTENT_URI), values);
id = ContentUris.parseId(calendarURI);
Log.d(TAG, "Created test calendar with ID " + id);
}
testCalendar = new LocalCalendar(testAccount, providerClient, id, null);
}
protected void tearDown() throws Exception {
Uri uri = ContentUris.withAppendedId(syncAdapterURI(Calendars.CONTENT_URI), testCalendar.getId());
providerClient.delete(uri, null, null);
}
// tests
public void testCTags() throws LocalStorageException {
assertNull(testCalendar.getCTag());
final String cTag = "just-modified";
testCalendar.setCTag(cTag);
assertEquals(cTag, testCalendar.getCTag());
}
public void testFindNew() throws LocalStorageException, RemoteException {
// at the beginning, there are no dirty events
assertTrue(testCalendar.findNew().length == 0);
assertTrue(testCalendar.findUpdated().length == 0);
// insert a "new" event
long id = insertNewEvent();
try {
// there must be one "new" event now
assertTrue(testCalendar.findNew().length == 1);
assertTrue(testCalendar.findUpdated().length == 0);
// nothing has changed, the record must still be "new"
// see issue #233
assertTrue(testCalendar.findNew().length == 1);
assertTrue(testCalendar.findUpdated().length == 0);
} finally {
deleteEvent(id);
}
}
}

View File

@@ -1,85 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.syncadapter;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import android.test.InstrumentationTestCase;
import at.bitfire.davdroid.resource.DavResourceFinder;
import at.bitfire.davdroid.resource.ServerInfo;
import at.bitfire.davdroid.resource.ServerInfo.ResourceInfo;
import at.bitfire.davdroid.TestConstants;
import ezvcard.VCardVersion;
public class DavResourceFinderTest extends InstrumentationTestCase {
DavResourceFinder finder;
@Override
protected void setUp() {
finder = new DavResourceFinder(getInstrumentation().getContext());
}
@Override
protected void tearDown() throws IOException {
finder.close();
}
public void testFindResourcesRobohydra() throws Exception {
ServerInfo info = new ServerInfo(new URI(TestConstants.ROBOHYDRA_BASE), "test", "test", true);
finder.findResources(info);
/*** CardDAV ***/
assertTrue(info.isCardDAV());
List<ResourceInfo> collections = info.getAddressBooks();
// two address books
assertEquals(2, collections.size());
// first one
ResourceInfo collection = collections.get(0);
assertEquals(TestConstants.roboHydra.resolve("/dav/addressbooks/test/default-v4.vcf/").toString(), collection.getURL());
assertEquals("Default Address Book", collection.getDescription());
assertEquals(VCardVersion.V4_0, collection.getVCardVersion());
// second one
collection = collections.get(1);
assertEquals("https://my.server/absolute:uri/my-address-book/", collection.getURL());
assertEquals("Absolute URI VCard3 Book", collection.getDescription());
assertEquals(VCardVersion.V3_0, collection.getVCardVersion());
/*** CalDAV ***/
assertTrue(info.isCalDAV());
collections = info.getCalendars();
assertEquals(2, collections.size());
ResourceInfo resource = collections.get(0);
assertEquals("Private Calendar", resource.getTitle());
assertEquals("This is my private calendar.", resource.getDescription());
assertFalse(resource.isReadOnly());
resource = collections.get(1);
assertEquals("Work Calendar", resource.getTitle());
assertTrue(resource.isReadOnly());
}
public void testGetInitialContextURL() throws Exception {
// without SRV records, but with well-known paths
ServerInfo roboHydra = new ServerInfo(new URI(TestConstants.ROBOHYDRA_BASE), "test", "test", true);
assertEquals(TestConstants.roboHydra.resolve("/"), finder.getInitialContextURL(roboHydra, "caldav"));
assertEquals(TestConstants.roboHydra.resolve("/"), finder.getInitialContextURL(roboHydra, "carddav"));
// with SRV records and well-known paths
ServerInfo iCloud = new ServerInfo(new URI("mailto:test@icloud.com"), "", "", true);
assertEquals(new URI("https://contacts.icloud.com/"), finder.getInitialContextURL(iCloud, "carddav"));
assertEquals(new URI("https://caldav.icloud.com/"), finder.getInitialContextURL(iCloud, "caldav"));
}
}

View File

@@ -1,81 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.webdav;
import java.io.IOException;
import junit.framework.TestCase;
import at.bitfire.davdroid.TestConstants;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HttpContext;
public class DavRedirectStrategyTest extends TestCase {
CloseableHttpClient httpClient;
DavRedirectStrategy strategy = DavRedirectStrategy.INSTANCE;
@Override
protected void setUp() {
httpClient = HttpClientBuilder.create()
.useSystemProperties()
.disableRedirectHandling()
.build();
}
@Override
protected void tearDown() throws IOException {
httpClient.close();
}
// happy cases
public void testNonRedirection() throws Exception {
HttpUriRequest request = new HttpOptions(TestConstants.roboHydra);
HttpResponse response = httpClient.execute(request);
assertFalse(strategy.isRedirected(request, response, null));
}
public void testDefaultRedirection() throws Exception {
final String newLocation = "/new-location";
HttpContext context = HttpClientContext.create();
HttpUriRequest request = new HttpOptions(TestConstants.roboHydra.resolve("redirect/301?to=" + newLocation));
HttpResponse response = httpClient.execute(request, context);
assertTrue(strategy.isRedirected(request, response, context));
HttpUriRequest redirected = strategy.getRedirect(request, response, context);
assertEquals(TestConstants.roboHydra.resolve(newLocation), redirected.getURI());
}
// error cases
public void testMissingLocation() throws Exception {
HttpContext context = HttpClientContext.create();
HttpUriRequest request = new HttpOptions(TestConstants.roboHydra.resolve("redirect/without-location"));
HttpResponse response = httpClient.execute(request, context);
assertFalse(strategy.isRedirected(request, response, context));
}
public void testRelativeLocation() throws Exception {
HttpContext context = HttpClientContext.create();
HttpUriRequest request = new HttpOptions(TestConstants.roboHydra.resolve("redirect/relative"));
HttpResponse response = httpClient.execute(request, context);
assertTrue(strategy.isRedirected(request, response, context));
HttpUriRequest redirected = strategy.getRedirect(request, response, context);
assertEquals(TestConstants.roboHydra.resolve("/new/location"), redirected.getURI());
}
}

View File

@@ -1,118 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.webdav;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.security.cert.CertPathValidatorException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSocket;
import android.util.Log;
import junit.framework.TestCase;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.http.HttpHost;
import lombok.Cleanup;
public class TlsSniSocketFactoryTest extends TestCase {
private static final String TAG = "davdroid.TlsSniSocketFactoryTest";
TlsSniSocketFactory factory = TlsSniSocketFactory.getSocketFactory();
private InetSocketAddress sampleTlsEndpoint;
@Override
protected void setUp() {
// sni.velox.ch is used to test SNI (without SNI support, the certificate is invalid)
sampleTlsEndpoint = new InetSocketAddress("sni.velox.ch", 443);
}
public void testCreateSocket() {
try {
@Cleanup Socket socket = factory.createSocket(null);
assertFalse(socket.isConnected());
} catch (IOException e) {
fail();
}
}
public void testConnectSocket() {
try {
factory.connectSocket(1000, null, new HttpHost(sampleTlsEndpoint.getHostName()), sampleTlsEndpoint, null, null);
} catch (IOException e) {
Log.e(TAG, "I/O exception", e);
fail();
}
}
public void testCreateLayeredSocket() {
try {
// connect plain socket first
@Cleanup Socket plain = new Socket();
plain.connect(sampleTlsEndpoint);
assertTrue(plain.isConnected());
// then create TLS socket on top of it and establish TLS Connection
@Cleanup Socket socket = factory.createLayeredSocket(plain, sampleTlsEndpoint.getHostName(), sampleTlsEndpoint.getPort(), null);
assertTrue(socket.isConnected());
} catch (IOException e) {
Log.e(TAG, "I/O exception", e);
fail();
}
}
public void testProtocolVersions() throws IOException {
String enabledProtocols[] = factory.protocols;
// SSL (all versions) should be disabled
for (String protocol : enabledProtocols)
assertFalse(protocol.contains("SSL"));
// TLS v1+ should be enabled
assertTrue(ArrayUtils.contains(enabledProtocols, "TLSv1"));
assertTrue(ArrayUtils.contains(enabledProtocols, "TLSv1.1"));
assertTrue(ArrayUtils.contains(enabledProtocols, "TLSv1.2"));
}
public void testHostnameNotInCertificate() throws IOException {
try {
// host with certificate that doesn't match host name
// use the IP address as host name because IP addresses are usually not in the certificate subject
final String ipHostname = sampleTlsEndpoint.getAddress().getHostAddress();
InetSocketAddress host = new InetSocketAddress(ipHostname, 443);
@Cleanup Socket socket = factory.connectSocket(0, null, new HttpHost(ipHostname), host, null, null);
fail();
} catch (SSLException e) {
Log.i(TAG, "Expected exception", e);
assertFalse(ExceptionUtils.indexOfType(e, SSLException.class) == -1);
}
}
public void testUntrustedCertificate() throws IOException {
try {
// host with certificate that is not trusted by default
InetSocketAddress host = new InetSocketAddress("cacert.org", 443);
@Cleanup Socket socket = factory.connectSocket(0, null, new HttpHost(host.getHostName()), host, null, null);
fail();
} catch (SSLHandshakeException e) {
Log.i(TAG, "Expected exception", e);
assertFalse(ExceptionUtils.indexOfType(e, CertPathValidatorException.class) == -1);
}
}
}

View File

@@ -1,255 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.webdav;
import java.io.InputStream;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import javax.net.ssl.SSLPeerUnverifiedException;
import ezvcard.VCardVersion;
import lombok.Cleanup;
import org.apache.commons.io.IOUtils;
import android.content.res.AssetManager;
import android.test.InstrumentationTestCase;
import at.bitfire.davdroid.TestConstants;
import at.bitfire.davdroid.webdav.HttpPropfind.Mode;
import at.bitfire.davdroid.webdav.WebDavResource.PutMode;
import org.apache.http.impl.client.CloseableHttpClient;
// tests require running robohydra!
public class WebDavResourceTest extends InstrumentationTestCase {
static byte[] SAMPLE_CONTENT = new byte[] { 1, 2, 3, 4, 5 };
final static String PATH_SIMPLE_FILE = "collection/new.file";
AssetManager assetMgr;
CloseableHttpClient httpClient;
WebDavResource baseDAV;
WebDavResource davAssets,
davCollection, davNonExistingFile, davExistingFile;
@Override
protected void setUp() throws Exception {
httpClient = DavHttpClient.create();
assetMgr = getInstrumentation().getContext().getResources().getAssets();
baseDAV = new WebDavResource(httpClient, TestConstants.roboHydra.resolve("/dav/"));
davAssets = new WebDavResource(httpClient, TestConstants.roboHydra.resolve("assets/"));
davCollection = new WebDavResource(httpClient, TestConstants.roboHydra.resolve("dav/"));
davNonExistingFile = new WebDavResource(davCollection, "collection/new.file");
davExistingFile = new WebDavResource(davCollection, "collection/existing.file");
}
@Override
protected void tearDown() throws Exception {
httpClient.close();
}
/* test feature detection */
public void testOptions() throws Exception {
String[] davMethods = new String[] { "PROPFIND", "GET", "PUT", "DELETE", "REPORT" },
davCapabilities = new String[] { "addressbook", "calendar-access" };
WebDavResource capable = new WebDavResource(baseDAV);
capable.options();
for (String davMethod : davMethods)
assertTrue(capable.supportsMethod(davMethod));
for (String capability : davCapabilities)
assertTrue(capable.supportsDAV(capability));
}
public void testPropfindCurrentUserPrincipal() throws Exception {
davCollection.propfind(HttpPropfind.Mode.CURRENT_USER_PRINCIPAL);
assertEquals(new URI("/dav/principals/users/test"), davCollection.getCurrentUserPrincipal());
WebDavResource simpleFile = new WebDavResource(davAssets, "test.random");
try {
simpleFile.propfind(HttpPropfind.Mode.CURRENT_USER_PRINCIPAL);
fail();
} catch(DavException ex) {
}
assertNull(simpleFile.getCurrentUserPrincipal());
}
public void testPropfindHomeSets() throws Exception {
WebDavResource dav = new WebDavResource(davCollection, "principals/users/test");
dav.propfind(HttpPropfind.Mode.HOME_SETS);
assertEquals(new URI("/dav/addressbooks/test/"), dav.getAddressbookHomeSet());
assertEquals(new URI("/dav/calendars/test/"), dav.getCalendarHomeSet());
}
public void testPropfindAddressBooks() throws Exception {
WebDavResource dav = new WebDavResource(davCollection, "addressbooks/test");
dav.propfind(HttpPropfind.Mode.CARDDAV_COLLECTIONS);
// there should be two address books
assertEquals(3, dav.getMembers().size());
// the first one is not an address book and not even a collection (referenced by relative URI)
WebDavResource ab = dav.getMembers().get(0);
assertEquals(TestConstants.roboHydra.resolve("/dav/addressbooks/test/useless-member"), ab.getLocation());
assertEquals("useless-member", ab.getName());
assertFalse(ab.isAddressBook());
// the second one is a VCard4-capable address book (referenced by relative URI)
ab = dav.getMembers().get(1);
assertEquals(TestConstants.roboHydra.resolve("/dav/addressbooks/test/default-v4.vcf/"), ab.getLocation());
assertEquals("default-v4.vcf", ab.getName());
assertTrue(ab.isAddressBook());
assertEquals(VCardVersion.V4_0, ab.getVCardVersion());
// the third one is a (non-VCard4-capable) address book (referenced by an absolute URI)
ab = dav.getMembers().get(2);
assertEquals(new URI("https://my.server/absolute:uri/my-address-book/"), ab.getLocation());
assertEquals("my-address-book", ab.getName());
assertTrue(ab.isAddressBook());
assertNull(ab.getVCardVersion());
}
public void testPropfindCalendars() throws Exception {
WebDavResource dav = new WebDavResource(davCollection, "calendars/test");
dav.propfind(Mode.CALDAV_COLLECTIONS);
assertEquals(3, dav.getMembers().size());
assertEquals("0xFF00FF", dav.getMembers().get(2).getColor());
for (WebDavResource member : dav.getMembers()) {
if (member.getName().contains(".ics"))
assertTrue(member.isCalendar());
else
assertFalse(member.isCalendar());
assertFalse(member.isAddressBook());
}
}
public void testPropfindTrailingSlashes() throws Exception {
final String principalOK = "/principals/ok";
String requestPaths[] = {
"/dav/collection-response-with-trailing-slash",
"/dav/collection-response-with-trailing-slash/",
"/dav/collection-response-without-trailing-slash",
"/dav/collection-response-without-trailing-slash/"
};
for (String path : requestPaths) {
WebDavResource davSlash = new WebDavResource(davCollection, path);
davSlash.propfind(Mode.CARDDAV_COLLECTIONS);
assertEquals(new URI(principalOK), davSlash.getCurrentUserPrincipal());
}
}
/* test normal HTTP/WebDAV */
public void testPropfindRedirection() throws Exception {
// PROPFIND redirection
WebDavResource redirected = new WebDavResource(baseDAV, "/redirect/301?to=/dav/");
redirected.propfind(Mode.CURRENT_USER_PRINCIPAL);
assertEquals("/dav/", redirected.getLocation().getPath());
}
public void testGet() throws Exception {
WebDavResource simpleFile = new WebDavResource(davAssets, "test.random");
simpleFile.get("*/*");
@Cleanup InputStream is = assetMgr.open("test.random", AssetManager.ACCESS_STREAMING);
byte[] expected = IOUtils.toByteArray(is);
assertTrue(Arrays.equals(expected, simpleFile.getContent()));
}
public void testGetHttpsWithSni() throws Exception {
WebDavResource file = new WebDavResource(httpClient, new URI("https://sni.velox.ch"));
boolean sniWorking = false;
try {
file.get("* /*");
sniWorking = true;
} catch (SSLPeerUnverifiedException e) {
}
assertTrue(sniWorking);
}
public void testMultiGet() throws Exception {
WebDavResource davAddressBook = new WebDavResource(davCollection, "addressbooks/default.vcf/");
davAddressBook.multiGet(DavMultiget.Type.ADDRESS_BOOK, new String[] { "1.vcf", "2:3@my%40pc.vcf" });
// queried address book has a name
assertEquals("My Book", davAddressBook.getDisplayName());
// there are two contacts
assertEquals(2, davAddressBook.getMembers().size());
// contact file names should be unescaped (yes, it's really named ...%40pc... to check double-encoding)
assertEquals("1.vcf", davAddressBook.getMembers().get(0).getName());
assertEquals("2:3@my%40pc.vcf", davAddressBook.getMembers().get(1).getName());
// both contacts have content
for (WebDavResource member : davAddressBook.getMembers())
assertNotNull(member.getContent());
}
public void testPutAddDontOverwrite() throws Exception {
// should succeed on a non-existing file
assertEquals("has-just-been-created", davNonExistingFile.put(SAMPLE_CONTENT, PutMode.ADD_DONT_OVERWRITE));
// should fail on an existing file
try {
davExistingFile.put(SAMPLE_CONTENT, PutMode.ADD_DONT_OVERWRITE);
fail();
} catch(PreconditionFailedException ex) {
}
}
public void testPutUpdateDontOverwrite() throws Exception {
// should succeed on an existing file
assertEquals("has-just-been-updated", davExistingFile.put(SAMPLE_CONTENT, PutMode.UPDATE_DONT_OVERWRITE));
// should fail on a non-existing file
try {
davNonExistingFile.put(SAMPLE_CONTENT, PutMode.UPDATE_DONT_OVERWRITE);
fail();
} catch(PreconditionFailedException ex) {
}
}
public void testDelete() throws Exception {
// should succeed on an existing file
davExistingFile.delete();
// should fail on a non-existing file
try {
davNonExistingFile.delete();
fail();
} catch (NotFoundException e) {
}
}
/* test CalDAV/CardDAV */
/* special test */
public void testGetSpecialURLs() throws Exception {
WebDavResource dav = new WebDavResource(davAssets, "member-with:colon.vcf");
try {
dav.get("*/*");
fail();
} catch(NotFoundException e) {
assertTrue(true);
}
}
}

View File

@@ -1 +0,0 @@
node_modules

View File

@@ -1,5 +0,0 @@
{"plugins":[
"assets",
"redirect",
"dav"
]}

View File

@@ -1,12 +0,0 @@
var RoboHydraHeadFilesystem = require("robohydra").heads.RoboHydraHeadFilesystem;
exports.getBodyParts = function(conf) {
return {
heads: [
new RoboHydraHeadFilesystem({
mountPath: '/assets/',
documentRoot: '../assets'
})
]
};
};

View File

@@ -1,293 +0,0 @@
var roboHydraHeadDAV = require("../headdav");
exports.getBodyParts = function(conf) {
return {
heads: [
/* base URL, provide default DAV here */
new RoboHydraHeadDAV({ path: "/dav/" }),
/* multistatus parsing */
new RoboHydraHeadDAV({
path: "/dav/collection-response-with-trailing-slash",
handler: function(req,res,next) {
if (req.method == "PROPFIND") {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:">\
<response>\
<href>/dav/collection-response-with-trailing-slash/</href> \
<propstat>\
<prop>\
<current-user-principal>\
<href>/principals/ok</href>\
</current-user-principal>\
<resourcetype>\
<collection/>\
</resourcetype>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
}
}
}),
new RoboHydraHeadDAV({
path: "/dav/collection-response-without-trailing-slash",
handler: function(req,res,next) {
if (req.method == "PROPFIND") {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:">\
<response>\
<href>/dav/collection-response-without-trailing-slash</href> \
<propstat>\
<prop>\
<current-user-principal>\
<href>/principals/ok</href>\
</current-user-principal>\
<resourcetype>\
<collection/>\
</resourcetype>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
}
}
}),
/* principal URL */
new RoboHydraHeadDAV({
path: "/dav/principals/users/test",
handler: function(req,res,next) {
if (req.method == "PROPFIND" && req.rawBody.toString().match(/home-?set/)) {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:">\
<response>\
<href>/dav/principals/users/t%65st</href> \
<propstat>\
<prop>\
<CARD:addressbook-home-set xmlns:CARD="urn:ietf:params:xml:ns:carddav">\
<href>/dav/addressbooks/test</href>\
</CARD:addressbook-home-set>\
<CAL:calendar-home-set xmlns:CAL="urn:ietf:params:xml:ns:caldav">\
<href>/dav/calendars/test/</href>\
</CAL:calendar-home-set>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
}
}
}),
/* address-book home set */
new RoboHydraHeadDAV({
path: "/dav/addressbooks/test/",
handler: function(req,res,next) {
if (!req.url.match(/\/$/)) {
res.statusCode = 302;
res.headers['location'] = "/dav/addressbooks/test/";
}
else if (req.method == "PROPFIND" && req.rawBody.toString().match(/addressbook-description/)) {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:">\
<response>\
<href>/dav/addressbooks/test/useless-member</href>\
<propstat>\
<prop>\
<resourcetype/>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
<response>\
<href>/dav/addressbooks/test/default-v4.vcf</href>\
<propstat>\
<prop xmlns:CARD="urn:ietf:params:xml:ns:carddav">\
<resourcetype>\
<collection/>\
<CARD:addressbook/>\
</resourcetype>\
<CARD:addressbook-description>Default Address Book</CARD:addressbook-description>\
<CARD:supported-address-data>\
<CARD:address-data-type content-type="text/vcard" version="3.0" />\
<CARD:address-data-type content-type="text/vcard" version="4.0" />\
</CARD:supported-address-data>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
<response>\
<href>https://my.server/absolute:uri/my-address-book</href>\
<propstat>\
<prop xmlns:CARD="urn:ietf:params:xml:ns:carddav">\
<resourcetype>\
<collection/>\
<CARD:addressbook/>\
</resourcetype>\
<CARD:addressbook-description>Absolute URI VCard3 Book</CARD:addressbook-description>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
}
}
}),
/* calendar home set */
new RoboHydraHeadDAV({
path: "/dav/calendars/test/",
handler: function(req,res,next) {
if (req.method == "PROPFIND" && req.rawBody.toString().match(/calendar-description/)) {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">\
<response>\
<href>/dav/calendars/test/shared.forbidden</href>\
<propstat>\
<prop>\
<resourcetype/>\
</prop>\
<status>HTTP/1.1 403 Forbidden</status>\
</propstat>\
</response>\
<response>\
<href>/dav/calendars/test/private.ics</href>\
<propstat>\
<prop>\
<resourcetype>\
<collection/>\
<CAL:calendar/>\
</resourcetype>\
<displayname>Private Calendar</displayname>\
<CAL:calendar-description>This is my private calendar.</CAL:calendar-description>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
<response>\
<href>/dav/calendars/test/work.ics</href>\
<propstat>\
<prop>\
<resourcetype>\
<collection/>\
<CAL:calendar/>\
</resourcetype>\
<current-user-privilege-set>\
<privilege><read/></privilege>\
</current-user-privilege-set>\
<displayname>Work Calendar</displayname>\
<A:calendar-color xmlns:A="http://apple.com/ns/ical/">0xFF00FF</A:calendar-color>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
}
}
}),
/* non-existing file */
new RoboHydraHeadDAV({
path: "/dav/collection/new.file",
handler: function(req,res,next) {
if (req.method == "PUT") {
if (req.headers['if-match']) /* can't overwrite new file */
res.statusCode = 412;
else {
res.statusCode = 201;
res.headers["ETag"] = "has-just-been-created";
}
} else if (req.method == "DELETE")
res.statusCode = 404;
}
}),
/* existing file */
new RoboHydraHeadDAV({
path: "/dav/collection/existing.file",
handler: function(req,res,next) {
if (req.method == "PUT") {
if (req.headers['if-none-match']) /* requested "don't overwrite", but this file exists */
res.statusCode = 412;
else {
res.statusCode = 204;
res.headers["ETag"] = "has-just-been-updated";
}
} else if (req.method == "DELETE")
res.statusCode = 204;
}
}),
/* address-book multiget */
new RoboHydraHeadDAV({
path: "/dav/addressbooks/default.vcf/",
handler: function(req,res,next) {
if (req.method == "REPORT" && req.rawBody.toString().match(/addressbook-multiget[\s\S]+<prop>[\s\S]+<href>/m &&
req.rawBody.toString().match(/<href>\/dav\/addressbooks\/default\.vcf\/2:3@my%2540pc\.vcf<\/href>/m))) {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">\
<response>\
<href>/dav/addressbooks/default.vcf</href>\
<propstat>\
<prop>\
<resourcetype><collection/></resourcetype>\
<displayname>My Book</displayname>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
<response>\
<href>/dav/addressbooks/default.vcf/1.vcf</href>\
<propstat>\
<prop>\
<getetag/>\
<CARD:address-data>BEGIN:VCARD\
VERSION:3.0\
NICKNAME:MULTIGET1\
UID:1\
END:VCARD\
</CARD:address-data>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
<response>\
<href>/dav/addressbooks/default.vcf/2:3%40my%2540pc.vcf</href>\
<propstat>\
<prop>\
<getetag/>\
<CARD:address-data>BEGIN:VCARD\
VERSION:3.0\
NICKNAME:MULTIGET2\
UID:2\
END:VCARD\
</CARD:address-data>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
}
}
}),
]
};
};

View File

@@ -1,57 +0,0 @@
var roboHydra = require("robohydra"),
roboHydraHeads = roboHydra.heads,
roboHydraHead = roboHydraHeads.RoboHydraHead;
RoboHydraHeadDAV = roboHydraHeads.roboHydraHeadType({
name: 'WebDAV Server',
mandatoryProperties: [ 'path' ],
optionalProperties: [ 'handler' ],
parentPropBuilder: function() {
var myHandler = this.handler;
return {
path: this.path,
handler: function(req,res,next) {
// default DAV behavior
res.headers['DAV'] = 'addressbook, calendar-access';
res.statusCode = 500;
// verify Accept header
var accept = req.headers['accept'];
if (req.method == "GET" && (accept == undefined || !accept.match(/text\/(calendar|vcard|xml)/)) ||
(req.method == "PROPFIND" || req.method == "REPORT") && (accept == undefined || accept != "text/xml"))
res.statusCode = 406;
// DAV operations that work on all URLs
else if (req.method == "OPTIONS") {
res.statusCode = 204;
res.headers['Allow'] = 'OPTIONS, PROPFIND, GET, PUT, DELETE, REPORT';
} else if (req.method == "PROPFIND" && req.rawBody.toString().match(/current-user-principal/)) {
res.statusCode = 207;
res.write('\<?xml version="1.0" encoding="utf-8" ?>\
<multistatus xmlns="DAV:">\
<response>\
<href>' + req.url + '</href> \
<propstat>\
<prop>\
<current-user-principal>\
<href>/dav/principals/users/test</href>\
</current-user-principal>\
</prop>\
<status>HTTP/1.1 200 OK</status>\
</propstat>\
</response>\
</multistatus>\
');
} else if (typeof myHandler != 'undefined')
myHandler(req,res,next);
res.end();
}
}
}
});
module.exports = RoboHydraHeadDAV;

View File

@@ -1,57 +0,0 @@
require('../simple');
var RoboHydraHead = require('robohydra').heads.RoboHydraHead;
exports.getBodyParts = function(conf) {
return {
heads: [
// well-known URIs
new SimpleResponseHead({
path: '/.well-known/caldav',
status: 302,
headers: { Location: '/dav/' }
}),
new SimpleResponseHead({
path: '/.well-known/carddav',
status: 302,
headers: { Location: '/dav/' }
}),
// generic redirections
new RoboHydraHead({
path: '/redirect/301',
handler: function(req,res,next) {
res.statusCode = 301;
var location = req.queryParams['to'] || '/assets/test.random';
res.headers = {
Location: location
}
res.end();
}
}),
new RoboHydraHead({
path: '/redirect/302',
handler: function(req,res,next) {
res.statusCode = 302;
var location = req.queryParams['to'] || '/assets/test.random';
res.headers = {
Location: location
}
res.end();
}
}),
// special redirections
new SimpleResponseHead({
path: '/redirect/relative',
status: 302,
headers: { Location: '/new/location' }
}),
new SimpleResponseHead({
path: '/redirect/without-location',
status: 302
})
]
};
};

View File

@@ -1,28 +0,0 @@
var roboHydra = require("robohydra"),
roboHydraHeads = roboHydra.heads,
roboHydraHead = roboHydraHeads.RoboHydraHead;
SimpleResponseHead = roboHydraHeads.roboHydraHeadType({
name: 'Simple HTTP Response',
mandatoryProperties: [ 'path', 'status' ],
optionalProperties: [ 'headers', 'body' ],
parentPropBuilder: function() {
var head = this;
return {
path: this.path,
handler: function(req,res,next) {
res.statusCode = head.status;
if (typeof head.headers != 'undefined')
res.headers = head.headers;
if (typeof head.body != 'undefined')
res.write(head.body);
else
res.write();
res.end();
}
}
}
});
module.exports = SimpleResponseHead;

View File

@@ -1,2 +0,0 @@
#!/bin/sh
node_modules/robohydra/bin/robohydra.js davdroid.conf -I plugins

View File

@@ -5,95 +5,180 @@
~ 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"
android:installLocation="internalOnly">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="at.bitfire.davdroid"
android:versionCode="57" android:versionName="0.7.2"
android:installLocation="internalOnly">
<!-- normal permissions -->
<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.WRITE_SYNC_SETTINGS"/>
<uses-sdk
android:minSdkVersion="14"
android:targetSdkVersion="21" />
<!-- legacy permissions -->
<!--
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"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<!-- other permissions -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<!-- android.permission-group.CALENDAR -->
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- ical4android declares task access permissions -->
<application
android:name=".App"
android:allowBackup="true"
android:fullBackupContent="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:process=":sync">
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>
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false" >
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator" />
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true" >
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts" />
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts" />
android:resource="@xml/contacts"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true" >
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars" />
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>
<activity
android:name=".ui.MainActivity"
android:label="@string/app_name" >
<service
android:name=".DavService"
android:enabled="true">
</service>
<receiver
android:name=".AccountsChangedReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
</intent-filter>
</receiver>
<activity
android:name=".ui.AccountsActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".ui.setup.AddAccountActivity"
android:excludeFromRecents="true" >
</activity>
android:name=".ui.AboutActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.settings.SettingsActivity"
android:label="@string/settings_title" >
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.setup.LoginActivity"
android:label="@string/login_title"
android:parentActivityName=".ui.AccountsActivity">
<intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<activity
android:name=".ui.settings.AccountActivity"
android:label="@string/settings_title" >
android:name=".ui.AccountActivity"
android:parentActivityName=".ui.AccountsActivity">
</activity>
<activity android:name=".ui.AccountSettingsActivity"/>
<activity android:name=".ui.CreateAddressBookActivity"
android:label="@string/create_addressbook"/>
<activity android:name=".ui.CreateCalendarActivity"
android:label="@string/create_calendar"/>
<activity
android:name=".ui.DebugInfoActivity"
android:exported="true"
android:label="@string/debug_info_title">
</activity>
<!-- MemorizingTrustManager -->
<activity
android:name="de.duenndns.ssl.MemorizingActivity"
android:theme="@android:style/Theme.Holo.Light.Dialog.NoActionBar"/>
</application>
</manifest>

View File

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

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

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

@@ -0,0 +1,628 @@
<h3 style="text-align: center;">GNU GENERAL PUBLIC LICENSE</h3>
<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>
<h3><a name="preamble"></a>Preamble</h3>
<p>The GNU General Public License is a free, copyleft license for
software and other kinds of works.</p>
<p>The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.</p>
<p>When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.</p>
<p>To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.</p>
<p>For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.</p>
<p>Developers that use the GNU GPL protect your rights with two steps:
(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
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
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
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.</p>
<p>Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.</p>
<p>The precise terms and conditions for copying, distribution and
modification follow.</p>
<h3><a name="terms"></a>TERMS AND CONDITIONS</h3>
<h4><a name="section0"></a>0. Definitions.</h4>
<p>&ldquo;This License&rdquo; refers to version 3 of the GNU General Public License.</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>
<p>To &ldquo;modify&rdquo; a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a &ldquo;modified version&rdquo; of the
earlier work or a work &ldquo;based on&rdquo; the earlier work.</p>
<p>A &ldquo;covered work&rdquo; means either the unmodified Program or a work based
on the Program.</p>
<p>To &ldquo;propagate&rdquo; a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.</p>
<p>To &ldquo;convey&rdquo; a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.</p>
<p>An interactive user interface displays &ldquo;Appropriate Legal Notices&rdquo;
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.</p>
<h4><a name="section1"></a>1. Source Code.</h4>
<p>The &ldquo;source code&rdquo; for a work means the preferred form of the work
for making modifications to it. &ldquo;Object code&rdquo; means any non-source
form of a work.</p>
<p>A &ldquo;Standard Interface&rdquo; means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.</p>
<p>The &ldquo;System Libraries&rdquo; of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
&ldquo;Major Component&rdquo;, in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
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
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
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.</p>
<p>The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.</p>
<p>The Corresponding Source for a work in source code form is that
same work.</p>
<h4><a name="section2"></a>2. Basic Permissions.</h4>
<p>All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.</p>
<p>You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.</p>
<p>Conveying under any other circumstances is permitted solely under
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>
<p>No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.</p>
<p>When you convey a covered work, you waive any legal power to forbid
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
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
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
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.</p>
<p>You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.</p>
<h4><a name="section5"></a>5. Conveying Modified Source Versions.</h4>
<p>You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:</p>
<ul>
<li>a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.</li>
<li>b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
&ldquo;keep intact all notices&rdquo;.</li>
<li>c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.</li>
<li>d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.</li>
</ul>
<p>A compilation of a covered work with other separate and independent
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
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>
<h4><a name="section6"></a>6. Conveying Non-Source Forms.</h4>
<p>You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:</p>
<ul>
<li>a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.</li>
<li>b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.</li>
<li>c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.</li>
<li>d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.</li>
<li>e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.</li>
</ul>
<p>A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.</p>
<p>A &ldquo;User Product&rdquo; is either (1) a &ldquo;consumer product&rdquo;, which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, &ldquo;normally used&rdquo; refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.</p>
<p>&ldquo;Installation Information&rdquo; for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.</p>
<p>If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).</p>
<p>The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.</p>
<p>Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.</p>
<h4><a name="section7"></a>7. Additional Terms.</h4>
<p>&ldquo;Additional permissions&rdquo; are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.</p>
<p>When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.</p>
<p>Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:</p>
<ul>
<li>a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or</li>
<li>b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or</li>
<li>c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or</li>
<li>d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or</li>
<li>e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or</li>
<li>f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.</li>
</ul>
<p>All other non-permissive additional terms are considered &ldquo;further
restrictions&rdquo; within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.</p>
<p>If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.</p>
<p>Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.</p>
<h4><a name="section8"></a>8. Termination.</h4>
<p>You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).</p>
<p>However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.</p>
<p>Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.</p>
<p>Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.</p>
<h4><a name="section9"></a>9. Acceptance Not Required for Having Copies.</h4>
<p>You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.</p>
<h4><a name="section10"></a>10. Automatic Licensing of Downstream Recipients.</h4>
<p>Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.</p>
<p>An &ldquo;entity transaction&rdquo; is a transaction transferring control of an
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
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>
<p>You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.</p>
<h4><a name="section11"></a>11. Patents.</h4>
<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>
<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,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, &ldquo;control&rdquo; includes the right to grant
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
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.</p>
<p>In the following three paragraphs, a &ldquo;patent license&rdquo; is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To &ldquo;grant&rdquo; such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.</p>
<p>If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
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
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
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.</p>
<p>A patent license is &ldquo;discriminatory&rdquo; if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.</p>
<p>Nothing in this License shall be construed as excluding or limiting
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>
<p>If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.</p>
<h4><a name="section13"></a>13. Use with the GNU Affero General Public License.</h4>
<p>Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.</p>
<h4><a name="section14"></a>14. Revised Versions of this License.</h4>
<p>The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.</p>
<p>Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License &ldquo;or any later version&rdquo; applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
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
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.</p>
<p>Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.</p>
<h4><a name="section15"></a>15. Disclaimer of Warranty.</h4>
<p>THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM &ldquo;AS IS&rdquo; WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.</p>
<h4><a name="section16"></a>16. Limitation of Liability.</h4>
<p>IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.</p>
<h4><a name="section17"></a>17. Interpretation of Sections 15 and 16.</h4>
<p>If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.</p>
<p>END OF TERMS AND CONDITIONS</p>

View File

@@ -0,0 +1,7 @@
<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,351 @@
/*
* 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.app.Notification;
import android.app.NotificationManager;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.PeriodicSync;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Calendars;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
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 lombok.Cleanup;
import okhttp3.HttpUrl;
public class AccountSettings {
private final static int CURRENT_VERSION = 3;
private final static String
KEY_SETTINGS_VERSION = "version",
KEY_USERNAME = "user_name",
KEY_AUTH_PREEMPTIVE = "auth_preemptive";
/** 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;
public final static long SYNC_INTERVAL_MANUALLY = -1;
final Context context;
final AccountManager accountManager;
final Account account;
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) {
Notification notify = new NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_new_releases_light)
.setLargeIcon(((BitmapDrawable)context.getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
.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)
.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 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(Integer days) {
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, String.valueOf(days == null ? -1 : days));
}
// 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);
} 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);
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "2");
}
@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);
}
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "3");
}
}

View File

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

@@ -0,0 +1,140 @@
/*
* 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 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");
@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

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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
@@ -7,10 +7,22 @@
*/
package at.bitfire.davdroid;
import android.net.Uri;
public class Constants {
public static final String
APP_VERSION = "0.7.2",
ACCOUNT_TYPE = "bitfire.at.davdroid",
WEB_URL_HELP = "https://davdroid.bitfire.at/configuration?pk_campaign=davdroid-app",
WEB_URL_VIEW_LOGS = "https://github.com/bitfireAT/davdroid/wiki/How-to-view-the-logs";
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;
public static final Uri webUri = Uri.parse("https://davdroid.bitfire.at/?pk_campaign=davdroid-app");
}

View File

@@ -0,0 +1,402 @@
/*
* 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.v7.app.NotificationCompat;
import android.text.TextUtils;
import java.io.IOException;
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<RefreshingStatusListener> refreshingStatusListeners = new LinkedList<>();
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
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 (RefreshingStatusListener listener : refreshingStatusListeners)
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(RefreshingStatusListener listener, boolean callImmediate) {
refreshingStatusListeners.add(listener);
if (callImmediate)
for (long id : runningRefresh)
listener.onDavRefreshStatusChanged(id, true);
}
public void removeRefreshingStatusListener(RefreshingStatusListener listener) {
refreshingStatusListeners.remove(listener);
}
}
/* 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> iterator = homeSets.iterator(); iterator.hasNext(); ) {
HttpUrl homeSet = iterator.next();
App.log.fine("Listing home set " + homeSet);
DavResource dav = new DavResource(httpClient, homeSet);
try {
dav.propfind(1, CollectionInfo.DAV_PROPERTIES);
for (DavResource member : dav.members) {
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)
iterator.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.beginTransaction();
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_EXCEPTION, 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, 0))
.build();
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify);
} finally {
dbHelper.close();
runningRefresh.remove(service);
for (RefreshingStatusListener listener : refreshingStatusListeners)
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)));
}
}
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");
}
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");
}
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;
}
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);
}
}
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,22 @@
/*
* 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.util.regex.Matcher;
import java.util.regex.Pattern;
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);
}
}

View File

@@ -0,0 +1,157 @@
/*
* 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());
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(MemoryCookieStore.INSTANCE);
// 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,19 @@
/*
* 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,46 @@
/*
* 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 java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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 {
public static final MemoryCookieStore INSTANCE = new MemoryCookieStore();
protected final Map<HttpUrl, List<Cookie>> store = new ConcurrentHashMap<>();
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
store.put(url, cookies);
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
List<Cookie> cookies = store.get(url);
if (cookies == null)
cookies = Collections.emptyList();
return cookies;
}
}

View File

@@ -0,0 +1,172 @@
/*
* 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.net.UnknownHostException;
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.fine("Available cipher suites: " + TextUtils.join(", ", availableCiphers));
App.log.fine("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,84 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid;
import android.util.Log;
import org.apache.commons.codec.EncoderException;
import org.apache.commons.codec.net.URLCodec;
import org.apache.commons.lang.StringEscapeUtils;
import org.apache.commons.lang.StringUtils;
import java.net.URI;
import java.net.URISyntaxException;
public class URIUtils {
private static final String TAG = "davdroid.URIUtils";
public static String ensureTrailingSlash(String href) {
if (!href.endsWith("/")) {
Log.d(TAG, "Implicitly appending trailing slash to collection " + href);
return href + "/";
} else
return href;
}
public static URI ensureTrailingSlash(URI href) {
if (!href.getPath().endsWith("/")) {
try {
URI newURI = new URI(href.getScheme(), href.getAuthority(), href.getPath() + "/", null, null);
Log.d(TAG, "Appended trailing slash to collection " + href + " -> " + newURI);
href = newURI;
} catch (URISyntaxException e) {
}
}
return href;
}
/**
* Parse a received absolute/relative URL and generate a normalized URI that can be compared.
* @param original URI to be parsed, may be absolute or relative
* @param mustBePath true if it's known that original is a path (may contain ":") and not an URI, i.e. ":" is not the scheme separator
* @return normalized URI
* @throws URISyntaxException
*/
public static URI parseURI(String original, boolean mustBePath) throws URISyntaxException {
if (mustBePath) {
// may contain ":"
// case 1: "my:file" won't be parsed by URI correctly because it would consider "my" as URI scheme
// case 2: "path/my:file" will be parsed by URI correctly
// case 3: "my:path/file" won't be parsed by URI correctly because it would consider "my" as URI scheme
int idxSlash = original.indexOf('/'),
idxColon = original.indexOf(':');
if (idxColon != -1) {
// colon present
if ((idxSlash != -1) && idxSlash < idxColon) // There's a slash, and it's before the colon → everything OK
;
else // No slash before the colon; we have to put it there
original = "./" + original;
}
}
// escape some common invalid characters servers keep sending unescaped crap like "my calendar.ics" or "{guid}.vcf"
// this is only a hack, because for instance, "[" may be valid in URLs (IPv6 literal in host name)
String repaired = original
.replaceAll(" ", "%20")
.replaceAll("\\{", "%7B")
.replaceAll("\\}", "%7D");
if (!repaired.equals(original))
Log.w(TAG, "Repaired invalid URL: " + original + " -> " + repaired);
URI uri = new URI(repaired);
URI normalized = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery(), uri.getFragment());
Log.v(TAG, "Normalized URL " + original + " -> " + normalized.toASCIIString());
return normalized;
}
}

View File

@@ -0,0 +1,59 @@
/*
* 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,64 @@
/*
* 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.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"));
builder.append(String.format(" %d ", r.getThreadID()));
}
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,40 @@
/*
* 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,163 @@
/*
* 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,29 @@
/*
* 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 lombok.ToString;
@ToString
public class HomeSet {
public long id, serviceID;
public String URL;
public static HomeSet fromDB(ContentValues values) {
HomeSet homeSet = new HomeSet();
homeSet.id = values.getAsLong(ServiceDB.HomeSets.ID);
homeSet.serviceID = values.getAsLong(ServiceDB.HomeSets.SERVICE_ID);
homeSet.URL = values.getAsString(ServiceDB.HomeSets.URL);
return homeSet;
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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;
public class Service {
public long id;
public String accountName, service, principal;
public long lastRefresh;
public static Service fromDB(ContentValues values) {
Service service = new Service();
service.id = values.getAsLong(ServiceDB.Services.ID);
service.accountName = values.getAsString(ServiceDB.Services.ACCOUNT_NAME);
service.service = values.getAsString(ServiceDB.Services.SERVICE);
service.principal = values.getAsString(ServiceDB.Services.PRINCIPAL);
return service;
}
}

View File

@@ -0,0 +1,180 @@
/*
* 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);
}
@Override
public void onOpen(SQLiteDatabase db) {
db.enableWriteAheadLogging();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
db.setForeignKeyConstraintsEnabled(true);
else
db.execSQL("PRAGMA foreign_keys=ON;");
}
@Override
public void onCreate(SQLiteDatabase db) {
App.log.info("Creating services database");
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,45 @@
/*
* 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

@@ -1,38 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import org.apache.http.impl.client.CloseableHttpClient;
import java.net.URISyntaxException;
import at.bitfire.davdroid.webdav.DavMultiget;
public class CalDavCalendar extends RemoteCollection<Event> {
//private final static String TAG = "davdroid.CalDavCalendar";
@Override
protected String memberContentType() {
return "text/calendar";
}
@Override
protected DavMultiget.Type multiGetType() {
return DavMultiget.Type.CALENDAR;
}
@Override
protected Event newResourceSkeleton(String name, String ETag) {
return new Event(name, ETag);
}
public CalDavCalendar(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
super(httpClient, baseURL, user, password, preemptiveAuth);
}
}

View File

@@ -1,38 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import org.apache.http.impl.client.CloseableHttpClient;
import java.net.URISyntaxException;
import at.bitfire.davdroid.webdav.DavMultiget;
public class CardDavAddressBook extends RemoteCollection<Contact> {
//private final static String TAG = "davdroid.CardDavAddressBook";
@Override
protected String memberContentType() {
return Contact.MIME_TYPE;
}
@Override
protected DavMultiget.Type multiGetType() {
return DavMultiget.Type.ADDRESS_BOOK;
}
@Override
protected Contact newResourceSkeleton(String name, String ETag) {
return new Contact(name, ETag);
}
public CardDavAddressBook(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
super(httpClient, baseURL, user, password, preemptiveAuth);
}
}

View File

@@ -1,421 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import android.util.Log;
import org.apache.commons.lang.StringUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
import at.bitfire.davdroid.Constants;
import ezvcard.Ezvcard;
import ezvcard.VCard;
import ezvcard.VCardVersion;
import ezvcard.ValidationWarnings;
import ezvcard.parameter.EmailType;
import ezvcard.parameter.ImageType;
import ezvcard.parameter.TelephoneType;
import ezvcard.property.Address;
import ezvcard.property.Anniversary;
import ezvcard.property.Birthday;
import ezvcard.property.Categories;
import ezvcard.property.Email;
import ezvcard.property.FormattedName;
import ezvcard.property.Impp;
import ezvcard.property.Logo;
import ezvcard.property.Nickname;
import ezvcard.property.Note;
import ezvcard.property.Organization;
import ezvcard.property.Photo;
import ezvcard.property.RawProperty;
import ezvcard.property.Revision;
import ezvcard.property.Role;
import ezvcard.property.Sound;
import ezvcard.property.Source;
import ezvcard.property.StructuredName;
import ezvcard.property.Telephone;
import ezvcard.property.Title;
import ezvcard.property.Uid;
import ezvcard.property.Url;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* Represents a contact. Locally, this is a Contact in the Android
* device; remote, this is a VCard.
*/
@ToString(callSuper = true)
public class Contact extends Resource {
private final static String TAG = "davdroid.Contact";
public final static String MIME_TYPE = "text/vcard";
public final static String
PROPERTY_STARRED = "X-DAVDROID-STARRED",
PROPERTY_PHONETIC_FIRST_NAME = "X-PHONETIC-FIRST-NAME",
PROPERTY_PHONETIC_MIDDLE_NAME = "X-PHONETIC-MIDDLE-NAME",
PROPERTY_PHONETIC_LAST_NAME = "X-PHONETIC-LAST-NAME",
PROPERTY_SIP = "X-SIP";
public final static EmailType EMAIL_TYPE_MOBILE = EmailType.get("X-MOBILE");
public final static TelephoneType
PHONE_TYPE_CALLBACK = TelephoneType.get("X-CALLBACK"),
PHONE_TYPE_COMPANY_MAIN = TelephoneType.get("X-COMPANY_MAIN"),
PHONE_TYPE_RADIO = TelephoneType.get("X-RADIO"),
PHONE_TYPE_ASSISTANT = TelephoneType.get("X-ASSISTANT"),
PHONE_TYPE_MMS = TelephoneType.get("X-MMS");
@Getter @Setter private String unknownProperties;
@Getter @Setter private boolean starred;
@Getter @Setter private String displayName, nickName;
@Getter @Setter private String prefix, givenName, middleName, familyName, suffix;
@Getter @Setter private String phoneticGivenName, phoneticMiddleName, phoneticFamilyName;
@Getter @Setter private String note;
@Getter @Setter private Organization organization;
@Getter @Setter private String jobTitle, jobDescription;
@Getter @Setter private byte[] photo;
@Getter @Setter private Anniversary anniversary;
@Getter @Setter private Birthday birthDay;
@Getter private List<Telephone> phoneNumbers = new LinkedList<Telephone>();
@Getter private List<Email> emails = new LinkedList<Email>();
@Getter private List<Impp> impps = new LinkedList<Impp>();
@Getter private List<Address> addresses = new LinkedList<Address>();
@Getter private List<String> categories = new LinkedList<String>();
@Getter private List<String> URLs = new LinkedList<String>();
/* instance methods */
public Contact(String name, String ETag) {
super(name, ETag);
}
public Contact(long localID, String resourceName, String eTag) {
super(localID, resourceName, eTag);
}
@Override
public void initialize() {
generateUID();
name = uid + ".vcf";
}
protected void generateUID() {
uid = UUID.randomUUID().toString();
}
/* VCard methods */
@SuppressWarnings("LoopStatementThatDoesntLoop")
@Override
public void parseEntity(InputStream is, AssetDownloader downloader) throws IOException {
VCard vcard = Ezvcard.parse(is).first();
if (vcard == null)
return;
// now work through all supported properties
// supported properties are removed from the VCard after parsing
// so that only unknown properties are left and can be stored separately
// UID
Uid uid = vcard.getUid();
if (uid != null) {
this.uid = uid.getValue();
vcard.removeProperties(Uid.class);
} else {
Log.w(TAG, "Received VCard without UID, generating new one");
generateUID();
}
// X-DAVDROID-STARRED
RawProperty starred = vcard.getExtendedProperty(PROPERTY_STARRED);
if (starred != null && starred.getValue() != null) {
this.starred = starred.getValue().equals("1");
vcard.removeExtendedProperty(PROPERTY_STARRED);
} else
this.starred = false;
// FN
FormattedName fn = vcard.getFormattedName();
if (fn != null) {
displayName = fn.getValue();
vcard.removeProperties(FormattedName.class);
} else
Log.w(TAG, "Received invalid VCard without FN (formatted name) property");
// N
StructuredName n = vcard.getStructuredName();
if (n != null) {
prefix = StringUtils.join(n.getPrefixes(), " ");
givenName = n.getGiven();
middleName = StringUtils.join(n.getAdditional(), " ");
familyName = n.getFamily();
suffix = StringUtils.join(n.getSuffixes(), " ");
vcard.removeProperties(StructuredName.class);
}
// phonetic names
RawProperty
phoneticFirstName = vcard.getExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME),
phoneticMiddleName = vcard.getExtendedProperty(PROPERTY_PHONETIC_MIDDLE_NAME),
phoneticLastName = vcard.getExtendedProperty(PROPERTY_PHONETIC_LAST_NAME);
if (phoneticFirstName != null) {
phoneticGivenName = phoneticFirstName.getValue();
vcard.removeExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME);
}
if (phoneticMiddleName != null) {
this.phoneticMiddleName = phoneticMiddleName.getValue();
vcard.removeExtendedProperty(PROPERTY_PHONETIC_MIDDLE_NAME);
}
if (phoneticLastName != null) {
phoneticFamilyName = phoneticLastName.getValue();
vcard.removeExtendedProperty(PROPERTY_PHONETIC_LAST_NAME);
}
// TEL
phoneNumbers = vcard.getTelephoneNumbers();
vcard.removeProperties(Telephone.class);
// EMAIL
emails = vcard.getEmails();
vcard.removeProperties(Email.class);
// PHOTO
for (Photo photo : vcard.getPhotos()) {
this.photo = photo.getData();
if (this.photo == null && photo.getUrl() != null)
try {
URI uri = new URI(photo.getUrl());
Log.i(TAG, "Downloading contact photo from " + uri);
this.photo = downloader.download(uri);
} catch(Exception e) {
Log.w(TAG, "Couldn't fetch contact photo", e);
}
vcard.removeProperties(Photo.class);
break;
}
// ORG
organization = vcard.getOrganization();
vcard.removeProperties(Organization.class);
// TITLE
for (Title title : vcard.getTitles()) {
jobTitle = title.getValue();
vcard.removeProperties(Title.class);
break;
}
// ROLE
for (Role role : vcard.getRoles()) {
this.jobDescription = role.getValue();
vcard.removeProperties(Role.class);
break;
}
// IMPP
impps = vcard.getImpps();
vcard.removeProperties(Impp.class);
// NICKNAME
Nickname nicknames = vcard.getNickname();
if (nicknames != null) {
if (nicknames.getValues() != null)
nickName = StringUtils.join(nicknames.getValues(), ", ");
vcard.removeProperties(Nickname.class);
}
// NOTE
List<String> notes = new LinkedList<String>();
for (Note note : vcard.getNotes())
notes.add(note.getValue());
if (!notes.isEmpty())
note = StringUtils.join(notes, "\n---\n");
vcard.removeProperties(Note.class);
// ADR
addresses = vcard.getAddresses();
vcard.removeProperties(Address.class);
// CATEGORY
Categories categories = vcard.getCategories();
if (categories != null)
this.categories = categories.getValues();
vcard.removeProperties(Categories.class);
// URL
for (Url url : vcard.getUrls())
URLs.add(url.getValue());
vcard.removeProperties(Url.class);
// BDAY
birthDay = vcard.getBirthday();
vcard.removeProperties(Birthday.class);
// ANNIVERSARY
anniversary = vcard.getAnniversary();
vcard.removeProperties(Anniversary.class);
// X-SIP
for (RawProperty sip : vcard.getExtendedProperties(PROPERTY_SIP))
impps.add(new Impp("sip", sip.getValue()));
vcard.removeExtendedProperty(PROPERTY_SIP);
// remove binary properties because of potential OutOfMemory / TransactionTooLarge exceptions
vcard.removeProperties(Logo.class);
vcard.removeProperties(Sound.class);
// remove properties that don't apply anymore
vcard.removeProperties(Revision.class);
vcard.removeProperties(Source.class);
// store all remaining properties into unknownProperties
if (!vcard.getProperties().isEmpty() || !vcard.getExtendedProperties().isEmpty())
try {
unknownProperties = vcard.write();
} catch(Exception e) {
Log.w(TAG, "Couldn't store unknown properties (maybe illegal syntax), dropping them");
}
}
@Override
public ByteArrayOutputStream toEntity() throws IOException {
VCard vcard = null;
try {
if (unknownProperties != null)
vcard = Ezvcard.parse(unknownProperties).first();
} catch (Exception e) {
Log.w(TAG, "Couldn't parse original property set, beginning from scratch");
}
if (vcard == null)
vcard = new VCard();
if (uid != null)
vcard.setUid(new Uid(uid));
else
Log.wtf(TAG, "Generating VCard without UID");
if (starred)
vcard.setExtendedProperty(PROPERTY_STARRED, "1");
if (displayName != null)
vcard.setFormattedName(displayName);
else if (organization != null && organization.getValues() != null && organization.getValues().get(0) != null)
vcard.setFormattedName(organization.getValues().get(0));
else
Log.w(TAG, "No FN (formatted name) available to generate VCard");
// N
if (prefix != null || familyName != null || middleName != null || givenName != null || suffix != null) {
StructuredName n = new StructuredName();
if (prefix != null)
for (String p : StringUtils.split(prefix))
n.addPrefix(p);
n.setGiven(givenName);
if (middleName != null)
for (String middle : StringUtils.split(middleName))
n.addAdditional(middle);
n.setFamily(familyName);
if (suffix != null)
for (String s : StringUtils.split(suffix))
n.addSuffix(s);
vcard.setStructuredName(n);
}
// phonetic names
if (phoneticGivenName != null)
vcard.addExtendedProperty(PROPERTY_PHONETIC_FIRST_NAME, phoneticGivenName);
if (phoneticMiddleName != null)
vcard.addExtendedProperty(PROPERTY_PHONETIC_MIDDLE_NAME, phoneticMiddleName);
if (phoneticFamilyName != null)
vcard.addExtendedProperty(PROPERTY_PHONETIC_LAST_NAME, phoneticFamilyName);
// TEL
for (Telephone phoneNumber : phoneNumbers)
vcard.addTelephoneNumber(phoneNumber);
// EMAIL
for (Email email : emails)
vcard.addEmail(email);
// ORG, TITLE, ROLE
if (organization != null)
vcard.setOrganization(organization);
if (jobTitle != null)
vcard.addTitle(jobTitle);
if (jobDescription != null)
vcard.addRole(jobDescription);
// IMPP
for (Impp impp : impps)
vcard.addImpp(impp);
// NICKNAME
if (!StringUtils.isBlank(nickName))
vcard.setNickname(nickName);
// NOTE
if (!StringUtils.isBlank(note))
vcard.addNote(note);
// ADR
for (Address address : addresses)
vcard.addAddress(address);
// CATEGORY
if (!categories.isEmpty())
vcard.setCategories(categories.toArray(new String[0]));
// URL
for (String url : URLs)
vcard.addUrl(url);
// ANNIVERSARY
if (anniversary != null)
vcard.setAnniversary(anniversary);
// BDAY
if (birthDay != null)
vcard.setBirthday(birthDay);
// PHOTO
if (photo != null)
vcard.addPhoto(new Photo(photo, ImageType.JPEG));
// PRODID, REV
vcard.setProductId("DAVdroid/" + Constants.APP_VERSION + " (ez-vcard/" + Ezvcard.VERSION + ")");
vcard.setRevision(Revision.now());
// validate and print warnings
ValidationWarnings warnings = vcard.validate(VCardVersion.V3_0);
if (!warnings.isEmpty())
Log.w(TAG, "Created potentially invalid VCard! " + warnings);
ByteArrayOutputStream os = new ByteArrayOutputStream();
Ezvcard
.write(vcard)
.version(VCardVersion.V3_0)
.versionStrict(false)
.prodId(false) // we provide our own PRODID
.go(os);
return os;
}
}

View File

@@ -1,326 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import android.content.Context;
import android.util.Log;
import org.apache.http.HttpException;
import org.apache.http.impl.client.CloseableHttpClient;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.SRVRecord;
import org.xbill.DNS.TXTRecord;
import org.xbill.DNS.TextParseException;
import org.xbill.DNS.Type;
import java.io.Closeable;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.DavHttpClient;
import at.bitfire.davdroid.webdav.DavIncapableException;
import at.bitfire.davdroid.webdav.HttpPropfind.Mode;
import at.bitfire.davdroid.webdav.NotAuthorizedException;
import at.bitfire.davdroid.webdav.WebDavResource;
import ezvcard.VCardVersion;
public class DavResourceFinder implements Closeable {
private final static String TAG = "davdroid.ResourceFinder";
protected Context context;
protected CloseableHttpClient httpClient;
public DavResourceFinder(Context context) {
this.context = context;
// disable compression and enable network logging for debugging purposes
httpClient = DavHttpClient.create();
}
@Override
public void close() throws IOException {
httpClient.close();
}
public void findResources(ServerInfo serverInfo) throws URISyntaxException, DavException, HttpException, IOException {
// CardDAV
Log.i(TAG, "*** Starting CardDAV resource detection");
WebDavResource principal = getCurrentUserPrincipal(serverInfo, "carddav");
URI uriAddressBookHomeSet = null;
try {
principal.propfind(Mode.HOME_SETS);
uriAddressBookHomeSet = principal.getAddressbookHomeSet();
} catch (Exception e) {
Log.i(TAG, "Couldn't find address-book home set", e);
}
if (uriAddressBookHomeSet != null) {
Log.i(TAG, "Found address-book home set: " + uriAddressBookHomeSet);
WebDavResource homeSetAddressBooks = new WebDavResource(principal, uriAddressBookHomeSet);
if (checkHomesetCapabilities(homeSetAddressBooks, "addressbook")) {
serverInfo.setCardDAV(true);
homeSetAddressBooks.propfind(Mode.CARDDAV_COLLECTIONS);
List<WebDavResource> possibleAddressBooks = new LinkedList<>();
possibleAddressBooks.add(homeSetAddressBooks);
if (homeSetAddressBooks.getMembers() != null)
possibleAddressBooks.addAll(homeSetAddressBooks.getMembers());
List<ServerInfo.ResourceInfo> addressBooks = new LinkedList<>();
for (WebDavResource resource : possibleAddressBooks)
if (resource.isAddressBook()) {
Log.i(TAG, "Found address book: " + resource.getLocation().getPath());
ServerInfo.ResourceInfo info = new ServerInfo.ResourceInfo(
ServerInfo.ResourceInfo.Type.ADDRESS_BOOK,
resource.isReadOnly(),
resource.getLocation().toString(),
resource.getDisplayName(),
resource.getDescription(), resource.getColor()
);
VCardVersion version = resource.getVCardVersion();
if (version == null)
version = VCardVersion.V3_0; // VCard 3.0 MUST be supported
info.setVCardVersion(version);
addressBooks.add(info);
}
serverInfo.setAddressBooks(addressBooks);
} else
Log.w(TAG, "Found address-book home set, but it doesn't advertise CardDAV support");
}
// CalDAV
Log.i(TAG, "*** Starting CalDAV resource detection");
principal = getCurrentUserPrincipal(serverInfo, "caldav");
URI uriCalendarHomeSet = null;
try {
principal.propfind(Mode.HOME_SETS);
uriCalendarHomeSet = principal.getCalendarHomeSet();
} catch(Exception e) {
Log.i(TAG, "Couldn't find calendar home set", e);
}
if (uriCalendarHomeSet != null) {
Log.i(TAG, "Found calendar home set: " + uriCalendarHomeSet);
WebDavResource homeSetCalendars = new WebDavResource(principal, uriCalendarHomeSet);
if (checkHomesetCapabilities(homeSetCalendars, "calendar-access")) {
serverInfo.setCalDAV(true);
homeSetCalendars.propfind(Mode.CALDAV_COLLECTIONS);
List<WebDavResource> possibleCalendars = new LinkedList<>();
possibleCalendars.add(homeSetCalendars);
if (homeSetCalendars.getMembers() != null)
possibleCalendars.addAll(homeSetCalendars.getMembers());
List<ServerInfo.ResourceInfo> calendars = new LinkedList<>();
for (WebDavResource resource : possibleCalendars)
if (resource.isCalendar()) {
Log.i(TAG, "Found calendar: " + resource.getLocation().getPath());
if (resource.getSupportedComponents() != null) {
// CALDAV:supported-calendar-component-set available
boolean supportsEvents = false;
for (String supportedComponent : resource.getSupportedComponents())
if (supportedComponent.equalsIgnoreCase("VEVENT"))
supportsEvents = true;
if (!supportsEvents) { // ignore collections without VEVENT support
Log.i(TAG, "Ignoring this calendar because of missing VEVENT support");
continue;
}
}
ServerInfo.ResourceInfo info = new ServerInfo.ResourceInfo(
ServerInfo.ResourceInfo.Type.CALENDAR,
resource.isReadOnly(),
resource.getLocation().toString(),
resource.getDisplayName(),
resource.getDescription(), resource.getColor()
);
info.setTimezone(resource.getTimezone());
calendars.add(info);
}
serverInfo.setCalendars(calendars);
} else
Log.w(TAG, "Found calendar home set, but it doesn't advertise CalDAV support");
}
if (!serverInfo.isCalDAV() && !serverInfo.isCardDAV())
throw new DavIncapableException(context.getString(R.string.setup_neither_caldav_nor_carddav));
}
/**
* Finds the initial service URL from a given base URI (HTTP[S] or mailto URI, user name, password)
* @param serverInfo User-given service information (including base URI, i.e. HTTP[S] URL+user name+password or mailto URI and password)
* @param serviceName Service name ("carddav" or "caldav")
* @return Initial service URL (HTTP/HTTPS), without user credentials
* @throws URISyntaxException when the user-given URI is invalid
* @throws MalformedURLException when the user-given URI is invalid
*/
public URI getInitialContextURL(ServerInfo serverInfo, String serviceName) throws URISyntaxException, MalformedURLException {
String scheme = null,
domain;
int port = -1;
String path = "/";
URI baseURI = serverInfo.getBaseURI();
if ("mailto".equalsIgnoreCase(baseURI.getScheme())) {
// mailto URIs
String mailbox = serverInfo.getBaseURI().getSchemeSpecificPart();
// determine service FQDN
int pos = mailbox.lastIndexOf("@");
if (pos == -1)
throw new URISyntaxException(mailbox, "Missing @ sign");
scheme = "https";
domain = mailbox.substring(pos + 1);
if (domain.isEmpty())
throw new URISyntaxException(mailbox, "Missing domain name");
} else {
// HTTP(S) URLs
scheme = baseURI.getScheme();
domain = baseURI.getHost();
port = baseURI.getPort();
path = baseURI.getPath();
}
// try to determine FQDN and port number using SRV records
try {
String name = "_" + serviceName + "s._tcp." + domain;
Log.d(TAG, "Looking up SRV records for " + name);
Record[] records = new Lookup(name, Type.SRV).run();
if (records != null && records.length >= 1) {
SRVRecord srv = selectSRVRecord(records);
scheme = "https";
domain = srv.getTarget().toString(true);
port = srv.getPort();
Log.d(TAG, "Found " + serviceName + "s service for " + domain + " -> " + domain + ":" + port);
if (port == 443) // no reason to explicitly give the default port
port = -1;
// SRV record found, look for TXT record too (for initial context path)
records = new Lookup(name, Type.TXT).run();
if (records != null && records.length >= 1) {
TXTRecord txt = (TXTRecord)records[0];
for (Object o : txt.getStrings().toArray()) {
String segment = (String)o;
if (segment.startsWith("path=")) {
path = segment.substring(5);
Log.d(TAG, "Found initial context path for " + serviceName + " at " + domain + " -> " + path);
break;
}
}
}
}
} catch (TextParseException e) {
throw new URISyntaxException(domain, "Invalid domain name");
}
return new URI(scheme, null, domain, port, path, null, null);
}
/**
* Detects the current-user-principal for a given WebDavResource. At first, /.well-known/ is tried. Only
* if no current-user-principal can be detected for the .well-known location, the given location of the resource
* is tried.
* @param serverInfo Location that will be queried
* @param serviceName Well-known service name ("carddav", "caldav")
* @return WebDavResource of current-user-principal for the given service, or null if it can't be found
*
* TODO: If a TXT record is given, always use it instead of trying .well-known first
*/
WebDavResource getCurrentUserPrincipal(ServerInfo serverInfo, String serviceName) throws URISyntaxException, IOException, NotAuthorizedException {
URI initialURL = getInitialContextURL(serverInfo, serviceName);
if (initialURL != null) {
Log.i(TAG, "Looking up principal URL for service " + serviceName + "; initial context: " + initialURL);
// determine base URL (host name and initial context path)
WebDavResource base = new WebDavResource(httpClient,
initialURL,
serverInfo.getUserName(), serverInfo.getPassword(), serverInfo.isAuthPreemptive());
// look for well-known service (RFC 5785)
try {
WebDavResource wellKnown = new WebDavResource(base, "/.well-known/" + serviceName);
wellKnown.propfind(Mode.CURRENT_USER_PRINCIPAL);
if (wellKnown.getCurrentUserPrincipal() != null) {
URI principal = wellKnown.getCurrentUserPrincipal();
Log.i(TAG, "Principal URL found from Well-Known URI: " + principal);
return new WebDavResource(wellKnown, principal);
}
} catch (NotAuthorizedException e) {
Log.w(TAG, "Not authorized for well-known " + serviceName + " service detection", e);
throw e;
} catch (URISyntaxException e) {
Log.e(TAG, "Well-known" + serviceName + " service detection failed because of invalid URIs", e);
} catch (HttpException e) {
Log.d(TAG, "Well-known " + serviceName + " service detection failed with HTTP error", e);
} catch (DavException e) {
Log.w(TAG, "Well-known " + serviceName + " service detection failed with unexpected DAV response", e);
} catch (IOException e) {
Log.e(TAG, "Well-known " + serviceName + " service detection failed with I/O error", e);
}
// fall back to user-given initial context path
Log.d(TAG, "Well-known service detection failed, trying initial context path " + initialURL);
try {
base.propfind(Mode.CURRENT_USER_PRINCIPAL);
if (base.getCurrentUserPrincipal() != null) {
URI principal = base.getCurrentUserPrincipal();
Log.i(TAG, "Principal URL found from initial context path: " + principal);
return new WebDavResource(base, principal);
}
} catch (NotAuthorizedException e) {
Log.e(TAG, "Not authorized for querying principal", e);
throw e;
} catch (HttpException e) {
Log.e(TAG, "HTTP error when querying principal", e);
} catch (DavException e) {
Log.e(TAG, "DAV error when querying principal", e);
}
Log.i(TAG, "Couldn't find current-user-principal for service " + serviceName + ", assuming initial context path is principal path");
return base;
}
return null;
}
public static boolean checkHomesetCapabilities(WebDavResource resource, String davCapability) throws URISyntaxException, IOException {
// check for necessary capabilities
try {
resource.options();
if (resource.supportsDAV(davCapability) &&
resource.supportsMethod("PROPFIND")) // check only for methods that MUST be available for home sets
return true;
} catch(HttpException e) {
// for instance, 405 Method not allowed
}
return false;
}
SRVRecord selectSRVRecord(Record[] records) {
if (records.length > 1)
Log.w(TAG, "Multiple SRV records not supported yet; using first one");
return (SRVRecord)records[0];
}
}

View File

@@ -1,396 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import android.text.format.Time;
import android.util.Log;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.CalendarOutputter;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.Date;
import net.fortuna.ical4j.model.DateTime;
import net.fortuna.ical4j.model.DefaultTimeZoneRegistryFactory;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.TimeZoneRegistry;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.component.VTimeZone;
import net.fortuna.ical4j.model.parameter.Value;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.Clazz;
import net.fortuna.ical4j.model.property.DateProperty;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.DtEnd;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.ExRule;
import net.fortuna.ical4j.model.property.LastModified;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.ProdId;
import net.fortuna.ical4j.model.property.RDate;
import net.fortuna.ical4j.model.property.RRule;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.model.property.Transp;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.util.CompatibilityHints;
import net.fortuna.ical4j.util.SimpleHostInfo;
import net.fortuna.ical4j.util.UidGenerator;
import org.apache.commons.lang.StringUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.util.Calendar;
import java.util.LinkedList;
import java.util.List;
import java.util.SimpleTimeZone;
import java.util.TimeZone;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.syncadapter.DavSyncAdapter;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
public class Event extends Resource {
private final static String TAG = "davdroid.Event";
public final static String MIME_TYPE = "text/calendar";
private final static TimeZoneRegistry tzRegistry = new DefaultTimeZoneRegistryFactory().createRegistry();
@Getter @Setter private String summary, location, description;
@Getter private DtStart dtStart;
@Getter private DtEnd dtEnd;
@Getter @Setter private Duration duration;
@Getter @Setter private RDate rdate;
@Getter @Setter private RRule rrule;
@Getter @Setter private ExDate exdate;
@Getter @Setter private ExRule exrule;
@Getter @Setter private Boolean forPublic;
@Getter @Setter private Status status;
@Getter @Setter private boolean opaque;
@Getter @Setter private Organizer organizer;
@Getter private List<Attendee> attendees = new LinkedList<Attendee>();
public void addAttendee(Attendee attendee) {
attendees.add(attendee);
}
@Getter private List<VAlarm> alarms = new LinkedList<VAlarm>();
public void addAlarm(VAlarm alarm) {
alarms.add(alarm);
}
static {
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_UNFOLDING, true);
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_PARSING, true);
CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_OUTLOOK_COMPATIBILITY, true);
// disable automatic time-zone updates (causes unnecessary network traffic for most people)
System.setProperty("net.fortuna.ical4j.timezone.update.enabled", "false");
}
public Event(String name, String ETag) {
super(name, ETag);
}
public Event(long localID, String name, String ETag) {
super(localID, name, ETag);
}
@Override
public void initialize() {
generateUID();
name = uid.replace("@", "_") + ".ics";
}
protected void generateUID() {
UidGenerator generator = new UidGenerator(new SimpleHostInfo(DavSyncAdapter.getAndroidID()), String.valueOf(android.os.Process.myPid()));
uid = generator.generateUid().getValue();
}
@Override
@SuppressWarnings("unchecked")
public void parseEntity(@NonNull InputStream entity, AssetDownloader downloader) throws IOException, InvalidResourceException {
net.fortuna.ical4j.model.Calendar ical;
try {
CalendarBuilder builder = new CalendarBuilder();
ical = builder.build(entity);
if (ical == null)
throw new InvalidResourceException("No iCalendar found");
} catch (ParserException e) {
throw new InvalidResourceException(e);
}
// event
ComponentList events = ical.getComponents(Component.VEVENT);
if (events == null || events.isEmpty())
throw new InvalidResourceException("No VEVENT found");
VEvent event = (VEvent)events.get(0);
if (event.getUid() != null)
uid = event.getUid().getValue();
else {
Log.w(TAG, "Received VEVENT without UID, generating new one");
generateUID();
}
if ((dtStart = event.getStartDate()) == null || (dtEnd = event.getEndDate()) == null)
throw new InvalidResourceException("Invalid start time/end time/duration");
if (hasTime(dtStart)) {
validateTimeZone(dtStart);
validateTimeZone(dtEnd);
}
// all-day events and "events on that day":
// * related UNIX times must be in UTC
// * must have a duration (set to one day if missing)
if (!hasTime(dtStart) && !dtEnd.getDate().after(dtStart.getDate())) {
Log.i(TAG, "Repairing iCal: DTEND := DTSTART+1");
Calendar c = Calendar.getInstance(TimeZone.getTimeZone(Time.TIMEZONE_UTC));
c.setTime(dtStart.getDate());
c.add(Calendar.DATE, 1);
dtEnd.setDate(new Date(c.getTimeInMillis()));
}
rrule = (RRule)event.getProperty(Property.RRULE);
rdate = (RDate)event.getProperty(Property.RDATE);
exrule = (ExRule)event.getProperty(Property.EXRULE);
exdate = (ExDate)event.getProperty(Property.EXDATE);
if (event.getSummary() != null)
summary = event.getSummary().getValue();
if (event.getLocation() != null)
location = event.getLocation().getValue();
if (event.getDescription() != null)
description = event.getDescription().getValue();
status = event.getStatus();
opaque = event.getTransparency() != Transp.TRANSPARENT;
organizer = event.getOrganizer();
for (Object o : event.getProperties(Property.ATTENDEE))
attendees.add((Attendee)o);
Clazz classification = event.getClassification();
if (classification != null) {
if (classification == Clazz.PUBLIC)
forPublic = true;
else if (classification == Clazz.CONFIDENTIAL || classification == Clazz.PRIVATE)
forPublic = false;
}
this.alarms = event.getAlarms();
}
@Override
@SuppressWarnings("unchecked")
public ByteArrayOutputStream toEntity() throws IOException {
net.fortuna.ical4j.model.Calendar ical = new net.fortuna.ical4j.model.Calendar();
ical.getProperties().add(Version.VERSION_2_0);
ical.getProperties().add(new ProdId("-//bitfire web engineering//DAVdroid " + Constants.APP_VERSION + " (ical4j 1.0.x)//EN"));
VEvent event = new VEvent();
PropertyList props = event.getProperties();
if (uid != null)
props.add(new Uid(uid));
props.add(dtStart);
if (dtEnd != null)
props.add(dtEnd);
if (duration != null)
props.add(duration);
if (rrule != null)
props.add(rrule);
if (rdate != null)
props.add(rdate);
if (exrule != null)
props.add(exrule);
if (exdate != null)
props.add(exdate);
if (summary != null && !summary.isEmpty())
props.add(new Summary(summary));
if (location != null && !location.isEmpty())
props.add(new Location(location));
if (description != null && !description.isEmpty())
props.add(new Description(description));
if (status != null)
props.add(status);
if (!opaque)
props.add(Transp.TRANSPARENT);
if (organizer != null)
props.add(organizer);
props.addAll(attendees);
if (forPublic != null)
event.getProperties().add(forPublic ? Clazz.PUBLIC : Clazz.PRIVATE);
event.getAlarms().addAll(alarms);
props.add(new LastModified());
ical.getComponents().add(event);
// add VTIMEZONE components
net.fortuna.ical4j.model.TimeZone
tzStart = (dtStart == null ? null : dtStart.getTimeZone()),
tzEnd = (dtEnd == null ? null : dtEnd.getTimeZone());
if (tzStart != null)
ical.getComponents().add(tzStart.getVTimeZone());
if (tzEnd != null && tzEnd != tzStart)
ical.getComponents().add(tzEnd.getVTimeZone());
CalendarOutputter output = new CalendarOutputter(false);
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
output.output(ical, os);
} catch (ValidationException e) {
Log.e(TAG, "Generated invalid iCalendar");
}
return os;
}
public long getDtStartInMillis() {
return dtStart.getDate().getTime();
}
public String getDtStartTzID() {
return getTzId(dtStart);
}
public void setDtStart(long tsStart, String tzID) {
if (tzID == null) { // all-day
dtStart = new DtStart(new Date(tsStart));
} else {
DateTime start = new DateTime(tsStart);
start.setTimeZone(tzRegistry.getTimeZone(tzID));
dtStart = new DtStart(start);
}
}
public long getDtEndInMillis() {
return dtEnd.getDate().getTime();
}
public String getDtEndTzID() {
return getTzId(dtEnd);
}
public void setDtEnd(long tsEnd, String tzID) {
if (tzID == null) { // all-day
dtEnd = new DtEnd(new Date(tsEnd));
} else {
DateTime end = new DateTime(tsEnd);
end.setTimeZone(tzRegistry.getTimeZone(tzID));
dtEnd = new DtEnd(end);
}
}
// helpers
public boolean isAllDay() {
return !hasTime(dtStart);
}
protected static boolean hasTime(DateProperty date) {
return date.getDate() instanceof DateTime;
}
protected static String getTzId(DateProperty date) {
if (date.isUtc() || !hasTime(date))
return Time.TIMEZONE_UTC;
else if (date.getTimeZone() != null)
return date.getTimeZone().getID();
else if (date.getParameter(Value.TZID) != null)
return date.getParameter(Value.TZID).getValue();
// fallback
return Time.TIMEZONE_UTC;
}
/* guess matching Android timezone ID */
protected static void validateTimeZone(DateProperty date) {
if (date.isUtc() || !hasTime(date))
return;
String tzID = getTzId(date);
if (tzID == null)
return;
String localTZ = null;
String availableTZs[] = SimpleTimeZone.getAvailableIDs();
// first, try to find an exact match (case insensitive)
for (String availableTZ : availableTZs)
if (tzID.equalsIgnoreCase(availableTZ)) {
localTZ = availableTZ;
break;
}
// if that doesn't work, try to find something else that matches
if (localTZ == null) {
Log.w(TAG, "Coulnd't find time zone with matching identifiers, trying to guess");
for (String availableTZ : availableTZs)
if (StringUtils.indexOfIgnoreCase(tzID, availableTZ) != -1) {
localTZ = availableTZ;
break;
}
}
// if that doesn't work, use UTC as fallback
if (localTZ == null) {
Log.e(TAG, "Couldn't identify time zone, using UTC as fallback");
localTZ = Time.TIMEZONE_UTC;
}
Log.d(TAG, "Assuming time zone " + localTZ + " for " + tzID);
date.setTimeZone(tzRegistry.getTimeZone(localTZ));
}
public static String TimezoneDefToTzId(String timezoneDef) throws IllegalArgumentException {
try {
if (timezoneDef != null) {
CalendarBuilder builder = new CalendarBuilder();
net.fortuna.ical4j.model.Calendar cal = builder.build(new StringReader(timezoneDef));
VTimeZone timezone = (VTimeZone)cal.getComponent(VTimeZone.VTIMEZONE);
return timezone.getTimeZoneId().getValue();
}
} catch (Exception ex) {
Log.w(TAG, "Can't understand time zone definition, ignoring", ex);
}
throw new IllegalArgumentException();
}
}

View File

@@ -1,20 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
public class InvalidResourceException extends Exception {
private static final long serialVersionUID = 1593585432655578220L;
public InvalidResourceException(String message) {
super(message);
}
public InvalidResourceException(Throwable throwable) {
super(throwable);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,615 +1,261 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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.SuppressLint;
import android.annotation.TargetApi;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.provider.ContactsContract;
import android.util.Log;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import net.fortuna.ical4j.model.Dur;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.ParameterList;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.component.VAlarm;
import net.fortuna.ical4j.model.parameter.Cn;
import net.fortuna.ical4j.model.parameter.CuType;
import net.fortuna.ical4j.model.parameter.PartStat;
import net.fortuna.ical4j.model.parameter.Role;
import net.fortuna.ical4j.model.property.Action;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.Duration;
import net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.ExRule;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.RDate;
import net.fortuna.ical4j.model.property.RRule;
import net.fortuna.ical4j.model.property.Status;
import net.fortuna.ical4j.model.component.VTimeZone;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang3.StringUtils;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.text.ParseException;
import java.io.FileNotFoundException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import at.bitfire.davdroid.App;
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;
import lombok.Getter;
/**
* Represents a locally stored calendar, containing Events.
* Communicates with the Android Contacts Provider which uses an SQLite
* database to store the contacts.
*/
public class LocalCalendar extends LocalCollection<Event> {
private static final String TAG = "davdroid.LocalCalendar";
public class LocalCalendar extends AndroidCalendar implements LocalCollection {
@Getter protected long id;
@Getter protected String url;
protected static String COLLECTION_COLUMN_CTAG = Calendars.CAL_SYNC1;
public static final int defaultColor = 0xFF8bc34a; // light green 500
/* database fields */
@Override
protected Uri entriesURI() {
return syncAdapterURI(Events.CONTENT_URI);
}
public static final String COLUMN_CTAG = Calendars.CAL_SYNC1;
protected String entryColumnAccountType() { return Events.ACCOUNT_TYPE; }
protected String entryColumnAccountName() { return Events.ACCOUNT_NAME; }
protected String entryColumnParentID() { return Events.CALENDAR_ID; }
protected String entryColumnID() { return Events._ID; }
protected String entryColumnRemoteName() { return Events._SYNC_ID; }
protected String entryColumnETag() { return Events.SYNC_DATA1; }
protected static final int
DIRTY_INCREASE_SEQUENCE = 1,
DIRTY_DONT_INCREASE_SEQUENCE = 2;
protected String entryColumnDirty() { return Events.DIRTY; }
protected String entryColumnDeleted() { return Events.DELETED; }
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
protected String entryColumnUID() {
return (android.os.Build.VERSION.SDK_INT >= 17) ?
Events.UID_2445 : Events.SYNC_DATA2;
}
static String[] BASE_INFO_COLUMNS = new String[] {
Events._ID,
Events._SYNC_ID,
LocalEvent.COLUMN_ETAG
};
/* class methods, constructor */
@SuppressLint("InlinedApi")
public static void create(Account account, ContentResolver resolver, ServerInfo.ResourceInfo info) throws LocalStorageException {
ContentProviderClient client = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY);
if (client == null)
throw new LocalStorageException("No Calendar Provider found (Calendar app disabled?)");
int color = 0xFFC3EA6E; // fallback: "DAVdroid green"
if (info.getColor() != null) {
Pattern p = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?");
Matcher m = p.matcher(info.getColor());
if (m.find()) {
int color_rgb = Integer.parseInt(m.group(1), 16);
int color_alpha = m.group(2) != null ? (Integer.parseInt(m.group(2), 16) & 0xFF) : 0xFF;
color = (color_alpha << 24) | color_rgb;
}
}
ContentValues values = new ContentValues();
values.put(Calendars.ACCOUNT_NAME, account.name);
values.put(Calendars.ACCOUNT_TYPE, account.type);
values.put(Calendars.NAME, info.getURL());
values.put(Calendars.CALENDAR_DISPLAY_NAME, info.getTitle());
values.put(Calendars.CALENDAR_COLOR, color);
values.put(Calendars.OWNER_ACCOUNT, account.name);
values.put(Calendars.SYNC_EVENTS, 1);
values.put(Calendars.VISIBLE, 1);
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
if (info.isReadOnly())
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_ORGANIZER_RESPOND, 1);
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1);
}
if (android.os.Build.VERSION.SDK_INT >= 15) {
values.put(Calendars.ALLOWED_AVAILABILITY, Events.AVAILABILITY_BUSY + "," + Events.AVAILABILITY_FREE + "," + Events.AVAILABILITY_TENTATIVE);
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, Attendees.TYPE_NONE + "," + Attendees.TYPE_OPTIONAL + "," + Attendees.TYPE_REQUIRED + "," + Attendees.TYPE_RESOURCE);
}
if (info.getTimezone() != null)
values.put(Calendars.CALENDAR_TIME_ZONE, info.getTimezone());
Log.i(TAG, "Inserting calendar: " + values.toString() + " -> " + calendarsURI(account).toString());
try {
client.insert(calendarsURI(account), values);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
public static LocalCalendar[] findAll(Account account, ContentProviderClient providerClient) throws RemoteException {
@Cleanup Cursor cursor = providerClient.query(calendarsURI(account),
new String[] { Calendars._ID, Calendars.NAME },
Calendars.DELETED + "=0 AND " + Calendars.SYNC_EVENTS + "=1", null, null);
LinkedList<LocalCalendar> calendars = new LinkedList<LocalCalendar>();
while (cursor != null && cursor.moveToNext())
calendars.add(new LocalCalendar(account, providerClient, cursor.getInt(0), cursor.getString(1)));
return calendars.toArray(new LocalCalendar[0]);
}
public LocalCalendar(Account account, ContentProviderClient providerClient, long id, String url) throws RemoteException {
super(account, providerClient);
this.id = id;
this.url = url;
}
/* collection operations */
@Override
public String getCTag() throws LocalStorageException {
try {
@Cleanup Cursor c = providerClient.query(ContentUris.withAppendedId(calendarsURI(), id),
new String[] { COLLECTION_COLUMN_CTAG }, null, null, null);
if (c.moveToFirst()) {
return c.getString(0);
} else
throw new LocalStorageException("Couldn't query calendar CTag");
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
public void setCTag(String cTag) throws LocalStorageException {
ContentValues values = new ContentValues(1);
values.put(COLLECTION_COLUMN_CTAG, cTag);
try {
providerClient.update(ContentUris.withAppendedId(calendarsURI(), id), values, null, null);
} catch(RemoteException e) {
throw new LocalStorageException(e);
}
}
@Override
protected String[] eventBaseInfoColumns() {
return BASE_INFO_COLUMNS;
}
/* create/update/delete */
public Event newResource(long localID, String resourceName, String eTag) {
return new Event(localID, resourceName, eTag);
}
public void deleteAllExceptRemoteNames(Resource[] remoteResources) {
String where;
if (remoteResources.length != 0) {
List<String> sqlFileNames = new LinkedList<String>();
for (Resource res : remoteResources)
sqlFileNames.add(DatabaseUtils.sqlEscapeString(res.getName()));
where = entryColumnRemoteName() + " NOT IN (" + StringUtils.join(sqlFileNames, ",") + ")";
} else
where = entryColumnRemoteName() + " IS NOT NULL";
Builder builder = ContentProviderOperation.newDelete(entriesURI())
.withSelection(entryColumnParentID() + "=? AND (" + where + ")", new String[] { String.valueOf(id) });
pendingOperations.add(builder
.withYieldAllowed(true)
.build());
}
/* methods for populating the data object from the content provider */
protected LocalCalendar(Account account, ContentProviderClient provider, long id) {
super(account, provider, LocalEvent.Factory.INSTANCE, id);
}
@Override
public void populate(Resource resource) throws LocalStorageException {
Event e = (Event)resource;
try {
@Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), e.getLocalID()),
new String[] {
/* 0 */ Events.TITLE, Events.EVENT_LOCATION, Events.DESCRIPTION,
/* 3 */ Events.DTSTART, Events.DTEND, Events.EVENT_TIMEZONE, Events.EVENT_END_TIMEZONE, Events.ALL_DAY,
/* 8 */ Events.STATUS, Events.ACCESS_LEVEL,
/* 10 */ Events.RRULE, Events.RDATE, Events.EXRULE, Events.EXDATE,
/* 14 */ Events.HAS_ATTENDEE_DATA, Events.ORGANIZER, Events.SELF_ATTENDEE_STATUS,
/* 17 */ entryColumnUID(), Events.DURATION, Events.AVAILABILITY
}, null, null, null);
if (cursor != null && cursor.moveToNext()) {
e.setUid(cursor.getString(17));
e.setSummary(cursor.getString(0));
e.setLocation(cursor.getString(1));
e.setDescription(cursor.getString(2));
boolean allDay = cursor.getInt(7) != 0;
long tsStart = cursor.getLong(3),
tsEnd = cursor.getLong(4);
String duration = cursor.getString(18);
String tzId = null;
if (allDay) {
e.setDtStart(tsStart, null);
// provide only DTEND and not DURATION for all-day events
if (tsEnd == 0) {
Dur dur = new Dur(duration);
java.util.Date dEnd = dur.getTime(new java.util.Date(tsStart));
tsEnd = dEnd.getTime();
}
e.setDtEnd(tsEnd, null);
} else {
// use the start time zone for the end time, too
// because apps like Samsung Planner allow the user to change "the" time zone but change the start time zone only
tzId = cursor.getString(5);
e.setDtStart(tsStart, tzId);
if (tsEnd != 0)
e.setDtEnd(tsEnd, tzId);
else if (!StringUtils.isEmpty(duration))
e.setDuration(new Duration(new Dur(duration)));
}
// recurrence
try {
String strRRule = cursor.getString(10);
if (!StringUtils.isEmpty(strRRule))
e.setRrule(new RRule(strRRule));
String strRDate = cursor.getString(11);
if (!StringUtils.isEmpty(strRDate)) {
RDate rDate = new RDate();
rDate.setValue(strRDate);
e.setRdate(rDate);
}
String strExRule = cursor.getString(12);
if (!StringUtils.isEmpty(strExRule)) {
ExRule exRule = new ExRule();
exRule.setValue(strExRule);
e.setExrule(exRule);
}
String strExDate = cursor.getString(13);
if (!StringUtils.isEmpty(strExDate)) {
// ignored, see https://code.google.com/p/android/issues/detail?id=21426
ExDate exDate = new ExDate();
exDate.setValue(strExDate);
e.setExdate(exDate);
}
} catch (ParseException ex) {
Log.w(TAG, "Couldn't parse recurrence rules, ignoring", ex);
} catch (IllegalArgumentException ex) {
Log.w(TAG, "Invalid recurrence rules, ignoring", ex);
}
// status
switch (cursor.getInt(8)) {
case Events.STATUS_CONFIRMED:
e.setStatus(Status.VEVENT_CONFIRMED);
break;
case Events.STATUS_TENTATIVE:
e.setStatus(Status.VEVENT_TENTATIVE);
break;
case Events.STATUS_CANCELED:
e.setStatus(Status.VEVENT_CANCELLED);
}
// availability
e.setOpaque(cursor.getInt(19) != Events.AVAILABILITY_FREE);
// attendees
if (cursor.getInt(14) != 0) { // has attendees
try {
e.setOrganizer(new Organizer(new URI("mailto", cursor.getString(15), null)));
} catch (URISyntaxException ex) {
Log.e(TAG, "Error when creating ORGANIZER URI, ignoring", ex);
}
populateAttendees(e);
}
// classification
switch (cursor.getInt(9)) {
case Events.ACCESS_CONFIDENTIAL:
case Events.ACCESS_PRIVATE:
e.setForPublic(false);
break;
case Events.ACCESS_PUBLIC:
e.setForPublic(true);
}
populateReminders(e);
} else
throw new RecordNotFoundException();
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
public static Uri create(@NonNull Account account, @NonNull ContentProviderClient provider, @NonNull CollectionInfo info) throws CalendarStorageException {
ContentValues values = valuesFromCollectionInfo(info);
void populateAttendees(Event e) throws RemoteException {
Uri attendeesUri = Attendees.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build();
@Cleanup Cursor c = providerClient.query(attendeesUri, new String[] {
/* 0 */ Attendees.ATTENDEE_EMAIL, Attendees.ATTENDEE_NAME, Attendees.ATTENDEE_TYPE,
/* 3 */ Attendees.ATTENDEE_RELATIONSHIP, Attendees.STATUS
}, Attendees.EVENT_ID + "=?", new String[] { String.valueOf(e.getLocalID()) }, null);
while (c != null && c.moveToNext()) {
try {
Attendee attendee = new Attendee(new URI("mailto", c.getString(0), null));
ParameterList params = attendee.getParameters();
String cn = c.getString(1);
if (cn != null)
params.add(new Cn(cn));
// type
int type = c.getInt(2);
params.add((type == Attendees.TYPE_RESOURCE) ? CuType.RESOURCE : CuType.INDIVIDUAL);
// role
int relationship = c.getInt(3);
switch (relationship) {
case Attendees.RELATIONSHIP_ORGANIZER:
params.add(Role.CHAIR);
break;
case Attendees.RELATIONSHIP_ATTENDEE:
case Attendees.RELATIONSHIP_PERFORMER:
case Attendees.RELATIONSHIP_SPEAKER:
params.add((type == Attendees.TYPE_REQUIRED) ? Role.REQ_PARTICIPANT : Role.OPT_PARTICIPANT);
break;
case Attendees.RELATIONSHIP_NONE:
params.add(Role.NON_PARTICIPANT);
}
// status
switch (c.getInt(4)) {
case Attendees.ATTENDEE_STATUS_INVITED:
params.add(PartStat.NEEDS_ACTION);
break;
case Attendees.ATTENDEE_STATUS_ACCEPTED:
params.add(PartStat.ACCEPTED);
break;
case Attendees.ATTENDEE_STATUS_DECLINED:
params.add(PartStat.DECLINED);
break;
case Attendees.ATTENDEE_STATUS_TENTATIVE:
params.add(PartStat.TENTATIVE);
break;
}
e.addAttendee(attendee);
} catch (URISyntaxException ex) {
Log.e(TAG, "Couldn't parse attendee information, ignoring", ex);
}
}
}
void populateReminders(Event e) throws RemoteException {
// reminders
Uri remindersUri = Reminders.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
.build();
@Cleanup Cursor c = providerClient.query(remindersUri, new String[] {
/* 0 */ Reminders.MINUTES, Reminders.METHOD
}, Reminders.EVENT_ID + "=?", new String[] { String.valueOf(e.getLocalID()) }, null);
while (c != null && c.moveToNext()) {
VAlarm alarm = new VAlarm(new Dur(0, 0, -c.getInt(0), 0));
PropertyList props = alarm.getProperties();
switch (c.getInt(1)) {
/*case Reminders.METHOD_EMAIL:
props.add(Action.EMAIL);
break;*/
default:
props.add(Action.DISPLAY);
props.add(new Description(e.getSummary()));
}
e.addAlarm(alarm);
}
}
/* content builder methods */
// 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);
@Override
protected Builder buildEntry(Builder builder, Resource resource) {
Event event = (Event)resource;
return create(account, provider, values);
}
builder = builder
.withValue(Events.CALENDAR_ID, id)
.withValue(entryColumnRemoteName(), event.getName())
.withValue(entryColumnETag(), event.getETag())
.withValue(entryColumnUID(), event.getUid())
.withValue(Events.ALL_DAY, event.isAllDay() ? 1 : 0)
.withValue(Events.DTSTART, event.getDtStartInMillis())
.withValue(Events.EVENT_TIMEZONE, event.getDtStartTzID())
.withValue(Events.HAS_ATTENDEE_DATA, event.getAttendees().isEmpty() ? 0 : 1)
.withValue(Events.GUESTS_CAN_INVITE_OTHERS, 1)
.withValue(Events.GUESTS_CAN_MODIFY, 1)
.withValue(Events.GUESTS_CAN_SEE_GUESTS, 1);
boolean recurring = false;
if (event.getRrule() != null) {
recurring = true;
builder = builder.withValue(Events.RRULE, event.getRrule().getValue());
}
if (event.getRdate() != null) {
recurring = true;
builder = builder.withValue(Events.RDATE, event.getRdate().getValue());
}
if (event.getExrule() != null)
builder = builder.withValue(Events.EXRULE, event.getExrule().getValue());
if (event.getExdate() != null)
builder = builder.withValue(Events.EXDATE, event.getExdate().getValue());
// set either DTEND for single-time events or DURATION for recurring events
// because that's the way Android likes it (see docs)
if (recurring) {
// calculate DURATION from start and end date
Duration duration = new Duration(event.getDtStart().getDate(), event.getDtEnd().getDate());
builder = builder.withValue(Events.DURATION, duration.getValue());
} else {
builder = builder
.withValue(Events.DTEND, event.getDtEndInMillis())
.withValue(Events.EVENT_END_TIMEZONE, event.getDtEndTzID());
}
if (event.getSummary() != null)
builder = builder.withValue(Events.TITLE, event.getSummary());
if (event.getLocation() != null)
builder = builder.withValue(Events.EVENT_LOCATION, event.getLocation());
if (event.getDescription() != null)
builder = builder.withValue(Events.DESCRIPTION, event.getDescription());
if (event.getOrganizer() != null && event.getOrganizer().getCalAddress() != null) {
URI organizer = event.getOrganizer().getCalAddress();
if (organizer.getScheme() != null && organizer.getScheme().equalsIgnoreCase("mailto"))
builder = builder.withValue(Events.ORGANIZER, organizer.getSchemeSpecificPart());
}
Status status = event.getStatus();
if (status != null) {
int statusCode = Events.STATUS_TENTATIVE;
if (status == Status.VEVENT_CONFIRMED)
statusCode = Events.STATUS_CONFIRMED;
else if (status == Status.VEVENT_CANCELLED)
statusCode = Events.STATUS_CANCELED;
builder = builder.withValue(Events.STATUS, statusCode);
}
builder = builder.withValue(Events.AVAILABILITY, event.isOpaque() ? Events.AVAILABILITY_BUSY : Events.AVAILABILITY_FREE);
if (event.getForPublic() != null)
builder = builder.withValue(Events.ACCESS_LEVEL, event.getForPublic() ? Events.ACCESS_PUBLIC : Events.ACCESS_PRIVATE);
public void update(CollectionInfo info) throws CalendarStorageException {
update(valuesFromCollectionInfo(info));
}
return builder;
}
@TargetApi(15)
private static ContentValues valuesFromCollectionInfo(CollectionInfo info) {
ContentValues values = new ContentValues();
values.put(Calendars.NAME, info.url);
values.put(Calendars.CALENDAR_DISPLAY_NAME, info.displayName);
values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor);
@Override
protected void addDataRows(Resource resource, long localID, int backrefIdx) {
Event event = (Event)resource;
for (Attendee attendee : event.getAttendees())
pendingOperations.add(buildAttendee(newDataInsertBuilder(Attendees.CONTENT_URI, Attendees.EVENT_ID, localID, backrefIdx), attendee).build());
for (VAlarm alarm : event.getAlarms())
pendingOperations.add(buildReminder(newDataInsertBuilder(Reminders.CONTENT_URI, Reminders.EVENT_ID, localID, backrefIdx), alarm).build());
}
@Override
protected void removeDataRows(Resource resource) {
Event event = (Event)resource;
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Attendees.CONTENT_URI))
.withSelection(Attendees.EVENT_ID + "=?",
new String[] { String.valueOf(event.getLocalID()) }).build());
pendingOperations.add(ContentProviderOperation.newDelete(syncAdapterURI(Reminders.CONTENT_URI))
.withSelection(Reminders.EVENT_ID + "=?",
new String[] { String.valueOf(event.getLocalID()) }).build());
}
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);
}
@SuppressLint("InlinedApi")
protected Builder buildAttendee(Builder builder, Attendee attendee) {
Uri member = Uri.parse(attendee.getValue());
String email = member.getSchemeSpecificPart();
Cn cn = (Cn)attendee.getParameter(Parameter.CN);
if (cn != null)
builder = builder.withValue(Attendees.ATTENDEE_NAME, cn.getValue());
int type = Attendees.TYPE_NONE;
CuType cutype = (CuType)attendee.getParameter(Parameter.CUTYPE);
if (cutype == CuType.RESOURCE)
type = Attendees.TYPE_RESOURCE;
else {
Role role = (Role)attendee.getParameter(Parameter.ROLE);
int relationship;
if (role == Role.CHAIR)
relationship = Attendees.RELATIONSHIP_ORGANIZER;
else {
relationship = Attendees.RELATIONSHIP_ATTENDEE;
if (role == Role.OPT_PARTICIPANT)
type = Attendees.TYPE_OPTIONAL;
else if (role == Role.REQ_PARTICIPANT)
type = Attendees.TYPE_REQUIRED;
}
builder = builder.withValue(Attendees.ATTENDEE_RELATIONSHIP, relationship);
}
int status = Attendees.ATTENDEE_STATUS_NONE;
PartStat partStat = (PartStat)attendee.getParameter(Parameter.PARTSTAT);
if (partStat == null || partStat == PartStat.NEEDS_ACTION)
status = Attendees.ATTENDEE_STATUS_INVITED;
else if (partStat == PartStat.ACCEPTED)
status = Attendees.ATTENDEE_STATUS_ACCEPTED;
else if (partStat == PartStat.DECLINED)
status = Attendees.ATTENDEE_STATUS_DECLINED;
else if (partStat == PartStat.TENTATIVE)
status = Attendees.ATTENDEE_STATUS_TENTATIVE;
return builder
.withValue(Attendees.ATTENDEE_EMAIL, email)
.withValue(Attendees.ATTENDEE_TYPE, type)
.withValue(Attendees.ATTENDEE_STATUS, status);
}
protected Builder buildReminder(Builder builder, VAlarm alarm) {
int minutes = 0;
Dur duration;
if (alarm.getTrigger() != null && (duration = alarm.getTrigger().getDuration()) != null)
minutes = duration.getDays() * 24*60 + duration.getHours()*60 + duration.getMinutes();
Log.d(TAG, "Adding alarm " + minutes + " min before");
return builder
.withValue(Reminders.METHOD, Reminders.METHOD_ALERT)
.withValue(Reminders.MINUTES, minutes);
}
/* private helper methods */
protected static Uri calendarsURI(Account account) {
return Calendars.CONTENT_URI.buildUpon().appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true").build();
}
values.put(Calendars.SYNC_EVENTS, 1);
values.put(Calendars.VISIBLE, 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;
}
protected Uri calendarsURI() {
return calendarsURI(account);
}
@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

@@ -1,362 +1,26 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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.ContentProviderOperation;
import android.content.ContentProviderOperation.Builder;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.util.Log;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public interface LocalCollection {
/**
* Represents a locally-stored synchronizable collection (for instance, the
* address book or a calendar). Manages a CTag that stores the last known
* remote CTag (the remote CTag changes whenever something in the remote collection changes).
*
* @param <T> Subtype of Resource that can be stored in the collection
*/
public abstract class LocalCollection<T extends Resource> {
private static final String TAG = "davdroid.LocalCollection";
protected Account account;
protected ContentProviderClient providerClient;
protected ArrayList<ContentProviderOperation> pendingOperations = new ArrayList<ContentProviderOperation>();
LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException;
LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException;
LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException, FileNotFoundException;
// database fields
/** base Uri of the collection's entries (for instance, Events.CONTENT_URI);
* apply syncAdapterURI() before returning a value */
abstract protected Uri entriesURI();
LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException;
/** column name of the type of the account the entry belongs to */
abstract protected String entryColumnAccountType();
/** column name of the name of the account the entry belongs to */
abstract protected String entryColumnAccountName();
/** column name of the collection ID the entry belongs to */
abstract protected String entryColumnParentID();
/** column name of an entry's ID */
abstract protected String entryColumnID();
/** column name of an entry's file name on the WebDAV server */
abstract protected String entryColumnRemoteName();
/** column name of an entry's last ETag on the WebDAV server; null if entry hasn't been uploaded yet */
abstract protected String entryColumnETag();
/** column name of an entry's "dirty" flag (managed by content provider) */
abstract protected String entryColumnDirty();
/** column name of an entry's "deleted" flag (managed by content provider) */
abstract protected String entryColumnDeleted();
/** column name of an entry's UID */
abstract protected String entryColumnUID();
LocalCollection(Account account, ContentProviderClient providerClient) {
this.account = account;
this.providerClient = providerClient;
}
// collection operations
/** gets the ID if the collection (for instance, ID of the Android calendar) */
abstract public long getId();
/** gets the CTag of the collection */
abstract public String getCTag() throws LocalStorageException;
/** sets the CTag of the collection */
abstract public void setCTag(String cTag) throws LocalStorageException;
// content provider (= database) querying
/**
* Finds new resources (resources which haven't been uploaded yet).
* New resources are 1) dirty, and 2) don't have an ETag yet.
*
* @return IDs of new resources
* @throws LocalStorageException when the content provider couldn't be queried
*/
public long[] findNew() throws LocalStorageException {
String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NULL";
if (entryColumnParentID() != null)
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID() },
where, null, null);
if (cursor == null)
throw new LocalStorageException("Couldn't query new records");
long[] fresh = new long[cursor.getCount()];
for (int idx = 0; cursor.moveToNext(); idx++) {
long id = cursor.getLong(0);
// new record: generate UID + remote file name so that we can upload
T resource = findById(id, false);
resource.initialize();
// write generated UID + remote file name into database
ContentValues values = new ContentValues(2);
values.put(entryColumnUID(), resource.getUid());
values.put(entryColumnRemoteName(), resource.getName());
providerClient.update(ContentUris.withAppendedId(entriesURI(), id), values, null, null);
fresh[idx] = id;
}
return fresh;
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds updated resources (resources which have already been uploaded, but have changed locally).
* Updated resources are 1) dirty, and 2) already have an ETag.
*
* @return IDs of updated resources
* @throws LocalStorageException when the content provider couldn't be queried
*/
public long[] findUpdated() throws LocalStorageException {
String where = entryColumnDirty() + "=1 AND " + entryColumnETag() + " IS NOT NULL";
if (entryColumnParentID() != null)
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, null, null);
if (cursor == null)
throw new LocalStorageException("Couldn't query updated records");
long[] updated = new long[cursor.getCount()];
for (int idx = 0; cursor.moveToNext(); idx++)
updated[idx] = cursor.getLong(0);
return updated;
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds deleted resources (resources which have been marked for deletion).
* Deleted resources have the "deleted" flag set.
*
* @return IDs of deleted resources
* @throws LocalStorageException when the content provider couldn't be queried
*/
public long[] findDeleted() throws LocalStorageException {
String where = entryColumnDeleted() + "=1";
if (entryColumnParentID() != null)
where += " AND " + entryColumnParentID() + "=" + String.valueOf(getId());
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
where, null, null);
if (cursor == null)
throw new LocalStorageException("Couldn't query dirty records");
long deleted[] = new long[cursor.getCount()];
for (int idx = 0; cursor.moveToNext(); idx++)
deleted[idx] = cursor.getLong(0);
return deleted;
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds a specific resource by ID.
* @param localID ID of the resource
* @param populate true: populates all data fields (for instance, contact or event details);
* false: only remote file name and ETag are populated
* @return resource with either ID/remote file/name/ETag or all fields populated
* @throws RecordNotFoundException when the resource couldn't be found
* @throws LocalStorageException when the content provider couldn't be queried
*/
public T findById(long localID, boolean populate) throws LocalStorageException {
try {
@Cleanup Cursor cursor = providerClient.query(ContentUris.withAppendedId(entriesURI(), localID),
new String[] { entryColumnRemoteName(), entryColumnETag() }, null, null, null);
if (cursor != null && cursor.moveToNext()) {
T resource = newResource(localID, cursor.getString(0), cursor.getString(1));
if (populate)
populate(resource);
return resource;
} else
throw new RecordNotFoundException();
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/**
* Finds a specific resource by remote file name.
* @param localID remote file name of the resource
* @param populate true: populates all data fields (for instance, contact or event details);
* false: only remote file name and ETag are populated
* @return resource with either ID/remote file/name/ETag or all fields populated
* @throws RecordNotFoundException when the resource couldn't be found
* @throws LocalStorageException when the content provider couldn't be queried
*/
public T findByRemoteName(String remoteName, boolean populate) throws LocalStorageException {
try {
@Cleanup Cursor cursor = providerClient.query(entriesURI(),
new String[] { entryColumnID(), entryColumnRemoteName(), entryColumnETag() },
entryColumnRemoteName() + "=?", new String[] { remoteName }, null);
if (cursor != null && cursor.moveToNext()) {
T resource = newResource(cursor.getLong(0), cursor.getString(1), cursor.getString(2));
if (populate)
populate(resource);
return resource;
} else
throw new RecordNotFoundException();
} catch(RemoteException ex) {
throw new LocalStorageException(ex);
}
}
/** populates all data fields from the content provider */
public abstract void populate(Resource record) throws LocalStorageException;
// create/update/delete
/**
* Creates a new resource object in memory. No content provider operations involved.
* @param localID the ID of the resource
* @param resourceName the (remote) file name of the resource
* @param ETag of the resource
* @return the new resource object */
abstract public T newResource(long localID, String resourceName, String eTag);
/** Enqueues adding the resource (including all data) to the local collection. Requires commit(). */
public void add(Resource resource) {
int idx = pendingOperations.size();
pendingOperations.add(
buildEntry(ContentProviderOperation.newInsert(entriesURI()), resource)
.withYieldAllowed(true)
.build());
addDataRows(resource, -1, idx);
}
/** Enqueues updating an existing resource in the local collection. The resource will be found by
* the remote file name and all data will be updated. Requires commit(). */
public void updateByRemoteName(Resource remoteResource) throws LocalStorageException {
T localResource = findByRemoteName(remoteResource.getName(), false);
pendingOperations.add(
buildEntry(ContentProviderOperation.newUpdate(ContentUris.withAppendedId(entriesURI(), localResource.getLocalID())), remoteResource)
.withValue(entryColumnETag(), remoteResource.getETag())
.withYieldAllowed(true)
.build());
removeDataRows(localResource);
addDataRows(remoteResource, localResource.getLocalID(), -1);
}
/** Enqueues deleting a resource from the local collection. Requires commit(). */
public void delete(Resource resource) {
pendingOperations.add(ContentProviderOperation
.newDelete(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
.withYieldAllowed(true)
.build());
}
/**
* Enqueues deleting all resources except the give ones from the local collection. Requires commit().
* @param remoteResources resources with these remote file names will be kept
*/
public abstract void deleteAllExceptRemoteNames(Resource[] remoteResources);
/** Updates the locally-known ETag of a resource. */
public void updateETag(Resource res, String eTag) throws LocalStorageException {
Log.d(TAG, "Setting ETag of local resource " + res + " to " + eTag);
ContentValues values = new ContentValues(1);
values.put(entryColumnETag(), eTag);
try {
providerClient.update(ContentUris.withAppendedId(entriesURI(), res.getLocalID()), values, null, new String[] {});
} catch (RemoteException e) {
throw new LocalStorageException(e);
}
}
/** Enqueues removing the dirty flag from a locally-stored resource. Requires commit(). */
public void clearDirty(Resource resource) {
pendingOperations.add(ContentProviderOperation
.newUpdate(ContentUris.withAppendedId(entriesURI(), resource.getLocalID()))
.withValue(entryColumnDirty(), 0)
.build());
}
/** Commits enqueued operations to the content provider (for batch operations). */
public void commit() throws LocalStorageException {
if (!pendingOperations.isEmpty())
try {
Log.d(TAG, "Committing " + pendingOperations.size() + " operations");
providerClient.applyBatch(pendingOperations);
pendingOperations.clear();
} catch (RemoteException ex) {
throw new LocalStorageException(ex);
} catch(OperationApplicationException ex) {
throw new LocalStorageException(ex);
}
}
// helpers
protected void queueOperation(Builder builder) {
if (builder != null)
pendingOperations.add(builder.build());
}
/** Appends account type, name and CALLER_IS_SYNCADAPTER to an Uri. */
protected Uri syncAdapterURI(Uri baseURI) {
return baseURI.buildUpon()
.appendQueryParameter(entryColumnAccountType(), account.type)
.appendQueryParameter(entryColumnAccountName(), account.name)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.build();
}
protected Builder newDataInsertBuilder(Uri dataUri, String refFieldName, long raw_ref_id, Integer backrefIdx) {
Builder builder = ContentProviderOperation.newInsert(syncAdapterURI(dataUri));
if (backrefIdx != -1)
return builder.withValueBackReference(refFieldName, backrefIdx);
else
return builder.withValue(refFieldName, raw_ref_id);
}
// content builders
/**
* Builds the main entry (for instance, a ContactsContract.RawContacts row) from a resource.
* The entry is built for insertion to the location identified by entriesURI().
*
* @param builder Builder to be extended by all resource data that can be stored without extra data rows.
*/
protected abstract Builder buildEntry(Builder builder, Resource resource);
/** Enqueues adding extra data rows of the resource to the local collection. */
protected abstract void addDataRows(Resource resource, long localID, int backrefIdx);
/** Enqueues removing all extra data rows of the resource from the local collection. */
protected abstract void removeDataRows(Resource resource);
String getCTag() throws CalendarStorageException, ContactsStorageException;
void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException;
}

View File

@@ -0,0 +1,143 @@
/*
* 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 java.io.FileNotFoundException;
import java.util.logging.Level;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidContactFactory;
import at.bitfire.vcard4android.BatchOperation;
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 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);
}
}
// group support
@Override
protected void populateGroupMembership(ContentValues row) {
if (row.containsKey(GroupMembership.GROUP_ROW_ID)) {
long groupId = row.getAsLong(GroupMembership.GROUP_ROW_ID);
// fetch group
LocalGroup group = new LocalGroup(addressBook, groupId);
try {
Contact groupInfo = group.getContact();
// add to CATEGORIES
contact.getCategories().add(groupInfo.displayName);
} catch (FileNotFoundException|ContactsStorageException e) {
App.log.log(Level.WARNING, "Couldn't find assigned group #" + groupId + ", ignoring membership", e);
}
}
}
@Override
protected void insertGroupMemberships(BatchOperation batch) throws ContactsStorageException {
for (String category : contact.getCategories()) {
// Is there already a category with this display name?
LocalGroup group = ((LocalAddressBook)addressBook).findGroupByTitle(category);
if (group == null) {
// no, we have to create the group before inserting the membership
Contact groupInfo = new Contact();
groupInfo.displayName = category;
group = new LocalGroup(addressBook, groupInfo);
group.create();
}
Long groupId = group.getId();
if (groupId != null) {
ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
if (id == null)
builder.withValueBackReference(GroupMembership.RAW_CONTACT_ID, 0);
else
builder.withValue(GroupMembership.RAW_CONTACT_ID, id);
builder .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.GROUP_ROW_ID, groupId);
batch.enqueue(builder.build());
}
}
}
// 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);
}
public LocalContact[] newArray(int size) {
return new LocalContact[size];
}
}
}

View File

@@ -0,0 +1,146 @@
/*
* 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,35 @@
/*
* 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.ContentValues;
import android.provider.ContactsContract;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
public class LocalGroup extends AndroidGroup {
public LocalGroup(AndroidAddressBook addressBook, long id) {
super(addressBook, id);
}
public LocalGroup(AndroidAddressBook addressBook, Contact contact) {
super(addressBook, contact);
}
public void clearDirty() throws ContactsStorageException {
ContentValues values = new ContentValues(1);
values.put(ContactsContract.Groups.DIRTY, 0);
update(values);
}
}

View File

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

@@ -1,31 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
public class LocalStorageException extends Exception {
private static final long serialVersionUID = -7787658815291629529L;
private static final String detailMessage = "Couldn't access local content provider";
public LocalStorageException(String detailMessage, Throwable throwable) {
super(detailMessage, throwable);
}
public LocalStorageException(String detailMessage) {
super(detailMessage);
}
public LocalStorageException(Throwable throwable) {
super(detailMessage, throwable);
}
public LocalStorageException() {
super(detailMessage);
}
}

View File

@@ -0,0 +1,138 @@
/*
* 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,157 @@
/*
* 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.ContentResolver;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import org.dmfs.provider.tasks.TaskContract.TaskLists;
import org.dmfs.provider.tasks.TaskContract.Tasks;
import java.io.FileNotFoundException;
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);
values.put(TaskLists.OWNER, account.name);
return create(account, provider, values);
}
public void update(CollectionInfo info) throws CalendarStorageException {
update(valuesFromCollectionInfo(info));
}
private static ContentValues valuesFromCollectionInfo(CollectionInfo info) {
ContentValues values = new ContentValues();
values.put(TaskLists._SYNC_ID, info.url);
values.put(TaskLists.LIST_NAME, info.displayName);
values.put(TaskLists.LIST_COLOR, info.color != null ? info.color : defaultColor);
values.put(TaskLists.SYNC_ENABLED, 1);
values.put(TaskLists.VISIBLE, 1);
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 ContentResolver resolver) {
if (tasksProviderAvailable != null)
return tasksProviderAvailable;
else {
@Cleanup TaskProvider provider = TaskProvider.acquire(resolver, 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

@@ -1,28 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
/**
* Thrown when a local record (for instance, Contact with ID 12345) should be read
* but could not be found.
*/
public class RecordNotFoundException extends LocalStorageException {
private static final long serialVersionUID = 4961024282198632578L;
private static final String detailMessage = "Record not found in local content provider";
RecordNotFoundException(Throwable ex) {
super(detailMessage, ex);
}
RecordNotFoundException() {
super(detailMessage);
}
}

View File

@@ -1,215 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import android.util.Log;
import net.fortuna.ical4j.model.ValidationException;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.utils.URIUtilsHC4;
import org.apache.http.impl.client.CloseableHttpClient;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.URIUtils;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.DavMultiget;
import at.bitfire.davdroid.webdav.DavNoContentException;
import at.bitfire.davdroid.webdav.HttpException;
import at.bitfire.davdroid.webdav.HttpPropfind;
import at.bitfire.davdroid.webdav.WebDavResource;
import at.bitfire.davdroid.webdav.WebDavResource.PutMode;
import ezvcard.io.text.VCardParseException;
import lombok.Cleanup;
import lombok.Getter;
/**
* Represents a remotely stored synchronizable collection (collection as in
* WebDAV terminology).
*
* @param <T> Subtype of Resource that can be stored in the collection
*/
public abstract class RemoteCollection<T extends Resource> {
private static final String TAG = "davdroid.RemoteCollection";
CloseableHttpClient httpClient;
URI baseURI;
@Getter WebDavResource collection;
abstract protected String memberContentType();
abstract protected DavMultiget.Type multiGetType();
abstract protected T newResourceSkeleton(String name, String ETag);
public RemoteCollection(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException {
this.httpClient = httpClient;
baseURI = URIUtils.parseURI(baseURL, false);
collection = new WebDavResource(httpClient, baseURI, user, password, preemptiveAuth);
}
/* collection operations */
public String getCTag() throws URISyntaxException, IOException, HttpException {
try {
if (collection.getCTag() == null && collection.getMembers() == null) // not already fetched
collection.propfind(HttpPropfind.Mode.COLLECTION_CTAG);
} catch (DavException e) {
return null;
}
return collection.getCTag();
}
public Resource[] getMemberETags() throws URISyntaxException, IOException, DavException, HttpException {
collection.propfind(HttpPropfind.Mode.MEMBERS_ETAG);
List<T> resources = new LinkedList<T>();
if (collection.getMembers() != null) {
for (WebDavResource member : collection.getMembers())
resources.add(newResourceSkeleton(member.getName(), member.getETag()));
}
return resources.toArray(new Resource[0]);
}
@SuppressWarnings("unchecked")
public Resource[] multiGet(Resource[] resources) throws URISyntaxException, IOException, DavException, HttpException {
try {
if (resources.length == 1)
return (T[]) new Resource[]{get(resources[0])};
Log.i(TAG, "Multi-getting " + resources.length + " remote resource(s)");
LinkedList<String> names = new LinkedList<String>();
for (Resource resource : resources)
names.add(resource.getName());
LinkedList<T> foundResources = new LinkedList<T>();
collection.multiGet(multiGetType(), names.toArray(new String[0]));
if (collection.getMembers() == null)
throw new DavNoContentException();
for (WebDavResource member : collection.getMembers()) {
T resource = newResourceSkeleton(member.getName(), member.getETag());
try {
if (member.getContent() != null) {
@Cleanup InputStream is = new ByteArrayInputStream(member.getContent());
resource.parseEntity(is, getDownloader());
foundResources.add(resource);
} else
Log.e(TAG, "Ignoring entity without content");
} catch (InvalidResourceException e) {
Log.e(TAG, "Ignoring unparseable entity in multi-response", e);
}
}
return foundResources.toArray(new Resource[0]);
} catch (InvalidResourceException e) {
Log.e(TAG, "Couldn't parse entity from GET", e);
}
return new Resource[0];
}
/* internal member operations */
public Resource get(Resource resource) throws URISyntaxException, IOException, HttpException, DavException, InvalidResourceException {
WebDavResource member = new WebDavResource(collection, resource.getName());
if (resource instanceof Contact)
member.get(Contact.MIME_TYPE);
else if (resource instanceof Event)
member.get(Event.MIME_TYPE);
else {
Log.wtf(TAG, "Should fetch something, but neither contact nor calendar");
throw new InvalidResourceException("Didn't now which MIME type to accept");
}
byte[] data = member.getContent();
if (data == null)
throw new DavNoContentException();
@Cleanup InputStream is = new ByteArrayInputStream(data);
try {
resource.parseEntity(is, getDownloader());
} catch (VCardParseException e) {
throw new InvalidResourceException(e);
}
return resource;
}
// returns ETag of the created resource, if returned by server
public String add(Resource res) throws URISyntaxException, IOException, HttpException, ValidationException {
WebDavResource member = new WebDavResource(collection, res.getName(), res.getETag());
member.setContentType(memberContentType());
@Cleanup ByteArrayOutputStream os = res.toEntity();
String eTag = member.put(os.toByteArray(), PutMode.ADD_DONT_OVERWRITE);
// after a successful upload, the collection has implicitely changed, too
collection.invalidateCTag();
return eTag;
}
public void delete(Resource res) throws URISyntaxException, IOException, HttpException {
WebDavResource member = new WebDavResource(collection, res.getName(), res.getETag());
member.delete();
collection.invalidateCTag();
}
// returns ETag of the updated resource, if returned by server
public String update(Resource res) throws URISyntaxException, IOException, HttpException, ValidationException {
WebDavResource member = new WebDavResource(collection, res.getName(), res.getETag());
member.setContentType(memberContentType());
@Cleanup ByteArrayOutputStream os = res.toEntity();
String eTag = member.put(os.toByteArray(), PutMode.UPDATE_DONT_OVERWRITE);
// after a successful upload, the collection has implicitely changed, too
collection.invalidateCTag();
return eTag;
}
// helpers
Resource.AssetDownloader getDownloader() {
return new Resource.AssetDownloader() {
@Override
public byte[] download(URI uri) throws URISyntaxException, IOException, HttpException, DavException {
if (!uri.isAbsolute())
throw new URISyntaxException(uri.toString(), "URI referenced from entity must be absolute");
if (uri.getScheme().equalsIgnoreCase(baseURI.getScheme()) &&
uri.getAuthority().equalsIgnoreCase(baseURI.getAuthority())) {
// resource is on same server, send Authorization
WebDavResource file = new WebDavResource(collection, uri);
file.get("image/*");
return file.getContent();
} else {
// resource is on an external server, don't send Authorization
return IOUtils.toByteArray(uri);
}
}
};
}
}

View File

@@ -1,60 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.HttpException;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
/**
* Represents a resource that can be contained in a LocalCollection or RemoteCollection
* for synchronization by WebDAV.
*/
@ToString
public abstract class Resource {
@Getter @Setter protected String name, ETag;
@Getter @Setter protected String uid;
@Getter protected long localID;
public Resource(String name, String ETag) {
this.name = name;
this.ETag = ETag;
}
public Resource(long localID, String name, String ETag) {
this(name, ETag);
this.localID = localID;
}
/** initializes UID and remote file name (required for first upload) */
public abstract void initialize();
/** fills the resource data from an input stream (for instance, .vcf file for Contact)
* @param entity entity to parse
* @param downloader will be used to fetch additional resources like contact images
**/
public abstract void parseEntity(InputStream entity, AssetDownloader downloader) throws IOException, InvalidResourceException;
/** writes the resource data to an output stream (for instance, .vcf file for Contact) */
public abstract ByteArrayOutputStream toEntity() throws IOException;
public interface AssetDownloader {
public byte[] download(URI url) throws URISyntaxException, IOException, HttpException, DavException;
}
}

View File

@@ -1,86 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.resource;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.util.LinkedList;
import java.util.List;
import ezvcard.VCardVersion;
import lombok.Data;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(suppressConstructorProperties=true)
@Data
public class ServerInfo implements Serializable {
private static final long serialVersionUID = 6744847358282980437L;
enum Scheme {
HTTP, HTTPS, MAILTO
}
final private URI baseURI;
final private String userName, password;
final boolean authPreemptive;
private String errorMessage;
private boolean calDAV = false, cardDAV = false;
private List<ResourceInfo>
addressBooks = new LinkedList<ResourceInfo>(),
calendars = new LinkedList<ResourceInfo>();
public boolean hasEnabledCalendars() {
for (ResourceInfo calendar : calendars)
if (calendar.enabled)
return true;
return false;
}
@RequiredArgsConstructor(suppressConstructorProperties=true)
@Data
public static class ResourceInfo implements Serializable {
private static final long serialVersionUID = -5516934508229552112L;
public enum Type {
ADDRESS_BOOK,
CALENDAR
}
boolean enabled = false;
final Type type;
final boolean readOnly;
final String URL, // absolute URL of resource
title,
description,
color;
VCardVersion vCardVersion;
String timezone;
public String getTitle() {
if (title == null) {
try {
java.net.URL url = new java.net.URL(URL);
return url.getPath();
} catch (MalformedURLException e) {
return URL;
}
} else
return title;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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
@@ -18,7 +18,7 @@ import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import at.bitfire.davdroid.ui.setup.AddAccountActivity;
import at.bitfire.davdroid.ui.setup.LoginActivity;
public class AccountAuthenticatorService extends Service {
private static AccountAuthenticator accountAuthenticator;
@@ -38,7 +38,7 @@ public class AccountAuthenticatorService extends Service {
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
Context context;
final Context context;
public AccountAuthenticator(Context context) {
super(context);
@@ -48,7 +48,7 @@ public class AccountAuthenticatorService extends Service {
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
String[] requiredFeatures, Bundle options) throws NetworkErrorException {
Intent intent = new Intent(context, AddAccountActivity.class);
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);
@@ -65,7 +65,7 @@ public class AccountAuthenticatorService extends Service {
return null;
}
@Override
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}

View File

@@ -1,239 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.PeriodicSync;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Calendars;
import android.provider.ContactsContract;
import android.util.Log;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import at.bitfire.davdroid.resource.ServerInfo;
import ezvcard.VCardVersion;
import lombok.Cleanup;
public class AccountSettings {
private final static String TAG = "davdroid.AccountSettings";
private final static int CURRENT_VERSION = 1;
private final static String
KEY_SETTINGS_VERSION = "version",
KEY_USERNAME = "user_name",
KEY_AUTH_PREEMPTIVE = "auth_preemptive",
KEY_ADDRESSBOOK_URL = "addressbook_url",
KEY_ADDRESSBOOK_CTAG = "addressbook_ctag",
KEY_ADDRESSBOOK_VCARD_VERSION = "addressbook_vcard_version";
public final static long SYNC_INTERVAL_MANUALLY = -1;
Context context;
AccountManager accountManager;
Account account;
public AccountSettings(Context context, Account account) {
this.context = context;
this.account = account;
accountManager = AccountManager.get(context);
synchronized(AccountSettings.class) {
int version = 0;
try {
version = Integer.parseInt(accountManager.getUserData(account, KEY_SETTINGS_VERSION));
} catch(NumberFormatException e) {
}
if (version < CURRENT_VERSION)
update(version);
}
}
public static Bundle createBundle(ServerInfo serverInfo) {
Bundle bundle = new Bundle();
bundle.putString(KEY_SETTINGS_VERSION, String.valueOf(CURRENT_VERSION));
bundle.putString(KEY_USERNAME, serverInfo.getUserName());
bundle.putString(KEY_AUTH_PREEMPTIVE, Boolean.toString(serverInfo.isAuthPreemptive()));
for (ServerInfo.ResourceInfo addressBook : serverInfo.getAddressBooks())
if (addressBook.isEnabled()) {
bundle.putString(KEY_ADDRESSBOOK_URL, addressBook.getURL());
bundle.putString(KEY_ADDRESSBOOK_VCARD_VERSION, addressBook.getVCardVersion().getVersion());
continue;
}
return bundle;
}
// authentication settings
public String getUserName() {
return accountManager.getUserData(account, KEY_USERNAME);
}
public void setUserName(String userName) { accountManager.setUserData(account, KEY_USERNAME, userName); }
public String getPassword() {
return accountManager.getPassword(account);
}
public void setPassword(String password) { accountManager.setPassword(account, password); }
public boolean getPreemptiveAuth() { return Boolean.parseBoolean(accountManager.getUserData(account, KEY_AUTH_PREEMPTIVE)); }
public void setPreemptiveAuth(boolean preemptive) { accountManager.setUserData(account, KEY_AUTH_PREEMPTIVE, Boolean.toString(preemptive)); }
// sync. settings
public Long getContactsSyncInterval() {
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) <= 0)
return null;
if (ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY)) {
List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY);
if (syncs.isEmpty())
return SYNC_INTERVAL_MANUALLY;
else
return syncs.get(0).period;
} else
return SYNC_INTERVAL_MANUALLY;
}
public void setContactsSyncInterval(long seconds) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, false);
} else {
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true);
ContentResolver.addPeriodicSync(account, ContactsContract.AUTHORITY, new Bundle(), seconds);
}
}
public Long getCalendarsSyncInterval() {
if (ContentResolver.getIsSyncable(account, CalendarContract.AUTHORITY) <= 0)
return null;
if (ContentResolver.getSyncAutomatically(account, CalendarContract.AUTHORITY)) {
List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(account, CalendarContract.AUTHORITY);
if (syncs.isEmpty())
return SYNC_INTERVAL_MANUALLY;
else
return syncs.get(0).period;
} else
return SYNC_INTERVAL_MANUALLY;
}
public void setCalendarsSyncInterval(long seconds) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, false);
} else {
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true);
ContentResolver.addPeriodicSync(account, CalendarContract.AUTHORITY, new Bundle(), seconds);
}
}
// address book (CardDAV) settings
public String getAddressBookURL() {
return accountManager.getUserData(account, KEY_ADDRESSBOOK_URL);
}
public String getAddressBookCTag() {
return accountManager.getUserData(account, KEY_ADDRESSBOOK_CTAG);
}
public void setAddressBookCTag(String cTag) {
accountManager.setUserData(account, KEY_ADDRESSBOOK_CTAG, cTag);
}
public VCardVersion getAddressBookVCardVersion() {
VCardVersion version = VCardVersion.V3_0;
String versionStr = accountManager.getUserData(account, KEY_ADDRESSBOOK_VCARD_VERSION);
if (versionStr != null)
version = VCardVersion.valueOfByStr(versionStr);
return version;
}
// update from previous account settings
private void update(int fromVersion) {
Log.i(TAG, "Account settings must be updated from v" + fromVersion + " to v" + CURRENT_VERSION);
for (int toVersion = CURRENT_VERSION; toVersion > fromVersion; toVersion--)
update(fromVersion, toVersion);
}
private void update(int fromVersion, int toVersion) {
Log.i(TAG, "Updating account settings from v" + fromVersion + " to " + toVersion);
try {
if (fromVersion == 0 && toVersion == 1)
update_0_1();
else
Log.wtf(TAG, "Don't know how to update settings from v" + fromVersion + " to v" + toVersion);
} catch(Exception e) {
Log.e(TAG, "Couldn't update account settings (DAVdroid will probably crash)!", e);
}
}
private void update_0_1() throws URISyntaxException {
String v0_principalURL = accountManager.getUserData(account, "principal_url"),
v0_addressBookPath = accountManager.getUserData(account, "addressbook_path");
Log.d(TAG, "Old principal URL = " + v0_principalURL);
Log.d(TAG, "Old address book path = " + v0_addressBookPath);
URI principalURI = new URI(v0_principalURL);
// update address book
if (v0_addressBookPath != null) {
String addressBookURL = principalURI.resolve(v0_addressBookPath).toASCIIString();
Log.d(TAG, "New address book URL = " + addressBookURL);
accountManager.setUserData(account, "addressbook_url", addressBookURL);
}
// update calendars
ContentResolver resolver = context.getContentResolver();
Uri calendars = Calendars.CONTENT_URI.buildUpon()
.appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true").build();
@Cleanup Cursor cursor = resolver.query(calendars, new String[] { Calendars._ID, Calendars.NAME }, null, null, null);
while (cursor != null && cursor.moveToNext()) {
int id = cursor.getInt(0);
String v0_path = cursor.getString(1),
v1_url = principalURI.resolve(v0_path).toASCIIString();
Log.d(TAG, "Updating calendar #" + id + " name: " + v0_path + " -> " + v1_url);
Uri calendar = ContentUris.appendId(Calendars.CONTENT_URI.buildUpon()
.appendQueryParameter(Calendars.ACCOUNT_NAME, account.name)
.appendQueryParameter(Calendars.ACCOUNT_TYPE, account.type)
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true"), id).build();
ContentValues newValues = new ContentValues(1);
newValues.put(Calendars.NAME, v1_url);
if (resolver.update(calendar, newValues, null, null) != 1)
Log.e(TAG, "Number of modified calendars != 1");
}
Log.d(TAG, "Cleaning old principal URL and address book path");
accountManager.setUserData(account, "principal_url", null);
accountManager.setUserData(account, "addressbook_path", null);
Log.d(TAG, "Updated settings successfully!");
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "1");
}
}

View File

@@ -0,0 +1,220 @@
/*
* 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.Account;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import android.provider.CalendarContract.Calendars;
import android.text.TextUtils;
import org.apache.commons.codec.Charsets;
import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.DavCalendar;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.CalendarColor;
import at.bitfire.dav4android.property.CalendarData;
import at.bitfire.dav4android.property.DisplayName;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalEvent;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.Event;
import at.bitfire.ical4android.InvalidCalendarException;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
public class CalendarSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 20;
public CalendarSyncManager(Context context, Account account, Bundle extras, String authority, SyncResult result, LocalCalendar calendar) throws InvalidAccountException {
super(Constants.NOTIFICATION_CALENDAR_SYNC, context, account, extras, authority, result);
localCollection = calendar;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_calendar, account.name);
}
@Override
protected void prepare() {
collectionURL = HttpUrl.parse(localCalendar().getName());
davCollection = new DavCalendar(httpClient, collectionURL);
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
davCollection.propfind(0, GetCTag.NAME);
}
@Override
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
super.prepareDirty();
localCalendar().processDirtyExceptions();
}
@Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
LocalEvent local = (LocalEvent)resource;
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
local.getEvent().toStream().toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// calculate time range limits
Date limitStart = null;
Integer pastDays = settings.getTimeRangePastDays();
if (pastDays != null) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, -pastDays);
limitStart = calendar.getTime();
}
// fetch list of remote VEVENTs and build hash table to index file name
davCalendar().calendarQuery("VEVENT", limitStart, null);
remoteResources = new HashMap<>(davCollection.members.size());
for (DavResource iCal : davCollection.members) {
String fileName = iCal.fileName();
App.log.fine("Found remote VEVENT: " + fileName);
remoteResources.put(fileName, iCal);
}
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
App.log.info("Downloading " + toDownload.size() + " events (" + MAX_MULTIGET + " at once)");
// download new/updated iCalendars from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
if (Thread.interrupted())
return;
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
if (bunch.length == 1) {
// only one contact, use GET
DavResource remote = bunch[0];
ResponseBody body = remote.get("text/calendar");
String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag;
Charset charset = Charsets.UTF_8;
MediaType contentType = body.contentType();
if (contentType != null)
charset = contentType.charset(Charsets.UTF_8);
@Cleanup InputStream stream = body.byteStream();
processVEvent(remote.fileName(), eTag, stream, charset);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()]));
// process multiget results
for (DavResource remote : davCollection.members) {
String eTag;
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
if (getETag != null)
eTag = getETag.eTag;
else
throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
if (getContentType != null && getContentType.type != null) {
MediaType type = MediaType.parse(getContentType.type);
if (type != null)
charset = type.charset(Charsets.UTF_8);
}
CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME);
if (calendarData == null || calendarData.iCalendar == null)
throw new DavException("Received multi-get response without address data");
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes());
processVEvent(remote.fileName(), eTag, stream, charset);
}
}
}
}
// helpers
private LocalCalendar localCalendar() { return ((LocalCalendar)localCollection); }
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
private void processVEvent(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
Event[] events;
try {
events = Event.fromStream(stream, charset);
} catch (InvalidCalendarException e) {
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e);
return;
}
if (events.length == 1) {
Event newData = events[0];
// delete local event, if it exists
LocalEvent localEvent = (LocalEvent)localResources.get(fileName);
if (localEvent != null) {
App.log.info("Updating " + fileName + " in local calendar");
localEvent.setETag(eTag);
localEvent.update(newData);
syncResult.stats.numUpdates++;
} else {
App.log.info("Adding " + fileName + " to local calendar");
localEvent = new LocalEvent(localCalendar(), newData, fileName, eTag);
localEvent.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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
@@ -9,74 +9,128 @@ package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;
import android.provider.CalendarContract;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.resource.CalDavCalendar;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.OpenHelper;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.RemoteCollection;
import at.bitfire.ical4android.CalendarStorageException;
import lombok.Cleanup;
public class CalendarsSyncAdapterService extends Service {
private static SyncAdapter syncAdapter;
@Override
public void onCreate() {
if (syncAdapter == null)
syncAdapter = new SyncAdapter(getApplicationContext());
}
public class CalendarsSyncAdapterService extends SyncAdapterService {
@Override
public void onDestroy() {
syncAdapter.close();
syncAdapter = null;
}
@Override
public void onCreate() {
super.onCreate();
syncAdapter = new SyncAdapter(this, dbHelper);
}
@Override
public IBinder onBind(Intent intent) {
return syncAdapter.getSyncAdapterBinder();
}
private static class SyncAdapter extends DavSyncAdapter {
private final static String TAG = "davdroid.CalendarsSyncAdapter";
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
private SyncAdapter(Context context) {
super(context);
}
@Override
protected Map<LocalCollection<?>, RemoteCollection<?>> getSyncPairs(Account account, ContentProviderClient provider) {
AccountSettings settings = new AccountSettings(getContext(), account);
String userName = settings.getUserName(),
password = settings.getPassword();
boolean preemptive = settings.getPreemptiveAuth();
public SyncAdapter(Context context, OpenHelper dbHelper) {
super(context, dbHelper);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, provider, syncResult);
try {
updateLocalCalendars(provider, account);
for (LocalCalendar calendar : (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null)) {
App.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName());
CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, extras, authority, syncResult, calendar);
syncManager.performSync();
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't enumerate local calendars", e);
syncResult.databaseError = true;
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
}
App.log.info("Calendar sync complete");
}
private void updateLocalCalendars(ContentProviderClient provider, Account account) throws CalendarStorageException {
// enumerate remote and local calendars
Long service = getService(account);
Map<String, CollectionInfo> remote = remoteCalendars(service);
LocalCalendar[] local = (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null);
// delete obsolete local calendar
for (LocalCalendar calendar : local) {
String url = calendar.getName();
if (!remote.containsKey(url)) {
App.log.fine("Deleting obsolete local calendar " + url);
calendar.delete();
} else {
// remote CollectionInfo found for this local collection, update data
CollectionInfo info = remote.get(url);
App.log.fine("Updating local calendar " + url + " with " + info);
calendar.update(info);
// we already have a local calendar for this remote collection, don't take into consideration anymore
remote.remove(url);
}
}
// create new local calendars
for (String url : remote.keySet()) {
CollectionInfo info = remote.get(url);
App.log.info("Adding local calendar list " + info);
LocalCalendar.create(account, provider, info);
}
}
@Nullable
Long getService(Account account) {
@Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID },
Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@NonNull
private Map<String, CollectionInfo> remoteCalendars(Long service) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
if (service != null) {
@Cleanup Cursor cursor = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VEVENT + "!=0 AND " + Collections.SYNC,
new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
CollectionInfo info = CollectionInfo.fromDB(values);
collections.put(info.url, info);
}
}
return collections;
}
}
try {
Map<LocalCollection<?>, RemoteCollection<?>> map = new HashMap<LocalCollection<?>, RemoteCollection<?>>();
for (LocalCalendar calendar : LocalCalendar.findAll(account, provider)) {
RemoteCollection<?> dav = new CalDavCalendar(httpClient, calendar.getUrl(), userName, password, preemptive);
map.put(calendar, dav);
}
return map;
} catch (RemoteException ex) {
Log.e(TAG, "Couldn't find local calendars", ex);
} catch (URISyntaxException ex) {
Log.e(TAG, "Couldn't build calendar URI", ex);
}
return null;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* 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
@@ -9,75 +9,91 @@ package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.davdroid.resource.CardDavAddressBook;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.RemoteCollection;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.OpenHelper;
import lombok.Cleanup;
public class ContactsSyncAdapterService extends Service {
private static ContactsSyncAdapter syncAdapter;
public class ContactsSyncAdapterService extends SyncAdapterService {
@Override
public void onCreate() {
if (syncAdapter == null)
syncAdapter = new ContactsSyncAdapter(getApplicationContext());
super.onCreate();
syncAdapter = new ContactsSyncAdapter(this, dbHelper);
}
@Override
public void onDestroy() {
syncAdapter.close();
syncAdapter = null;
}
@Override
public IBinder onBind(Intent intent) {
return syncAdapter.getSyncAdapterBinder();
}
private static class ContactsSyncAdapter extends SyncAdapter {
private static class ContactsSyncAdapter extends DavSyncAdapter {
private final static String TAG = "davdroid.ContactsSyncAdapter";
public ContactsSyncAdapter(Context context, OpenHelper dbHelper) {
super(context, dbHelper);
}
private ContactsSyncAdapter(Context context) {
super(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, provider, syncResult);
@Override
protected Map<LocalCollection<?>, RemoteCollection<?>> getSyncPairs(Account account, ContentProviderClient provider) {
AccountSettings settings = new AccountSettings(getContext(), account);
String userName = settings.getUserName(),
password = settings.getPassword();
boolean preemptive = settings.getPreemptiveAuth();
Long service = getService(account);
if (service != null) {
CollectionInfo remote = remoteAddressBook(service);
if (remote != null)
try {
ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, extras, authority, provider, syncResult, remote);
syncManager.performSync();
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
}
else
App.log.info("No address book collection selected for synchronization");
} else
App.log.info("No CardDAV service found in DB");
App.log.info("Address book sync complete");
}
@Nullable
private Long getService(@NonNull Account account) {
@Cleanup Cursor c = db.query(ServiceDB.Services._TABLE, new String[] { ServiceDB.Services.ID },
ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", new String[] { account.name, ServiceDB.Services.SERVICE_CARDDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@Nullable
private CollectionInfo remoteAddressBook(long service) {
@Cleanup Cursor c = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, new String[] { String.valueOf(service) }, null, null, null);
if (c.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(c, values);
return CollectionInfo.fromDB(values);
} else
return null;
}
}
String addressBookURL = settings.getAddressBookURL();
if (addressBookURL == null)
return null;
try {
LocalCollection<?> database = new LocalAddressBook(account, provider, settings);
RemoteCollection<?> dav = new CardDavAddressBook(httpClient, addressBookURL, userName, password, preemptive);
Map<LocalCollection<?>, RemoteCollection<?>> map = new HashMap<LocalCollection<?>, RemoteCollection<?>>();
map.put(database, dav);
return map;
} catch (URISyntaxException ex) {
Log.e(TAG, "Couldn't build address book URI", ex);
}
return null;
}
}
}

View File

@@ -0,0 +1,326 @@
/*
* 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.Account;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import org.apache.commons.codec.Charsets;
import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.DavAddressBook;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressData;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalContact;
import at.bitfire.davdroid.resource.LocalGroup;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.VCardVersion;
import ezvcard.util.IOUtils;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
public class ContactsSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 10;
final private ContentProviderClient provider;
final private CollectionInfo remote;
private boolean hasVCard4;
public ContactsSyncManager(Context context, Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, CollectionInfo remote) throws InvalidAccountException {
super(Constants.NOTIFICATION_CONTACTS_SYNC, context, account, extras, authority, result);
this.provider = provider;
this.remote = remote;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_contacts, account.name);
}
@Override
protected void prepare() throws ContactsStorageException {
// prepare local address book
localCollection = new LocalAddressBook(account, provider);
String url = remote.url;
String lastUrl = localAddressBook().getURL();
if (!url.equals(lastUrl)) {
App.log.info("Selected address book has changed from " + lastUrl + " to " + url + ", deleting all local contacts");
((LocalAddressBook)localCollection).deleteAll();
}
collectionURL = HttpUrl.parse(url);
davCollection = new DavAddressBook(httpClient, collectionURL);
processChangedGroups();
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
// prepare remote address book
hasVCard4 = false;
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData) davCollection.properties.get(SupportedAddressData.NAME);
if (supportedAddressData != null)
for (MediaType type : supportedAddressData.types)
if ("text/vcard; version=4.0".equalsIgnoreCase(type.toString()))
hasVCard4 = true;
App.log.info("Server advertises VCard/4 support: " + hasVCard4);
}
@Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, ContactsStorageException {
LocalContact local = (LocalContact)resource;
return RequestBody.create(
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
local.getContact().toStream(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0).toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// fetch list of remote VCards and build hash table to index file name
try {
davAddressBook().addressbookQuery();
} catch(HttpException e) {
/* non-successful responses to CARDDAV:addressbook-query with empty filter, tested on 2015/10/21
* fastmail.com 403 Forbidden (DAV:error CARDDAV:supported-filter)
* mailbox.org (OpenXchange) 400 Bad Request
* SOGo 207 Multi-status, but without entries http://www.sogo.nu/bugs/view.php?id=3370
* Zimbra ZCS 500 Server Error https://bugzilla.zimbra.com/show_bug.cgi?id=101902
*/
if (e.status == 400 || e.status == 403 || e.status == 500 || e.status == 501) {
App.log.log(Level.WARNING, "Server error on REPORT addressbook-query, falling back to PROPFIND", e);
davAddressBook().propfind(1, GetETag.NAME);
} else
// no defined fallback, pass through exception
throw e;
}
remoteResources = new HashMap<>(davCollection.members.size());
for (DavResource vCard : davCollection.members) {
String fileName = vCard.fileName();
App.log.fine("Found remote VCard: " + fileName);
remoteResources.put(fileName, vCard);
}
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException {
App.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)");
// prepare downloader which may be used to download external resource like contact photos
Contact.Downloader downloader = new ResourceDownloader(collectionURL);
// download new/updated VCards from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
if (Thread.interrupted())
return;
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
if (bunch.length == 1) {
// only one contact, use GET
DavResource remote = bunch[0];
ResponseBody body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5");
String eTag = ((GetETag) remote.properties.get(GetETag.NAME)).eTag;
Charset charset = Charsets.UTF_8;
MediaType contentType = body.contentType();
if (contentType != null)
charset = contentType.charset(Charsets.UTF_8);
@Cleanup InputStream stream = body.byteStream();
processVCard(remote.fileName(), eTag, stream, charset, downloader);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
davAddressBook().multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
// process multiget results
for (DavResource remote : davCollection.members) {
String eTag;
GetETag getETag = (GetETag) remote.properties.get(GetETag.NAME);
if (getETag != null)
eTag = getETag.eTag;
else
throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType) remote.properties.get(GetContentType.NAME);
if (getContentType != null && getContentType.type != null) {
MediaType type = MediaType.parse(getContentType.type);
if (type != null)
charset = type.charset(Charsets.UTF_8);
}
AddressData addressData = (AddressData) remote.properties.get(AddressData.NAME);
if (addressData == null || addressData.vCard == null)
throw new DavException("Received multi-get response without address data");
@Cleanup InputStream stream = new ByteArrayInputStream(addressData.vCard.getBytes());
processVCard(remote.fileName(), eTag, stream, charset, downloader);
}
}
}
}
@Override
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
super.saveSyncState();
((LocalAddressBook)localCollection).setURL(remote.url);
}
// helpers
private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; }
private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; }
private void processChangedGroups() throws ContactsStorageException {
LocalAddressBook addressBook = localAddressBook();
// groups with DELETED=1: remove group finally
for (LocalGroup group : addressBook.getDeletedGroups()) {
long groupId = group.getId();
App.log.fine("Finally removing group #" + groupId);
// remove group memberships, but not as sync adapter (should marks contacts as DIRTY)
// NOTE: doesn't work that way because Contact Provider removes the group memberships even for DELETED groups
// addressBook.removeGroupMemberships(groupId, false);
group.delete();
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (LocalGroup group : addressBook.getDirtyGroups()) {
long groupId = group.getId();
App.log.fine("Marking members of modified group #" + groupId + " as dirty");
addressBook.markMembersDirty(groupId);
group.clearDirty();
}
}
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
Contact[] contacts = Contact.fromStream(stream, charset, downloader);
if (contacts.length == 1) {
Contact newData = contacts[0];
// update local contact, if it exists
LocalContact localContact = (LocalContact)localResources.get(fileName);
if (localContact != null) {
App.log.info("Updating " + fileName + " in local address book");
localContact.eTag = eTag;
localContact.update(newData);
syncResult.stats.numUpdates++;
} else {
App.log.info("Adding " + fileName + " to local address book");
localContact = new LocalContact(localAddressBook(), newData, fileName, eTag);
localContact.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCard with not exactly one VCARD, ignoring " + fileName);
}
// downloader helper class
@RequiredArgsConstructor
private class ResourceDownloader implements Contact.Downloader {
final HttpUrl baseUrl;
@Override
public byte[] download(String url, String accepts) {
HttpUrl httpUrl = HttpUrl.parse(url);
if (httpUrl == null) {
App.log.log(Level.SEVERE, "Invalid external resource URL", url);
return null;
}
String host = httpUrl.host();
if (host == null) {
App.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url);
return null;
}
OkHttpClient resourceClient = HttpClient.create();
// authenticate only against a certain host, and only upon request
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), settings.username(), settings.password());
// allow redirects
resourceClient = resourceClient.newBuilder()
.followRedirects(true)
.build();
try {
Response response = resourceClient.newCall(new Request.Builder()
.get()
.url(httpUrl)
.build()).execute();
ResponseBody body = response.body();
if (body != null) {
@Cleanup InputStream stream = body.byteStream();
if (response.isSuccessful() && stream != null) {
return IOUtils.toByteArray(stream);
} else
App.log.severe("Couldn't download external resource");
}
} catch(IOException e) {
App.log.log(Level.SEVERE, "Couldn't download external resource", e);
}
return null;
}
}
}

View File

@@ -1,212 +0,0 @@
/*
* 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
*/
package at.bitfire.davdroid.syncadapter;
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.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SyncResult;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.provider.Settings;
import android.util.Log;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.apache.http.HttpStatus;
import org.apache.http.impl.client.CloseableHttpClient;
import java.io.Closeable;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import javax.net.ssl.SSLException;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalStorageException;
import at.bitfire.davdroid.resource.RemoteCollection;
import at.bitfire.davdroid.ui.settings.AccountActivity;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.DavHttpClient;
import at.bitfire.davdroid.webdav.HttpException;
import lombok.Getter;
public abstract class DavSyncAdapter extends AbstractThreadedSyncAdapter implements Closeable {
private final static String TAG = "davdroid.DavSyncAdapter";
@Getter private static String androidID;
protected Context context;
/* We use one static httpClient for
* - all sync adapters (CalendarsSyncAdapter, ContactsSyncAdapter)
* - and all threads (= accounts) of each sync adapter
* so that HttpClient's threaded pool management can do its best.
*/
protected static CloseableHttpClient httpClient;
/* One static read/write lock pair for the static httpClient:
* Use the READ lock when httpClient will only be called (to prevent it from being unset while being used).
* Use the WRITE lock when httpClient will be modified (set/unset). */
private final static ReentrantReadWriteLock httpClientLock = new ReentrantReadWriteLock();
public DavSyncAdapter(Context context) {
super(context, true);
synchronized(this) {
if (androidID == null)
androidID = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
}
this.context = context;
}
@Override
public void close() {
Log.d(TAG, "Closing httpClient");
// may be called from a GUI thread, so we need an AsyncTask
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try {
httpClientLock.writeLock().lock();
if (httpClient != null) {
httpClient.close();
httpClient = null;
}
httpClientLock.writeLock().unlock();
} catch (IOException e) {
Log.w(TAG, "Couldn't close HTTP client", e);
}
return null;
}
}.execute();
}
protected abstract Map<LocalCollection<?>, RemoteCollection<?>> getSyncPairs(Account account, ContentProviderClient provider);
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
Log.i(TAG, "Performing sync for authority " + authority);
// set class loader for iCal4j ResourceLoader
Thread.currentThread().setContextClassLoader(getContext().getClassLoader());
// create httpClient, if necessary
httpClientLock.writeLock().lock();
if (httpClient == null) {
Log.d(TAG, "Creating new DavHttpClient");
SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(getContext());
httpClient = DavHttpClient.create();
}
// prevent httpClient shutdown until we're ready by holding a read lock
// acquiring read lock before releasing write lock will downgrade the write lock to a read lock
httpClientLock.readLock().lock();
httpClientLock.writeLock().unlock();
// TODO use VCard 4.0 if possible
AccountSettings accountSettings = new AccountSettings(getContext(), account);
Log.d(TAG, "Server supports VCard version " + accountSettings.getAddressBookVCardVersion());
Exception exceptionToShow = null; // exception to show notification for
Intent exceptionIntent = null; // what shall happen when clicking on the exception notification
try {
// get local <-> remote collection pairs
Map<LocalCollection<?>, RemoteCollection<?>> syncCollections = getSyncPairs(account, provider);
if (syncCollections == null)
Log.i(TAG, "Nothing to synchronize");
else
try {
for (Map.Entry<LocalCollection<?>, RemoteCollection<?>> entry : syncCollections.entrySet())
new SyncManager(entry.getKey(), entry.getValue()).synchronize(extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL), syncResult);
} catch (DavException ex) {
syncResult.stats.numParseExceptions++;
Log.e(TAG, "Invalid DAV response", ex);
exceptionToShow = ex;
} catch (HttpException ex) {
if (ex.getCode() == HttpStatus.SC_UNAUTHORIZED) {
Log.e(TAG, "HTTP Unauthorized " + ex.getCode(), ex);
syncResult.stats.numAuthExceptions++; // hard error
exceptionToShow = ex;
exceptionIntent = new Intent(context, AccountActivity.class);
exceptionIntent.putExtra(AccountActivity.EXTRA_ACCOUNT, account);
} else if (ex.isClientError()) {
Log.e(TAG, "Hard HTTP error " + ex.getCode(), ex);
syncResult.stats.numParseExceptions++; // hard error
exceptionToShow = ex;
} else {
Log.w(TAG, "Soft HTTP error " + ex.getCode() + " (Android will try again later)", ex);
syncResult.stats.numIoExceptions++; // soft error
}
} catch (LocalStorageException ex) {
syncResult.databaseError = true; // hard error
Log.e(TAG, "Local storage (content provider) exception", ex);
exceptionToShow = ex;
} catch (IOException ex) {
syncResult.stats.numIoExceptions++; // soft error
Log.e(TAG, "I/O error (Android will try again later)", ex);
if (ex instanceof SSLException) // always notify on SSL/TLS errors
exceptionToShow = ex;
} catch (URISyntaxException ex) {
syncResult.stats.numParseExceptions++; // hard error
Log.e(TAG, "Invalid URI (file name) syntax", ex);
exceptionToShow = ex;
}
} finally {
// allow httpClient shutdown
httpClientLock.readLock().unlock();
}
// show sync errors as notification
if (exceptionToShow != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
if (exceptionIntent == null)
exceptionIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(Constants.WEB_URL_VIEW_LOGS));
PendingIntent contentIntent = PendingIntent.getActivity(context, 0, exceptionIntent, 0);
Notification.Builder builder = new Notification.Builder(context)
.setSmallIcon(R.drawable.ic_launcher)
.setPriority(Notification.PRIORITY_LOW)
.setOnlyAlertOnce(true)
.setWhen(System.currentTimeMillis())
.setContentTitle(context.getString(R.string.sync_error_title))
.setContentText(exceptionToShow.getLocalizedMessage())
.setContentInfo(account.name)
.setStyle(new Notification.BigTextStyle().bigText(account.name + ":\n" + ExceptionUtils.getFullStackTrace(exceptionToShow)))
.setContentIntent(contentIntent);
NotificationManager notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.notify(account.name.hashCode(), builder.build());
}
Log.i(TAG, "Sync complete for " + authority);
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.syncadapter;
import android.accounts.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.database.sqlite.SQLiteDatabase;
import android.os.Bundle;
import android.os.IBinder;
import android.support.annotation.Nullable;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.ServiceDB;
public abstract class SyncAdapterService extends Service {
ServiceDB.OpenHelper dbHelper;
AbstractThreadedSyncAdapter syncAdapter;
@Override
public void onCreate() {
dbHelper = new ServiceDB.OpenHelper(this);
}
@Override
public void onDestroy() {
dbHelper.close();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return syncAdapter.getSyncAdapterBinder();
}
public static abstract class SyncAdapter extends AbstractThreadedSyncAdapter {
protected final SQLiteDatabase db;
public SyncAdapter(Context context, ServiceDB.OpenHelper dbHelper) {
super(context, false);
db = dbHelper.getReadableDatabase();
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
App.log.info("Starting " + authority + " sync");
// required for dav4android (ServiceLoader)
Thread.currentThread().setContextClassLoader(getContext().getClassLoader());
// peek into AccountSettings to cause possible migration (v0.9 -> v1.0)
try {
new AccountSettings(getContext(), account);
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't check for updated account settings", e);
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
/*
* 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
@@ -7,210 +7,429 @@
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.annotation.TargetApi;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.util.Log;
import net.fortuna.ical4j.model.ValidationException;
import android.os.Build;
import android.os.Bundle;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.ConflictException;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.exception.PreconditionFailedException;
import at.bitfire.dav4android.exception.ServiceUnavailableException;
import at.bitfire.dav4android.exception.UnauthorizedException;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalStorageException;
import at.bitfire.davdroid.resource.RecordNotFoundException;
import at.bitfire.davdroid.resource.RemoteCollection;
import at.bitfire.davdroid.resource.Resource;
import at.bitfire.davdroid.webdav.DavException;
import at.bitfire.davdroid.webdav.HttpException;
import at.bitfire.davdroid.webdav.NotFoundException;
import at.bitfire.davdroid.webdav.PreconditionFailedException;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.davdroid.ui.AccountActivity;
import at.bitfire.davdroid.ui.AccountSettingsActivity;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
public class SyncManager {
private static final String TAG = "davdroid.SyncManager";
private static final int MAX_MULTIGET_RESOURCES = 35;
protected LocalCollection<? extends Resource> local;
protected RemoteCollection<? extends Resource> remote;
public SyncManager(LocalCollection<? extends Resource> local, RemoteCollection<? extends Resource> remote) {
this.local = local;
this.remote = remote;
}
abstract public class SyncManager {
public void synchronize(boolean manualSync, SyncResult syncResult) throws URISyntaxException, LocalStorageException, IOException, HttpException, DavException {
// PHASE 1: push local changes to server
int deletedRemotely = pushDeleted(),
addedRemotely = pushNew(),
updatedRemotely = pushDirty();
syncResult.stats.numEntries = deletedRemotely + addedRemotely + updatedRemotely;
// PHASE 2A: check if there's a reason to do a sync with remote (= forced sync or remote CTag changed)
boolean fetchCollection = syncResult.stats.numEntries > 0;
if (manualSync) {
Log.i(TAG, "Synchronization forced");
fetchCollection = true;
}
if (!fetchCollection) {
String currentCTag = remote.getCTag(),
lastCTag = local.getCTag();
Log.d(TAG, "Last local CTag = " + lastCTag + "; current remote CTag = " + currentCTag);
if (currentCTag == null || !currentCTag.equals(lastCTag))
fetchCollection = true;
}
if (!fetchCollection) {
Log.i(TAG, "No local changes and CTags match, no need to sync");
return;
}
// PHASE 2B: detect details of remote changes
Log.i(TAG, "Fetching remote resource list");
Set<Resource> remotelyAdded = new HashSet<Resource>(),
remotelyUpdated = new HashSet<Resource>();
Resource[] remoteResources = remote.getMemberETags();
for (Resource remoteResource : remoteResources) {
try {
Resource localResource = local.findByRemoteName(remoteResource.getName(), false);
if (localResource.getETag() == null || !localResource.getETag().equals(remoteResource.getETag()))
remotelyUpdated.add(remoteResource);
} catch(RecordNotFoundException e) {
remotelyAdded.add(remoteResource);
}
}
protected final int SYNC_PHASE_PREPARE = 0,
SYNC_PHASE_QUERY_CAPABILITIES = 1,
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2,
SYNC_PHASE_PREPARE_DIRTY = 3,
SYNC_PHASE_UPLOAD_DIRTY = 4,
SYNC_PHASE_CHECK_SYNC_STATE = 5,
SYNC_PHASE_LIST_LOCAL = 6,
SYNC_PHASE_LIST_REMOTE = 7,
SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8,
SYNC_PHASE_DOWNLOAD_REMOTE = 9,
SYNC_PHASE_SAVE_SYNC_STATE = 10;
// PHASE 3: pull remote changes from server
syncResult.stats.numInserts = pullNew(remotelyAdded.toArray(new Resource[0]));
syncResult.stats.numUpdates = pullChanged(remotelyUpdated.toArray(new Resource[0]));
syncResult.stats.numEntries += syncResult.stats.numInserts + syncResult.stats.numUpdates;
Log.i(TAG, "Removing non-dirty resources that are not present remotely anymore");
local.deleteAllExceptRemoteNames(remoteResources);
local.commit();
protected final NotificationManager notificationManager;
protected final int notificationId;
// update collection CTag
Log.i(TAG, "Sync complete, fetching new CTag");
local.setCTag(remote.getCTag());
}
private int pushDeleted() throws URISyntaxException, LocalStorageException, IOException, HttpException {
int count = 0;
long[] deletedIDs = local.findDeleted();
try {
Log.i(TAG, "Remotely removing " + deletedIDs.length + " deleted resource(s) (if not changed)");
for (long id : deletedIDs)
try {
Resource res = local.findById(id, false);
if (res.getName() != null) // is this resource even present remotely?
try {
remote.delete(res);
} catch(NotFoundException e) {
Log.i(TAG, "Locally-deleted resource has already been removed from server");
} catch(PreconditionFailedException e) {
Log.i(TAG, "Locally-deleted resource has been changed on the server in the meanwhile");
}
// always delete locally so that the record with the DELETED flag doesn't cause another deletion attempt
local.delete(res);
count++;
} catch (RecordNotFoundException e) {
Log.wtf(TAG, "Couldn't read locally-deleted record", e);
}
} finally {
local.commit();
}
return count;
}
private int pushNew() throws URISyntaxException, LocalStorageException, IOException, HttpException {
int count = 0;
long[] newIDs = local.findNew();
Log.i(TAG, "Uploading " + newIDs.length + " new resource(s) (if not existing)");
try {
for (long id : newIDs)
try {
Resource res = local.findById(id, true);
String eTag = remote.add(res);
if (eTag != null)
local.updateETag(res, eTag);
local.clearDirty(res);
count++;
} catch(PreconditionFailedException e) {
Log.i(TAG, "Didn't overwrite existing resource with other content");
} catch (ValidationException e) {
Log.e(TAG, "Couldn't create entity for adding: " + e.toString());
} catch (RecordNotFoundException e) {
Log.wtf(TAG, "Couldn't read new record", e);
}
} finally {
local.commit();
}
return count;
}
private int pushDirty() throws URISyntaxException, LocalStorageException, IOException, HttpException {
int count = 0;
long[] dirtyIDs = local.findUpdated();
Log.i(TAG, "Uploading " + dirtyIDs.length + " modified resource(s) (if not changed)");
try {
for (long id : dirtyIDs) {
try {
Resource res = local.findById(id, true);
String eTag = remote.update(res);
if (eTag != null)
local.updateETag(res, eTag);
local.clearDirty(res);
count++;
} catch(PreconditionFailedException e) {
Log.i(TAG, "Locally changed resource has been changed on the server in the meanwhile");
} catch (ValidationException e) {
Log.e(TAG, "Couldn't create entity for updating: " + e.toString());
} catch (RecordNotFoundException e) {
Log.e(TAG, "Couldn't read dirty record", e);
}
}
} finally {
local.commit();
}
return count;
}
private int pullNew(Resource[] resourcesToAdd) throws URISyntaxException, LocalStorageException, IOException, HttpException, DavException {
int count = 0;
Log.i(TAG, "Fetching " + resourcesToAdd.length + " new remote resource(s)");
for (Resource[] resources : ArrayUtils.partition(resourcesToAdd, MAX_MULTIGET_RESOURCES))
for (Resource res : remote.multiGet(resources)) {
Log.d(TAG, "Adding " + res.getName());
local.add(res);
local.commit();
count++;
}
return count;
}
private int pullChanged(Resource[] resourcesToUpdate) throws URISyntaxException, LocalStorageException, IOException, HttpException, DavException {
int count = 0;
Log.i(TAG, "Fetching " + resourcesToUpdate.length + " updated remote resource(s)");
for (Resource[] resources : ArrayUtils.partition(resourcesToUpdate, MAX_MULTIGET_RESOURCES))
for (Resource res : remote.multiGet(resources)) {
Log.i(TAG, "Updating " + res.getName());
local.updateByRemoteName(res);
local.commit();
count++;
}
return count;
}
protected final Context context;
protected final Account account;
protected final Bundle extras;
protected final String authority;
protected final SyncResult syncResult;
protected final AccountSettings settings;
protected LocalCollection localCollection;
protected OkHttpClient httpClient;
protected HttpUrl collectionURL;
protected DavResource davCollection;
/** remote CTag at the time of {@link #listRemote()} */
protected String remoteCTag = null;
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
protected Map<String, LocalResource> localResources;
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
protected Map<String, DavResource> remoteResources;
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
protected Set<DavResource> toDownload;
public SyncManager(int notificationId, Context context, Account account, Bundle extras, String authority, SyncResult syncResult) throws InvalidAccountException {
this.context = context;
this.account = account;
this.extras = extras;
this.authority = authority;
this.syncResult = syncResult;
// get account settings (for sync interval etc.)
settings = new AccountSettings(context, account);
// create HttpClient with given logger
httpClient = HttpClient.create(context, account);
// dismiss previous error notifications
notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(account.name, this.notificationId = notificationId);
}
protected abstract String getSyncErrorTitle();
@TargetApi(21)
public void performSync() {
int syncPhase = SYNC_PHASE_PREPARE;
try {
App.log.info("Preparing synchronization");
prepare();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
App.log.info("Querying capabilities");
queryCapabilities();
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
App.log.info("Processing locally deleted entries");
processLocallyDeleted();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_PREPARE_DIRTY;
App.log.info("Locally preparing dirty entries");
prepareDirty();
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
App.log.info("Uploading dirty entries");
uploadDirty();
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE;
App.log.info("Checking sync state");
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL;
App.log.info("Listing local entries");
listLocal();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_LIST_REMOTE;
App.log.info("Listing remote entries");
listRemote();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE;
App.log.info("Comparing local/remote entries");
compareLocalRemote();
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
App.log.info("Downloading remote entries");
downloadRemote();
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
App.log.info("Saving sync state");
saveSyncState();
} else
App.log.info("Remote collection didn't change, skipping remote sync");
} catch (IOException|ServiceUnavailableException e) {
App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e);
syncResult.stats.numIoExceptions++;
if (e instanceof ServiceUnavailableException) {
Date retryAfter = ((ServiceUnavailableException) e).retryAfter;
if (retryAfter != null) {
// how many seconds to wait? getTime() returns ms, so divide by 1000
syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000;
}
}
} catch(Exception|OutOfMemoryError e) {
final int messageString;
if (e instanceof UnauthorizedException) {
App.log.log(Level.SEVERE, "Not authorized anymore", e);
messageString = R.string.sync_error_unauthorized;
syncResult.stats.numAuthExceptions++;
} else if (e instanceof HttpException || e instanceof DavException) {
App.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e);
messageString = R.string.sync_error_http_dav;
syncResult.stats.numParseExceptions++;
} else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) {
App.log.log(Level.SEVERE, "Couldn't access local storage", e);
messageString = R.string.sync_error_local_storage;
syncResult.databaseError = true;
} else {
App.log.log(Level.SEVERE, "Unknown sync error", e);
messageString = R.string.sync_error;
syncResult.stats.numParseExceptions++;
}
final Intent detailsIntent;
if (e instanceof UnauthorizedException) {
detailsIntent = new Intent(context, AccountSettingsActivity.class);
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account);
} else {
detailsIntent = new Intent(context, DebugInfoActivity.class);
detailsIntent.putExtra(DebugInfoActivity.KEY_EXCEPTION, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
}
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
builder .setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, notificationId, detailsIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setLocalOnly(true);
try {
String[] phases = context.getResources().getStringArray(R.array.sync_error_phases);
String message = context.getString(messageString, phases[syncPhase]);
builder.setContentText(message);
} catch (IndexOutOfBoundsException ex) {
// should never happen
}
notificationManager.notify(account.name, notificationId, builder.build());
}
}
abstract protected void prepare() throws ContactsStorageException;
abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException;
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException {
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
LocalResource[] localList = localCollection.getDeleted();
for (LocalResource local : localList) {
if (Thread.interrupted())
return;
final String fileName = local.getFileName();
if (!TextUtils.isEmpty(fileName)) {
App.log.info(fileName + " has been deleted locally -> deleting from server");
try {
new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
.delete(local.getETag());
} catch (IOException|HttpException e) {
App.log.warning("Couldn't delete " + fileName + " from server; ignoring (may be downloaded again)");
}
} else
App.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded");
local.delete();
syncResult.stats.numDeletes++;
}
}
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
// assign file names and UIDs to new contacts so that we can use the file name as an index
for (LocalResource local : localCollection.getWithoutFileName()) {
String uuid = UUID.randomUUID().toString();
App.log.info("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid);
local.updateFileNameAndUID(uuid);
}
}
abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException;
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException {
// upload dirty contacts
for (LocalResource local : localCollection.getDirty()) {
if (Thread.interrupted())
return;
final String fileName = local.getFileName();
DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
// generate entity to upload (VCard, iCal, whatever)
RequestBody body = prepareUpload(local);
try {
if (local.getETag() == null) {
App.log.info("Uploading new record " + fileName);
remote.put(body, null, true);
} else {
App.log.info("Uploading locally modified record " + fileName);
remote.put(body, local.getETag(), false);
}
} catch (ConflictException|PreconditionFailedException e) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e);
}
String eTag = null;
GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME);
if (newETag != null) {
eTag = newETag.eTag;
App.log.fine("Received new ETag=" + eTag + " after uploading");
} else
App.log.fine("Didn't receive new ETag after uploading, setting to null");
local.clearDirty(eTag);
}
}
/**
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
* @return <ul>
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
* <li><code>false</code> if the remote collection hasn't changed</li>
* </ul>
*/
protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException {
// check CTag (ignore on manual sync)
GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME);
if (getCTag != null)
remoteCTag = getCTag.cTag;
String localCTag = null;
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
App.log.info("Manual sync, ignoring CTag");
else
localCTag = localCollection.getCTag();
if (remoteCTag != null && remoteCTag.equals(localCTag)) {
App.log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children");
return false;
} else
return true;
}
/**
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
*/
protected void listLocal() throws CalendarStorageException, ContactsStorageException {
// fetch list of local contacts and build hash table to index file name
LocalResource[] localList = localCollection.getAll();
localResources = new HashMap<>(localList.length);
for (LocalResource resource : localList) {
App.log.fine("Found local resource: " + resource.getFileName());
localResources.put(resource.getFileName(), resource);
}
}
/**
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
*/
abstract protected void listRemote() throws IOException, HttpException, DavException;
/**
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
* <ul>
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
* </ul>
*/
protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException {
/* check which contacts
1. are not present anymore remotely -> delete immediately on local side
2. updated remotely -> add to downloadNames
3. added remotely -> add to downloadNames
*/
toDownload = new HashSet<>();
for (String localName : localResources.keySet()) {
DavResource remote = remoteResources.get(localName);
if (remote == null) {
App.log.info(localName + " is not on server anymore, deleting");
localResources.get(localName).delete();
syncResult.stats.numDeletes++;
} else {
// contact is still on server, check whether it has been updated remotely
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
if (getETag == null || getETag.eTag == null)
throw new DavException("Server didn't provide ETag");
String localETag = localResources.get(localName).getETag(),
remoteETag = getETag.eTag;
if (remoteETag.equals(localETag))
syncResult.stats.numSkippedEntries++;
else {
App.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")");
toDownload.add(remote);
}
// remote entry has been seen, remove from list
remoteResources.remove(localName);
}
}
// add all unseen (= remotely added) remote contacts
if (!remoteResources.isEmpty()) {
App.log.info("New resources have been found on the server: " + TextUtils.join(", ", remoteResources.keySet()));
toDownload.addAll(remoteResources.values());
}
}
/**
* Downloads the remote resources in {@link #toDownload} and stores them locally.
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
*/
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException;
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
(for instance, because another client has uploaded changes), because this will simply
cause all remote entries to be listed at the next sync. */
App.log.info("Saving CTag=" + remoteCTag);
localCollection.setCTag(remoteCTag);
}
}

View File

@@ -0,0 +1,141 @@
/*
* 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.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.os.Bundle;
import android.os.IBinder;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.OpenHelper;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
public class TasksSyncAdapterService extends SyncAdapterService {
@Override
public void onCreate() {
super.onCreate();
syncAdapter = new SyncAdapter(this, dbHelper);
}
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
public SyncAdapter(Context context, OpenHelper dbHelper) {
super(context, dbHelper);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient providerClient, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, providerClient, syncResult);
try {
@Cleanup TaskProvider provider = TaskProvider.acquire(getContext().getContentResolver(), TaskProvider.ProviderName.OpenTasks);
if (provider == null)
throw new CalendarStorageException("Couldn't access OpenTasks provider");
updateLocalTaskLists(provider, account);
for (LocalTaskList taskList : (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null)) {
App.log.info("Synchronizing task list #" + taskList.getId() + ", URL: " + taskList.getSyncId());
TasksSyncManager syncManager = new TasksSyncManager(getContext(), account, extras, authority, provider, syncResult, taskList);
syncManager.performSync();
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't enumerate local task lists", e);
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
} finally {
db.close();
}
App.log.info("Task sync complete");
}
private void updateLocalTaskLists(TaskProvider provider, Account account) throws CalendarStorageException {
// enumerate remote and local task lists
Long service = getService(account);
Map<String, CollectionInfo> remote = remoteTaskLists(service);
LocalTaskList[] local = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
// delete obsolete local task lists
for (LocalTaskList list : local) {
String url = list.getSyncId();
if (!remote.containsKey(url)) {
App.log.fine("Deleting obsolete local task list" + url);
list.delete();
} else {
// remote CollectionInfo found for this local collection, update data
CollectionInfo info = remote.get(url);
App.log.fine("Updating local task list " + url + " with " + info);
list.update(info);
// we already have a local task list for this remote collection, don't take into consideration anymore
remote.remove(url);
}
}
// create new local task lists
for (String url : remote.keySet()) {
CollectionInfo info = remote.get(url);
App.log.info("Adding local task list " + info);
LocalTaskList.create(account, provider, info);
}
}
@Nullable
Long getService(Account account) {
@Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID },
Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@NonNull
private Map<String, CollectionInfo> remoteTaskLists(Long service) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
if (service != null) {
@Cleanup Cursor cursor = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VTODO + "!=0 AND " + Collections.SYNC,
new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
CollectionInfo info = CollectionInfo.fromDB(values);
collections.put(info.url, info);
}
}
return collections;
}
}
}

View File

@@ -0,0 +1,208 @@
/*
* 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.Account;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import android.text.TextUtils;
import org.apache.commons.codec.Charsets;
import org.apache.commons.lang3.StringUtils;
import org.dmfs.provider.tasks.TaskContract.TaskLists;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.DavCalendar;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.CalendarColor;
import at.bitfire.dav4android.property.CalendarData;
import at.bitfire.dav4android.property.DisplayName;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.davdroid.resource.LocalTask;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.InvalidCalendarException;
import at.bitfire.ical4android.Task;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
public class TasksSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 30;
final protected TaskProvider provider;
public TasksSyncManager(Context context, Account account, Bundle extras, String authority, TaskProvider provider, SyncResult result, LocalTaskList taskList) throws InvalidAccountException {
super(Constants.NOTIFICATION_TASK_SYNC, context, account, extras, authority, result);
this.provider = provider;
localCollection = taskList;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_tasks, account.name);
}
@Override
protected void prepare() {
collectionURL = HttpUrl.parse(localTaskList().getSyncId());
davCollection = new DavCalendar(httpClient, collectionURL);
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
davCollection.propfind(0, GetCTag.NAME);
}
@Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
LocalTask local = (LocalTask)resource;
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
local.getTask().toStream().toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// fetch list of remote VTODOs and build hash table to index file name
davCalendar().calendarQuery("VTODO", null, null);
remoteResources = new HashMap<>(davCollection.members.size());
for (DavResource vCard : davCollection.members) {
String fileName = vCard.fileName();
App.log.fine("Found remote VTODO: " + fileName);
remoteResources.put(fileName, vCard);
}
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
App.log.info("Downloading " + toDownload.size() + " tasks (" + MAX_MULTIGET + " at once)");
// download new/updated iCalendars from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
if (Thread.interrupted())
return;
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
if (bunch.length == 1) {
// only one contact, use GET
DavResource remote = bunch[0];
ResponseBody body = remote.get("text/calendar");
String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag;
Charset charset = Charsets.UTF_8;
MediaType contentType = body.contentType();
if (contentType != null)
charset = contentType.charset(Charsets.UTF_8);
@Cleanup InputStream stream = body.byteStream();
processVTodo(remote.fileName(), eTag, stream, charset);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()]));
// process multiget results
for (DavResource remote : davCollection.members) {
String eTag;
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
if (getETag != null)
eTag = getETag.eTag;
else
throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
if (getContentType != null && getContentType.type != null) {
MediaType type = MediaType.parse(getContentType.type);
if (type != null)
charset = type.charset(Charsets.UTF_8);
}
CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME);
if (calendarData == null || calendarData.iCalendar == null)
throw new DavException("Received multi-get response without address data");
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes());
processVTodo(remote.fileName(), eTag, stream, charset);
}
}
}
}
// helpers
private LocalTaskList localTaskList() { return ((LocalTaskList)localCollection); }
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
private void processVTodo(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
Task[] tasks;
try {
tasks = Task.fromStream(stream, charset);
} catch (InvalidCalendarException e) {
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e);
return;
}
if (tasks.length == 1) {
Task newData = tasks[0];
// update local task, if it exists
LocalTask localTask = (LocalTask)localResources.get(fileName);
if (localTask != null) {
App.log.info("Updating " + fileName + " in local tasklist");
localTask.setETag(eTag);
localTask.update(newData);
syncResult.stats.numUpdates++;
} else {
App.log.info("Adding " + fileName + " to local task list");
localTask = new LocalTask(localTaskList(), newData, fileName, eTag);
localTask.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring " + fileName);
}
}

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