Compare commits

..
774 Commits
Author SHA1 Message Date
Ricki Hirner 591a4ad423 another 5 minutes wasted life time 2017-10-31 14:01:42 +01:00
Ricki Hirner 7b479d270a Version bump 2017-10-31 13:41:29 +01:00
Ricki Hirner 58c44f6213 Fetch translations from Transifex 2017-10-31 13:38:38 +01:00
Ricki Hirner b81a0c1eb6 Change tasks icon, add some view descriptions 2017-10-31 13:36:50 +01:00
Ricki Hirner 37a28df7d8 Handle exceptions in settings providers 2017-10-31 13:22:52 +01:00
Ricki Hirner 19796e6669 Fetch translations from Transifex 2017-10-31 09:19:50 +01:00
Ricki Hirner 87719f322b ical4android minor bug fix 2017-10-31 09:19:12 +01:00
Ricki Hirner d40c2f5c56 lint 2017-10-29 14:57:40 +01:00
Ricki Hirner 48962d1bfa Use vector drawable support library for Android <5 2017-10-29 14:57:38 +01:00
Ricki Hirner 42ba48253f Update build tools, gradle, support library 2017-10-28 00:46:09 +02:00
Ricki Hirner d55a1aa8b5 Version bump to 1.9.1 2017-10-25 20:43:00 +02:00
Ricki Hirner dbc4ef9618 Fetch translations from Transifex 2017-10-25 20:37:40 +02:00
Ricki Hirner 3bf9be1443 Prevent multiple syncs to be run at the same time for the same account and authority 2017-10-25 18:40:02 +02:00
Ricki Hirner e2b5ce7b10 Better handling of contact group method change
* ask for confirmation when group method is changed in account settings activity
* reset address books/force reload when contact group method has changed since last sync
2017-10-25 18:38:50 +02:00
Ricki Hirner 3d537b4472 Settings: add force reload 2017-10-25 15:22:32 +02:00
Ricki Hirner 64c3ca96ce Change homepage to davdroid.com 2017-10-23 14:23:41 +02:00
Ricki Hirner 71f73d66f2 Resource detection: use related replies to gather collection info, too 2017-10-20 13:56:38 +02:00
Ricki Hirner 0e4bb317f5 Version bump to 1.9-ose 2017-10-15 18:14:50 +02:00
Ricki Hirner 583280cf55 Change CI 2017-10-15 17:53:55 +02:00
Ricki Hirner 018f06fd53 Fetch translations from Transifex 2017-10-15 17:43:06 +02:00
Ricki Hirner bf63605d50 Calendars: allow email reminders for events, too 2017-10-15 17:34:30 +02:00
Ricki Hirner 9643cb7661 StartupDialogFragment: Use activity instead of context 2017-10-13 21:37:00 +02:00
Ricki Hirner 46669dd0e9 README updates 2017-10-13 21:21:50 +02:00
Ricki Hirner 299f50f6d9 Fetch translations from Transifex 2017-10-13 20:43:58 +02:00
Ricki Hirner af7f5e60bf Move to davdroid-ose repo 2017-10-13 14:55:38 +02:00
Ricki Hirner d869ecbc0c Minor bug fixes, move beta feedback to navigation drawer 2017-10-13 13:41:14 +02:00
Ricki Hirner d8536765e1 Managed DAVdroid, settings framework, theming, launcher icons, lib update 2017-10-13 12:54:43 +02:00
Ricki Hirner 9036f3e130 Assign UID/file name for empty contact even when creating in read-only address book (so that it can be deleted later) 2017-09-25 14:41:41 +02:00
Ricki Hirner ab284c80df cert4android: fetch translations from Transifex 2017-09-25 12:08:37 +02:00
Ricki Hirner 89b324b299 Fetch translations from Transifex 2017-09-25 12:07:10 +02:00
Ricki Hirner a5f86b13bd Version bump to 1.8.1 2017-09-25 12:03:46 +02:00
Ricki Hirner 3cdfc70692 Optimize read-only contact notifications 2017-09-25 12:03:04 +02:00
Ricki Hirner 29eb1fda9f Fetch translations from Transifex 2017-09-23 21:23:15 +02:00
Ricki Hirner 3c00520a09 Notify on read-only address books
* notify when a read-only address book is selected for synchronization
* notify when local changes have been discarded during sync
2017-09-23 21:12:09 +02:00
Ricki Hirner d20aa562ad Initial implementation of read-only address books 2017-09-23 21:12:09 +02:00
Ricki Hirner 550612a282 Fetch translations from Transifex 2017-09-20 22:15:09 +02:00
Ricki Hirner ef496f3986 Version bump to 1.8 2017-09-20 22:14:27 +02:00
Ricki Hirner f4e7fd957d Reminders: use seconds, too 2017-09-20 22:13:45 +02:00
Ricki Hirner 4f6051a03b Fetch translations from Transifex 2017-09-15 13:29:02 +02:00
Ricki Hirner 670dc5ae9b Abbreviate too long log messages to reduce OOM errors 2017-09-15 13:26:03 +02:00
Ricki Hirner 7d6a052e74 Move strings out of global App context 2017-09-15 13:26:01 +02:00
Ricki Hirner a7f59ded3f Change options for sync intervals
* remove 5 and 10 minutes because Android now enforces a minimum interval of 15 min
  (which makes sense in terms of battery optimization)
* add 30 min
2017-09-14 18:18:32 +02:00
Ricki Hirner 67646e4ce0 ical4j update 2017-09-14 17:29:58 +02:00
Ricki Hirner d8346fa24a Fetch translations from Transifex 2017-09-13 17:20:51 +02:00
Ricki Hirner 54a41f02a4 Tests 2017-09-13 17:17:18 +02:00
Ricki Hirner 62d989d40a Webcal support in UI 2017-09-13 17:11:12 +02:00
Ricki Hirner 533abe80a1 database support for subscribed calendars (Webcal feeds)
* resource detection: detect/check subscribed calendars
* database: distinguish between CalDAV calendar and subscribed calendar
2017-09-13 17:09:01 +02:00
Ricki Hirner 4c8fa249cd Version bump to 1.7.3 2017-09-10 20:19:01 +02:00
Ricki Hirner 0551140f90 Fetch translations from Transifex 2017-09-10 20:17:10 +02:00
Ricki Hirner a0bfe7a784 Make SyncManager Closeable 2017-09-10 20:16:08 +02:00
Ricki Hirner 45d6bf1eae Don't allow calendars which don't support VEVENT and/or VTODO to be selected for sync 2017-09-10 14:04:08 +02:00
Ricki Hirner 856f0bdf53 Upgrade to okhttp/3.9.0 2017-09-10 01:31:19 +02:00
Ricki Hirner 27831a0d4d cert4android: don't keep CustomCertManager in memory all the time
* remove static reference to CustomCertManager (and thus a Context)
* HttpClient now wraps OkHttpClient and is Closeable
2017-09-09 22:34:45 +02:00
Ricki Hirner 39fa8a1a14 Update cert4android 2017-09-09 22:25:42 +02:00
Ricki Hirner 13ca1a7ef9 Fetch cert4android translations from Transifex 2017-09-01 15:13:30 +02:00
Ricki Hirner 71804a59bb Fetch translations from Transifex 2017-09-01 15:12:16 +02:00
Ricki Hirner 37b3ee879d Version bump to 1.7.2 2017-09-01 15:11:46 +02:00
Ricki Hirner 57cb80768d donation popup; ProGuard
* don't show donation popup too often
* ProGuard fixes
2017-08-31 01:05:38 +02:00
Ricki Hirner b6d378d346 Fetch cert4android translations from Transifex 2017-08-30 21:04:49 +02:00
Ricki Hirner 9ff53fbb1e Fetch translations from Transifex 2017-08-30 21:01:55 +02:00
Ricki Hirner eac976379a Make event color support optional (opt-in) 2017-08-30 20:22:04 +02:00
Ricki Hirner d444d95f12 Update to Android support library 26.0.1 2017-08-30 12:32:16 +02:00
Ricki Hirner af61ac38c8 Optimize logging 2017-08-29 18:07:45 +02:00
Ricki Hirner 2e9f1d2d66 Fetch translations from Transifex 2017-08-22 15:16:54 +02:00
Ricki Hirner 2262685465 Version bump to 1.7.1 2017-08-22 15:16:04 +02:00
Ricki Hirner 4df37759d7 Add donation startup fragment again 2017-08-22 14:58:11 +02:00
Ricki Hirner fd9e626593 ProGuard: keep ThreeTen; update build tools and Kotlin version 2017-08-22 14:44:38 +02:00
Ricki Hirner 9f890a8c8d Fetch translations from Transifex 2017-08-19 12:16:52 +02:00
Ricki Hirner 1e0a146dc4 Version bump to 1.7 2017-08-19 12:16:26 +02:00
Ricki Hirner 8836bf89a8 Fix migration 2017-08-15 16:11:38 +02:00
Ricki Hirner dcbbc9a545 Fetch translations from Transifex 2017-08-15 15:52:23 +02:00
Ricki Hirner 319845f5b1 Update Kotlin, vcard4android 2017-08-15 15:51:55 +02:00
Ricki Hirner dd7f1399f3 Handle conflict when renaming accounts 2017-08-10 17:39:39 +02:00
Ricki Hirner 8de6ebbe8f WiFi restriction: allow multiple SSIDs (comma-separated) 2017-08-08 13:28:04 +02:00
Ricki Hirner 82c5d424a7 Add support for event colors 2017-08-07 22:50:19 +02:00
Ricki Hirner bdb15e0845 Scroll to external log settings when tapping notification 2017-08-05 15:50:50 +02:00
Ricki Hirner 14b5e66192 Version bump to 1.6.5 (160) 2017-08-05 13:30:02 +02:00
Ricki Hirner 636dd1b86a Fetch lib translations from Transifex 2017-08-05 13:15:10 +02:00
Ricki Hirner 152624a984 Fetch translations from Transifex 2017-08-05 13:13:25 +02:00
Ricki Hirner 3b9e95d4e3 Minor optimizations 2017-08-05 13:12:47 +02:00
Ricki Hirner e64a5f408c Fix DavResourceFinder 2017-08-03 23:51:39 +02:00
Ricki Hirner 09e53e898d Fix device rotate crash bug, cert4android race condition 2017-08-03 20:47:28 +02:00
Ricki Hirner d0f879820b Fix custom certificate socket factory 2017-08-02 17:58:57 +02:00
Ricki Hirner 02f9843d47 Remove TextUtils dependency; tests 2017-08-02 17:31:51 +02:00
Ricki Hirner 1c303e43eb Tests 2017-08-02 16:35:28 +02:00
Ricki Hirner cfc7f96f83 Rewrite last UI classes to Kotlin; allow cancellation of resource detection 2017-08-02 16:28:59 +02:00
Ricki Hirner 47c1e0b8ea Rewrite most UI classes to Kotlin 2017-08-01 15:00:06 +02:00
Ricki Hirner c5958de17b Fix addAccount authenticator crash 2017-07-31 17:48:18 +02:00
Ricki Hirner b890c73d04 Rewrite some UI classes to Kotlin 2017-07-31 17:38:28 +02:00
Ricki Hirner 13b749974b Rewrite to Kotlin
* rewrite some UI classes
* move logic from App to CustomCertificates and Logger singletons
2017-07-31 15:55:01 +02:00
Ricki Hirner f65ac7275e Allow to remove all WiFi SSID restrictions when "sync only on WiFi" is active 2017-07-30 14:55:49 +02:00
Ricki Hirner 0210ba0ad9 cert4android: fetch translations from Transifex 2017-07-27 16:51:08 +02:00
Ricki Hirner d5a9a65209 Fetch translations from Transifex 2017-07-27 16:49:22 +02:00
Ricki Hirner b16c7679b8 Version bump to 1.6.4 2017-07-27 16:48:06 +02:00
Ricki Hirner c4ecc28d66 cert4android, vcard4android updates 2017-07-27 16:47:52 +02:00
Ricki Hirner b638c2584b Improve debug info 2017-07-27 16:47:52 +02:00
Ricki Hirner 4be6a25eb6 Some minor fixes 2017-07-20 17:26:50 +02:00
Ricki Hirner 797bb78d20 Rewrite LoginCredentials to Kotlin 2017-07-19 22:05:58 +02:00
Ricki Hirner ee696d233e Fix: SSLSocket doesn't implement Closeable in Android 4.1 2017-07-19 19:49:49 +02:00
Ricki Hirner a1d2b2ab59 Update copyright 2017-07-19 19:32:46 +02:00
Ricki Hirner 4cb60ca78c Rewrite syncadapter package to Kotlin 2017-07-19 19:11:02 +02:00
Ricki Hirner e2c47bbe92 Rewrite all remaining classes except syncadapter and ui package 2017-07-16 15:30:58 +02:00
Ricki Hirner 53e333ee22 Rewrite DavService to Kotlin 2017-07-16 01:07:58 +02:00
Ricki Hirner a3181d35b4 Rewrite resource package to Kotlin 2017-07-14 20:58:29 +02:00
Ricki Hirner 4948471068 Rewrite some classes to Kotlin 2017-07-10 21:09:15 +02:00
Ricki Hirner ed1acb47c7 Rewrite model package to Kotlin 2017-07-10 01:31:45 +02:00
Ricki Hirner c73d47b1ec Rewrite log package to Kotlin 2017-07-08 14:31:23 +02:00
Ricki Hirner b306933f95 Use x86 emulator 2017-07-07 00:14:05 +02:00
Ricki Hirner dba55d2f2a Fetch translations from Transifex 2017-07-06 12:10:08 +02:00
Ricki Hirner d20f0986d9 Version bump to 1.6.3 2017-07-06 12:07:27 +02:00
Ricki Hirner 1bc6880dc0 More logging (ical4j) + ProGuard 2017-07-06 12:06:54 +02:00
Ricki Hirner 8d0e4c6a46 Some fixes
* don't acquire contacts provider client for address book management (would require contacts permission check)
* rename task lists: handle all kinds of Exception
* lib updates
2017-07-04 15:01:06 +02:00
Ricki Hirner 1da8543e24 Minor bug/NPE fixes 2017-07-04 15:01:06 +02:00
Ricki Hirner 29792d915a Fetch translations from Transifex 2017-07-03 18:54:12 +02:00
Ricki Hirner 9654a23fd1 Require SDK level 16
In Android < 4.1 (< level 16), android.database.Cursor doesn't implement Closeable,
although it provides close(). Since we now use the Kotlin .use() idiom (which requires
a Closeable) instead of calling close() manually or via Lombok, we need Closeable
Cursors and it's not worth to work around this because Android 4.0.4 is now very old.
2017-07-03 18:46:56 +02:00
Ricki Hirner 4f8031a3a4 Improve preview release startup fragment 2017-07-02 14:42:01 +02:00
Ricki Hirner eb6fa1ab7f Contacts: use hashCode() hack only for Android 7; problem has been fixed by Android 8 2017-07-02 14:08:04 +02:00
Ricki Hirner 29bf7ad204 vcard4android: Kotlin 2017-06-25 16:06:15 +02:00
Ricki Hirner 18e2a07415 Update gradle plugin 2017-06-21 13:00:08 +02:00
Ricki Hirner 49d1011b4a SDK/build tools level 26 2017-06-21 00:03:44 +02:00
Ricki Hirner 9b6c9b6c03 Version bump to 1.6.2 2017-06-20 15:18:16 +02:00
Ricki Hirner 953ffa6afe Update libs; update Commons Lang to 3.6 2017-06-20 15:17:30 +02:00
Ricki Hirner e0a0f92c1e Lib updates, okhttp/3.8.1 2017-06-20 15:17:30 +02:00
Ricki Hirner ce493865e7 ical4android: Kotlin 2017-06-20 15:17:30 +02:00
Ricki Hirner 77c9126be4 Version bump to 1.6.1.1 (151) 2017-06-16 18:57:06 +02:00
Ricki Hirner 9bb56d4f58 Lib updates 2017-06-16 17:54:41 +02:00
Ricki Hirner 908a3be678 Fetch translations from Transifex 2017-06-09 16:33:38 +02:00
Ricki Hirner 75dbbbf4ed Version bump to 1.6.1 2017-06-09 16:33:06 +02:00
Ricki Hirner 2599a15515 dav4android: Kotlin, cert4android: handle InterruptedException 2017-06-09 00:41:34 +02:00
Ricki Hirner 15fe102049 Birthdays without years, again 2017-06-05 14:35:54 +02:00
Ricki Hirner 6f55e51fef Fetch translations from Transifex 2017-06-03 19:27:49 +02:00
Ricki Hirner 1bdb004daf Version bump to 1.6 2017-06-03 19:27:06 +02:00
Ricki Hirner 30e83f45a5 Improve resource detection
* scan unrequested responses for useful auto-detection information
* cert4android: Kotlin
2017-06-03 19:26:43 +02:00
Ricki Hirner 7c8a8ee9fc Support for birthdays without year 2017-06-02 00:38:23 +02:00
Ricki Hirner 8747deeea7 Remove "ical4android" from iCal PRODID (same format as for VCard) 2017-05-14 13:36:49 +02:00
Ricki Hirner 63d2a7d0ef okhttp 3.8.0 ProGuard settings 2017-05-14 13:01:13 +02:00
Ricki Hirner d89e0a392f Fetch translations from Transifex 2017-05-14 12:18:50 +02:00
Ricki Hirner bb6ce6a355 Version bump to 1.5.2 2017-05-14 12:18:02 +02:00
Ricki Hirner f59fd7205d Remove "vcard4android" from VCard PRODID to avoid folding for better compatibility 2017-05-14 12:11:58 +02:00
Ricki Hirner 0dd1fa47b8 Upgrade to okhttp/3.8.0 2017-05-14 11:33:42 +02:00
Ricki Hirner a067ff03b3 Use UUIDs for newly generated event/task UIDs (RFC 7986 5.3 UID Property) 2017-04-27 13:36:10 +02:00
Ricki Hirner a652a230c6 Fetch translations from Transifex 2017-04-25 14:11:47 +02:00
Ricki Hirner c2a616fe52 Version bump to 1.5.1-ose 2017-04-25 14:11:22 +02:00
Ricki Hirner 1ac573d403 Use untranslated User-Agent string
* refactoring: don't use global string variables
2017-04-24 23:00:11 +02:00
Ricki Hirner 80a8ce6f8c Check sync conditions for contacts sync, too 2017-04-23 18:10:01 +02:00
Ricki Hirner 33e45d3ae9 Remove unnecessary InvalidAccountException 2017-04-22 21:37:19 +02:00
Ricki Hirner f701a63e27 Allow null values for IS_ORGANIZER 2017-04-18 23:54:11 +02:00
Ricki Hirner 5594d2b284 README updates 2017-04-16 21:58:30 +02:00
Ricki Hirner dcd99f7bca Version bump to 1.5.0.3 2017-04-16 15:51:51 +02:00
Ricki Hirner df086e7ab5 Fetch translations from Transifex 2017-04-16 15:49:02 +02:00
Ricki Hirner 5b31d06dcf Upgrade libraries 2017-04-16 14:31:50 +02:00
Ricki Hirner a39564a179 Unify strings
* HTTP client: use app name as User-Agent
* use string resources for homepage URLs
2017-04-11 16:35:29 +02:00
Ricki Hirner 18d8162074 gradle, dav4android updates
* update Android gradle plugin to 2.3.1
* dav4android update: follow redirects on DELETE
2017-04-08 20:26:38 +02:00
Ricki Hirner 27a4c47c35 Version bump to 1.5.0.2 2017-04-02 19:23:38 +02:00
Ricki Hirner 1bed502e67 Fix some inconsistencies 2017-04-02 19:19:38 +02:00
Ricki Hirner d2dd27f99a Open DAVdroid main activity when add a "DAVdroid Address book" account is added over Settings 2017-03-30 21:23:29 +02:00
Ricki Hirner 2d83ed6be4 Account settings: restart loader after sync interval update
* debug info: add signature
2017-03-29 12:39:59 +02:00
Ricki Hirner de608312a2 Hotfix: don't crash on empty address book displayName 2017-03-27 11:43:14 +02:00
Ricki Hirner 0b1bd35517 Version bump to 1.5 2017-03-26 19:30:16 +02:00
Ricki Hirner 44fb3713a4 Fetch translations from Transifex 2017-03-26 19:30:13 +02:00
Ricki Hirner ce2e7e24b1 Remove all references to Constants.ACCOUNT_TYPE (now a to string resource) 2017-03-26 19:13:51 +02:00
Ricki Hirner fa574aaa8d Improve address book details in debug info 2017-03-26 19:07:26 +02:00
Ricki Hirner 82aa7012d3 Add more debug information
* power saving status
* permissions
* address book accounts
2017-03-25 20:13:07 +01:00
Ricki Hirner 0789134948 Enable SSL_RSA_WITH_3DES_EDE_CBC_SHA for all Android versions
* refactor cipher selection
2017-03-25 20:13:07 +01:00
Ricki Hirner d4bd96d846 Version bump to 1.5-beta1 2017-03-19 20:44:07 +01:00
Ricki Hirner 038b106b37 Multiple address books, segment 2
* migration from old address book to new address book accounts (5 -> 6)
* support renaming of address book accounts
* change address book accounts properly when the main account is renamed
2017-03-19 20:36:06 +01:00
Ricki Hirner 5a7aeb3eba Support for multiple address books per account
* new account type: "DAVdroid address book", which is assigned to a DAVdroid main account
* stub content provider "Address books", which enumerates all DAVdroid address book accounts and runs contacts sync for them
2017-03-19 20:28:13 +01:00
Ricki Hirner a33e70162c Unify action bar icon colors 2017-03-12 13:47:52 +01:00
Ricki Hirner 3245b47635 Fix maven 2017-03-03 13:25:51 +01:00
Ricki Hirner 7b965769c9 Update translations from Transifex 2017-03-03 12:52:51 +01:00
Ricki Hirner 91f62614ab Update gradle 2017-03-03 12:51:12 +01:00
Ricki Hirner c875b4b3ae Version bump to 1.4.1 2017-03-02 23:44:43 +01:00
Ricki Hirner 286ceeea3f Fetch translations from Transifex 2017-03-02 23:44:43 +01:00
Ricki Hirner 8b026d7e16 AccountsActivity: show message when global sync is disabled 2017-03-02 23:44:39 +01:00
Ricki Hirner 2f97830f28 Gradle update to 3.4 2017-02-27 17:39:22 +01:00
Ricki Hirner 75827d89c5 Fetch translations from Transifex 2017-02-17 13:55:08 +01:00
Ricki Hirner 1899c86494 Version bump to 1.4.0.3 2017-02-17 13:54:09 +01:00
Ricki Hirner 4a7b6cf546 Don't use uid2445 column on Android <4.2; alarm ACTION: compare only value (ignore parameters) 2017-02-17 09:55:50 +01:00
Ricki Hirner 0b83c1ad2f Version bump to 1.4.0.2 2017-02-12 19:01:43 +01:00
Ricki Hirner a9380a8e1b Improve Android 7 workaround behavior in combination with CATEGORIES/VCard4 contact groups 2017-02-12 18:59:41 +01:00
Ricki Hirner b756a5f54c Retain Events.UID_2445 when preparing events for upload
* move file name/UID generation from SyncManager to LocalContact, LocalEvent, LocalTask
* rename updateFileNameAndUID() to prepareForUpload()
* use random UUID for contacts, UidGenerator with Android device ID for events/tasks
* LocalEvent.prepareForUpload(): use existing UID_2445 if available
2017-02-10 18:06:28 +01:00
Ricki Hirner 762c379c54 Android 7 workaround: update hash after group membership operations 2017-02-10 18:06:28 +01:00
Ricki Hirner 8f8421e65e Version bump to 1.4.0.1 2017-02-06 11:59:06 +01:00
Ricki Hirner 0e67da5718 Android 7 workaround bugfix
* use local version of contact before calculating hash code
* don't stop upload sync if there are deleted contacts
2017-02-06 11:57:51 +01:00
Ricki Hirner cf8b1e97bd Version bump to 1.4.0 2017-02-05 17:21:09 +01:00
Ricki Hirner 61885b41d9 Use contact hash codes only on Android 7+ (workaround)
vcard4android: don't hash CATEGORIES, more verbose logging
2017-02-05 17:20:49 +01:00
Ricki Hirner f74cecdc1f Implement checksum to check whether DIRTY contacts have "really" changed
* contact data hash code = hash code of data fields and group memberships
* Before every contact sync, all dirty contacts are checked whether they're
  "really dirty" (= data hash code has changed). If they're not, the DIRTY
  flag is reset. Works around Android 7 behavior of setting contacts to DIRTY
  even if onky meta data has been updated (for instance, lastContacted after
  a call or SMS),
* When an "upload" sync is initiated by notifyChange and there are no
  "really dirty" contacts, the sync is ignored.
* contact upload: clearDirty() saves hash code, too
* contact download: create()/update() saves hash code, too
* debugging: sync flags (extras) are now logged
2017-02-01 19:19:17 +01:00
Ricki Hirner d8ffd83e1e AccountSettingsActivity: use loader
* use Loader for AccountSettingsActivity sync intervals (fixes Android 7 display "issues")
* SyncManager: allow prepare() to skip synchronization
2017-02-01 01:20:44 +01:00
Ricki Hirner 666233136d Upgrade to okhttp 3.6.0 2017-01-30 22:27:54 +01:00
Ricki Hirner eae5631d8a Version bump to 1.3.8 2017-01-29 20:11:15 +01:00
Ricki Hirner dc614bf733 Fetch translations from Transifex (fixes crash in Spanish version) 2017-01-29 19:18:57 +01:00
Ricki Hirner 22c9603b3c Add information about current local and remote resource to debug info 2017-01-29 19:17:53 +01:00
Ricki Hirner 073f9230ba Upgrade to gradle 3.3 2017-01-29 19:17:53 +01:00
Ricki Hirner 3948e83f83 Use isAlwaysSyncable for contacts/calendars again because of buggy Android firmwares 2017-01-09 21:28:28 +01:00
Ricki Hirner 12664652e5 Version bump to 1.3.7 2017-01-08 23:35:41 +01:00
Ricki Hirner d1162abf4c Change authentication restriction to domains instead of host names 2017-01-08 19:09:34 +01:00
Ricki Hirner a93fa72d5a Delete local contacts when no CardDAV collection is selected 2017-01-06 15:53:23 +01:00
Ricki Hirner 3c8c3fe9b8 Version bump to 1.3.6 2017-01-01 12:43:47 +01:00
Ricki Hirner a46f2d118c Fetch translations from Transifex 2017-01-01 12:26:02 +01:00
Ricki Hirner dc0221dabb Update to ez-vcard 0.10.1
* fix REV and PREF problems
2017-01-01 01:13:03 +01:00
Ricki Hirner a5e80d2268 Fix permissions notification
* ical4android: remove ORGANIZER from all VEVENT components if there are not attendees
2016-12-31 14:16:18 +01:00
Ricki Hirner dbd200cb6c AccountSettings version 5: enable/disable OpenTasks by availability (Android 7.1.1 fix)
* better handling of setIsSyncable
2016-12-30 14:29:56 +01:00
Ricki Hirner aacff632b7 Don't show warning on AccountSettings version updates 2016-12-30 14:28:28 +01:00
Ricki Hirner e4861b0a68 Update to SDK level 25 2016-12-30 02:58:54 +01:00
Ricki Hirner 8d1b6da197 Change handling of tasks sync when OpenTasks is not installed
* AccountDetailsFragment: at account creation, enable task sync only when OpenTasks is installed
* PackageChangedReceiver: when packages are (un)installed, check for OpenTasks availability and (de)activate task sync for all accounts accordingly
* LocalTaskList: don't cache OpenTasks availability
* sync_*.xml: don't activate sync by default
2016-12-28 22:23:13 +01:00
Ricki Hirner bf21c1338e Fetch translations from Transifex 2016-12-23 15:54:20 +01:00
Ricki Hirner 9452d6667a Version bump to 1.3.5 2016-12-23 15:51:56 +01:00
Ricki Hirner 6967773690 Address book selection changed: update URL as soon as possible 2016-12-19 18:58:35 +01:00
Ricki Hirner 73b95379fe Update gradle to 3.2.1; ical4android/vcard4android updates 2016-12-18 22:02:44 +01:00
Ricki Hirner 9f89173638 Update okhttp to 3.5.0 2016-12-02 15:01:52 +01:00
Ricki Hirner 77a0201327 Log group assignments more verbosely 2016-11-25 21:40:40 +01:00
Ricki Hirner 21c3f81a8b Rename account: don't crash when content providers are not accessible 2016-11-17 19:59:23 +01:00
Ricki Hirner 035dc4f0b2 Version bump to 1.3.4.1 2016-11-14 18:48:26 +01:00
Ricki Hirner 48fec240cf Fetch translations from Transifex 2016-11-14 18:41:48 +01:00
Ricki Hirner 82a6faa8de Avoid some crashes
* check whether ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATION can be resolved before launching it
* cert4android: don't crash when service can't be bound
2016-11-14 18:39:25 +01:00
Ricki Hirner a4bd6d6a07 Allow renaming of accounts
* allow renaming of accounts
* always open AccountActivity, even if there are no services (so that users can delete the account from within DAVdroid)
2016-11-14 01:14:46 +01:00
Ricki Hirner b91743239a Fetch translations from Transifex 2016-11-13 20:34:11 +01:00
Ricki Hirner 611dff15bb Update build tools to 25.0.0, fix WiFiManager leak 2016-11-13 20:22:10 +01:00
Ricki Hirner cdd56d88fb Fetch translations from Transifex
ical4android: fix for events without dtend/duration
2016-11-06 17:36:59 +01:00
Ricki Hirner 198c53698d Fetch translations from Transifex 2016-11-04 12:02:08 +01:00
Ricki Hirner 3924b72a40 Version bump to 1.3.4
* library updates
2016-11-04 12:01:24 +01:00
Ricki Hirner 6520a4dff8 Add app-wide HTTP proxy setting 2016-10-30 22:21:54 +01:00
Ricki Hirner 9e62c44bef Debug info: send inline on Android <4.1 and when creating an attachment doesn't work 2016-10-22 02:05:58 +02:00
Ricki Hirner 9df01a7477 Version bump to 1.3.3.1 2016-10-21 19:52:55 +02:00
Ricki Hirner c62eb6cd0e Fetch translations from Transifex 2016-10-21 19:50:09 +02:00
Ricki Hirner 8465df483e Library updates
* dav4android: disable compression for GET requests because it may change the ETag
* better logging for ical4j messages
* tests
2016-10-21 11:08:50 +02:00
Ricki Hirner 2366356922 ProGuard update; signing config 2016-10-18 12:36:41 +02:00
Ricki Hirner 5655aad59a Use string resource for logging file provider authority; vcard4android update 2016-10-17 23:57:53 +02:00
Ricki Hirner 65ddab6ed5 Share debug info: always use attachment
* share debug info: always use attachment (before: send inline if it was small enough)
* use FileProvider for debug info attachment (for Android 7 compatibility)
* dav4android, ical4android fixes
2016-10-17 17:47:27 +02:00
Ricki Hirner d914addbb2 Add useless ProGuard rule 2016-10-15 16:51:35 +02:00
Ricki Hirner b9a4489f3c Fetch translations from Transifex 2016-10-14 21:19:39 +02:00
Ricki Hirner 9d3b9c963a Version bump to 1.3.3 2016-10-14 21:00:29 +02:00
Ricki Hirner 72d981f361 dav4android: always use UTF-8 for Basic/Digest auth credentials 2016-10-13 15:23:11 +02:00
Ricki Hirner 6eeb484c56 ical4android: ignore invalid DUE < DTSTART for tasks 2016-10-12 17:03:46 +02:00
Ricki Hirner cd7332b67c Remove VCard RFC6868 setting (always enabled now; setting not needed for Posteo compatibility anymore) 2016-10-12 16:45:26 +02:00
Ricki Hirner 1a8ff69750 Gitlab CI: install OpenTasks before tests 2016-10-12 13:04:21 +02:00
Ricki Hirner 991221f0ee vcard4android: ez-vcard 0.10.0 2016-10-11 23:36:30 +02:00
Ricki Hirner ef4267e173 Test adaptions 2016-10-11 00:28:22 +02:00
Ricki Hirner ba029e3da4 Switch to JUnit4 2016-10-10 21:03:18 +02:00
Ricki Hirner 4270807e2a Add Gitlab CI 2016-10-10 20:18:37 +02:00
Ricki Hirner 8456d078f1 Improve tests 2016-10-07 14:39:21 +02:00
Ricki Hirner aa693cebb9 Fix NPE in "is refreshing progress bar" 2016-10-07 14:39:18 +02:00
Ricki Hirner 219a7ed38f Version bump to 1.3.2.2 2016-10-05 11:14:41 +02:00
Ricki Hirner 2aa90b4f9c Enable verbose logging of allow loggers (for instance, okhttp) / dav4android update 2016-10-04 23:42:03 +02:00
Ricki Hirner 26f9352411 Android 4.0/4.1 fixes
* require API level 15 for TransactionTooLargeException
* use SQLite WAL only on API level 16+
* various database access, provider access and UI fixes
2016-10-04 16:23:23 +02:00
Ricki Hirner 279c51c4a6 Version bump to 1.3.2 2016-10-03 20:57:14 +02:00
Ricki Hirner 98f4dd4c87 Fetch translations from Transifex 2016-10-03 20:43:01 +02:00
Ricki Hirner 5e1cb21d55 Avoid "no transaction" exception 2016-10-03 20:11:56 +02:00
Ricki Hirner aa6e43cc94 Minimal layout change 2016-10-03 12:13:59 +02:00
Ricki Hirner 992e83fdb1 Show progress bar when synchronization is active 2016-09-26 23:07:35 +02:00
Ricki Hirner cb73c1ebf7 Increase SEQUENCE only when we're ORGANIZER 2016-09-26 23:07:35 +02:00
Ricki Hirner 09d8f63515 Query/use CalDAV email address as account name, if available 2016-09-26 23:07:35 +02:00
Ricki Hirner 4f4cf25027 Always increase SEQUENCE 2016-09-26 23:07:35 +02:00
Ricki Hirner de4e81977a lint: don't keep references to Context in static fields 2016-09-26 23:07:35 +02:00
Ricki Hirner 10bbfcabcb Version bump to 1.3.1
* some cert4android tests
2016-09-18 17:39:07 +02:00
Ricki Hirner b5d295e57d Import strings from Transifex 2016-09-18 16:50:27 +02:00
Ricki Hirner 49555c34d8 Always use PROPFIND instead of REPORT addressbook-query 2016-09-18 16:43:11 +02:00
Ricki Hirner 9b6903572a README changes 2016-09-02 12:22:45 +02:00
Ricki Hirner f16d64d636 Fetch translations from Transifex 2016-09-02 12:13:22 +02:00
Ricki Hirner bceb5b643a lint optimizations
* permissions: declare AUTHENTICATE_ACCOUNTS, GET_ACCOUNTS and MANAGE_ACCOUNTS only until SDK level 22
* minor optimizations and bug fixes
2016-09-02 12:02:42 +02:00
Ricki Hirner 7e783feecf Version bump to 1.3
* vcard4android: fix bug concerning generated formatted postal addresses
2016-09-02 00:55:44 +02:00
Ricki Hirner f9040f65fe New launcher logo 2016-09-02 00:55:39 +02:00
Ricki Hirner b73a825ac9 Use cert4android instead of MemorizingTrustManager
* use cert4android instead of MemorizingTrustManager
* new app setting: distrust system certificates
* add network security config to manifest so that user-installed CAs will be accepted in Android 7 again
* update gradle
2016-09-02 00:38:02 +02:00
Ricki Hirner 67104b96fd Accept intent extras for LoginActivity 2016-08-13 23:14:33 +02:00
Ricki Hirner e2c8fc9662 Fetch translations from Transifex 2016-08-06 00:13:16 +02:00
Ricki Hirner 40e46b721f Fix OpenTasks regression bug
* version bump to 1.2.3
* enable OpenTasks sync on Android <6 again
2016-08-05 23:32:03 +02:00
Ricki Hirner 30e6f44e44 Improve HTTP authentication
* use preemptive Basic auth automatically for HTTPS connections
* cache auth parameters (Basic/Digest)
2016-08-05 23:20:19 +02:00
Ricki Hirner fd06c00fca Fetch translations from Transifex 2016-08-02 19:30:20 +02:00
Ricki Hirner 307c234f4a Request ignoring battery optimization
* startup dialog: request to ignore battery optimizations
* remove F-Droid donation startup dialog (only useful for davdroid-ose)
* version bump to 1.2.2
2016-08-02 19:30:15 +02:00
Ricki Hirner d6dda6fbeb Avoid sync error when OpenTasks is not installed 2016-08-01 21:54:56 +02:00
Ricki Hirner b4cb8234f1 Clean up launcher icon
* clean up launcher icon
* update dependencies
2016-08-01 21:15:55 +02:00
Ricki Hirner a5fb9fd214 Allow large transactions
* version bump to 1.2.1-ose
* upgrade to okhttp 3.4.1
* ical4android/vcard4android: split oversized transactions
2016-07-27 14:33:06 +02:00
Ricki Hirner 3bbbba15b3 Remove gplay flavour to keep DAVdroid-OSE repo clean
* Remove gplay flavour to keep DAVdroid-OSE repo clean
* update Android gradle plugin to 2.1.2
2016-07-11 13:45:27 +02:00
Ricki Hirner ab72b0df1d Fix NPE, add intent to view FAQ to upgrade notification 2016-07-11 12:55:55 +02:00
Ricki Hirner 909051521e Version bump to 1.2
* move ETag requirement from vcard4android to davdroid
* more debug info
* vcard4android: support for custom labels (X-ABLabel)
2016-07-02 10:32:12 +02:00
Ricki Hirner b9f07386c1 Support X-ABLabel for custom types
* vcard4android: support X-ABLabel for custom types
2016-07-01 22:10:20 +02:00
Ricki Hirner ddca7992ff Version bump to 1.1.1.2
* allow ProGuard optimization to remove non-relevant bytecode from flavors
* check flavors with direct comparison instead of .equals() to allwo optimizations
* store cookies per HttpClient, and not per DAVdroid instance (allows multiple sessions for parallel syncs)
* fetch translations from Transifex
2016-06-24 13:39:26 +02:00
Ricki Hirner 216c1dee80 Add standard and gplay product flavor 2016-06-24 00:06:43 +02:00
Ricki Hirner 27cf6ab222 Version bump to 1.1.1.1
* add yield points to allow processing of groups with many contacts
* new script to generate contacts for testing
2016-06-23 11:42:12 +02:00
Ricki Hirner 70ce90b40b Version bump to 1.1.1
* fetch translations from Transifex
2016-06-21 21:05:18 +02:00
Ricki Hirner 0feacb5daa Handle cookies correctly using a name/domain/path MultiKeyMap 2016-06-21 20:51:52 +02:00
Ricki Hirner 230e64ecac Version bump to 1.1
* fetch translations from Transifex
* fix account settings version update routine
2016-06-19 19:15:38 +02:00
Ricki Hirner ea1bef4764 Better group support
* change group methods to less specific values
* new account settings version: change group method to CATEGORIES for updated accounts
* change group method from CATEGORIES to GROUP_VCARDS automatically when a group VCard is received

GUI:
* AccountSettings: disable CalDAV/CardDAV options when the corresponding service is not available
* AccountSettings: new option to choose contact group method
* account setup: allow to choose contact group method at account creation
2016-06-19 18:52:56 +02:00
Ricki Hirner 1c61f537d2 Merge branch 'master' into vcard4-groups 2016-06-12 15:52:07 +02:00
Ricki Hirner 123aea3766 Version bump to 1.0.9.2
* version bump to 1.0.9.2
* fetch translations from Transifex
2016-06-11 09:06:21 +02:00
Ricki Hirner e5bf898656 Make use of RFC6868 optional
* make use of RFC6868 for VCards optional because some defect servers don't accept it
* minor UI improvements (thanks biociahi)
2016-06-11 08:53:29 +02:00
Ricki Hirner 369eab19d1 Switch from preference-v7 to preference-v14 to make preferences look more Material (thanks biociahi!) 2016-06-11 08:53:15 +02:00
Ricki Hirner 81563cf62c Switch from preference-v7 to preference-v14 to make preferences look more Material (thanks biociahi!) 2016-06-09 10:13:47 +02:00
Ricki Hirner 6542ea3bc3 Basic support for VCard4-style groups
* rewritten contact group support to support VCard3 CATEGORIES and VCard4-style KIND/MEMBER groups
* new account setting: contact group method (VCard3/VCard4/Apple "VCard4-as-VCard3")
* keep unknown properties when saving/generating VCards
2016-06-08 21:44:31 +02:00
Ricki Hirner 586621dbba Upgrade to okhttp 3.3.1 2016-05-30 12:02:04 +02:00
Ricki Hirner ea3f5d44a6 Better alarm handling
* ical4android: better alarm handling
* API change: pass OutputStream instead of returning it
2016-05-28 16:00:23 +02:00
Ricki Hirner c9a7c71362 Revert "Contact/event/task upload: use streams directly without extra byte[] array"
This reverts commit 3bde3758fc.
Version bump to 1.0.9.1

Reason: A server MAY reject a request that contains a message body but not a Content-Length
by responding with 411 (Length Required). (RFC 7230 3.3.3 Message Body Length)
2016-05-26 22:08:22 +02:00
Ricki Hirner 60ff9dae1c Revert "Contact/event/task upload: use streams directly without extra byte[] array"
This reverts commit 3bde3758fc.
2016-05-26 22:04:03 +02:00
Ricki Hirner 8c846f82b1 Version bump to 1.0.9
* upgrade to okhttp 3.3 to reduce HTTP/2 incompatibilties
* vcard4android: enable RFC 6868 support in ez-vcard
* minor improvements and bug fixes
* new translations from Transifex
* version bump to 1.0.9
2016-05-25 21:37:43 +02:00
Ricki Hirner d438d2bbdb Contact/event/task upload: use streams directly without extra byte[] array 2016-05-23 15:10:35 +02:00
Ricki Hirner 8456984341 Logs: show which contact/event/task is being prepared for upload 2016-05-23 14:28:27 +02:00
Ricki Hirner 707d03e481 Minor improvements
* use weak references for DavService RefreshingStatusListener
* additional null checks for cases which shouldn't appear, but apparently appear
* additional database conflict handling for cases which shouldn't appear, but apparently appear
* setup by URL: null check for empty host names
* vcard4android: upgrade to ezvcard 0.9.10
2016-05-20 21:38:04 +02:00
Ricki Hirner 53ef0c5261 Work around unexpected IllegalArgumentException when user enters garbage host name 2016-04-30 12:39:19 +02:00
Ricki Hirner 9098d1e051 Always update all task fields (including null values)
* ical4android: always update all task fields (including null values)
* lint optimizations
* fetch translations from Transifex
* version bump to 1.0.8
2016-04-26 23:32:24 +02:00
Ricki Hirner feaaf88940 Introduce local unit tests
* split tests into Android tests and local unit tests
* LoginCredentialsFragment: check for empty host before doing IDN conversion
2016-04-26 13:10:42 +02:00
Ricki Hirner 63f3881f44 Minor optimizations
* catch IllegalArgumentException from HttpUrl in DavResourceFinder (caused crash when logging in with email "test@server/withslash")
* use IteratorChain in DavService collection enumeration
2016-04-19 21:56:05 +02:00
Ricki Hirner b04c098398 Find collections when they're identical with their home set 2016-04-18 00:04:18 +02:00
Ricki Hirner 82b1619dcd Task list synchronization conditions, tests
* task list synchronization: don't set VISIBLE=1 and SYNC_ENABLED=1 at every sync, but only at creation
* task list synchronization: sync only task lists which are SYNC_ENABLED
* honor "manage calendar colors" account setting for task list colors, too
* add run-tests-connected.sh, to be used as pre-commit hook
* ical4android/vcard4android tests
* fetch translations from Transifex
* version bump to 1.0.7
2016-04-14 20:37:22 +02:00
Ricki Hirner 0b53d77228 Changes in ical4android and vcard4android
* don't set ORGANIZER for events without attendees
* make some lists public final instead of @Getter private
* PermissionsActivity: call refresh in onResume() instead of onCreate()
2016-04-13 14:15:34 +02:00
Ricki Hirner e1e95f0c2a Version bump to 1.0.7-beta1
* fetch translations from Transifex
2016-04-10 20:43:01 +02:00
Ricki Hirner 1209dd67ae More detailled OpenTasks installation hint
* show "reinstall DAVdroid" hint only for Android <6
* fetch translations from Transifex
2016-04-10 18:50:42 +02:00
Ricki Hirner b59fb55033 Implement Android 6-style permissions
* increase target API level to 23 (Android 6), which makes Android 6-style permissions mandatory
* AUTHENTICATE_ACCOUNTS permission is only required up to API level 22
* new activity: PermissionsActivity which shows missing permissions and provides buttons to request them
* DavService: Android shouldn't send a null Intent, but sometimes it does, so implement null check
* LocalTaskList: tasksProviderAvailable may return true on API level 23+ even if permissions are not sufficient
* SyncAdapterService: show a notification (with Intent for PermissionsActivity) when permissions are not sufficient
* when creating accounts, set OpenTasks sync always to true if API level is 23+ (even if OpenTasks is not installed [yet])
* update Lombok
2016-04-10 15:55:11 +02:00
Ricki Hirner 15985b5fcb Fetch translations from Transifex
* fetch translations
* minor changes (lint)
2016-04-07 08:38:09 +02:00
Ricki Hirner ee78ee3f53 New feature: only sync in WiFi
* new setting: only sync in WiFi (or when sync is triggered manually)
* new setting: only sync in specific WiFI (by SSID)
* lower default sync interval when account is created to 4 hours (was 1 day)
* version bump to 1.0.6
2016-04-06 21:04:16 +02:00
Ricki Hirner 8cd52bd115 Various tests 2016-04-05 23:25:18 +02:00
Ricki Hirner 5ef81de00e Check for migrations only when package is replaced, DB fixes
* AccountSettings$AppUpdatedReceiver: check for migrations only when package is replaced
* SyncAdapter: move DB helper from service to SyncAdapter to prevent databases from being closed too early
* Manual sync button: run sync immediately (without queueing)
2016-04-05 16:52:43 +02:00
Ricki Hirner 23525c9da0 SyncManager notifications: create a unique notification for every synced collection 2016-04-01 17:45:57 +02:00
Ricki Hirner 63dc698a92 Add account setting: manage calendar colors 2016-03-31 20:07:36 +02:00
Ricki Hirner c6540b3f97 Sync database optimizations
* enable WAL as early as possible
* don't close database in SyncAdapter but only in SyncService
* version bump to 1.0.4
2016-03-31 14:08:48 +02:00
Ricki Hirner cae3856edb OOM handling, DB transactions, calandar VISIBLE, service refresh notification
* handle and show OutOfMemoryErrors correctly (they're not Exceptions)
* use db.beginTransactionNonExclusive() because WAL is enabled
* set calendar VISIBLE=1 AND SYNC=1 only at creation and not at every sync
* update PendingIntent of service refresh notification
2016-03-31 12:47:43 +02:00
Ricki Hirner 14602e6743 Use last path segment as collection display name if there's no DAV:displayName
* use last path segment as collection display name if there's no DAV:displayName
* add Contacts Provider Settings again to show contacts without groups in all clients (bluetooth cars etc.)
2016-03-31 00:52:10 +02:00
Ricki Hirner bc431edeac 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 6a812e2993 Fix NPE for synchronization while CalDAV/CardDAV services is not available in DB 2016-03-30 16:07:41 +02:00
Ricki Hirner 0ddc916d1c Fetch translations from Transifex
* version 1.0_1 for commercial stores
2016-03-29 15:39:10 +02:00
Ricki Hirner bb477b3f15 Resource detection: fix NPE 2016-03-29 15:19:53 +02:00
Ricki Hirner c5664d29db Version bump to 1.0
* fetch translations from Transifex
2016-03-27 15:55:05 +02:00
Ricki Hirner e5cc346b70 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 a3e702afdc 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 5310e8ec87 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 288df32654 Import translations from Transifex 2016-03-24 19:32:45 +01:00
Ricki Hirner 228ddaa0c0 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 24ce0c1af5 Minor refactoring (lint) 2016-03-24 17:43:35 +01:00
Ricki Hirner ace720f269 Implement AboutActivity (license information) 2016-03-24 13:48:43 +01:00
Ricki Hirner f42075257b 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 6fd6279691 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 db07c6099e 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 a4a3f6cee2 SSLSocketFactoryCompat: fix typo 2016-03-23 11:30:42 +01:00
Ricki Hirner 2a7711f3cd Translation fix 2016-03-20 21:03:25 +01:00
Ricki Hirner 99fa342fa6 Version bump to 1.0-beta1
* fetch translations from Transifex
* minor changes (lint/strings)
2016-03-20 18:31:11 +01:00
Ricki Hirner 8e36e25025 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 5c2a20493b 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 b6b4d02eee Provide settings migration v0.9 -> v1.0 2016-03-19 22:30:07 +01:00
Ricki Hirner 6db1e1edc0 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 9dce028f31 Add SQLite dump to debug report 2016-03-18 19:02:27 +01:00
Ricki Hirner 730a875e23 App settings UI 2016-03-18 17:24:46 +01:00
Ricki Hirner 17fbae2aed Account management: Create address book (similar to create calendar) 2016-03-18 15:40:05 +01:00
Ricki Hirner 9e2bcda06a 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 1b5ba0b28a Minor refactoring 2016-02-24 23:21:25 +01:00
Ricki Hirner 849b75f345 Use java.util.logging instead of sl4fj 2016-02-24 23:08:19 +01:00
Ricki Hirner ba3a5d308d 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 a048461fba Refactoring
* make DavResourceFinder.Configuration really serializable
2016-02-24 12:29:07 +01:00
Ricki Hirner 3bb3327543 Show debug info on management errors 2016-02-23 23:10:44 +01:00
Ricki Hirner de4fd5804d Create/delete calendars 2016-02-23 18:42:50 +01:00
Ricki Hirner 821bdba18a Proof of concept: create remote address books, delete remote collections 2016-02-22 14:33:55 +01:00
Ricki Hirner 03d02b25bf 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 280c8ba462 Fix crash bug caused by leaking OnAccountsUpdateListener 2016-02-19 14:15:32 +01:00
Ricki Hirner 332eb4b8cc Upgrade to okhttp/3.1.2 + tests 2016-02-19 13:16:34 +01:00
Ricki Hirner f4f7e79dcf 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 a10649d818 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 6f98fdb76e Selectable calendars 2016-01-20 21:12:37 +01:00
Ricki Hirner e2965c2cbf Update local calendars according to ServiceDB at sync 2016-01-20 15:22:58 +01:00
Ricki Hirner aaef563a74 Update local task lists according to ServiceDB at sync 2016-01-20 15:01:17 +01:00
Ricki Hirner 671a11e479 DavService: refresh collections 2016-01-20 00:39:10 +01:00
Ricki Hirner c1a8cab84e Collections refresh 2016-01-19 20:04:25 +01:00
Ricki Hirner 10411d8afb Add DavService for long-running operations 2016-01-19 13:51:52 +01:00
Ricki Hirner e9bb1b5542 Add account details activity (AccountActivity) 2016-01-18 14:59:19 +01:00
Ricki Hirner dbbc038682 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 53300e3899 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 3268226a97 Upgrade to okhttp3 2016-01-16 21:34:41 +01:00
Ricki Hirner 9d05592e85 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 35c6ccdfa9 Initial changes for new GUI 2016-01-15 01:07:56 +01:00
Ricki Hirner 06a046f520 Append trailing slashes to Web URLs; okhttp upgrade 2016-01-08 17:57:51 +01:00
Ricki Hirner cf5c5d3461 README updates 2016-01-04 01:43:53 +01:00
Ricki Hirner 51f787aa8b Version bump to 0.9.1.3 2016-01-03 01:47:32 +01:00
Ricki Hirner 15b560c22d Upgrade to okhttp/2.7.1 2016-01-02 10:59:52 +01:00
Ricki Hirner 46dc5c7a61 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 37118784ff BuildConfig: use build time instead of current time for timestamp 2015-11-27 14:04:24 +01:00
Ricki Hirner 2c47780e86 README changes; fix EXDATE bug
* ical4android: process EXDATEs when there are no explicit exceptions
2015-11-27 12:31:15 +01:00
Ricki Hirner 92e3017d13 Show open-source information when MainActivity is created 2015-11-24 18:36:25 +01:00
Ricki Hirner 933fc5e07b 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 bb611b0fd7 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 8da4725c7c 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 39e6fc8373 Increase version code……………………………………………………………………………. 2015-11-16 13:24:44 +01:00
Ricki Hirner 5f79a340ff 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 5b1d55aa7b Optimize soft keyboard handling, make resource detection dialog not cancelable 2015-11-09 11:31:40 +01:00
Ricki Hirner 340dcd374c 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 7434ea0868 Merge branch 'master' of https://gitlab.com/bitfireAT/davdroid 2015-11-07 22:03:54 +01:00
Ricki Hirner 4e83ef73d0 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 18f5b8cd5e * 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 c922291d33 Version bump to 0.9.1
* filter ":" and "/" from external log file names
2015-11-07 15:35:22 +01:00
Ricki Hirner 4f07ca3ca0 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 2db80a8b7a 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 7b3d1a66b8 Fix crash bug when external log file can't be created 2015-10-28 14:10:03 +01:00
Ricki Hirner 24768626fa Update .gitmodules to publically accessible URLs 2015-10-24 12:08:51 +02:00
Ricki Hirner 1fc8ef0df7 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 26cb32df9b vcard4android: ignore raw contact data rows without MIMETYPE 2015-10-23 02:28:23 +02:00
Ricki Hirner 6e2152d79f 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 2fee1583c7 Merge translations from Transifex and bump version to 0.9.0.3 2015-10-21 02:15:10 +02:00
Ricki Hirner e840f0fc2d 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 1a2c8df821 Version bump to 0.9.0.2 2015-10-20 13:28:34 +02:00
Ricki Hirner 4e33a62c1d Better handling of contacts without N/FN 2015-10-20 13:23:01 +02:00
Ricki Hirner c7460dc9e1 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 605d692b00 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 df7d3e9c3d Version bump to 0.9.0.1
* with minor fixes
2015-10-19 19:04:01 +02:00
Ricki Hirner 37f7f7fff3 SSLSocketFactoryCompatTest 2015-10-19 16:55:01 +02:00
Ricki Hirner 31493650fb 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 3bd2989fec Enable TLSv1.1 and TLSv1.2 (if available) for Android <5 again 2015-10-19 15:16:44 +02:00
Ricki Hirner b30e99d18c Remove Robohydra (obsoleted by okhttp-mockwebserver) 2015-10-19 13:18:21 +02:00
Ricki Hirner 6e75ca0281 Fix lint warnings, don't require external storage permission for SDK >18 2015-10-19 11:57:43 +02:00
Ricki Hirner fcb381b970 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 f2d5ae81fe 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 950b5f3155 Changed source strings 2015-10-18 19:36:03 +02:00
Ricki Hirner 2f704b11ea 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 c37c219afc 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 c99ab38439 Merge translations from Transifex 2015-10-17 22:42:45 +02:00
Ricki Hirner 127e32732c 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 557e69f1cc 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 a68717de44 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 2f87b51f57 Support Basic and Digest auth 2015-10-16 19:30:50 +02:00
Ricki Hirner 46a2880e0d New collection/service discovery: CalDAV+CardDAV 2015-10-16 12:40:44 +02:00
Ricki Hirner 275335ad26 New resource detection
* new resource detection: only CalDAV yet
2015-10-16 03:27:56 +02:00
Ricki Hirner f824386db4 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 68ee33b2f9 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 fa766906c8 Tasks with new sync logic 2015-10-15 00:49:15 +02:00
Ricki Hirner 65c15258ca Process recurring events, exceptions etc. 2015-10-14 21:45:19 +02:00
Ricki Hirner de7ca7b91c Basic implementation of calendar sync. with common SyncManager 2015-10-14 18:20:51 +02:00
Ricki Hirner fa4f090ff7 Remove legacy calendar/task/WebDAV code 2015-10-14 13:38:18 +02:00
Ricki Hirner c71dc2ba7e New DebugInfoActivity
* DebugInfoActivity shows and allows to share sync exceptions
* log sync phase
2015-10-14 12:23:02 +02:00
Ricki Hirner c90a340159 README changes 2015-10-13 11:27:33 +02:00
Ricki Hirner f2a5bce53c Integrate MemorizingTrustManager by Georg Lukas 2015-10-13 02:34:24 +02:00
Ricki Hirner bdddd23441 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 c27443d921 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 652f98848d New sync logic for ContactsSyncAdapter, using dav4android and vcard4android 2015-10-11 22:34:03 +02:00
Ricki Hirner a4d1c2ab86 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 0d26eac245 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 5aa7caec99 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 448e052912 Make well-known URLs work again when user enters an initial context path 2015-09-22 12:19:39 +02:00
Ricki Hirner b800556607 Merge branch 'french_translations' of https://github.com/callmemagnus/davdroid 2015-09-15 17:53:23 +02:00
Ricki Hirner d9855190cb Fix UI crash bug 2015-09-15 17:37:36 +02:00
Magnus Anderssen bc96a7f305 Added missing french translations 2015-09-06 21:22:43 +02:00
Ricki Hirner 4910d59c21 Merge pull request #628 from gjtoth/master
Hungarian translation updated.
2015-09-06 13:16:02 +02:00
Ricki Hirner 73f75cebed Update README 2015-09-05 01:35:02 +02:00
Ricki Hirner d6e375361f 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 fb57c7fd55 Hungarian translation updated. 2015-08-28 17:46:24 +02:00
Ricki Hirner ce61dd224a Version bump to 0.8.4 2015-08-25 22:06:33 +02:00
Ricki Hirner 0e3348c298 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 65307f9d02 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 78b32ee150 Handle attendees and reminders for exceptions of recurring events 2015-08-10 11:54:05 +02:00
Ricki Hirner 34399181aa 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 91544b3e87 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 bac737a8f9 Improve ATTENDEE/ORGANIZER handling 2015-08-08 15:39:58 +02:00
Ricki Hirner 31644f47b0 Process Content-Type character set information (fixes #594) 2015-08-06 15:57:06 +02:00
Ricki Hirner dd7d2a6368 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 0c9251080f Send used VTIMEZONEs with VTODOs 2015-08-03 16:54:10 +02:00
Ricki Hirner 4821107996 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 fa95bdc6e5 Send charset information with MIME type when uploading VCard/3.0 resources 2015-08-02 16:35:02 +02:00
Ricki Hirner 316102cca4 Detect VCard/4 support per sync, too 2015-08-02 16:24:00 +02:00
Ricki Hirner 8f933362b6 Merge pull request #593 from svetlemodry/master
Czech translation update
2015-08-02 14:47:04 +02:00
Jaroslav Lichtblau 8e3cfedf94 Czech translation update
for davdroid
2015-08-02 12:33:41 +02:00
Ricki Hirner 2be029a4f8 Added uninstall warning in "Install Tasks app" string (fixes #589) 2015-08-02 09:29:28 +02:00
Ricki Hirner 9e5e18f20a Update collection properties (name, color) on every sync 2015-08-02 08:57:03 +02:00
Ricki Hirner 062bfc6007 Convert RDate/ExDate properties <-> Android RDATE/EXDATE strings more precisely (+ tests) 2015-08-01 13:25:35 +02:00
Ricki Hirner f7cc59ebdb Version bump to 0.8.2 2015-07-29 21:59:36 +02:00
Ricki Hirner 76c943718e 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 25675d4567 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 ca68e59b60 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 54497ffbcc Fix problem of recent commit with deleting local records 2015-07-28 15:04:57 +02:00
Ricki Hirner 050bc48454 Merge branch 'master' of github.com:bitfireAT/davdroid 2015-07-28 14:48:32 +02:00
Ricki Hirner 9d835950c6 Merge pull request #580 from oskarjakiela/master
Add Polish translation
2015-07-28 14:48:02 +02:00
Oskar S. Jakieła f76d15cc82 Add Polish translation 2015-07-20 21:12:53 +02:00
Ricki Hirner f57f2a16f5 Small refactoring 2015-07-18 15:15:36 +02:00
Ricki Hirner b38b719a46 Null-pointer check for SIP address types (fixes #506) 2015-07-18 00:52:44 +02:00
Ricki Hirner 3779d5c9b2 Handle 409 Conflict status codes (fixes #563) 2015-07-07 00:25:49 +02:00
Ricki Hirner 1c6a3517b5 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 1c6a7a4b50 Add trailing slash to sample URL (closes #522) 2015-07-06 00:27:00 +02:00
Ricki Hirner afd0fedfca Specify encoding details of member names passed to WebDavResource (fixes #482) 2015-07-05 23:51:53 +02:00
Ricki Hirner d8c6b971e8 Merge pull request #504 from svetlemodry/master
Czech translation for davdroid
2015-07-05 22:18:06 +02:00
Ricki Hirner 723982e327 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 1a91c098e5 Check for null values of Events.ORIGINAL_ALL_DAY (should fix #551)
* fix indentation
2015-06-14 12:39:36 +02:00
Ricki Hirner b7d692732b Check for null values in relations (should fix #547) 2015-06-14 12:07:04 +02:00
Ricki Hirner ea8783cc1f Check for null values in StructuredPostal.TYPE (should fix #549) 2015-06-14 12:00:34 +02:00
Ricki Hirner 5bc2287b66 Don't disable per-session cookie management + test (closes #525) 2015-06-12 00:41:21 +02:00
Ricki Hirner 6ec0e6119f Set _DIRTY=0 for new tasks explicitly (fixes #524) 2015-06-11 23:58:03 +02:00
Ricki Hirner 7309e6ab0b Merge pull request #519 from pejakm/srup
Update Serbian translation
2015-05-27 23:46:29 +02:00
Mladen Pejaković 00ee1a71e5 Update Serbian translation 2015-05-27 23:20:36 +02:00
Ricki Hirner bfc5c9237b Add missing files (fixes #517) 2015-05-27 15:51:31 +02:00
Ricki Hirner 73b3cb469c 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 0325eaacb2 Translations in zh-rcn for v0.7. 2015-05-27 20:22:27 +08:00
Ricki Hirner 99d13b9782 Extra icon for tasks in "Select collections" fragment 2015-05-27 12:04:06 +02:00
Ricki Hirner 1621a9ab5d 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 781759ae84 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 d1b29554f9 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 e29920504e Sync notes and tasks 2015-05-22 03:06:30 +02:00
Jaroslav Lichtblau 69ef299208 Czech translation for davdroid
updated
2015-05-17 11:48:40 +02:00
Ricki Hirner c1671b7d62 Implement remote filters to fetch only CalDAV resources with useful components (VEVENT for now) 2015-05-15 23:35:27 +02:00
Ricki Hirner 1326e8bbdc Version bump to 0.7.7
* SettingsActivity: up navigation
* tests
2015-05-15 14:47:03 +02:00
Ricki Hirner 290f36619c Handle Android "INTEGER (boolean)" values which are read as CharSequences correctly (fixes #503) 2015-05-15 02:31:53 +02:00
Ricki Hirner ed3cd9a4ba Merge pull request #498 from dehart/master
Added dutch translation file
2015-05-12 18:42:59 +02:00
Michael de Hart 63ff1453d2 Added dutch translation file 2015-05-12 14:56:29 +02:00
Ricki Hirner 7f5bfc975c Version bump to 0.7.6
* additional test
* minor code optimizations
2015-05-09 13:29:28 +02:00
Ricki Hirner bd92949f3c Process multiple RDATE/EXDATE values (see #340, see #495) 2015-05-09 11:55:35 +02:00
Ricki Hirner 57ea496fe3 EXDATE processing
* don't ignore the time zone of EXDATEs (fixes #495)
2015-05-08 17:46:10 +02:00
Ricki Hirner cea35a9162 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 02cf7d8603 Use EntityEvent to populate entities from local DB 2015-05-03 21:56:31 +02:00
Ricki Hirner 181be62ef3 Use RawContactsEntity to query raw contact data 2015-05-03 17:09:20 +02:00
Ricki Hirner 346b918fac RDATE processing
* don't ignore the time zone of RDATEs (see #340)
2015-05-02 11:39:25 +02:00
Ricki Hirner 2cc5c42b26 New target SDK: API level 22 (Android 5.1) 2015-05-02 10:55:31 +02:00
Ricki Hirner 63839a19dc Support relations the VCard 4.0 way (closes #278) 2015-05-01 02:47:08 +02:00
Ricki Hirner d4f06c1b37 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 d7dc909859 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 a13dbb7164 Process <status> in multi-get responses without <propstat> (see #475) 2015-04-29 20:04:56 +02:00
Ricki Hirner 1f9b8a296a Merge branch 'master' of github.com:bitfireAT/davdroid 2015-04-29 00:25:49 +02:00
Ricki Hirner 6a8326c36e Version bump to 0.7.3 2015-04-29 00:25:07 +02:00
Ricki Hirner d3dbbf4e17 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
Ricki Hirner 071151012c Merge pull request #490 from FabianoK/master
Portugues Brazilian translation
2015-04-28 22:29:49 +02:00
Fabiano Sardenberg Kuss - COCOE/COASC/COSAM 50c93c7ad8 Portugues Brazilian translation 2015-04-28 17:16:52 -03:00
Ricki Hirner 7eaa6a6bb3 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 97c1bf9ed9 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 4bbebe98fe 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
Ricki Hirner 550adf78d1 Merge pull request #486 from astalavister/master
Russain language added
2015-04-25 21:36:16 +02:00
astalavister a92050bc5e Russian language translation v. 0.7 2015-04-24 12:41:53 +08:00
astalavister 4c5bde0a28 Russian language translation v. 0.7 2015-04-24 12:32:01 +08:00
Ricki Hirner 32eafc096d Generate VCARD N property for prefix- and suffix-only contacts, too (closes #469) 2015-03-29 15:13:10 +02:00
Ricki Hirner e3c66e00ef Version bump to 0.7.2
* catch illegal SIP addresses (fixes #470)
* version bump to 0.7.2
2015-03-29 15:05:56 +02:00
Ricki Hirner 8034dc454c Improve time-zone detection in VEVENTs 2015-03-29 14:57:12 +02:00
Ricki Hirner 1c069a2efb Merge branch 'master' of github.com:bitfireAT/davdroid 2015-03-29 14:44:34 +02:00
Ricki Hirner d88e53ac62 Merge pull request #473 from Springuin/master
Time zone guessing improved, fixes 'Assuming time zone Etc/GMT for Etc/G...
2015-03-29 14:44:25 +02:00
Ricki Hirner d4510e0d1e Correctly detect address-book and calendar home-sets which are address books/calendars themselves 2015-03-29 14:39:47 +02:00
Marc de Hoop 17a2fa679e Time zone guessing improved, fixes 'Assuming time zone Etc/GMT for Etc/GMT-2' 2015-03-28 10:15:40 +01:00
Ricki Hirner fcec61cc45 Merge pull request #471 from xphnx/patch-3
Update spanish translation
2015-03-23 16:04:21 +01:00
xphnx dd2bc3043d Update spanish translation 2015-03-23 15:27:27 +01:00
Ricki Hirner cca1319ce2 Merge pull request #467 from pejakm/srtr
Update Serbian translation
2015-03-13 18:33:37 +01:00
Mladen Pejaković a7a070af46 Update Serbian translation 2015-03-13 15:47:18 +01:00
Ricki Hirner 3faa7913d4 Merge pull request #466 from gjtoth/master
Updated Hungarian translation
2015-03-12 23:24:03 +01:00
Gábor J.Tóth 2e8bef7091 Updated Hungarian translation. 2015-03-12 18:48:02 +01:00
Ricki Hirner 1c59bca599 Bug fixes
* show "Settings" in Android Settings/Accounts again
2015-03-12 18:25:10 +01:00
Ricki Hirner d10b805881 Minor UI changes
* always notify user on SSL/TLS exceptions (closes #447)
* re-organize UI classes to separate ui package
* re-organize layout and menu resources
* make MANAGE_NETWORK_USAGE intent filter work
2015-03-12 18:25:10 +01:00
Ricki Hirner d86379b72a Fix Settings rendering issues
* fix Settings rendering issues (fixes #459)
* version bump to 0.7.1
2015-03-12 18:25:10 +01:00
Jaroslav Lichtblau cd382a9b0a Czech translation update for v0.7 2015-03-12 18:25:09 +01:00
phy25 c9f33975b1 Translations in zh-rcn for v0.7. 2015-03-12 18:25:09 +01:00
Ricki Hirner 54abf4ad8d Version 0.7
* new Settings activity
* Settings: display/change user name, password, preemptive auth.
* Settings: display/change sync. interval for contacts and calendars
* requires permission GET_ACCOUNTS to list accounts in Settings
* requires permission READ_SYNC_SETTINGS to display current sync intervals
* remove obsolete files from res/
* update copyright notices
* version bump to 0.7
2015-03-12 18:25:09 +01:00
Ricki Hirner 15beaadba0 New version: 0.6.12
* if well-known URI detection fails with I/O errors, ignore it instead of aborting
* if current-user-principal is not supported by the server, use the user-given URL is the principal URL
* determine availability of CalDAV/CardDAV by whether a calendar/address-book home set is available
* if DAV:displayname is not available for a collection, use its URL path instead
* remove SSLPeerUnverifiedException handling (Apache HttpClient doesn't use this exception)
* use Depth: 1 (instead of Depth: 0) for multi-get REPORT requests
* don't display address books in "Select collections" UI if CardDAV is not available
* don't display calendars in "Select collections" UI if CalDAV is not available
* version bump to 0.6.12
2015-03-12 18:25:08 +01:00
Ricki Hirner c924d24320 Bug fixes
* show "Settings" in Android Settings/Accounts again
2015-03-11 14:56:39 +01:00
Ricki Hirner 36673c0020 Minor UI changes
* always notify user on SSL/TLS exceptions (closes #447)
* re-organize UI classes to separate ui package
* re-organize layout and menu resources
* make MANAGE_NETWORK_USAGE intent filter work
2015-03-11 14:34:13 +01:00
Ricki Hirner 146f70959d Fix Settings rendering issues
* fix Settings rendering issues (fixes #459)
* version bump to 0.7.1
2015-03-11 14:01:43 +01:00
Ricki Hirner 7df3967b2f Merge pull request #461 from phy25/values-zh-CN
Translations in zh-rcn for v0.7
2015-03-11 12:20:32 +01:00
Ricki Hirner c5ea2a9de9 Merge pull request #462 from svetlemodry/master
Czech translation update for v0.7
2015-03-11 12:20:16 +01:00
Jaroslav Lichtblau 54e4732675 Czech translation update for v0.7 2015-03-09 16:34:06 +01:00
phy25 0c2c209437 Translations in zh-rcn for v0.7. 2015-03-09 20:28:52 +08:00
Ricki Hirner ede7fd2033 Version 0.7
* new Settings activity
* Settings: display/change user name, password, preemptive auth.
* Settings: display/change sync. interval for contacts and calendars
* requires permission GET_ACCOUNTS to list accounts in Settings
* requires permission READ_SYNC_SETTINGS to display current sync intervals
* remove obsolete files from res/
* update copyright notices
* version bump to 0.7
2015-03-08 23:37:48 +01:00
Ricki Hirner 9682b535cb New version: 0.6.12
* if well-known URI detection fails with I/O errors, ignore it instead of aborting
* if current-user-principal is not supported by the server, use the user-given URL is the principal URL
* determine availability of CalDAV/CardDAV by whether a calendar/address-book home set is available
* if DAV:displayname is not available for a collection, use its URL path instead
* remove SSLPeerUnverifiedException handling (Apache HttpClient doesn't use this exception)
* use Depth: 1 (instead of Depth: 0) for multi-get REPORT requests
* don't display address books in "Select collections" UI if CardDAV is not available
* don't display calendars in "Select collections" UI if CalDAV is not available
* version bump to 0.6.12
2015-02-13 18:30:00 +01:00
Ricki Hirner 8642003dc5 Download photo URIs in VCards
* download photo URIs in VCards (closes #430); send authorization only when on same server
* version bump to 0.6.11
* minor code optimizations
2015-01-18 20:34:57 +01:00
Ricki Hirner 50271e679a Don't show notifications on soft (I/O) errors
* don't show notifications on soft (I/O) errors (closes #425)
* version bump to 0.6.10.2
2015-01-18 17:35:44 +01:00
Ricki Hirner 699478a513 Use Apache HttpClient-Android SSLConnectionSocketFactory
* SSLConnectionSocketFactory is now SNI-capable, so there's no need
  to manage SNI in DAVdroid
* keep secure protocol/cipher suite definitions
* use a patched jar until https://issues.apache.org/jira/browse/HTTPCLIENT-1591 is fixed
2015-01-18 17:04:32 +01:00
Ricki Hirner 7216415d34 Loosen some restrictions
* re-enable relaxed iCal unfolding and parsing + test
* switch to BrowserCompatHostnameVerifier again to allow IP addresses in certificate CN
2015-01-06 18:42:15 +01:00
Ricki Hirner 09da0df62f Version bump to 0.6.10 2015-01-01 21:51:28 +01:00
Ricki Hirner da83c32bb6 Show basic notifications on sync errors (closes #182)
* only works for Android 4.1+
* pinch in to expand the message
2015-01-01 21:23:31 +01:00
Ricki Hirner 53e0de29bd Handle absolute URIs in resource detection (+ tests) 2015-01-01 18:53:03 +01:00
Ricki Hirner 4de71331b9 TlsSniSocketFactory improvements
* make it work again with non-proxied connections
* add tests for proxied and non-proxied connections
* add tests for SNI, host name and certificate verification
2015-01-01 17:56:21 +01:00
Ricki Hirner f6aa8a2c6c TLS/SNI support, proxy support
* SNI now works for proxied connections, too
* "Cannot verify hostname" message removed and split into two localized messages: one for unknown certificates, the other for hostname mismatch
2014-12-28 16:33:45 +01:00
Ricki Hirner 1d1fe00ee6 Handle resources with "%" and other special characters in file name more accurately (fixes #401) 2014-12-27 22:42:57 +01:00
Ricki Hirner 2b99b82a1b Repair some common invalid URLs, version bump to 0.6.9. invalid URLs, version bump to 0.6.9.1 2014-12-26 19:55:52 +01:00
Ricki Hirner d10fdd5f2a Mention used Apache HttpClient Android port 2014-12-26 18:57:51 +01:00
Ricki Hirner ee5ea0a90b Handle relative URIs with ":" in path names correctly 2014-12-26 18:53:22 +01:00
Ricki Hirner 8c341a18c3 Minor code tweaks (by lint) 2014-12-21 11:24:00 +01:00
Ricki Hirner 358e691457 Temporarily use patched version of HttpClient-Android 4.3.5 to avoid Basic Authorization bug 2014-12-21 10:50:53 +01:00
Ricki Hirner 9ae1d552c9 Use HttpClient HC4 classes whenever possible (instead of stock Android HttpClient 4.0-beta classes); reorganize imports 2014-12-21 02:19:06 +01:00
Ricki Hirner d1fb2d0968 version bump to 0.6.9
* fix i18n
2014-12-20 23:38:37 +01:00
Ricki Hirner 45464be2f2 shrink bytecode with ProGuard for both debug/release builds (closes #202)
* enable ProGuard for debug builds because real-life testing is done with debug builds
2014-12-20 23:34:32 +01:00
Ricki Hirner e6573ed4c1 Re-enable tests again 2014-12-20 23:33:02 +01:00
Ricki Hirner e7f610c150 Remove Debug settings activity
* remove debug settings activity; logging can now be enabled via adb setprop, since that's the default way of enabling verbose logs in HttpClient's Android flavour and adb is required for viewing the logs anway
* update i18n accordingly
2014-12-20 21:35:52 +01:00
Ricki Hirner 59d3f76f0c Merge with origin/master 2014-12-20 21:15:08 +01:00
Ricki Hirner 62fa9055d0 Merge remote-tracking branch 'origin/master' into android-studio
Conflicts:
	.gitignore
2014-12-20 20:40:28 +01:00
Ricki Hirner 4a3c910506 Migrated to Android Studio/gradle
* moved all dependencies to gradle instead of shipping .jar files in the app/lib directory
* switched to official Android port of HttpClient instead of httpclientandroidlib
* new .gitignore and project files
2014-12-20 20:21:46 +01:00
Ricki Hirner f41c0d36cc Fiddling around with UI again and again and again … hoping for console-only mobile phones one day (fixes #386) 2014-12-07 12:58:50 +01:00
Ricki Hirner acddc6d0af Use https links for davdroid.bitfire.at 2014-12-05 21:26:34 +01:00
Ricki Hirner 864a5732cb Mark as compatible with Android 5.0 2014-12-01 01:17:01 +01:00
Ricki Hirner 1143341600 Merge branch 'master' of github.com:bitfireAT/davdroid 2014-11-30 21:07:49 +01:00
Ricki Hirner aadff35f00 Various bug fixes
* don't set left/right padding in list items (fixes #240)
* don't allow list headers to be selected (part of issue #355)
* version bump to 0.6.8
2014-11-30 21:04:59 +01:00
Ricki Hirner 28bf93d453 Bug fixes
* fix a bug in handling TXT records (hopefully fixes #383)
* fix invalid translation strings
2014-11-30 18:09:31 +01:00
Ricki Hirner 6a96af6dbb Bug fixes
* fix a bug in handling TXT records (hopefully fixes #383)
* fix invalid translation strings
2014-11-29 22:52:51 +01:00
Ricki Hirner cc89028b42 Merge pull request #376 from pokoli/update_catalan_translation
Update catalan translation
2014-11-22 22:20:40 +01:00
Sergi Almacellas Abellana f9a092ceb8 Update catalan translation 2014-11-22 18:20:19 +01:00
Ricki Hirner 333bf99147 Merge pull request #375 from gjtoth/master
Updated Hungarian translation.
2014-11-22 11:51:46 +01:00
jtg 62ad7431df Updated Hungarian translation. 2014-11-22 00:54:32 +01:00
Ricki Hirner e48cb8d546 Merge pull request #372 from svetlemodry/master
Updated Czech translation
2014-11-19 13:03:12 +01:00
Jaroslav Lichtblau 07c0474874 Updated Czech translation 2014-11-18 20:50:22 +01:00
Ricki Hirner 4ffe2b7c48 Improve handling of unknown timezone definitions (should fix #333) 2014-11-15 13:49:38 +01:00
Ricki Hirner 2739a05fac Don't require # for calendar colors (see #136, closes #238) 2014-11-15 12:58:38 +01:00
Ricki Hirner 3dfcffc015 Merge pull request #364 from phy25/values-zh-CN
Updated the Chinese Simplified translations
2014-11-14 20:39:19 +01:00
Ricki Hirner 9541a05f3a Merge pull request #365 from pejakm/srupd
Updated Serbian translations
2014-11-14 20:39:09 +01:00
Mladen Pejaković 7087b4f1df Oops... 2014-11-14 19:59:11 +01:00
Mladen Pejaković 9867904de3 [Translations] Update Serbian 2014-11-14 19:54:55 +01:00
phy25 a9f49f8ced Updated the Chinese Simplified translations according to the simplified strings.xml. 2014-11-14 01:02:19 +08:00
Ricki Hirner 4594f35065 Minor changes
* new icons with 3D effect as required in Android Iconography Guide
* target SDK version updated to 20
* replaced UI directions left/right by start/end for RTL support
* Show "unnamed calendar"/"unnamed address book" instead of "null" in "Select collections" fragment
* "Add account" button now directly adds DAVdroid account
* .svgz for "How DAVdroid interacts with other components" added to doc
2014-11-12 14:37:22 +01:00
Ricki Hirner f415c6b082 Various fixes
* fix minor translation issue that caused DAVdroid to crash when showing an I/O error
* don't select TLS ciphers for Android 5.0+ (it has more secure default settings); closes #344
2014-11-09 19:58:07 +01:00
Ricki Hirner 8deb5fd864 Add license info for dnsjava and some copyright notes 2014-11-08 17:57:14 +01:00
Ricki Hirner fb3225d92a Merge branch 'master' of github.com:rfc2822/davdroid
Conflicts:
	res/values-hu/strings.xml
2014-11-08 17:38:27 +01:00
Ricki Hirner ce39581136 Version bump to 0.6.7
* a few fixes for SRV service detection
* localization strings cleanup
* new policy for localization: translators are only mentioned on their own translation from now on
2014-11-08 17:32:54 +01:00
Ricki Hirner 3af75a2baa CalDAV/CardDAV service discovery with SRV/TXT records
* Structural changes in the strings file (for translations)
2014-11-08 16:33:34 +01:00
Ricki Hirner fe173e6041 Merge pull request #354 from gjtoth/master
Refined Hungarian translation.
2014-11-05 20:34:50 +01:00
jtg e517899818 Refined Hungarian translation. 2014-11-05 15:44:50 +01:00
Ricki Hirner 2560ec4cab Initial support for SRV/TXT service discovery 2014-11-04 23:04:24 +01:00
Ricki Hirner 5a1dbcd860 Merge pull request #347 from gjtoth/master
Attribution for translations added to the Hungarian version.
2014-11-04 09:06:25 +01:00
Ricki Hirner fa4ef5d81a Version bump to 0.6.6 2014-11-03 20:24:10 +01:00
Ricki Hirner 2b891d2887 Show Website icon in main activity only if there's enough room 2014-11-03 19:39:55 +01:00
Ricki Hirner 6bd8214cca Messing around with trailing slash again and again + tests (fixes #349) 2014-11-03 19:30:07 +01:00
jtg 014cd5d431 Merge remote-tracking branch 'upstream/master' 2014-11-01 21:37:28 +01:00
jtg f2395c07cd Attribution for translations added. 2014-11-01 21:31:11 +01:00
Ricki Hirner 6e7d3dba37 Merge pull request #346 from gjtoth/master
Hungarian translation added. Thanks to @gjtoth
2014-11-01 21:06:38 +01:00
jtg 3508aad847 Hungarian translation added. 2014-11-01 19:58:34 +01:00
Ricki Hirner 1dbb67fbbe Mention Chinese translator 2014-10-31 17:36:59 +01:00
Ricki Hirner 55731923ef Ensure trailing slash on user-given base URL (assuming users will always provide collections or base paths) 2014-10-31 17:32:16 +01:00
Ricki Hirner c5cc468ed4 Set reasonable TLS settings
* disallow SSLv3
* allow more secure ciphers (closes #344)
* version bump to 0.6.5
2014-10-31 16:56:30 +01:00
Ricki Hirner 9d60228c0e Add TLS cipher info to tests (see #344) 2014-10-31 15:39:12 +01:00
Ricki Hirner 3bd94ea577 Ensure trailing slashes are always used for collections + tests 2014-10-31 15:29:00 +01:00
Ricki Hirner d5326e31bd Merge branch 'master' of github.com:rfc2822/davdroid 2014-10-27 15:18:58 +01:00
Ricki Hirner 1c4b51b15a Use new ical4j version to fix timezone bug
* use ical4j 1.0.x-maintenance (self-compiled 27 Oct 2014)
* upstream fixes timezone bug (fixes #186)
* try to turn off the check for time zone updates because it's unnecessary in most cases (fixes #239)
* mention ical4j and its major version in generated iCalendars
* update to ez-vcard 0.9.6 from upstream
* version bump to 0.6.4
2014-10-27 15:14:49 +01:00
Ricki Hirner 326ee9033d Merge pull request #338 from phy25/values-zh-CN
Add Chinese Simplified translation. Thanks to @phy25
2014-10-18 15:19:47 +02:00
phy25 3ef1193d42 Added Chinese Simplified translation. 2014-10-18 19:41:15 +08:00
Ricki Hirner 65790e5d0e Allow adb backups (not Google cloud backups!) for DAVdroid because of user request 2014-10-17 15:33:00 +02:00
Ricki Hirner dc1a5d70d2 Merge branch 'master' of github.com:rfc2822/davdroid 2014-10-10 13:00:36 +02:00
Ricki Hirner 7d8acef77d Send appropriate Accept/Content-Type headers + tests (closes #328) 2014-10-10 12:59:53 +02:00
Ricki Hirner f5801ee797 Merge pull request #329 from pejakm/srupd
Serbian translation fixes and updates
2014-10-07 10:02:59 +02:00
Mladen Pejaković 48bb3c1e31 Serbian translation fixes and updates 2014-10-03 20:30:20 +02:00
Ricki Hirner ee9735472b Setup: always show 401 Unauthorized errors
* also increase version code and version to 0.6.3
2014-10-02 17:05:20 +02:00
Ricki Hirner 1a1a81cd9e Version bump to 0.6.3
Add translator info
2014-10-02 15:37:36 +02:00
Ricki Hirner 2363ed12c8 Don't fail to detect resources if principal is not available for CalDAV/CardDAV 2014-10-02 12:04:29 +02:00
Ricki Hirner deca6bb955 Merge pull request #318 from svetlemodry/master
Czech translation – thanks to @svetlemodry
2014-09-30 18:07:50 +02:00
Ricki Hirner 7d382bfd65 Merge pull request #324 from pejakm/srtr
[Translations] Add Serbian – thanks to @srtr
2014-09-30 18:07:17 +02:00
Mladen Pejaković 2e5ea2c2c6 Serbian translation fixes 2014-09-25 22:19:54 +02:00
Mladen Pejaković c05b0161d2 [Translations] Add Serbian 2014-09-25 16:30:08 +02:00
Jaroslav Lichtblau a7734b3d83 typo/missing string fix 2014-09-11 12:48:51 +02:00
Jaroslav Lichtblau e332b3a8c6 Czech translation file
czech translation of davdroid text strings
2014-09-11 12:44:07 +02:00
Ricki Hirner 0a3578f3da Set STARRED as integer, not boolean (should fix #294) 2014-08-10 18:56:19 +02:00
Ricki Hirner cdbd588d60 Version bump to 0.6.2 2014-08-09 10:29:09 +02:00
Ricki Hirner b353b74041 Don't require capabilities on home sets that MAY not be available on these collections (fixes #289) 2014-07-31 14:00:36 +02:00
Ricki Hirner 7e04c4b88a VCard 4 support detection; new ez-vcard version
* detect CardDAV VCard version support using supported-address-data + test
* account setting for supported VCard version
* don't ask for calendar details when querying CardDAV collections
* don't ask for address book details when querying CalDAV collections
* ez-vcard update to 0.9.5 (fixes #268), adapted exception handling
* refactoring: unnecessary DavProp prefixes removed
2014-07-30 01:47:35 +02:00
Ricki Hirner 89624c6b9c Move resource detection to separate class + tests 2014-07-27 13:01:54 +02:00
Ricki Hirner 126b89beb5 Don't check capabilities on principal URL but on calendar/address book home set (fixes #286) 2014-07-25 00:04:35 +02:00
Ricki Hirner c83588d736 Version bump to 0.6.1; show exceptions from resource detection (see #284, see #286) 2014-07-24 23:12:48 +02:00
Ricki Hirner 10a1374492 Add group support via VCard CATEGORIES (closes #48) 2014-07-24 23:12:43 +02:00
Ricki Hirner d630954cb1 Handle redirections to relative URLs correctly (see #282) + tests; minor GUI change 2014-07-20 16:08:02 +02:00
Ricki Hirner 0381bd4aae Version bump to 0.6 (drop -alpha suffix) 2014-07-19 00:44:28 +02:00
Ricki Hirner 2b4f9d478d Add support for well-known URIs (RFC 5785) - closes #148 2014-07-19 00:39:07 +02:00
Ricki Hirner 18f36f0257 Handle HTTP redirections (fixes #83) 2014-07-18 22:31:45 +02:00
Ricki Hirner ba8275a6cf Don't crash when Calendar Provider is not available at account creation
(fixes #276)
2014-07-12 13:24:49 +02:00
Ricki Hirner c7d26ecd74 Allow only one anniversary to comply with VCard 4 (fixes #277) 2014-07-12 13:08:00 +02:00
Ricki Hirner 262a7a701a Version bump to 0.5.14-alpha
* added some documentation (JavaDoc)
* CTags are not cached in LocalCollection anymore
* added CTag test for LocalCalendar
2014-05-24 13:40:14 +02:00
Ricki Hirner 5ed506d434 Various upload changes
* fix issue when first upload fails (fixes #233)
* show debug settings menu item in main activity
* update ETag in local database when a PUT request returns an updated ETag, meaning that
  locally created/updated records won't be downloaded from the server immediately after the upload
2014-05-23 13:08:08 +02:00
Ricki Hirner c1882ac189 Better invalid VCard handling
* ignore unknown properties which have invalid syntax instead of crashing
* log validation warnings for created VCards
* add VCard tests
2014-05-09 16:54:02 +02:00
Ricki Hirner 7d56dee43c Ignore crappy unknown properties with illegal syntax (see issue #118) 2014-05-09 15:55:05 +02:00
Ricki Hirner ecf842e8cd Debugging improvements
* force debugging options for resource detection (because there's no way to turn them on until there's an account)
* provide "report issue" button in debug settings
* provide CONTRIBUTING.md, including instructions on how to report issues
* increase socket timeout from 20 to 45 seconds (hopefully fixes #237)
* previous commit: increase total simultaneous connections to 3 (2 per host)
2014-05-09 15:44:38 +02:00
Ricki Hirner 9d2124ca3a Debug settings
* preference screen for debug settings
* allow to disable HTTP compression
* verbose network logging only when activated
* single HttpClient for all threads (before: single HttpClient per adapter)
* bump version to 0.5.13-alpha
2014-05-08 21:22:52 +02:00
Ricki Hirner 6a23d4ac56 Make server URL, user name and password horizontally scrollable (fixes
#241)
2014-05-08 15:57:43 +02:00
Ricki Hirner e40b997a8b Support HTTP(S) proxies (closes #232) 2014-05-07 22:53:22 +02:00
Ricki Hirner d96c3eb86c Enable all supported TLS protocols (enables TLSv1.1/1.2 if supported by Android version on device, see #229) 2014-04-06 17:54:27 +02:00
Ricki Hirner 3bda992fad Version bump to 0.5.12 2014-04-05 12:46:00 +02:00
Ricki Hirner d1c9be0ba0 Read non-high-res photos, too (fixes #53 again); remove stale connection check because RetryStrategy should be enough 2014-04-05 12:37:46 +02:00
Ricki Hirner fe9945fdd2 don't close httpClient while syncing 2014-04-04 21:18:11 +02:00
Ricki Hirner 758a704dd2 Retain unknown VCard properties, ez-vcard update, handle stale connections
* store unknown VCard properties in an extra column and load them when generating a new VCard (closes #118)
* upgrade to ez-vcard/0.9.3 (fixes sync error reported via Play Store)
* (re-)enable stale collection check, RetryHandler to retry idempotent CalDAV/CardDAV requests (hopefully fixes #225)
* always set FN/display name (take organization if no structured name is available) (hopefully fixes #227)
2014-04-04 20:47:46 +02:00
Ricki Hirner 7945116877 Version bump to 0.5.11-alpha 2014-03-29 22:42:53 +01:00
Ricki Hirner 02dec1507a SNI support for Android <4.2, too (see issue #9) 2014-03-29 17:03:29 +01:00
Ricki Hirner 5a1cc2d85f Merge pull request #214 from pokoli/catalan_translation
Escape reserved caracters on catalan translation to do not break the build
2014-03-26 22:41:54 +01:00
Sergi Almacellas Abellana 244c4defbb Escape reserved caracters on catalan translation to do not break the build 2014-03-26 20:58:33 +01:00
Ricki Hirner d0f5ad8c91 Merge pull request #213 from pokoli/catalan_translation
Add catalan translations – thanks @pokoli
2014-03-26 19:59:12 +01:00
Sergi Almacellas Abellana dab0f0d40e Add catalan translations 2014-03-26 19:50:41 +01:00
Ricki Hirner 268cae690a Version bump to 0.5.10.2-alpha 2014-03-25 19:17:12 +01:00
Ricki Hirner f830b7eb86 Multiple bug fixes
* doesn't try to update address book URL when there is no sync-able address book (fixes #208)
* add test for HTTPS/SNI
2014-03-25 19:12:59 +01:00
Ricki Hirner fb5a23a3cc Merge pull request #210 from bubak4/patch-1
Fix #207 (regression: http not supported). Thanks @bubak4
2014-03-25 13:51:42 +01:00
bubak4 973abcf315 Fix #207 (http not supported #207)
Re-register http factory, as it was removed by accident during refactoring.
2014-03-25 11:52:23 +01:00
Ricki Hirner bde87cbb74 Add SNI support for Android 4.2+ for new API again (fixes #205) 2014-03-23 22:19:59 +01:00
Ricki Hirner 367de865e3 Adapted tests 2014-03-23 13:01:46 +01:00
Ricki Hirner 1b5c27537e Upgrade to HttpComponents 4.3.2, version bump to 0.5.10 2014-03-23 01:12:32 +01:00
Ricki Hirner b0892c29be New settings management
* Account settings are now read/set using AccountSettings
* AccountSettings upgrades old account settings when necessary
* Settings v1: use absolute URLs instead of paths and discard principal path
  (required for future implementation of well-known URIs)
2014-03-16 20:38:53 +01:00
Ricki Hirner 2c3d8c8846 Show JB Workaround info when installed from Google Play 2014-03-16 16:07:40 +01:00
Ricki Hirner f134246ddf Check base URL validity with new URI() instead of new URL() (fixes #184) 2014-03-15 13:39:02 +01:00
Ricki Hirner 2160ea51dd Correct icon sizes to match Android iconography; translations 2014-03-15 12:32:45 +01:00
Ricki Hirner c44097eda8 Fix a bug that deletes entities remotely even if they have changed remotely (closes #197) 2014-03-14 23:14:32 +01:00
Ricki Hirner 4994df7d06 Version bump to 0.5.9
* GUI changes for read-only collections (new icons)
* require only bind, unbind and write-content privileges
2014-03-09 17:55:16 +01:00
Ricki Hirner fe4ceaf6a3 Support for read-only calendars (closes #126)
* relevant RFCs go into the doc/ directory for reference purposes
* read-only calendar collections are set as read-only in Android
* HTTP exception refactoring to mark 4xx HTTP errors as hard sync errors (numAuthExcetions/numParseExceptions) for Android sync manager
* query current-user-privilege-set for resources, detect read-only resources
* show read-only resources as read-only in SelectCollectionsFragment
* minor refactoring (DavProp.*)
2014-03-09 15:12:59 +01:00
Magnus Anderssen 00ca7eb043 French translation that compiles 2014-02-27 15:57:01 +01:00
unknown bf694f2300 French translation 2014-02-27 15:57:01 +01:00
xphnx 5c1f61456c Update strings.xml 2014-02-27 15:57:00 +01:00
xphnx 3962cf1d59 Update strings.xml 2014-02-27 15:57:00 +01:00
Ricki Hirner 4cea0cd585 Bump version to 0.5.8.1, ignore VCards without VEVENTs (fixes #178) 2014-02-27 15:56:56 +01:00
Ricki Hirner 32ced2ceff Version bump to 0.5.8-alpha
* version bump
* introduction of LocalCalendarTest
* always provide DTEND and not DURATION for all-day events (compatibility)
* ez-vcard 0.9.2
2014-02-09 00:56:00 +01:00
Ricki Hirner e59e6b9e01 Better DTSTART/DTEND handling
* generalized InvalidResourceException for parsing errors
* only iCals with both DtStart and DtEnd/Duration are processed (DtEnd will be derived by iCal4j when not present in .ics)
* all-day events must last at least one day (fixes #166)
* other DtEnd/Duration rewriting + tests
2014-02-08 18:53:31 +01:00
Ricki Hirner a8f60eb8e5 0.5.7-alpha1: crash bug fix 2014-02-02 19:21:51 +01:00
Ricki Hirner 95c1dd8770 Merge pull request #165 from xphnx/patch-1
Spanish translation
2014-02-02 19:21:02 +01:00
xphnx 43ccb3eb6f Update and rename strings.xml to strings-es.xml
Spanish translation
2014-01-28 18:47:01 +01:00
Ricki Hirner 6b10b47b70 Fix crash when ROLE/TITLE but no ORG value set (fixes #163) 2014-01-26 12:50:50 +01:00
Ricki Hirner 11eed4d80b Better organization, job title/description handling
* handle VCard structured organization (department), job title/description correctly
* there's still an EZVCard bug: https://code.google.com/p/ez-vcard/issues/detail?id=13
* bump version code
2014-01-25 13:07:33 +01:00
Ricki Hirner 9ee8ec8144 Version bump to 0.5.7
* use VCard ROLE as "department" and TITLE as "job title"
* version bump to 0.5.7
2014-01-25 11:47:36 +01:00
Ricki Hirner 8b8d4947da Fix regression (8a651f135b) causing unnecessary traffic (closes #162, closes #147) 2014-01-25 11:11:48 +01:00
Ricki Hirner 3384cec346 Minor changes
* improved logging
* allow installation to internal storage only (informational change, doesn't do anything actually, closes #160)
2014-01-21 23:11:17 +01:00
Ricki Hirner 31d788ff7a Minor bug fixes
* work-around for crash when remote ETag is empty (shouldn't happen but happens sometimes)
* fetch multiple contact URLs (not only the first one) from contacts provider (fixes #150)
2014-01-12 20:07:26 +01:00
Ricki Hirner 2d40335cb9 0.5.6α Wage Slave
* support primary (preferred) phone numbers/email addresses
* fix (crash) bug in URI sanitation
* don't set calendar properties not supported by device's Android version
* require already-set remote file name when finding updated records
* version bump to 0.5.6α
2014-01-06 20:03:59 +01:00
Ricki Hirner ad5f8c1d7c Version bump to 0.5.5-alpha 2014-01-06 05:48:56 +01:00
Ricki Hirner 6b8ff66a21 Commit after every full record insertion/update (closes #135)
Docs say that TransactionTooLargeException is thrown when the buffer reaches 1 MB. Because we can't know
how large a single insertion/update is and there may be contacts with larger photos, it's only safe to
commit after every insertion/update. If photos exceed ~ 900 kB it would be required to write the photos
to an asset file (maybe I'll do this later, then we can group insertions/updates again).
2014-01-06 05:45:31 +01:00
Ricki Hirner 60026d8ade Bug fixes
* optimise URL repairs: don't replace "[", "]", ":" in host names, allowing IP address literals (closes #98)
* SyncManager: minor refactoring
2014-01-06 05:28:28 +01:00
Ricki Hirner de9872da7e Use (final) static wherever possible for method signature clarity/performance 2013-12-31 19:13:22 +01:00
Ricki Hirner a283b18e91 Allow vCard4 fields (ANNIVERSARY) in vCard3, too (closes #131). 2013-12-31 16:55:29 +01:00
Ricki Hirner 63be212396 Minor refactoring
* update target SDK version to API level 19
* less null return values
* explicit Resource generateUID/generateName methods
* use StringUtils when it makes sense
2013-12-31 16:37:31 +01:00
Ricki Hirner b69855e61e Support for SIP addresses (closes #78)
* support for SIP addresses (always exported as IMPP, imported as IMPP/X-SIP)
* version bump to 0.5.4
2013-12-26 17:23:14 +01:00
Ricki Hirner 472760ead4 Minor improvements
* generalise davdroid.ArrayUtils.partition; tests
* lower multi-get limit to 35
2013-12-25 15:15:23 +01:00
Ricki Hirner 68e5525e1f Fetch remote records in chunks of max. 50 to avoid server and memory problems
* performance optimisation
* refactoring
2013-12-24 12:49:23 +01:00
Ricki Hirner 38ae48e685 Fetch resources for uploading one-by-one to save memory 2013-12-24 12:49:18 +01:00
Ricki Hirner ee8611f90b Refactoring
* Contacts/Event.toEntity() return ByteArrayOutputStream instead of String
* throw DavNoContentException instead of returning null to avoid NullPointerExceptions
* always close input/output streams
* always call consumeContent() for HTTP response entities to avoid memory leaks
2013-12-22 00:58:58 +01:00
Ricki Hirner 3bdbf37608 throw RecordNotFoundExceptions only when specific records are requested 2013-12-21 20:54:54 +01:00
Ricki Hirner f72f0f3b91 Refactoring
* add overflow marker "…" to LoggingInputStream
* split the populate() methods
* always close cursors (resolves #120)
* introduce LocalStorageException and its subclass RecordNotFoundException and catch RemoteException earlier
* split synchronize() method into phases
* commit after every added/modified record (obsoletes MAX_UPDATES_BEFORE_COMMIT)
* read contact photos from (high-res) asset instead of the thumbnail blob (resolves #121)
2013-12-20 22:42:18 +01:00
Ricki Hirner c01d969e21 Version bump to 0.5.3-alpha
* add null checks (should fix #117)
* more detailed DAV exceptions for error handling
* better logging (limited to 10 kB per log entry) to avoid memory problems
* DavMultiget creates requests itself (instead of WebDavResource)
2013-12-15 19:50:20 +01:00
Ricki Hirner 0256c50694 Version bump to 0.5.2-alpha 2013-12-11 17:08:10 +01:00
Ricki Hirner 7bc145ca47 avoid NullPointerExceptions when contact data rows with custom types have no LABEL 2013-12-11 15:48:21 +01:00
Ricki Hirner 6056bab6e2 Amend EditSchema and VCard fields
* write PRODID with DAVdroid and ez-vcard version to VCards
* remove limit of 1 for contact Web sites in contact EditSchema
* add organization to contact EditSchema
* don't set null values in ORG/ROLE
2013-12-11 15:19:50 +01:00
Ricki Hirner fa675658a8 More input checks
* check Android's DURATION, RRULE, RDATE, EXRULE, EXDATE values for empty strings before processing
* catch InvalidArgumentException on invalid RRULE values from Android
* add Depth: 0 to REPORT multi-get requests as required by CalDAV/CardDAV RFCs
* fix crash bug on multi-get responses without content (see issue #113)
2013-12-11 14:48:05 +01:00
Ricki Hirner 4f722ee392 Version bump to 0.5.1; fix crash bug (fixes #108) 2013-12-08 14:00:48 +01:00
Ricki Hirner 3066856e09 Ignore invalid IM addresses (fixes #105), Lombok 1.12.2 2013-12-08 13:31:59 +01:00
Ricki Hirner 14784504c8 Version bump to 0.5
* don't download contact photos from URLs (we only want thumbs anyway)
* always use yyyy-MM-dd for birthdays/anniversaries
2013-12-06 20:47:22 +01:00
Ricki Hirner 723fd58285 Add more vCard fields
* support for vCard organization (company / job title)
* support for vCard IMPP addresses (closes #105)
* refactoring
2013-12-06 14:19:00 +01:00
Ricki Hirner 3f10968228 VCard 3 support
* switch to ez-vcard instead if iCal4j-vcard
* generate VCard 3 by default (fixes #67)
* support for more phone number/email/address types, including "custom" types
* limit to 1 URL and 1 note (Android limit)
* support for anniversaries
2013-12-04 14:04:18 +01:00
Ricki Hirner b5cd37daed Fix crash bug when ORGANIZER has no URI scheme 2013-12-03 20:34:50 +01:00
Ricki Hirner effdc7fee4 Version 0.4.4
* allow to enter account name at the end of account setup (fixes #13)
* advise user to enter email address as account name (closes #24)
* support for iCal TRANSP (availability free/busy field)
* create better UIDs using iCal4j UidGenerator and Settings.Secure.ANDROID_ID
* better ORGANIZER/ATTENDEE support, but there are big Android issues yet
* various improvements and bug fixes
2013-12-01 16:08:36 +01:00
Ricki Hirner 1409da386e Provide DTEND for single-time events and DURATION for recurring events to Android calendar data provider (fixes #60) 2013-11-30 00:50:13 +01:00
Ricki Hirner 5528d19257 CalDAV: ignore collections without VEVENT support (for instance, VTODO collections) 2013-11-30 00:06:16 +01:00
Ricki Hirner b5f7597c38 add DURATION iCal->Android, collection refactoring 2013-11-26 22:20:00 +01:00
Ricki Hirner 48011928a3 version bump to 0.4.3-alpha 2013-11-24 13:21:59 +01:00
Ricki Hirner 62a066b55d treat 404 on DELETE as success (fixes #94) 2013-11-24 13:20:44 +01:00
Ricki Hirner 2039071c77 support for reminders; better support for recurring events; bug fixes
* use DURATION instead of DTEND when required (fixes #60)
* send VTIMEZONEs along with VEVENTs (fixes #66)
* support for reminders (closes #70)
* bug fixes (UID parsing)
2013-11-24 13:15:31 +01:00
Ricki Hirner 5995d3d319 security fix: validate certificates and not only host names (thanks Günter) 2013-11-24 03:07:50 +01:00
Ricki Hirner b14bd337de version bump to 0.4.2
* imports calendar default time zone on account creation
2013-11-22 15:11:39 +01:00
Ricki Hirner 70075f9f09 Bug fixes, SDK level 19
* use SDK level 19
* add action to all preference intents (fixes #92)
* use StrictHostnameVerifier instead of HttpsURLConnection.getDefaultHostnameVerifier() (fixes #88)
* use per-WebDavResource instead of static DavHttpClient to enable different settings for different accounts
2013-11-22 15:11:08 +01:00
Ricki Hirner 15ff7dfa18 Bug fixes, refactoring, version bump
* don't manually decode URLs for comparing (because we now always use encoded URLs)
* .ics: VERSION before PRODID
* refactoring: move WebDavResource.httpClient to new singleton DavHttpClient
* close input streams in propfind/multi-get
* version bumpt to 0.4.1
2013-11-17 15:49:10 +01:00
Ricki Hirner 7c19e1ac5b Fix invalid URLs one more time 2013-11-17 13:04:57 +01:00
Ricki Hirner 190e3f766f Preserve order in multiget requests (thanks Marten, fixes #65) 2013-11-15 11:21:03 +01:00
Ricki Hirner e95a5ce742 SNI support (fixes #9); only available for API level >= 17 (Android 4.2+) 2013-11-15 01:05:18 +01:00
Ricki Hirner 43e723dd5f Merge pull request #74 from oliver/master
fix NumberFormatException when parsing "ff" alpha value into a Byte
2013-11-14 01:41:46 -08:00
Oliver Gerlich 31bdfbe690 fix NumberFormatException when parsing "ff" alpha value into a Byte 2013-11-13 22:09:14 +01:00
Ricki Hirner 03290e2653 import Apple calendar-color with r,g,b,a (see issue #31) 2013-11-12 22:17:46 +01:00
Ricki Hirner 09e36b9b6f don't decode URI pathes (fixes #58), don't expect GET/POST/PUT on collections 2013-11-12 18:39:05 +01:00
Ricki Hirner c69212957a Version bump to 0.3.8 (version code 12) 2013-11-09 12:45:09 +01:00
Ricki Hirner 91691218b9 Importing calendar colors from <calendar:color xmlns="http://apple.com/ns/ical/"> element (see #31) 2013-11-09 12:43:21 +01:00
Ricki Hirner e5e9e0511c avoid NullPointerException (hint from ANR) 2013-11-09 10:54:29 +01:00
Ricki Hirner 25923cf3b2 Always append trailing slash to address-book/calendar home set to avoid redirections 2013-11-09 01:36:09 +01:00
Ricki Hirner 29b4087278 RoboHydra tests, URL sanitizing
* RoboHydra tests
* intelligent URL sanitizing (fixes #58, fixes #49, see issue #11, should fix #45)
2013-11-09 01:13:38 +01:00
Ricki Hirner 4ac38a9eb7 refactoring, RoboHydra tests 2013-11-09 01:04:35 +01:00
Ricki Hirner d48bb41003 basic WebDAV tests 2013-11-03 01:40:24 +01:00
Ricki Hirner 6039f884e3 Version bump to 0.3.7 (version code 11) 2013-11-02 16:18:17 +01:00
Ricki Hirner 0b291381e6 support for structured addresses (closes #29); bug fixes 2013-11-02 16:09:48 +01:00
Ricki Hirner ceebea5d1d iCal4j update, handle relative URLs with colons
* iCal4j 1.0.5 -> 1.0.5.2, iCal4j-vcard 0.9.5 -> 0.9.6.2
* handle relative URLs with colons in name (see issue 45)
2013-11-01 15:27:48 +01:00
Ricki Hirner a162eb664c improved photo parsing, DELETE statement
* let ical4l-vcard do the photo parsing; tested with posteo, OwnCloud,
eM Client; fixes #53
* improved DELETE statement to avoid
"android.database.sqlite.SQLiteException: Expression tree is too large"
(fixes #54)
* added "file name required" validation
2013-11-01 12:13:45 +01:00
Ricki Hirner 6dbe2cff9b Version bump to 0.3.6
* show app version in main activity
2013-10-30 17:27:44 +01:00
Ricki Hirner b0f6eab402 don't follow redirects (see issue #52) 2013-10-30 17:20:04 +01:00
Ricki Hirner 5065a5c06a better vcard parsing error logging, always append trailing slash to collection URLs 2013-10-30 15:14:19 +01:00
Ricki Hirner db32d909b0 Main activity, minor changes
* Main activity with description and links
* Decrease MAX_UPDATES_BEFORE_COMMIT to 25 (may fix #25)
2013-10-30 12:17:26 +01:00
Ricki Hirner 15886746a8 version 0.3.5-alpha
enhancements:
- don't show calendars/address books in account when no calendars/address books were added

bug fixes:
- select only events from correct calendar when querying (fixes #37)
- ignore non-OK values in multi-status response (fixes #35)
- fix wrong entries in setup calendar/addressbook selection
- commit after every 100 resource downloads to avoid TransactionTooLargeException
- don't delete resources without file name (fixes #33)
- avoid crashes when content providers return null cursors
- ignore invalid URLs (for instance, with spaces) in WebDAV multi-status responses
2013-10-27 01:38:47 +02:00
Ricki Hirner fac5767730 don't show calendars/address books as syncable in account when no calendars/address books were added; select only events from correct calendar when querying (fixes #37) 2013-10-27 01:20:57 +02:00
Ricki Hirner 3b4ef177b3 Don't delete resources without file name (fixes #33) 2013-10-27 00:14:55 +02:00
Ricki Hirner 68ccdca54f Use correct caching for address book/calendar selection ListView 2013-10-27 00:03:41 +02:00
Ricki Hirner d8699baa16 Bug fixes
* ignore non-OK values in multi-status response (fixes #35)
* fixes wrong entries in setup calendar/addressbook selection
2013-10-26 23:45:10 +02:00
Ricki Hirner 07f668fde6 Commit after every 100 resource downloads to avoid TransactionTooLargeException 2013-10-26 22:37:45 +02:00
Ricki Hirner a65f821802 bug fixes
* avoid crashes when content providers return null cursors
* ignore invalid URLs (for instance, with spaces) in WebDAV multi-status responses
2013-10-26 20:35:22 +02:00
Ricki Hirner 2b02439721 Fix "can't edit contact" / "account doesn't show up" and other bugs; refactoring
* support for phonetic names (closes #19)
* update contacts.xml, tested with 4.0 (Samsung), 4.2 (Cyanogen), 4.3 (Cyanogen) (fixes #5, fixes #6, fixes #7)
* smarter error handling (1): notify sync manager in case of HTTP auth errors
* smarter error handling (2): just ignore the dubious resources instead of notifying Android sync service
* refactoring: created DavSyncAdapter and move common code to it
* version bump to 0.3.4-alpha
2013-10-20 17:40:03 +02:00
Ricki Hirner f0f767718c fix potential crash bug when server returns no collection members 2013-10-16 18:22:59 +02:00
Ricki Hirner 844c63e8dc version bump to 0.3.3-alpha 2013-10-16 00:18:00 +02:00
Ricki Hirner f57e54803d fixes issue #11 2013-10-16 00:09:29 +02:00
Ricki Hirner 39f3e43890 add contacts.xml and birthday (couldn't test it) 2013-10-15 23:06:01 +02:00
Ricki Hirner 4ed3502286 disable strict parsing of XML response for PROPFIND and multiget 2013-10-15 23:06:01 +02:00
Ricki Hirner cefaffa650 Ant build support
* build.xml for Ant
* necessary library re-organization
* compatiblity changes to code
* now buildable via Ant and Eclipse ADT
* eclipse-libs has to be included for ADT building only
* ready for Fdroid
2013-10-12 14:55:49 +02:00
Ricki Hirner 7985ba2f0a various improvements
* fixed annoying bug where all imported contacts where starred
* checkbox for preemptive authentication in setup
* support for (non-preemptive) Digest authentication
* add UID when received entities don't contain one
* version bump to 0.3.2-alpha
* build.xml for ant/Fdroid
2013-10-12 13:38:13 +02:00
Ricki Hirner dd03b0450d fix annoying bug that marked contacts as starred 2013-10-11 21:50:22 +02:00
Ricki Hirner f1661fffac bug fixes
* added server path to account name to allow Baïkal CalDAV+CardDAV setup as two accounts
* enabled relaxed iCal parsing/unfolding for better compatibility
* fixed crash bug for .ics files that don't contain events
* fixed bug where all events where deleted locally when using multiple calendars
2013-10-09 21:59:56 +02:00
Ricki Hirner a251973761 Bug fixes:
* calendar multi-get
* debug output for propfind/multiget requests
* setup GUI crash if no address book/calendar available
2013-10-09 14:43:28 +02:00
Ricki Hirner 4fb94a89df refactoring
* ical4j-vcard instead of ez-vcard
* removed Guava dependencies in favor of Apache Commons IO
2013-10-06 13:48:53 +02:00
Ricki Hirner abd67609e6 Refactoring
* move common code to super-classes
* new icons
2013-10-05 20:34:26 +02:00
Ricki Hirner 7b262721e6 Calendar improvements
* recurring events
* classification
* status
* attendees
* ical4j 1.0.4 -> 1.0.5
* remove biweekly (replaced by ical4j)
2013-10-05 10:59:19 +02:00
Ricki Hirner 6f12a19b14 biweekly 0.3 -> 0.3.1, incomplete time zone support for events, Android-compatible Lombok 2013-09-27 18:19:54 +02:00
Ricki Hirner 1bc72f340f new Android test project 2013-09-18 21:11:42 +02:00
Ricki Hirner f724a89bd8 first commit 2013-09-18 20:03:36 +02:00
219 changed files with 12433 additions and 11302 deletions

No files matched your search

+3 -4
View File
@@ -3,13 +3,12 @@ image: registry.gitlab.com/bitfireat/davdroid:latest
before_script:
- git submodule update --init --recursive
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
- emulator64-arm -avd test -no-audio -no-window & wait-for-emulator.sh
- wget -q https://f-droid.org/repo/org.dmfs.tasks_103.apk && adb install org.dmfs.tasks_103.apk
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
- wget -q https://f-droid.org/repo/org.dmfs.tasks_481.apk && adb install org.dmfs.tasks_481.apk
cache:
paths:
- .gradle/wrapper
- .gradle/caches
- .gradle/
test:
script:
+10 -8
View File
@@ -5,14 +5,17 @@
DAVdroid
========
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) for
Please see the [DAVdroid Web site](https://www.davdroid.com) for
comprehensive information about DAVdroid.
DAVdroid is licensed under the [GPLv3 License](LICENSE).
News and updates: [@davdroidapp](https://twitter.com/davdroidapp)
News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
Help and discussion: [DAVdroid forums](https://www.davdroid.com/forums/)
**If you want to support DAVdroid, please consider [donating to DAVdroid](https://davdroid.bitfire.at/donate/)
or [purchasing it](https://davdroid.bitfire.at/download/).**
Parts of DAVdroid have been outsourced into these libraries:
@@ -21,16 +24,15 @@ Parts of DAVdroid have been outsourced into these libraries:
* [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
==========================
Those libraries are used by DAVdroid (alphabetically):
* [Ambilwarna](https://github.com/yukuku/ambilwarna) [Apache License, Version 2.0](https://github.com/yukuku/ambilwarna/blob/master/LICENSE)
* [dnsjava](http://www.xbill.org/dnsjava/) [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
* [ez-vcard](https://code.google.com/p/ez-vcard/) [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
* [iCal4j](http://ical4j.sourceforge.net/) [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
* [okhttp](https://square.github.io/okhttp/) [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
* [ez-vcard](https://github.com/mangstadt/ez-vcard) [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
* [iCal4j](https://github.com/ical4j/ical4j) [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
* [okhttp](https://square.github.io/okhttp) [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
* [Project Lombok](http://projectlombok.org/) [MIT License](http://opensource.org/licenses/mit-license.php)
+30 -28
View File
@@ -7,26 +7,31 @@
*/
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 25
buildToolsVersion '25.0.2'
compileSdkVersion 26
buildToolsVersion '26.0.2'
defaultConfig {
applicationId "at.bitfire.davdroid"
minSdkVersion 15
targetSdkVersion 25
versionCode 138
versionCode 184
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
buildConfigField "boolean", "customCerts", "true"
minSdkVersion 16
targetSdkVersion 25
vectorDrawables.useSupportLibrary = true
}
flavorDimensions "type"
productFlavors {
standard {
versionName "1.4.0.3-ose"
versionName "1.9.1-ose"
}
}
@@ -42,23 +47,17 @@ android {
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'GradleDependency'
disable 'GradleDynamicVersion'
disable 'IconColors'
disable 'IconLauncherShape'
disable 'IconMissingDensityFolder'
disable 'ImpliedQuantity', 'MissingQuantity'
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable "OnClick" // doesn't recognize Kotlin onClick methods
disable 'Recycle' // doesn't understand Lombok's @Cleanup
disable 'RtlEnabled'
disable 'RtlHardcoded'
disable 'Typos'
disable "RestrictedApi" // https://code.google.com/p/android/issues/detail?id=230387
}
packagingOptions {
exclude 'LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
}
defaultConfig {
@@ -72,30 +71,33 @@ dependencies {
compile project(':ical4android')
compile project(':vcard4android')
compile 'com.android.support:appcompat-v7:25.+'
compile 'com.android.support:cardview-v7:25.+'
compile 'com.android.support:design:25.+'
compile 'com.android.support:preference-v14:25.+'
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
compile 'com.android.support:appcompat-v7:26.1.0'
compile 'com.android.support:cardview-v7:26.1.0'
compile 'com.android.support:design:26.1.0'
compile 'com.android.support:preference-v14:26.1.0'
compile 'com.github.yukuku:ambilwarna:2.0.1'
compile 'com.squareup.okhttp3:logging-interceptor:3.6.0'
compile 'com.squareup.okhttp3:logging-interceptor:3.9.0'
compile 'commons-io:commons-io:2.5'
compile 'dnsjava:dnsjava:2.1.7'
compile 'org.apache.commons:commons-lang3:3.4'
compile 'dnsjava:dnsjava:2.1.8'
compile 'org.apache.commons:commons-lang3:3.6'
compile 'org.apache.commons:commons-collections4:4.1'
provided 'org.projectlombok:lombok:1.16.14'
// for tests
//noinspection GradleDynamicVersion
androidTestCompile('com.android.support.test:runner:+') {
exclude group: 'com.android.support', module: 'support-annotations'
}
//noinspection GradleDynamicVersion
androidTestCompile('com.android.support.test:rules:+') {
exclude group: 'com.android.support', module: 'support-annotations'
}
androidTestCompile 'junit:junit:4.12'
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.6.0'
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.9.0'
testCompile 'junit:junit:4.12'
testCompile 'com.squareup.okhttp3:mockwebserver:3.6.0'
testCompile 'com.squareup.okhttp3:mockwebserver:3.9.0'
}
+9 -4
View File
@@ -12,23 +12,28 @@
-allowaccessmodification
-dontpreverify
# Kotlin
-dontwarn kotlin.**
# ez-vcard
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
-dontwarn sun.misc.Perf
-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 net.fortuna.ical4j.model.**
-dontwarn org.codehaus.groovy.**
-dontwarn net.fortuna.ical4j.model.** # ignore warnings from Groovy dependency
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
# okhttp
-dontwarn java.nio.file.** # not available on Android
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn okio.**
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.ParametersAreNonnullByDefault
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
@@ -1,5 +1,5 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
@@ -8,40 +8,43 @@
package at.bitfire.davdroid;
import android.os.Build;
import android.support.test.runner.AndroidJUnit4;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.IOException;
import java.net.Socket;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import at.bitfire.cert4android.CustomCertManager;
import okhttp3.mockwebserver.MockWebServer;
import static android.support.test.InstrumentationRegistry.getInstrumentation;
import static android.support.test.InstrumentationRegistry.getTargetContext;
import static junit.framework.TestCase.assertFalse;
import static org.apache.commons.lang3.ArrayUtils.contains;
import static org.junit.Assert.assertTrue;
public class SSLSocketFactoryCompatTest {
CustomCertManager certMgr;
SSLSocketFactoryCompat factory;
MockWebServer server = new MockWebServer();
@Before
public void startServer() throws Exception {
factory = new SSLSocketFactoryCompat(new CustomCertManager(getTargetContext().getApplicationContext(), true));
certMgr = new CustomCertManager(getInstrumentation().getContext(), true, null);
factory = new SSLSocketFactoryCompat(certMgr);
server.start();
}
@After
public void stopServer() throws Exception {
server.shutdown();
certMgr.close();
}
@@ -51,13 +54,10 @@ public class SSLSocketFactoryCompatTest {
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"));
}
assertFalse(contains(ssl.getEnabledProtocols(), "SSLv3"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.1"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.2"));
}
}
@@ -1,5 +1,5 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
@@ -9,10 +9,10 @@
package at.bitfire.davdroid.model;
import android.content.ContentValues;
import android.support.test.runner.AndroidJUnit4;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.IOException;
@@ -32,8 +32,20 @@ import static org.junit.Assert.assertTrue;
public class CollectionInfoTest {
HttpClient httpClient;
MockWebServer server = new MockWebServer();
@Before
public void setUp() {
httpClient = new HttpClient.Builder().build();
}
@After
public void shutDown() {
httpClient.close();
}
@Test
public void testFromDavResource() throws IOException, HttpException, DavException {
// r/w address book
@@ -50,13 +62,13 @@ public class CollectionInfoTest {
"</response>" +
"</multistatus>"));
DavResource dav = new DavResource(HttpClient.create(null), server.url("/"));
DavResource dav = new DavResource(httpClient.getOkHttpClient(), server.url("/"));
dav.propfind(0, ResourceType.NAME);
CollectionInfo info = CollectionInfo.fromDavResource(dav);
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type);
assertFalse(info.readOnly);
assertEquals("My Contacts", info.displayName);
assertEquals("My Contacts Description", info.description);
CollectionInfo info = new CollectionInfo(dav);
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.getType());
assertFalse(info.getReadOnly());
assertEquals("My Contacts", info.getDisplayName());
assertEquals("My Contacts Description", info.getDescription());
// read-only calendar, no display name
server.enqueue(new MockResponse()
@@ -74,17 +86,17 @@ public class CollectionInfoTest {
"</response>" +
"</multistatus>"));
dav = new DavResource(HttpClient.create(null), server.url("/"));
dav = new DavResource(httpClient.getOkHttpClient(), server.url("/"));
dav.propfind(0, ResourceType.NAME);
info = CollectionInfo.fromDavResource(dav);
assertEquals(CollectionInfo.Type.CALENDAR, info.type);
assertTrue(info.readOnly);
assertNull(info.displayName);
assertEquals("My Calendar", info.description);
assertEquals(0xFFFF0000, (int)info.color);
assertEquals("tzdata", info.timeZone);
assertTrue(info.supportsVEVENT);
assertTrue(info.supportsVTODO);
info = new CollectionInfo(dav);
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
assertTrue(info.getReadOnly());
assertNull(info.getDisplayName());
assertEquals("My Calendar", info.getDescription());
assertEquals(0xFFFF0000, (int)info.getColor());
assertEquals("tzdata", info.getTimeZone());
assertTrue(info.getSupportsVEVENT());
assertTrue(info.getSupportsVTODO());
}
@Test
@@ -92,6 +104,7 @@ public class CollectionInfoTest {
ContentValues values = new ContentValues();
values.put(Collections.ID, 1);
values.put(Collections.SERVICE_ID, 1);
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name());
values.put(Collections.URL, "http://example.com");
values.put(Collections.READ_ONLY, 1);
values.put(Collections.DISPLAY_NAME, "display name");
@@ -102,18 +115,19 @@ public class CollectionInfoTest {
values.put(Collections.SUPPORTS_VTODO, 1);
values.put(Collections.SYNC, 1);
CollectionInfo info = CollectionInfo.fromDB(values);
assertEquals(1, info.id);
assertEquals(1, (long)info.serviceID);
assertEquals("http://example.com", info.url);
assertTrue(info.readOnly);
assertEquals("display name", info.displayName);
assertEquals("description", info.description);
assertEquals(0xFFFF0000, (int)info.color);
assertEquals("tzdata", info.timeZone);
assertTrue(info.supportsVEVENT);
assertTrue(info.supportsVTODO);
assertTrue(info.selected);
CollectionInfo info = new CollectionInfo(values);
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
assertEquals(1, (long)info.getId());
assertEquals(1, (long)info.getServiceID());
assertEquals("http://example.com", info.getUrl());
assertTrue(info.getReadOnly());
assertEquals("display name", info.getDisplayName());
assertEquals("description", info.getDescription());
assertEquals(0xFFFF0000, (int)info.getColor());
assertEquals("tzdata", info.getTimeZone());
assertTrue(info.getSupportsVEVENT());
assertTrue(info.getSupportsVTODO());
assertTrue(info.getSelected());
}
}
@@ -0,0 +1,39 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.App
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertFalse
import org.junit.Test
class DefaultsProviderTest {
private val provider: Provider = DefaultsProvider()
@Test
fun testHas() {
assertEquals(Pair(false, true), provider.has("notExisting"))
assertEquals(Pair(true, true), provider.has(App.OVERRIDE_PROXY))
}
@Test
fun testGet() {
assertEquals(Pair("localhost", true), provider.getString(App.OVERRIDE_PROXY_HOST))
assertEquals(Pair(8118, true), provider.getInt(App.OVERRIDE_PROXY_PORT))
}
@Test
fun testPutRemove() {
assertEquals(Pair(false, true), provider.isWritable(App.OVERRIDE_PROXY))
assertFalse(provider.putBoolean(App.OVERRIDE_PROXY, true))
assertFalse(provider.remove(App.OVERRIDE_PROXY))
}
}
@@ -0,0 +1,44 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.settings
import android.content.ServiceConnection
import android.support.test.InstrumentationRegistry
import android.support.test.InstrumentationRegistry.getTargetContext
import at.bitfire.davdroid.App
import junit.framework.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Test
class SettingsTest {
lateinit var settings: Settings.Stub
@Before
fun init() {
InstrumentationRegistry.getContext().isRestricted
settings = Settings.getInstance(getTargetContext())!!
}
@After
fun shutdown() {
settings.close()
}
@Test
fun testHas() {
assertFalse(settings.has("notExisting"))
// provided by DefaultsProvider
assertTrue(settings.has(App.OVERRIDE_PROXY))
}
}
@@ -1,5 +1,5 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
@@ -8,13 +8,9 @@
package at.bitfire.davdroid.ui.setup;
import android.support.test.runner.AndroidJUnit4;
import android.test.InstrumentationTestCase;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.IOException;
import java.net.URI;
@@ -24,12 +20,9 @@ import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressbookHomeSet;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.ui.setup.DavResourceFinder;
import at.bitfire.davdroid.log.Logger;
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
import at.bitfire.davdroid.ui.setup.LoginCredentials;
import okhttp3.OkHttpClient;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
@@ -46,7 +39,7 @@ public class DavResourceFinderTest {
MockWebServer server = new MockWebServer();
DavResourceFinder finder;
OkHttpClient client;
HttpClient client;
LoginCredentials credentials;
private static final String
@@ -68,8 +61,9 @@ public class DavResourceFinderTest {
credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
finder = new DavResourceFinder(getTargetContext(), credentials);
client = HttpClient.create(null);
client = HttpClient.addAuthentication(client, credentials.userName, credentials.password);
client = new HttpClient.Builder()
.addAuthentication(null, credentials.getUserName(), credentials.getPassword())
.build();
}
@After
@@ -82,25 +76,25 @@ public class DavResourceFinderTest {
ServiceInfo info;
// before dav.propfind(), no info is available
DavResource dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
DavResource dav = new DavResource(client.getOkHttpClient(), server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.collections.size());
assertEquals(0, info.homeSets.size());
assertEquals(0, info.getCollections().size());
assertEquals(0, info.getHomeSets().size());
// recognize home set
dav.propfind(0, AddressbookHomeSet.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.collections.size());
assertEquals(1, info.homeSets.size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.homeSets.iterator().next());
assertEquals(0, info.getCollections().size());
assertEquals(1, info.getHomeSets().size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.getHomeSets().iterator().next());
// recognize address book
dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
dav = new DavResource(client.getOkHttpClient(), server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
dav.propfind(0, ResourceType.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(1, info.collections.size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.collections.keySet().iterator().next());
assertEquals(0, info.homeSets.size());
assertEquals(1, info.getCollections().size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.getCollections().keySet().iterator().next());
assertEquals(0, info.getHomeSets().size());
}
@Test
@@ -184,7 +178,7 @@ public class DavResourceFinderTest {
"</resourcetype>";
break;
}
App.log.info("Sending props: " + props);
Logger.log.info("Sending props: " + props);
return new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
+89 -75
View File
@@ -52,81 +52,8 @@
android:theme="@style/AppTheme"
tools:ignore="UnusedAttribute">
<receiver
android:name=".App$ReinitLoggingReceiver"
android:exported="false"
android:process=":sync">
<intent-filter>
<action android:name="at.bitfire.davdroid.REINIT_LOGGER"/>
</intent-filter>
</receiver>
<receiver
android:name=".AccountSettings$AppUpdatedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_REPLACED" />
<data android:scheme="package" android:path="at.bitfire.davdroid" />
</intent-filter>
</receiver>
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks"/>
</service>
<service
android:name=".DavService"
android:enabled="true">
</service>
<service android:name=".DavService"/>
<service android:name=".settings.Settings"/>
<receiver android:name=".AccountsChangedReceiver">
<intent-filter>
@@ -188,6 +115,7 @@
<activity
android:name=".ui.DebugInfoActivity"
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="true"
android:label="@string/debug_info_title">
</activity>
@@ -201,6 +129,92 @@
android:resource="@xml/log_paths" />
</provider>
<!-- account type "DAVdroid" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
</service>
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks"/>
</service>
<!-- account type "DAVdroid Address book" -->
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator_address_book"/>
</service>
<provider
android:authorities="@string/address_books_authority"
android:exported="false"
android:label="@string/address_books_authority_title"
android:name=".syncadapter.AddressBookProvider"
android:multiprocess="false"/>
<service
android:name=".syncadapter.AddressBooksSyncAdapterService"
android:exported="true"
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_address_books"/>
</service>
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_contacts"/>
<meta-data
android:name="android.provider.CONTACTS_STRUCTURE"
android:resource="@xml/contacts"/>
</service>
</application>
@@ -0,0 +1,28 @@
package at.bitfire.davdroid.settings;
import at.bitfire.davdroid.settings.ISettingsObserver;
interface ISettings {
void forceReload();
boolean has(String key);
boolean getBoolean(String key, boolean defaultValue);
int getInt(String key, int defaultValue);
long getLong(String key, long defaultValue);
String getString(String key, String defaultValue);
boolean isWritable(String key);
boolean putBoolean(String key, boolean value);
boolean putInt(String key, int value);
boolean putLong(String key, long value);
boolean putString(String key, String value);
boolean remove(String key);
void registerObserver(ISettingsObserver observer);
void unregisterObserver(ISettingsObserver observer);
}
@@ -0,0 +1,7 @@
package at.bitfire.davdroid.settings;
interface ISettingsObserver {
void onSettingsChanged();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -1,416 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.BroadcastReceiver;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.PeriodicSync;
import android.database.sqlite.SQLiteDatabase;
import android.os.Build;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import lombok.Cleanup;
import okhttp3.HttpUrl;
public class AccountSettings {
private final static int CURRENT_VERSION = 5;
private final static String
KEY_SETTINGS_VERSION = "version",
KEY_USERNAME = "user_name",
KEY_WIFI_ONLY = "wifi_only", // sync on WiFi only (default: false)
KEY_WIFI_ONLY_SSID = "wifi_only_ssid"; // restrict sync to specific WiFi SSID
/** Time range limitation to the past [in days]
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
< 0 (-1) no limit
>= 0 entries more than n days in the past won't be synchronized
*/
private final static String KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days";
private final static int DEFAULT_TIME_RANGE_PAST_DAYS = 90;
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
value = null (not existing) true (default)
"0" false */
private final static String KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors";
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
private final static String KEY_CONTACT_GROUP_METHOD = "contact_group_method";
public final static long SYNC_INTERVAL_MANUALLY = -1;
final Context context;
final AccountManager accountManager;
final Account account;
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public AccountSettings(@NonNull Context context, @NonNull Account account) throws InvalidAccountException {
this.context = context;
this.account = account;
accountManager = AccountManager.get(context);
synchronized(AccountSettings.class) {
String versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION);
if (versionStr == null)
throw new InvalidAccountException(account);
int version = 0;
try {
version = Integer.parseInt(versionStr);
} catch (NumberFormatException ignored) {
}
App.log.fine("Account " + account.name + " has version " + version + ", current version: " + CURRENT_VERSION);
if (version < CURRENT_VERSION)
update(version);
}
}
public static Bundle initialUserData(String userName) {
Bundle bundle = new Bundle();
bundle.putString(KEY_SETTINGS_VERSION, String.valueOf(CURRENT_VERSION));
bundle.putString(KEY_USERNAME, userName);
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); }
// sync. settings
public Long getSyncInterval(@NonNull String authority) {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null;
if (ContentResolver.getSyncAutomatically(account, authority)) {
List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(account, authority);
if (syncs.isEmpty())
return SYNC_INTERVAL_MANUALLY;
else
return syncs.get(0).period;
} else
return SYNC_INTERVAL_MANUALLY;
}
public void setSyncInterval(@NonNull String authority, long seconds) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, authority, false);
} else {
ContentResolver.setSyncAutomatically(account, authority, true);
ContentResolver.addPeriodicSync(account, authority, new Bundle(), seconds);
}
}
public boolean getSyncWifiOnly() {
return accountManager.getUserData(account, KEY_WIFI_ONLY) != null;
}
public void setSyncWiFiOnly(boolean wiFiOnly) {
accountManager.setUserData(account, KEY_WIFI_ONLY, wiFiOnly ? "1" : null);
}
@Nullable
public String getSyncWifiOnlySSID() {
return accountManager.getUserData(account, KEY_WIFI_ONLY_SSID);
}
public void setSyncWifiOnlySSID(String ssid) {
accountManager.setUserData(account, KEY_WIFI_ONLY_SSID, ssid);
}
// CalDAV settings
@Nullable
public Integer getTimeRangePastDays() {
String strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS);
if (strDays != null) {
int days = Integer.valueOf(strDays);
return days < 0 ? null : days;
} else
return DEFAULT_TIME_RANGE_PAST_DAYS;
}
public void setTimeRangePastDays(@Nullable Integer days) {
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, String.valueOf(days == null ? -1 : days));
}
public boolean getManageCalendarColors() {
return accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null;
}
public void setManageCalendarColors(boolean manage) {
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, manage ? null : "0");
}
// CardDAV settings
@NonNull
public GroupMethod getGroupMethod() {
final String name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD);
return name != null ?
GroupMethod.valueOf(name) :
GroupMethod.GROUP_VCARDS;
}
public void setGroupMethod(@NonNull GroupMethod method) {
final String name = method == GroupMethod.GROUP_VCARDS ? null : method.name();
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, name);
}
// update from previous account settings
private void update(int fromVersion) {
for (int toVersion = fromVersion + 1; toVersion <= CURRENT_VERSION; toVersion++) {
App.log.info("Updating account " + account.name + " from version " + fromVersion + " to " + toVersion);
try {
Method updateProc = getClass().getDeclaredMethod("update_" + fromVersion + "_" + toVersion);
updateProc.invoke(this);
accountManager.setUserData(account, KEY_SETTINGS_VERSION, String.valueOf(toVersion));
} catch (Exception e) {
App.log.log(Level.SEVERE, "Couldn't update account settings", e);
}
fromVersion = toVersion;
}
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_1_2() throws ContactsStorageException {
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
*/
// move previous address book info to ContactsContract.SyncState
@Cleanup("release") ContentProviderClient provider = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
if (provider == null)
throw new ContactsStorageException("Couldn't access Contacts provider");
LocalAddressBook addr = new LocalAddressBook(account, provider);
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
ContentValues values = new ContentValues();
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
addr.updateSettings(values);
String url = accountManager.getUserData(account, "addressbook_url");
if (!TextUtils.isEmpty(url))
addr.setURL(url);
accountManager.setUserData(account, "addressbook_url", null);
String cTag = accountManager.getUserData(account, "addressbook_ctag");
if (!TextUtils.isEmpty(cTag))
addr.setCTag(cTag);
accountManager.setUserData(account, "addressbook_ctag", null);
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_2_3() {
// Don't show a warning for Android updates anymore
accountManager.setUserData(account, "last_android_version", null);
Long serviceCardDAV = null, serviceCalDAV = null;
ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(context);
try {
SQLiteDatabase db = dbHelper.getWritableDatabase();
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
// CardDAV: migrate address books
ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
if (client != null)
try {
LocalAddressBook addrBook = new LocalAddressBook(account, client);
String url = addrBook.getURL();
if (url != null) {
App.log.fine("Migrating address book " + url);
// insert CardDAV service
ContentValues values = new ContentValues();
values.put(Services.ACCOUNT_NAME, account.name);
values.put(Services.SERVICE, Services.SERVICE_CARDDAV);
serviceCardDAV = db.insert(Services._TABLE, null, values);
// insert address book
values.clear();
values.put(Collections.SERVICE_ID, serviceCardDAV);
values.put(Collections.URL, url);
values.put(Collections.SYNC, 1);
db.insert(Collections._TABLE, null, values);
// insert home set
HttpUrl homeSet = HttpUrl.parse(url).resolve("../");
values.clear();
values.put(HomeSets.SERVICE_ID, serviceCardDAV);
values.put(HomeSets.URL, homeSet.toString());
db.insert(HomeSets._TABLE, null, values);
}
} catch (ContactsStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate address book", e);
} finally {
client.release();
}
// CalDAV: migrate calendars + task lists
Set<String> collections = new HashSet<>();
Set<HttpUrl> homeSets = new HashSet<>();
client = context.getContentResolver().acquireContentProviderClient(CalendarContract.AUTHORITY);
if (client != null)
try {
LocalCalendar calendars[] = (LocalCalendar[])LocalCalendar.find(account, client, LocalCalendar.Factory.INSTANCE, null, null);
for (LocalCalendar calendar : calendars) {
String url = calendar.getName();
App.log.fine("Migrating calendar " + url);
collections.add(url);
homeSets.add(HttpUrl.parse(url).resolve("../"));
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate calendars", e);
} finally {
client.release();
}
TaskProvider provider = LocalTaskList.acquireTaskProvider(context.getContentResolver());
if (provider != null)
try {
LocalTaskList[] taskLists = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
for (LocalTaskList taskList : taskLists) {
String url = taskList.getSyncId();
App.log.fine("Migrating task list " + url);
collections.add(url);
homeSets.add(HttpUrl.parse(url).resolve("../"));
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate task lists", e);
} finally {
provider.close();
}
if (!collections.isEmpty()) {
// insert CalDAV service
ContentValues values = new ContentValues();
values.put(Services.ACCOUNT_NAME, account.name);
values.put(Services.SERVICE, Services.SERVICE_CALDAV);
serviceCalDAV = db.insert(Services._TABLE, null, values);
// insert collections
for (String url : collections) {
values.clear();
values.put(Collections.SERVICE_ID, serviceCalDAV);
values.put(Collections.URL, url);
values.put(Collections.SYNC, 1);
db.insert(Collections._TABLE, null, values);
}
// insert home sets
for (HttpUrl homeSet : homeSets) {
values.clear();
values.put(HomeSets.SERVICE_ID, serviceCalDAV);
values.put(HomeSets.URL, homeSet.toString());
db.insert(HomeSets._TABLE, null, values);
}
}
} finally {
dbHelper.close();
}
// initiate service detection (refresh) to get display names, colors etc.
Intent refresh = new Intent(context, DavService.class);
refresh.setAction(DavService.ACTION_REFRESH_COLLECTIONS);
if (serviceCardDAV != null) {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCardDAV);
context.startService(refresh);
}
if (serviceCalDAV != null) {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCalDAV);
context.startService(refresh);
}
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES);
}
/* Android 7.1.1 OpenTasks fix */
@SuppressWarnings({ "Recycle", "unused" })
private void update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context);
}
public static class AppUpdatedReceiver extends BroadcastReceiver {
@Override
@SuppressLint("UnsafeProtectedBroadcastReceiver,MissingPermission")
public void onReceive(Context context, Intent intent) {
App.log.info("DAVdroid was updated, checking for AccountSettings version");
// peek into AccountSettings to initiate a possible migration
AccountManager accountManager = AccountManager.get(context);
for (Account account : accountManager.getAccountsByType(Constants.ACCOUNT_TYPE))
try {
App.log.info("Checking account " + account.name);
new AccountSettings(context, account);
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't check for updated account settings", e);
}
}
}
}
@@ -0,0 +1,485 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
import java.util.*
import java.util.logging.Level
class AccountSettings @Throws(InvalidAccountException::class) constructor(
val context: Context,
val settings: ISettings,
val account: Account
) {
companion object {
val CURRENT_VERSION = 7
val KEY_SETTINGS_VERSION = "version"
val KEY_USERNAME = "user_name"
val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
/** Time range limitation to the past [in days]
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
< 0 (-1) no limit
>= 0 entries more than n days in the past won't be synchronized
*/
val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
value = null (not existing) true (default)
"0" false */
val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/* Whether DAVdroid populates and uses CalendarContract.Colors
value = null (not existing) false (default)
"1" true */
val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
@JvmField
val SYNC_INTERVAL_MANUALLY = -1L
fun initialUserData(userName: String): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
bundle.putString(KEY_USERNAME, userName)
return bundle
}
}
val accountManager: AccountManager = AccountManager.get(context)
init {
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
}
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION)
update(version)
}
}
// authentication settings
fun username(): String? = accountManager.getUserData(account, KEY_USERNAME)
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
fun password(): String? = accountManager.getPassword(account)
fun password(password: String) = accountManager.setPassword(account, password)
// sync. settings
fun getSyncInterval(authority: String): Long? {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null
return if (ContentResolver.getSyncAutomatically(account, authority))
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
else
SYNC_INTERVAL_MANUALLY
}
fun setSyncInterval(authority: String, seconds: Long) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, authority, false)
} else {
ContentResolver.setSyncAutomatically(account, authority, true)
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
}
}
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
settings.getBoolean(KEY_WIFI_ONLY, false)
else
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
settings.getString(KEY_WIFI_ONLY_SSIDS, null)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
// CalDAV settings
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
if (strDays != null) {
val days = Integer.valueOf(strDays)
return if (days < 0) null else days
} else
return DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS, false)
else
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
fun setManageCalendarColors(manage: Boolean) =
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
fun getEventColors() = if (settings.has(KEY_EVENT_COLORS))
settings.getBoolean(KEY_EVENT_COLORS, false)
else
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
fun setEventColors(useColors: Boolean) =
accountManager.setUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = settings.getString(KEY_CONTACT_GROUP_METHOD, null) ?:
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
if (name != null)
try {
return GroupMethod.valueOf(name)
}
catch (e: IllegalArgumentException) {
}
return GroupMethod.GROUP_VCARDS
}
fun setGroupMethod(method: GroupMethod) {
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
}
// update from previous account settings
private fun update(baseVersion: Int) {
for (toVersion in baseVersion+1 .. CURRENT_VERSION) {
val fromVersion = toVersion-1
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
try {
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
updateProc.invoke(this)
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
}
}
}
@Suppress("unused")
private fun update_6_7() {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
AndroidCalendar.insertColors(provider, account)
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
provider.release()
}
}
// update allowed WiFi settings key
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID)
accountManager.setUserData(account, "wifi_only_ssid", null)
}
@Suppress("unused")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
val parcel = Parcel.obtain()
try {
// don't run syncs during the migration
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
ContentResolver.cancelSync(account, null)
// get previous address book settings (including URL)
val raw = ContactsContract.SyncState.get(provider, account)
if (raw == null)
Logger.log.info("No contacts sync state, ignoring account")
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()
val url = params.getString("url")
if (url == null)
Logger.log.info("No address book URL, ignoring account")
else {
// create new address book
val info = CollectionInfo(url)
info.type = CollectionInfo.Type.ADDRESS_BOOK
info.displayName = account.name
Logger.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
Logger.log.info("Moving contacts from $account to $addressBookAccount")
val newAccount = ContentValues(2)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
newAccount,
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
arrayOf(account.name, account.type))
Logger.log.info("$affected contacts moved to new address book")
}
ContactsContract.SyncState.set(provider, account, null)
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
provider.release()
}
}
// update version number so that further syncs don't repeat the migration
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
}
/* Android 7.1.1 OpenTasks fix */
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context)
}
@Suppress("unused")
private fun update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES)
}
@Suppress("unused")
private fun update_2_3() {
// Don't show a warning for Android updates anymore
accountManager.setUserData(account, "last_android_version", null)
var serviceCardDAV: Long? = null
var serviceCalDAV: Long? = null
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
// CardDAV: migrate address books
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
try {
val addrBook = LocalAddressBook(context, account, client)
val url = addrBook.getURL()
Logger.log.fine("Migrating address book $url")
// insert CardDAV service
val values = ContentValues(3)
values.put(Services.ACCOUNT_NAME, account.name)
values.put(Services.SERVICE, Services.SERVICE_CARDDAV)
serviceCardDAV = db.insert(Services._TABLE, null, values)
// insert address book
values.clear()
values.put(Collections.SERVICE_ID, serviceCardDAV)
values.put(Collections.URL, url)
values.put(Collections.SYNC, 1)
db.insert(Collections._TABLE, null, values)
// insert home set
HttpUrl.parse(url)?.let {
val homeSet = it.resolve("../")
values.clear()
values.put(HomeSets.SERVICE_ID, serviceCardDAV)
values.put(HomeSets.URL, homeSet.toString())
db.insert(HomeSets._TABLE, null, values)
}
} catch (e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e)
} finally {
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
// CalDAV: migrate calendars + task lists
val collections = HashSet<String>()
val homeSets = HashSet<HttpUrl>()
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { client ->
try {
val calendars = AndroidCalendar.find(account, client, LocalCalendar.Factory, null, null)
for (calendar in calendars)
calendar.name?.let { url ->
Logger.log.fine("Migrating calendar $url")
collections.add(url)
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
}
} catch (e: CalendarStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate calendars", e)
} finally {
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
AndroidTaskList.acquireTaskProvider(context.contentResolver)?.use { provider ->
try {
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
for (taskList in taskLists)
taskList.syncId?.let { url ->
Logger.log.fine("Migrating task list $url")
collections.add(url)
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
}
} catch (e: CalendarStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e)
}
}
if (!collections.isEmpty()) {
// insert CalDAV service
val values = ContentValues(3)
values.put(Services.ACCOUNT_NAME, account.name)
values.put(Services.SERVICE, Services.SERVICE_CALDAV)
serviceCalDAV = db.insert(Services._TABLE, null, values)
// insert collections
for (url in collections) {
values.clear()
values.put(Collections.SERVICE_ID, serviceCalDAV)
values.put(Collections.URL, url)
values.put(Collections.SYNC, 1)
db.insert(Collections._TABLE, null, values)
}
// insert home sets
for (homeSet in homeSets) {
values.clear()
values.put(HomeSets.SERVICE_ID, serviceCalDAV)
values.put(HomeSets.URL, homeSet.toString())
db.insert(HomeSets._TABLE, null, values)
}
}
}
// initiate service detection (refresh) to get display names, colors etc.
val refresh = Intent(context, DavService::class.java)
refresh.action = DavService.ACTION_REFRESH_COLLECTIONS
serviceCardDAV?.let {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
context.startService(refresh)
}
serviceCalDAV?.let {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
context.startService(refresh)
}
}
@Suppress("unused")
private fun update_1_2() {
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
*/
// move previous address book info to ContactsContract.SyncState
val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) ?:
throw ContactsStorageException("Couldn't access Contacts provider")
try {
val addr = LocalAddressBook(context, account, provider)
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
val values = ContentValues()
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addr.updateSettings(values)
val url = accountManager.getUserData(account, "addressbook_url")
if (!url.isNullOrEmpty())
addr.setURL(url)
accountManager.setUserData(account, "addressbook_url", null)
val cTag = accountManager.getUserData (account, "addressbook_ctag")
if (!cTag.isNullOrEmpty())
addr.setCTag(cTag)
accountManager.setUserData(account, "addressbook_ctag", null)
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
@Suppress("deprecation")
provider.release()
}
}
}
@@ -1,46 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.AccountManager;
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) {
if (AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION.equals(intent.getAction())) {
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);
}
}
@@ -0,0 +1,48 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.*
class AccountsChangedReceiver: BroadcastReceiver() {
companion object {
private val listeners = LinkedList<OnAccountsUpdateListener>()
@JvmStatic
fun registerListener(listener: OnAccountsUpdateListener, callImmediately: Boolean) {
listeners += listener
if (callImmediately)
listener.onAccountsUpdated(null)
}
@JvmStatic
fun unregisterListener(listener: OnAccountsUpdateListener) {
listeners -= listener
}
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION) {
val serviceIntent = Intent(context, DavService::class.java)
serviceIntent.action = DavService.ACTION_ACCOUNTS_UPDATED
context.startService(serviceIntent)
for (listener in listeners)
listener.onAccountsUpdated(null)
}
}
}
@@ -1,189 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Application;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Process;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import android.util.Log;
import net.fortuna.ical4j.util.UidGenerator;
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.cert4android.CustomCertManager;
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 lombok.Cleanup;
import lombok.Getter;
import okhttp3.internal.tls.OkHostnameVerifier;
public class App extends Application {
public static final String FLAVOR_GOOGLE_PLAY = "gplay";
public static final String
DISTRUST_SYSTEM_CERTIFICATES = "distrustSystemCerts",
LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage",
OVERRIDE_PROXY = "overrideProxy",
OVERRIDE_PROXY_HOST = "overrideProxyHost",
OVERRIDE_PROXY_PORT = "overrideProxyPort";
public static final String OVERRIDE_PROXY_HOST_DEFAULT = "localhost";
public static final int OVERRIDE_PROXY_PORT_DEFAULT = 8118;
@Getter
private CustomCertManager certManager;
@Getter
private static SSLSocketFactoryCompat sslSocketFactoryCompat;
@Getter
private static HostnameVerifier hostnameVerifier;
@Getter
private static UidGenerator uidGenerator;
public final static Logger log = Logger.getLogger("davdroid");
static {
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android");
at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android");
}
@Override
@SuppressLint("HardwareIds")
public void onCreate() {
super.onCreate();
reinitCertManager();
reinitLogger();
uidGenerator = new UidGenerator(null, android.provider.Settings.Secure.getString(getContentResolver(), android.provider.Settings.Secure.ANDROID_ID));
}
public void reinitCertManager() {
if (BuildConfig.customCerts) {
if (certManager != null)
certManager.close();
@Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(this);
Settings settings = new Settings(dbHelper.getReadableDatabase());
certManager = new CustomCertManager(this, !settings.getBoolean(DISTRUST_SYSTEM_CERTIFICATES, false));
sslSocketFactoryCompat = new SSLSocketFactoryCompat(certManager);
hostnameVerifier = certManager.hostnameVerifier(OkHostnameVerifier.INSTANCE);
}
}
public void reinitLogger() {
@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);
App.log.info("Verbose logging: " + logVerbose);
// set logging level according to preferences
final Logger rootLogger = Logger.getLogger("");
rootLogger.setLevel(logVerbose ? Level.ALL : Level.INFO);
// remove all handlers and add our own logcat handler
rootLogger.setUseParentHandlers(false);
for (Handler handler : rootLogger.getHandlers())
rootLogger.removeHandler(handler);
rootLogger.addHandler(LogcatHandler.INSTANCE);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
// 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(getLauncherBitmap(this))
.setContentTitle(getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true);
File dir = getExternalFilesDir(null);
if (dir != null)
try {
String fileName = new File(dir, "davdroid-" + 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);
}
@Nullable
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static Bitmap getLauncherBitmap(@NonNull Context context) {
Bitmap bitmapLogo = null;
Drawable drawableLogo = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP ?
context.getDrawable(R.mipmap.ic_launcher) :
context.getResources().getDrawable(R.mipmap.ic_launcher);
if (drawableLogo instanceof BitmapDrawable)
bitmapLogo = ((BitmapDrawable)drawableLogo).getBitmap();
return bitmapLogo;
}
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();
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.support.v7.app.AppCompatDelegate
import at.bitfire.davdroid.log.Logger
class App: Application() {
companion object {
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
@JvmField val OVERRIDE_PROXY = "override_proxy"
@JvmField val OVERRIDE_PROXY_HOST = "override_proxy_host"
@JvmField val OVERRIDE_PROXY_PORT = "override_proxy_port"
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
@JvmStatic
fun getLauncherBitmap(context: Context): Bitmap? {
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
context.getDrawable(R.mipmap.ic_launcher)
else
@Suppress("deprecation")
context.resources.getDrawable(R.mipmap.ic_launcher)
return if (drawableLogo is BitmapDrawable)
drawableLogo.bitmap
else
null
}
}
override fun onCreate() {
super.onCreate()
Logger.initialize(this)
if (Build.VERSION.SDK_INT <= 21)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
}
}
@@ -1,33 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import java.lang.reflect.Array;
public class ArrayUtils {
@SuppressWarnings("unchecked")
public static <T> T[][] partition(T[] bigArray, int max) {
int nItems = bigArray.length;
int nPartArrays = (nItems + max-1)/max;
T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0);
// nItems is now the number of remaining items
for (int i = 0; nItems > 0; i++) {
int n = (nItems < max) ? nItems : max;
partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n);
System.arraycopy(bigArray, i*max, partArrays[i], 0, n);
nItems -= n;
}
return partArrays;
}
}
@@ -1,30 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.net.Uri;
public class Constants {
public static final String
ACCOUNT_TYPE = "bitfire.at.davdroid";
// notification IDs
public final static int
NOTIFICATION_EXTERNAL_FILE_LOGGING = 1,
NOTIFICATION_REFRESH_COLLECTIONS = 2,
NOTIFICATION_CONTACTS_SYNC = 10,
NOTIFICATION_CALENDAR_SYNC = 11,
NOTIFICATION_TASK_SYNC = 12,
NOTIFICATION_PERMISSIONS = 20;
public static final Uri webUri = Uri.parse("https://davdroid.bitfire.at/?pk_campaign=davdroid-app");
public static final int DEFAULT_SYNC_INTERVAL = 4 * 3600; // 4 hours
}
@@ -0,0 +1,23 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
object Constants {
// notification IDs
@JvmField val NOTIFICATION_EXTERNAL_FILE_LOGGING = 1
@JvmField val NOTIFICATION_REFRESH_COLLECTIONS = 2
@JvmField val NOTIFICATION_CONTACTS_SYNC = 10
@JvmField val NOTIFICATION_CALENDAR_SYNC = 11
@JvmField val NOTIFICATION_TASK_SYNC = 12
@JvmField val NOTIFICATION_PERMISSIONS = 20
@JvmField
val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
}
@@ -1,428 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.app.Notification;
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.os.Binder;
import android.os.IBinder;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import org.apache.commons.collections4.iterators.IteratorChain;
import org.apache.commons.collections4.iterators.SingletonIterator;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.UrlUtils;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressbookHomeSet;
import at.bitfire.dav4android.property.CalendarHomeSet;
import at.bitfire.dav4android.property.CalendarProxyReadFor;
import at.bitfire.dav4android.property.CalendarProxyWriteFor;
import at.bitfire.dav4android.property.GroupMembership;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
import at.bitfire.davdroid.model.ServiceDB.OpenHelper;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import lombok.Cleanup;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
public class DavService extends Service {
public static final String
ACTION_ACCOUNTS_UPDATED = "accountsUpdated",
ACTION_REFRESH_COLLECTIONS = "refreshCollections",
EXTRA_DAV_SERVICE_ID = "davServiceID";
private final IBinder binder = new InfoBinder();
private final Set<Long> runningRefresh = new HashSet<>();
private final List<WeakReference<RefreshingStatusListener>> refreshingStatusListeners = new LinkedList<>();
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null) {
String action = intent.getAction();
long id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1);
switch (action) {
case ACTION_ACCOUNTS_UPDATED:
cleanupAccounts();
break;
case ACTION_REFRESH_COLLECTIONS:
if (runningRefresh.add(id)) {
new Thread(new RefreshCollections(id)).start();
for (WeakReference<RefreshingStatusListener> ref : refreshingStatusListeners) {
RefreshingStatusListener listener = ref.get();
if (listener != null)
listener.onDavRefreshStatusChanged(id, true);
}
}
break;
}
}
return START_NOT_STICKY;
}
/* BOUND SERVICE PART
for communicating with the activities
*/
@Override
public IBinder onBind(Intent intent) {
return binder;
}
public interface RefreshingStatusListener {
void onDavRefreshStatusChanged(long id, boolean refreshing);
}
public class InfoBinder extends Binder {
public boolean isRefreshing(long id) {
return runningRefresh.contains(id);
}
public void addRefreshingStatusListener(@NonNull RefreshingStatusListener listener, boolean callImmediate) {
refreshingStatusListeners.add(new WeakReference<>(listener));
if (callImmediate)
for (long id : runningRefresh)
listener.onDavRefreshStatusChanged(id, true);
}
public void removeRefreshingStatusListener(@NonNull RefreshingStatusListener listener) {
for (Iterator<WeakReference<RefreshingStatusListener>> iterator = refreshingStatusListeners.iterator(); iterator.hasNext(); ) {
RefreshingStatusListener item = iterator.next().get();
if (listener.equals(item))
iterator.remove();
}
}
}
/* ACTION RUNNABLES
which actually do the work
*/
@SuppressLint("MissingPermission")
void cleanupAccounts() {
App.log.info("Cleaning up orphaned accounts");
final OpenHelper dbHelper = new OpenHelper(this);
try {
SQLiteDatabase db = dbHelper.getWritableDatabase();
List<String> sqlAccountNames = new LinkedList<>();
AccountManager am = AccountManager.get(this);
for (Account account : am.getAccountsByType(Constants.ACCOUNT_TYPE))
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name));
if (sqlAccountNames.isEmpty())
db.delete(Services._TABLE, null, null);
else
db.delete(Services._TABLE, Services.ACCOUNT_NAME + " NOT IN (" + TextUtils.join(",", sqlAccountNames) + ")", null);
} finally {
dbHelper.close();
}
}
private class RefreshCollections implements Runnable {
final long service;
final OpenHelper dbHelper;
SQLiteDatabase db;
RefreshCollections(long davServiceId) {
this.service = davServiceId;
dbHelper = new OpenHelper(DavService.this);
}
@Override
public void run() {
Account account = null;
try {
db = dbHelper.getWritableDatabase();
String serviceType = serviceType();
App.log.info("Refreshing " + serviceType + " collections of service #" + service);
// get account
account = account();
// create authenticating OkHttpClient (credentials taken from account settings)
OkHttpClient httpClient = HttpClient.create(DavService.this, account);
// refresh home sets: principal
Set<HttpUrl> homeSets = readHomeSets();
HttpUrl principal = readPrincipal();
if (principal != null) {
App.log.fine("Querying principal for home sets");
DavResource dav = new DavResource(httpClient, principal);
queryHomeSets(serviceType, dav, homeSets);
// refresh home sets: calendar-proxy-read/write-for
CalendarProxyReadFor proxyRead = (CalendarProxyReadFor)dav.properties.get(CalendarProxyReadFor.NAME);
if (proxyRead != null)
for (String href : proxyRead.principals) {
App.log.fine("Principal is a read-only proxy for " + href + ", checking for home sets");
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
}
CalendarProxyWriteFor proxyWrite = (CalendarProxyWriteFor)dav.properties.get(CalendarProxyWriteFor.NAME);
if (proxyWrite != null)
for (String href : proxyWrite.principals) {
App.log.fine("Principal is a read-write proxy for " + href + ", checking for home sets");
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
}
// refresh home sets: direct group memberships
GroupMembership groupMembership = (GroupMembership)dav.properties.get(GroupMembership.NAME);
if (groupMembership != null)
for (String href : groupMembership.hrefs) {
App.log.fine("Principal is member of group " + href + ", checking for home sets");
DavResource group = new DavResource(httpClient, dav.location.resolve(href));
try {
queryHomeSets(serviceType, group, homeSets);
} catch(HttpException|DavException e) {
App.log.log(Level.WARNING, "Couldn't query member group ", e);
}
}
}
// now refresh collections (taken from home sets)
Map<HttpUrl, CollectionInfo> collections = readCollections();
// (remember selections before)
Set<HttpUrl> selectedCollections = new HashSet<>();
for (CollectionInfo info : collections.values())
if (info.selected)
selectedCollections.add(HttpUrl.parse(info.url));
for (Iterator<HttpUrl> itHomeSets = homeSets.iterator(); itHomeSets.hasNext(); ) {
HttpUrl homeSet = itHomeSets.next();
App.log.fine("Listing home set " + homeSet);
DavResource dav = new DavResource(httpClient, homeSet);
try {
dav.propfind(1, CollectionInfo.DAV_PROPERTIES);
IteratorChain<DavResource> itCollections = new IteratorChain<>(dav.members.iterator(), new SingletonIterator(dav));
while (itCollections.hasNext()) {
DavResource member = itCollections.next();
CollectionInfo info = CollectionInfo.fromDavResource(member);
info.confirmed = true;
App.log.log(Level.FINE, "Found collection", info);
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type == CollectionInfo.Type.CALENDAR))
collections.put(member.location, info);
}
} catch(HttpException e) {
if (e.status == 403 || e.status == 404 || e.status == 410)
// delete home set only if it was not accessible (40x)
itHomeSets.remove();
}
}
// check/refresh unconfirmed collections
for (Iterator<Map.Entry<HttpUrl, CollectionInfo>> iterator = collections.entrySet().iterator(); iterator.hasNext(); ) {
Map.Entry<HttpUrl, CollectionInfo> entry = iterator.next();
HttpUrl url = entry.getKey();
CollectionInfo info = entry.getValue();
if (!info.confirmed)
try {
DavResource dav = new DavResource(httpClient, url);
dav.propfind(0, CollectionInfo.DAV_PROPERTIES);
info = CollectionInfo.fromDavResource(dav);
info.confirmed = true;
// remove unusable collections
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type != CollectionInfo.Type.CALENDAR))
iterator.remove();
} catch(HttpException e) {
if (e.status == 403 || e.status == 404 || e.status == 410)
// delete collection only if it was not accessible (40x)
iterator.remove();
else
throw e;
}
}
// restore selections
for (HttpUrl url : selectedCollections) {
CollectionInfo info = collections.get(url);
if (info != null)
info.selected = true;
}
db.beginTransactionNonExclusive();
try {
saveHomeSets(homeSets);
saveCollections(collections.values());
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
} catch(InvalidAccountException e) {
App.log.log(Level.SEVERE, "Invalid account", e);
} catch(IOException|HttpException|DavException e) {
App.log.log(Level.SEVERE, "Couldn't refresh collection list", e);
Intent debugIntent = new Intent(DavService.this, DebugInfoActivity.class);
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e);
if (account != null)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
NotificationManagerCompat nm = NotificationManagerCompat.from(DavService.this);
Notification notify = new NotificationCompat.Builder(DavService.this)
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(DavService.this))
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(DavService.this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.build();
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify);
} finally {
dbHelper.close();
runningRefresh.remove(service);
for (WeakReference<RefreshingStatusListener> ref : refreshingStatusListeners) {
RefreshingStatusListener listener = ref.get();
if (listener != null)
listener.onDavRefreshStatusChanged(service, false);
}
}
}
/**
* Checks if the given URL defines home sets and adds them to the home set list.
* @param serviceType CalDAV/CardDAV (calendar home set / addressbook home set)
* @param dav DavResource to check
* @param homeSets set where found home set URLs will be put into
*/
private void queryHomeSets(String serviceType, DavResource dav, Set<HttpUrl> homeSets) throws IOException, HttpException, DavException {
if (Services.SERVICE_CARDDAV.equals(serviceType)) {
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME);
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)dav.properties.get(AddressbookHomeSet.NAME);
if (addressbookHomeSet != null)
for (String href : addressbookHomeSet.hrefs)
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
} else if (Services.SERVICE_CALDAV.equals(serviceType)) {
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME);
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)dav.properties.get(CalendarHomeSet.NAME);
if (calendarHomeSet != null)
for (String href : calendarHomeSet.hrefs)
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
}
}
@NonNull
private Account account() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext()) {
return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE);
} else
throw new IllegalArgumentException("Service not found");
}
@NonNull
private String serviceType() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.SERVICE }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext())
return cursor.getString(0);
else
throw new IllegalArgumentException("Service not found");
}
@Nullable
private HttpUrl readPrincipal() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.PRINCIPAL }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext()) {
String principal = cursor.getString(0);
if (principal != null)
return HttpUrl.parse(cursor.getString(0));
}
return null;
}
@NonNull
private Set<HttpUrl> readHomeSets() {
Set<HttpUrl> homeSets = new LinkedHashSet<>();
@Cleanup Cursor cursor = db.query(HomeSets._TABLE, new String[] { HomeSets.URL }, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext())
homeSets.add(HttpUrl.parse(cursor.getString(0)));
return homeSets;
}
private void saveHomeSets(Set<HttpUrl> homeSets) {
db.delete(HomeSets._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
for (HttpUrl homeSet : homeSets) {
ContentValues values = new ContentValues(1);
values.put(HomeSets.SERVICE_ID, service);
values.put(HomeSets.URL, homeSet.toString());
db.insertOrThrow(HomeSets._TABLE, null, values);
}
}
@NonNull
private Map<HttpUrl, CollectionInfo> readCollections() {
Map<HttpUrl, CollectionInfo> collections = new LinkedHashMap<>();
@Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", new String[]{String.valueOf(service)}, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
collections.put(HttpUrl.parse(values.getAsString(Collections.URL)), CollectionInfo.fromDB(values));
}
return collections;
}
private void saveCollections(Iterable<CollectionInfo> collections) {
db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
for (CollectionInfo collection : collections) {
ContentValues values = collection.toDB();
App.log.log(Level.FINE, "Saving collection", values);
values.put(Collections.SERVICE_ID, service);
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}
}
}
@@ -0,0 +1,382 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.ContentValues
import android.content.Intent
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.os.Binder
import android.support.v4.app.NotificationManagerCompat
import android.support.v7.app.NotificationCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.vcard4android.ContactsStorageException
import okhttp3.HttpUrl
import org.apache.commons.collections4.iterators.IteratorChain
import org.apache.commons.collections4.iterators.SingletonIterator
import java.io.IOException
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
import kotlin.concurrent.thread
class DavService: Service() {
companion object {
@JvmField val ACTION_ACCOUNTS_UPDATED = "accountsUpdated"
@JvmField val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
@JvmField val EXTRA_DAV_SERVICE_ID = "davServiceID"
}
private val runningRefresh = HashSet<Long>()
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
when (intent.action) {
ACTION_ACCOUNTS_UPDATED ->
cleanupAccounts()
ACTION_REFRESH_COLLECTIONS ->
if (runningRefresh.add(id)) {
thread { refreshCollections(id) }
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
}
}
}
return START_NOT_STICKY
}
/* BOUND SERVICE PART
for communicating with the activities
*/
interface RefreshingStatusListener {
fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean)
}
private val binder = InfoBinder()
inner class InfoBinder: Binder() {
fun isRefreshing(id: Long) = runningRefresh.contains(id)
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) {
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
if (callImmediate)
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
}
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
val iter = refreshingStatusListeners.iterator()
while (iter.hasNext()) {
val item = iter.next().get()
if (listener == item)
iter.remove()
}
}
}
override fun onBind(intent: Intent?) = binder
/* ACTION RUNNABLES
which actually do the work
*/
@SuppressLint("MissingPermission")
fun cleanupAccounts() {
Logger.log.info("Cleaning up orphaned accounts")
OpenHelper(this).use { dbHelper ->
val db = dbHelper.writableDatabase
val sqlAccountNames = LinkedList<String>()
val accountNames = HashSet<String>()
val accountManager = AccountManager.get(this)
for (account in accountManager.getAccountsByType(getString(R.string.account_type))) {
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
accountNames += account.name
}
// delete orphaned address book accounts
accountManager.getAccountsByType(getString(R.string.account_type_address_book))
.map { LocalAddressBook(this, it, null) }
.forEach {
try {
if (!accountNames.contains(it.getMainAccount().name))
it.delete()
} catch(e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't get address book main account", e)
}
}
// delete orphaned services in DB
if (sqlAccountNames.isEmpty())
db.delete(Services._TABLE, null, null)
else
db.delete(Services._TABLE, "${Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
}
}
fun refreshCollections(service: Long) {
OpenHelper(this@DavService).use { dbHelper ->
val db = dbHelper.writableDatabase
val serviceType by lazy {
db.query(Services._TABLE, arrayOf(Services.SERVICE), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return@lazy cursor.getString(0)
} ?: throw IllegalArgumentException("Service not found")
}
val account by lazy {
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return@lazy Account(cursor.getString(0), getString(R.string.account_type))
}
throw IllegalArgumentException("Account not found")
}
val homeSets by lazy {
val homeSets = mutableSetOf<HttpUrl>()
db.query(HomeSets._TABLE, arrayOf(HomeSets.URL), "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext())
HttpUrl.parse(cursor.getString(0))?.let { homeSets += it }
}
homeSets
}
val collections by lazy {
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
db.query(Collections._TABLE, null, "${Collections.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, values)
values.getAsString(Collections.URL)?.let { url ->
HttpUrl.parse(url)?.let { collections.put(it, CollectionInfo(values)) }
}
}
}
collections
}
fun readPrincipal(): HttpUrl? {
db.query(Services._TABLE, arrayOf(Services.PRINCIPAL), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let { return HttpUrl.parse(it) }
}
return null
}
/**
* Checks if the given URL defines home sets and adds them to the home set list.
* @param dav DavResource to check
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun queryHomeSets(dav: DavResource) {
when (serviceType) {
Services.SERVICE_CARDDAV -> {
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
for ((resource, addressbookHomeSet) in dav.findProperties(AddressbookHomeSet.NAME) as List<Pair<DavResource, AddressbookHomeSet>>)
for (href in addressbookHomeSet.hrefs)
resource.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
}
Services.SERVICE_CALDAV -> {
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
for ((resource, calendarHomeSet) in dav.findProperties(CalendarHomeSet.NAME) as List<Pair<DavResource, CalendarHomeSet>>)
for (href in calendarHomeSet.hrefs)
resource.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
}
}
}
fun saveHomeSets() {
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
for (homeSet in homeSets) {
val values = ContentValues(2)
values.put(HomeSets.SERVICE_ID, service)
values.put(HomeSets.URL, homeSet.toString())
db.insertOrThrow(HomeSets._TABLE, null, values)
}
}
fun saveCollections() {
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
for ((_,collection) in collections) {
val values = collection.toDB()
Logger.log.log(Level.FINE, "Saving collection", values)
values.put(Collections.SERVICE_ID, service)
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
}
try {
Logger.log.info("Refreshing $serviceType collections of service #$service")
// create authenticating OkHttpClient (credentials taken from account settings)
HttpClient.Builder(this@DavService, account)
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
// refresh home set list (from principal)
readPrincipal()?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
val principal = DavResource(httpClient, principalUrl)
queryHomeSets(principal)
// refresh home sets: calendar-proxy-read/write-for
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor.NAME) as List<Pair<DavResource, CalendarProxyReadFor>>)
for (href in proxyRead.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
}
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor.NAME) as List<Pair<DavResource, CalendarProxyWriteFor>>)
for (href in proxyWrite.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
}
// refresh home sets: direct group memberships
(principal.properties[GroupMembership.NAME] as GroupMembership?)?.let { groupMembership ->
for (href in groupMembership.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
principal.location.resolve(href)?.let { url ->
try {
queryHomeSets(DavResource(httpClient, url))
} catch(e: HttpException) {
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
} catch(e: DavException) {
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
}
}
}
}
}
// remember selected collections
val selectedCollections = HashSet<HttpUrl>()
collections.values
.filter { it.selected }
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
// now refresh collections (taken from home sets)
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val homeSetUrl = itHomeSets.next()
Logger.log.fine("Listing home set $homeSetUrl")
val homeSet = DavResource(httpClient, homeSetUrl)
try {
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
val itCollections = IteratorChain<DavResource>(homeSet.members.iterator(), homeSet.related.iterator(), SingletonIterator(homeSet))
while (itCollections.hasNext()) {
val member = itCollections.next()
val info = CollectionInfo(member)
info.confirmed = true
Logger.log.log(Level.FINE, "Found collection", info)
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
collections[member.location] = info
}
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
}
// check/refresh unconfirmed collections
val itCollections = collections.entries.iterator()
while (itCollections.hasNext()) {
val (url, info) = itCollections.next()
if (!info.confirmed)
try {
val collection = DavResource(httpClient, url)
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
val info = CollectionInfo(collection)
info.confirmed = true
// remove unusable collections
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
itCollections.remove()
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
throw e
}
}
// restore selections
for (url in selectedCollections)
collections[url]?.let { it.selected = true }
}
db.beginTransactionNonExclusive()
try {
saveHomeSets()
saveCollections()
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val nm = NotificationManagerCompat.from(this@DavService)
val notify = NotificationCompat.Builder(this@DavService)
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(this@DavService))
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(this@DavService, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.build()
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify)
} finally {
runningRefresh.remove(service)
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
}
}
}
}
@@ -1,41 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.support.annotation.NonNull;
import org.apache.commons.lang3.StringUtils;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import okhttp3.HttpUrl;
public class DavUtils {
public static String ARGBtoCalDAVColor(int colorWithAlpha) {
byte alpha = (byte)(colorWithAlpha >> 24);
int color = colorWithAlpha & 0xFFFFFF;
return String.format("#%06X%02X", color, alpha);
}
public static String lastSegmentOfUrl(@NonNull String url) {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
List<String> segments = new LinkedList<>(HttpUrl.parse(url).pathSegments());
Collections.reverse(segments);
for (String segment : segments)
if (!StringUtils.isEmpty(segment))
return segment;
return "/";
}
}
@@ -0,0 +1,38 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import okhttp3.HttpUrl
import java.util.*
object DavUtils {
@JvmStatic
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
val alpha = (colorWithAlpha shr 24) and 0xFF
val color = colorWithAlpha and 0xFFFFFF
return String.format("#%06X%02X", color, alpha)
}
@JvmStatic
fun lastSegmentOfUrl(url: String): String {
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
val segments = LinkedList<String>(httpUrl.pathSegments())
Collections.reverse(segments)
for (segment in segments)
if (segment.isNotEmpty())
return segment
return "/"
}
}
@@ -1,169 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.Account;
import android.content.Context;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
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.BasicDigestAuthHandler;
import at.bitfire.dav4android.UrlUtils;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.Settings;
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(@Nullable Context context, @NonNull Account account, @NonNull final Logger logger) throws InvalidAccountException {
OkHttpClient.Builder builder = defaultBuilder(context, logger);
// use account settings for authentication
AccountSettings settings = new AccountSettings(context, account);
builder = addAuthentication(builder, null, settings.username(), settings.password());
return builder.build();
}
public static OkHttpClient create(@NonNull Context context, @NonNull Logger logger) {
return defaultBuilder(context, logger).build();
}
public static OkHttpClient create(@NonNull Context context, @NonNull Account account) throws InvalidAccountException {
return create(context, account, App.log);
}
public static OkHttpClient create(@Nullable Context context) {
return create(context, App.log);
}
private static OkHttpClient.Builder defaultBuilder(@Nullable Context context, @NonNull final Logger logger) {
OkHttpClient.Builder builder = client.newBuilder();
// use MemorizingTrustManager to manage self-signed certificates
if (context != null) {
App app = (App)context.getApplicationContext();
if (App.getSslSocketFactoryCompat() != null && app.getCertManager() != null)
builder.sslSocketFactory(App.getSslSocketFactoryCompat(), app.getCertManager());
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);
// custom proxy support
if (context != null) {
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(context);
try {
Settings settings = new Settings(dbHelper.getReadableDatabase());
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
InetSocketAddress address = new InetSocketAddress(
settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT),
settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
);
Proxy proxy = new Proxy(Proxy.Type.HTTP, address);
builder.proxy(proxy);
App.log.log(Level.INFO, "Using proxy", proxy);
}
} catch(IllegalArgumentException|NullPointerException e) {
App.log.log(Level.SEVERE, "Can't set proxy, ignoring", e);
} finally {
dbHelper.close();
}
}
// add User-Agent to every request
builder.addNetworkInterceptor(userAgentInterceptor);
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
builder.cookieJar(new MemoryCookieStore());
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
@Override
public void log(String message) {
logger.finest(message);
}
});
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
builder.addInterceptor(loggingInterceptor);
}
return builder;
}
private static OkHttpClient.Builder addAuthentication(@NonNull OkHttpClient.Builder builder, @Nullable String host, @NonNull String username, @NonNull String password) {
BasicDigestAuthHandler authHandler = new BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password);
return builder
.addNetworkInterceptor(authHandler)
.authenticator(authHandler);
}
public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String username, @NonNull String password) {
OkHttpClient.Builder builder = client.newBuilder();
addAuthentication(builder, null, username, password);
return builder.build();
}
public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String host, @NonNull String username, @NonNull String password) {
OkHttpClient.Builder builder = client.newBuilder();
addAuthentication(builder, host, username, password);
return builder.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);
}
}
}
@@ -0,0 +1,170 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.Account
import android.content.Context
import android.os.Build
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4android.BasicDigestAuthHandler
import at.bitfire.dav4android.UrlUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.Settings
import okhttp3.Cache
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.Closeable
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
private val certManager: CustomCertManager?
): Closeable {
override fun close() {
certManager?.close()
}
class Builder @JvmOverloads constructor(
val context: Context? = null,
account: Account? = null,
accountSettings: AccountSettings? = null,
logger: java.util.logging.Logger = Logger.log
) {
var certManager: CustomCertManager? = null
private val orig = OkHttpClient.Builder()
init {
// set timeouts
orig.connectTimeout(30, TimeUnit.SECONDS)
orig.writeTimeout(30, TimeUnit.SECONDS)
orig.readTimeout(120, TimeUnit.SECONDS)
// don't allow redirects by default, because it would break PROPFIND handling
orig.followRedirects(false)
// add User-Agent to every request
orig.addNetworkInterceptor(UserAgentInterceptor)
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
orig.cookieJar(MemoryCookieStore())
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
message -> logger.finest(message)
})
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
orig.addInterceptor(loggingInterceptor)
}
context?.let {
Settings.getInstance(context)?.use { settings ->
// custom proxy support
try {
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
val address = InetSocketAddress(
settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT),
settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
)
val proxy = Proxy(Proxy.Type.HTTP, address)
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy", proxy)
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
if (BuildConfig.customCerts)
customCertManager(CustomCertManager(context, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
// use account settings for authentication
val accountSettings = accountSettings ?: account?.let { AccountSettings(context, settings, it) }
accountSettings?.let {
val userName = accountSettings.username()
val password = accountSettings.password()
if (userName != null && password != null)
addAuthentication(null, userName, password)
}
}
}
}
constructor(context: Context, host: String?, username: String, password: String): this(context) {
addAuthentication(host, username, password)
}
fun withDiskCache(): Builder {
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
for (dir in arrayOf(context.externalCacheDir, context.cacheDir)) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
Logger.log.fine("Using disk cache: $cacheDir")
orig.cache(Cache(cacheDir, 10*1024*1024))
break
}
}
return this
}
fun followRedirects(follow: Boolean): Builder {
orig.followRedirects(follow)
return this
}
fun customCertManager(manager: CustomCertManager) {
certManager = manager
orig.sslSocketFactory(SSLSocketFactoryCompat(manager), manager)
orig.hostnameVerifier(manager.hostnameVerifier(OkHostnameVerifier.INSTANCE))
}
fun setForeground(foreground: Boolean): Builder {
certManager?.appInForeground = foreground
return this
}
fun addAuthentication(host: String?, username: String, password: String): Builder {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
return this
}
fun build() = HttpClient(orig.build(), certManager)
}
private object UserAgentInterceptor: Interceptor {
private val userAgentDate = SimpleDateFormat("yyyy/MM/dd", Locale.US).format(Date(BuildConfig.buildTime))
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
val request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
.build()
return chain.proceed(request)
}
}
}
@@ -1,19 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.Account;
public class InvalidAccountException extends Exception {
public InvalidAccountException(Account account) {
super("Invalid account: " + account);
}
}
@@ -0,0 +1,13 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.Account
class InvalidAccountException(account: Account): Exception("Invalid account: $account")
@@ -1,69 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import org.apache.commons.collections4.MapIterator;
import org.apache.commons.collections4.keyvalue.MultiKey;
import org.apache.commons.collections4.map.HashedMap;
import org.apache.commons.collections4.map.MultiKeyMap;
import java.util.LinkedList;
import java.util.List;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.HttpUrl;
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
public class MemoryCookieStore implements CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
protected final MultiKeyMap<String, Cookie> storage = MultiKeyMap.multiKeyMap(new HashedMap<MultiKey<? extends String>, Cookie>());
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
synchronized(storage) {
for (Cookie cookie : cookies)
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie);
}
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
List<Cookie> cookies = new LinkedList<>();
synchronized(storage) {
MapIterator<MultiKey<? extends String>, Cookie> iter = storage.mapIterator();
while (iter.hasNext()) {
iter.next();
Cookie cookie = iter.getValue();
// remove expired cookies
if (cookie.expiresAt() <= System.currentTimeMillis()) {
iter.remove();
continue;
}
// add applicable cookies
if (cookie.matches(url))
cookies.add(cookie);
}
}
return cookies;
}
}
@@ -0,0 +1,63 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import org.apache.commons.collections4.keyvalue.MultiKey
import org.apache.commons.collections4.map.HashedMap
import org.apache.commons.collections4.map.MultiKeyMap
import java.util.*
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
class MemoryCookieStore: CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(storage) {
for (cookie in cookies)
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookies = LinkedList<Cookie>()
synchronized(storage) {
val iter = storage.mapIterator()
while (iter.hasNext()) {
iter.next()
val cookie = iter.value
// remove expired cookies
if (cookie.expiresAt() <= System.currentTimeMillis()) {
iter.remove()
continue
}
// add applicable cookies
if (cookie.matches(url))
cookies += cookie
}
}
return cookies
}
}
@@ -1,63 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.accounts.Account;
import android.annotation.SuppressLint;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.os.Bundle;
import android.support.annotation.NonNull;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
public class PackageChangedReceiver extends BroadcastReceiver {
@Override
@SuppressLint("MissingPermission")
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_PACKAGE_ADDED.equals(intent.getAction()) ||
Intent.ACTION_PACKAGE_FULLY_REMOVED.equals(intent.getAction()))
updateTaskSync(context);
}
static void updateTaskSync(@NonNull Context context) {
boolean tasksInstalled = LocalTaskList.tasksProviderAvailable(context);
App.log.info("Package (un)installed; OpenTasks provider now available = " + tasksInstalled);
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
@Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(context);
SQLiteDatabase db = dbHelper.getReadableDatabase();
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME },
Services.SERVICE + "=?", new String[] { Services.SERVICE_CALDAV }, null, null, null);
while (cursor.moveToNext()) {
Account account = new Account(cursor.getString(0), Constants.ACCOUNT_TYPE);
if (tasksInstalled) {
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1);
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true);
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, new Bundle(), Constants.DEFAULT_SYNC_INTERVAL);
}
} else
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0);
}
}
}
@@ -0,0 +1,65 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider
class PackageChangedReceiver: BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED)
updateTaskSync(context)
}
companion object {
@JvmStatic
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
if (tasksInstalled) {
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL.toLong())
}
} else
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
}
}
}
}
}
}
@@ -1,170 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.os.Build;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.X509TrustManager;
import lombok.Cleanup;
public class SSLSocketFactoryCompat extends SSLSocketFactory {
private SSLSocketFactory delegate;
// Android 5.0+ (API level21) provides reasonable default settings
// but it still allows SSLv3
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
static String protocols[] = null, cipherSuites[] = null;
static {
try {
@Cleanup SSLSocket socket = (SSLSocket)SSLSocketFactory.getDefault().createSocket();
if (socket != null) {
/* set reasonable protocol versions */
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
// - remove all SSL versions (especially SSLv3) because they're insecure now
List<String> protocols = new LinkedList<>();
for (String protocol : socket.getSupportedProtocols())
if (!protocol.toUpperCase(Locale.US).contains("SSL"))
protocols.add(protocol);
App.log.info("Setting allowed TLS protocols: " + TextUtils.join(", ", protocols));
SSLSocketFactoryCompat.protocols = protocols.toArray(new String[protocols.size()]);
/* set up reasonable cipher suites */
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// choose known secure cipher suites
List<String> allowedCiphers = Arrays.asList(
// TLS 1.2
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
// maximum interoperability
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
// additionally
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA");
List<String> availableCiphers = Arrays.asList(socket.getSupportedCipherSuites());
App.log.info("Available cipher suites: " + TextUtils.join(", ", availableCiphers));
App.log.info("Cipher suites enabled by default: " + TextUtils.join(", ", socket.getEnabledCipherSuites()));
// take all allowed ciphers that are available and put them into preferredCiphers
HashSet<String> preferredCiphers = new HashSet<>(allowedCiphers);
preferredCiphers.retainAll(availableCiphers);
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus disabling
* ciphers which are enabled by default, but have become unsecure), but I guess for
* the security level of DAVdroid and maximum compatibility, disabling of insecure
* ciphers should be a server-side task */
// add preferred ciphers to enabled ciphers
HashSet<String> enabledCiphers = preferredCiphers;
enabledCiphers.addAll(new HashSet<>(Arrays.asList(socket.getEnabledCipherSuites())));
App.log.info("Enabling (only) those TLS ciphers: " + TextUtils.join(", ", enabledCiphers));
SSLSocketFactoryCompat.cipherSuites = enabledCiphers.toArray(new String[enabledCiphers.size()]);
}
}
} catch (IOException e) {
App.log.severe("Couldn't determine default TLS settings");
}
}
public SSLSocketFactoryCompat(@NonNull X509TrustManager trustManager) {
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new X509TrustManager[] { trustManager }, 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;
}
}
@@ -0,0 +1,160 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.os.Build
import at.bitfire.davdroid.log.Logger
import java.io.IOException
import java.net.InetAddress
import java.net.Socket
import java.security.GeneralSecurityException
import java.util.*
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
class SSLSocketFactoryCompat(trustManager: X509TrustManager): SSLSocketFactory() {
private var delegate: SSLSocketFactory
companion object {
// Android 5.0+ (API level 21) provides reasonable default settings
// but it still allows SSLv3
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
var protocols: Array<String>? = null
var cipherSuites: Array<String>? = null
init {
if (Build.VERSION.SDK_INT >= 23) {
// Since Android 6.0 (API level 23),
// - TLSv1.1 and TLSv1.2 is enabled by default
// - SSLv3 is disabled by default
// - all modern ciphers are activated by default
protocols = null
cipherSuites = null
Logger.log.fine("Using device default TLS protocols/ciphers")
} else {
val socket = SSLSocketFactory.getDefault().createSocket() as SSLSocket?
try {
socket?.let {
/* set reasonable protocol versions */
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
// - remove all SSL versions (especially SSLv3) because they're insecure now
val _protocols = LinkedList<String>()
for (protocol in socket.supportedProtocols.filterNot { it.contains("SSL", true) })
_protocols += protocol
Logger.log.info("Enabling (only) these TLS protocols: ${_protocols.joinToString(", ")}")
protocols = _protocols.toTypedArray()
/* set up reasonable cipher suites */
val knownCiphers = arrayOf<String>(
// TLS 1.2
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
// maximum interoperability
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"SSL_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
// additionally
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"
)
val availableCiphers = socket.supportedCipherSuites
Logger.log.info("Available cipher suites: ${availableCiphers.joinToString(", ")}")
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus
* disabling ciphers which are enabled by default, but have become unsecure), but for
* the security level of DAVdroid and maximum compatibility, disabling of insecure
* ciphers should be a server-side task */
// for the final set of enabled ciphers, take the ciphers enabled by default, ...
val _cipherSuites = LinkedList<String>()
_cipherSuites.addAll(socket.enabledCipherSuites)
Logger.log.fine("Cipher suites enabled by default: ${_cipherSuites.joinToString(", ")}")
// ... add explicitly allowed ciphers ...
_cipherSuites.addAll(knownCiphers)
// ... and keep only those which are actually available
_cipherSuites.retainAll(availableCiphers)
Logger.log.info("Enabling (only) these TLS ciphers: " + _cipherSuites.joinToString(", "))
cipherSuites = _cipherSuites.toTypedArray()
}
} catch(e: IOException) {
Logger.log.severe("Couldn't determine default TLS settings")
} finally {
socket?.close() // doesn't implement Closeable on all supported Android versions
}
}
}
}
init {
try {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(trustManager), null)
delegate = sslContext.socketFactory
} catch (e: GeneralSecurityException) {
throw IllegalStateException() // system has no TLS
}
}
override fun getDefaultCipherSuites(): Array<String>? = cipherSuites ?: delegate.defaultCipherSuites
override fun getSupportedCipherSuites(): Array<String>? = cipherSuites ?: delegate.supportedCipherSuites
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket {
val ssl = delegate.createSocket(s, host, port, autoClose)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: String, port: Int): Socket {
val ssl = delegate.createSocket(host, port)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
val ssl = delegate.createSocket(host, port, localHost, localPort)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: InetAddress, port: Int): Socket {
val ssl = delegate.createSocket(host, port)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
val ssl = delegate.createSocket(address, port, localAddress, localPort)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
private fun upgradeTLS(ssl: SSLSocket) {
protocols?.let { ssl.enabledProtocols = it }
cipherSuites?.let { ssl.enabledCipherSuites = it }
}
}
@@ -1,59 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log;
import android.util.Log;
import org.apache.commons.lang3.math.NumberUtils;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
public class LogcatHandler extends Handler {
private static final int MAX_LINE_LENGTH = 3000;
public static final LogcatHandler INSTANCE = new LogcatHandler();
private LogcatHandler() {
super();
setFormatter(PlainTextFormatter.LOGCAT);
setLevel(Level.ALL);
}
@Override
public void publish(LogRecord r) {
String text = getFormatter().format(r);
int level = r.getLevel().intValue();
int end = text.length();
for (int pos = 0; pos < end; pos += MAX_LINE_LENGTH) {
String line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end));
if (level >= Level.SEVERE.intValue())
Log.e(r.getLoggerName(), line);
else if (level >= Level.WARNING.intValue())
Log.w(r.getLoggerName(), line);
else if (level >= Level.CONFIG.intValue())
Log.i(r.getLoggerName(), line);
else if (level >= Level.FINER.intValue())
Log.d(r.getLoggerName(), line);
else
Log.v(r.getLoggerName(), line);
}
}
@Override
public void flush() {
}
@Override
public void close() {
}
}
@@ -0,0 +1,50 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log
import android.util.Log
import org.apache.commons.lang3.math.NumberUtils
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
object LogcatHandler: Handler() {
private val MAX_LINE_LENGTH = 3000
init {
formatter = PlainTextFormatter.LOGCAT
level = Level.ALL
}
override fun publish(r: LogRecord) {
val text = formatter.format(r)
val level = r.level.intValue()
val end = text.length
var pos = 0
while (pos < end) {
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
when {
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
else -> Log.v(r.loggerName, line)
}
pos += MAX_LINE_LENGTH
}
}
override fun flush() {}
override fun close() {}
}
@@ -0,0 +1,112 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Process
import android.preference.PreferenceManager
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.util.Log
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppSettingsActivity
import org.apache.commons.lang3.time.DateFormatUtils
import java.io.File
import java.io.IOException
import java.util.logging.FileHandler
import java.util.logging.Level
object Logger {
val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
@JvmField
val log = java.util.logging.Logger.getLogger("davdroid")!!
lateinit var context: Context
lateinit var preferences: SharedPreferences
fun initialize(context: Context) {
this.context = context
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener { _, s ->
if (s == LOG_TO_EXTERNAL_STORAGE)
reinitialize()
}
reinitialize()
}
private fun reinitialize() {
val logToFile = preferences.getBoolean(LOG_TO_EXTERNAL_STORAGE, false)
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
log.info("Verbose logging: $logVerbose; to file: $logToFile")
// set logging level according to preferences
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
// remove all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val builder = NotificationCompat.Builder(context)
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true)
val dir = context.getExternalFilesDir(null)
if (dir != null)
try {
val fileName = File(dir, "davdroid-${Process.myPid()}-${DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss")}.txt").toString()
log.info("Logging to $fileName")
val fileHandler = FileHandler(fileName)
fileHandler.formatter = PlainTextFormatter.DEFAULT
rootLogger.addHandler(fileHandler)
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_EXTERNAL_STORAGE)
builder .setContentText(dir.path)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setStyle(NotificationCompat.BigTextStyle()
.bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
.setOngoing(true)
} catch(e: IOException) {
log.log(Level.SEVERE, "Couldn't create external log file", e)
builder .setContentText(context.getString(R.string.logging_couldnt_create_file, e.localizedMessage))
.setCategory(NotificationCompat.CATEGORY_ERROR)
}
else
builder.setContentText(context.getString(R.string.logging_no_external_storage))
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build())
} else
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING)
}
}
@@ -1,64 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.util.logging.Formatter;
import java.util.logging.LogRecord;
public class PlainTextFormatter extends Formatter {
public final static PlainTextFormatter
LOGCAT = new PlainTextFormatter(true),
DEFAULT = new PlainTextFormatter(false);
private final boolean logcat;
private PlainTextFormatter(boolean onLogcat) {
this.logcat = onLogcat;
}
@Override
@SuppressWarnings("ThrowableResultOfMethodCallIgnored")
public String format(LogRecord r) {
StringBuilder builder = new StringBuilder();
if (!logcat)
builder .append(DateFormatUtils.format(r.getMillis(), "yyyy-MM-dd HH:mm:ss"))
.append(" ").append(r.getThreadID()).append(" ");
if (!r.getSourceClassName().replaceFirst("\\$.*", "").equals(r.getLoggerName()))
builder.append("[").append(shortClassName(r.getSourceClassName())).append("] ");
builder.append(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.", "");
}
}
@@ -0,0 +1,61 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.apache.commons.lang3.time.DateFormatUtils
import java.util.logging.Formatter
import java.util.logging.LogRecord
class PlainTextFormatter private constructor(
private val logcat: Boolean
): Formatter() {
companion object {
@JvmField val LOGCAT = PlainTextFormatter(true)
@JvmField val DEFAULT = PlainTextFormatter(false)
val MAX_MESSAGE_LENGTH = 20000
}
override fun format(r: LogRecord): String {
val builder = StringBuilder()
if (!logcat)
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss"))
.append(" ").append(r.threadID).append(" ")
val className = shortClassName(r.sourceClassName)
if (className != r.loggerName)
builder.append("[").append(className).append("] ")
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
r.thrown?.let {
builder .append("\nEXCEPTION ")
.append(ExceptionUtils.getStackTrace(it))
}
r.parameters?.let {
for ((idx, param) in it.withIndex())
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
}
if (!logcat)
builder.append("\n")
return builder.toString()
}
private fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
.replace(Regex("\\$.*$"), "")
}
@@ -1,40 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
public class StringHandler extends Handler {
StringBuilder builder = new StringBuilder();
public StringHandler() {
super();
setFormatter(PlainTextFormatter.DEFAULT);
}
@Override
public void publish(LogRecord record) {
builder.append(getFormatter().format(record));
}
@Override
public void flush() {
}
@Override
public void close() {
}
@Override
public String toString() {
return builder.toString();
}
}
@@ -0,0 +1,31 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.log
import java.util.logging.Handler;
import java.util.logging.LogRecord;
class StringHandler: Handler() {
val builder = StringBuilder()
init {
formatter = PlainTextFormatter.DEFAULT
}
override fun publish(record: LogRecord) {
builder.append(formatter.format(record))
}
override fun flush() {}
override fun close() {}
override fun toString() = builder.toString()
}
@@ -1,163 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
import android.content.ContentValues;
import org.apache.commons.lang3.StringUtils;
import java.io.Serializable;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.Property;
import at.bitfire.dav4android.property.AddressbookDescription;
import at.bitfire.dav4android.property.CalendarColor;
import at.bitfire.dav4android.property.CalendarDescription;
import at.bitfire.dav4android.property.CalendarTimezone;
import at.bitfire.dav4android.property.CurrentUserPrivilegeSet;
import at.bitfire.dav4android.property.DisplayName;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.dav4android.property.SupportedCalendarComponentSet;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import lombok.ToString;
@ToString
public class CollectionInfo implements Serializable {
public long id;
public Long serviceID;
public enum Type {
ADDRESS_BOOK,
CALENDAR
}
public Type type;
public String url;
public boolean readOnly;
public String displayName, description;
public Integer color;
public String timeZone;
public Boolean supportsVEVENT;
public Boolean supportsVTODO;
public boolean selected;
// non-persistent properties
public boolean confirmed;
public static final Property.Name[] DAV_PROPERTIES = {
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME
};
public static CollectionInfo fromDavResource(DavResource dav) {
CollectionInfo info = new CollectionInfo();
info.url = dav.location.toString();
ResourceType type = (ResourceType)dav.properties.get(ResourceType.NAME);
if (type != null) {
if (type.types.contains(ResourceType.ADDRESSBOOK))
info.type = Type.ADDRESS_BOOK;
else if (type.types.contains(ResourceType.CALENDAR))
info.type = Type.CALENDAR;
}
info.readOnly = false;
CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME);
if (privilegeSet != null)
info.readOnly = !privilegeSet.mayWriteContent;
DisplayName displayName = (DisplayName)dav.properties.get(DisplayName.NAME);
if (displayName != null && !StringUtils.isEmpty(displayName.displayName))
info.displayName = displayName.displayName;
if (info.type == Type.ADDRESS_BOOK) {
AddressbookDescription addressbookDescription = (AddressbookDescription)dav.properties.get(AddressbookDescription.NAME);
if (addressbookDescription != null)
info.description = addressbookDescription.description;
} else if (info.type == Type.CALENDAR) {
CalendarDescription calendarDescription = (CalendarDescription)dav.properties.get(CalendarDescription.NAME);
if (calendarDescription != null)
info.description = calendarDescription.description;
CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME);
if (calendarColor != null)
info.color = calendarColor.color;
CalendarTimezone timeZone = (CalendarTimezone)dav.properties.get(CalendarTimezone.NAME);
if (timeZone != null)
info.timeZone = timeZone.vTimeZone;
info.supportsVEVENT = info.supportsVTODO = true;
SupportedCalendarComponentSet supportedCalendarComponentSet = (SupportedCalendarComponentSet)dav.properties.get(SupportedCalendarComponentSet.NAME);
if (supportedCalendarComponentSet != null) {
info.supportsVEVENT = supportedCalendarComponentSet.supportsEvents;
info.supportsVTODO = supportedCalendarComponentSet.supportsTasks;
}
}
return info;
}
public static CollectionInfo fromDB(ContentValues values) {
CollectionInfo info = new CollectionInfo();
info.id = values.getAsLong(Collections.ID);
info.serviceID = values.getAsLong(Collections.SERVICE_ID);
info.url = values.getAsString(Collections.URL);
info.readOnly = values.getAsInteger(Collections.READ_ONLY) != 0;
info.displayName = values.getAsString(Collections.DISPLAY_NAME);
info.description = values.getAsString(Collections.DESCRIPTION);
info.color = values.getAsInteger(Collections.COLOR);
info.timeZone = values.getAsString(Collections.TIME_ZONE);
info.supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT);
info.supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO);
info.selected = values.getAsInteger(Collections.SYNC) != 0;
return info;
}
public ContentValues toDB() {
ContentValues values = new ContentValues();
// Collections.SERVICE_ID is never changed
values.put(Collections.URL, url);
values.put(Collections.READ_ONLY, readOnly ? 1 : 0);
values.put(Collections.DISPLAY_NAME, displayName);
values.put(Collections.DESCRIPTION, description);
values.put(Collections.COLOR, color);
values.put(Collections.TIME_ZONE, timeZone);
if (supportsVEVENT != null)
values.put(Collections.SUPPORTS_VEVENT, supportsVEVENT ? 1 : 0);
if (supportsVTODO != null)
values.put(Collections.SUPPORTS_VTODO, supportsVTODO ? 1 : 0);
values.put(Collections.SYNC, selected ? 1 : 0);
return values;
}
private static Boolean getAsBooleanOrNull(ContentValues values, String field) {
Integer i = values.getAsInteger(field);
return (i == null) ? null : (i != 0);
}
}
@@ -0,0 +1,159 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model
import android.content.ContentValues
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import java.io.Serializable
data class CollectionInfo @JvmOverloads constructor(
val url: String,
var id: Long? = null,
var serviceID: Long? = null,
var type: Type? = null,
var readOnly: Boolean = false,
var displayName: String? = null,
var description: String? = null,
var color: Int? = null,
var timeZone: String? = null,
var supportsVEVENT: Boolean = false,
var supportsVTODO: Boolean = false,
var selected: Boolean = false,
// subscriptions
var source: String? = null,
// non-persistent properties
var confirmed: Boolean = false
): Serializable {
enum class Type {
ADDRESS_BOOK,
CALENDAR,
WEBCAL // iCalendar subscription
}
companion object {
@JvmField
val DAV_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
)
}
constructor(dav: DavResource): this(dav.location.toString()) {
(dav.properties[ResourceType.NAME] as ResourceType?)?.let { type ->
when {
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL
}
}
(dav.properties[CurrentUserPrivilegeSet.NAME] as CurrentUserPrivilegeSet?)?.let { privilegeSet ->
readOnly = !privilegeSet.mayWriteContent
}
(dav.properties[DisplayName.NAME] as DisplayName?)?.let {
if (!it.displayName.isNullOrEmpty())
displayName = it.displayName
}
when (type) {
Type.ADDRESS_BOOK -> {
(dav.properties[AddressbookDescription.NAME] as AddressbookDescription?)?.let { description = it.description }
}
Type.CALENDAR -> {
(dav.properties[CalendarDescription.NAME] as CalendarDescription?)?.let { description = it.description }
(dav.properties[CalendarColor.NAME] as CalendarColor?)?.let { color = it.color }
(dav.properties[CalendarTimezone.NAME] as CalendarTimezone?)?.let { timeZone = it.vTimeZone }
supportsVEVENT = true
supportsVTODO = true
(dav.properties[SupportedCalendarComponentSet.NAME] as SupportedCalendarComponentSet?)?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
}
}
Type.WEBCAL -> {
(dav.properties[Source.NAME] as Source?)?.let { source = it.hrefs.firstOrNull() }
supportsVEVENT = true
}
}
}
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
id = values.getAsLong(Collections.ID)
serviceID = values.getAsLong(Collections.SERVICE_ID)
type = try {
Type.valueOf(values.getAsString(Collections.TYPE))
} catch (e: Exception) {
null
}
readOnly = values.getAsInteger(Collections.READ_ONLY) != 0
displayName = values.getAsString(Collections.DISPLAY_NAME)
description = values.getAsString(Collections.DESCRIPTION)
color = values.getAsInteger(Collections.COLOR)
timeZone = values.getAsString(Collections.TIME_ZONE)
supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false
supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false
source = values.getAsString(Collections.SOURCE)
selected = values.getAsInteger(Collections.SYNC) != 0
}
fun toDB(): ContentValues {
val values = ContentValues()
// Collections.SERVICE_ID is never changed
type?.let { values.put(Collections.TYPE, it.name) }
values.put(Collections.URL, url)
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
values.put(Collections.DISPLAY_NAME, displayName)
values.put(Collections.DESCRIPTION, description)
values.put(Collections.COLOR, color)
values.put(Collections.TIME_ZONE, timeZone)
values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0)
values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0)
values.put(Collections.SOURCE, source)
values.put(Collections.SYNC, if (selected) 1 else 0)
return values
}
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
val i = values.getAsInteger(field)
return if (i == null)
null
else
(i != 0)
}
}
@@ -1,195 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
import android.content.ContentValues;
import android.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 android.support.annotation.NonNull;
import android.support.annotation.RequiresApi;
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) {
if (Build.VERSION.SDK_INT < 16)
db.execSQL("PRAGMA foreign_keys=ON;");
}
@Override
@RequiresApi(16)
public void onConfigure(SQLiteDatabase db) {
setWriteAheadLoggingEnabled(true);
db.setForeignKeyConstraintsEnabled(true);
}
@Override
public void onCreate(SQLiteDatabase db) {
App.log.info("Creating database " + db.getPath());
db.execSQL("CREATE TABLE " + Settings._TABLE + "(" +
Settings.NAME + " TEXT NOT NULL," +
Settings.VALUE + " TEXT NOT NULL" +
")");
db.execSQL("CREATE UNIQUE INDEX settings_name ON " + Settings._TABLE + " (" + Settings.NAME + ")");
db.execSQL("CREATE TABLE " + Services._TABLE + "(" +
Services.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
Services.ACCOUNT_NAME + " TEXT NOT NULL," +
Services.SERVICE + " TEXT NOT NULL," +
Services.PRINCIPAL + " TEXT NULL" +
")");
db.execSQL("CREATE UNIQUE INDEX services_account ON " + Services._TABLE + " (" + Services.ACCOUNT_NAME + "," + Services.SERVICE + ")");
db.execSQL("CREATE TABLE " + HomeSets._TABLE + "(" +
HomeSets.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
HomeSets.SERVICE_ID + " INTEGER NOT NULL REFERENCES " + Services._TABLE +" ON DELETE CASCADE," +
HomeSets.URL + " TEXT NOT NULL" +
")");
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON " + HomeSets._TABLE + "(" + HomeSets.SERVICE_ID + "," + HomeSets.URL + ")");
db.execSQL("CREATE TABLE " + Collections._TABLE + "(" +
Collections.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
Collections.SERVICE_ID + " INTEGER NOT NULL REFERENCES " + Services._TABLE +" ON DELETE CASCADE," +
Collections.URL + " TEXT NOT NULL," +
Collections.READ_ONLY + " INTEGER DEFAULT 0 NOT NULL," +
Collections.DISPLAY_NAME + " TEXT NULL," +
Collections.DESCRIPTION + " TEXT NULL," +
Collections.COLOR + " INTEGER NULL," +
Collections.TIME_ZONE + " TEXT NULL," +
Collections.SUPPORTS_VEVENT + " INTEGER NULL," +
Collections.SUPPORTS_VTODO + " INTEGER NULL," +
Collections.SYNC + " INTEGER DEFAULT 0 NOT NULL" +
")");
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON " + Collections._TABLE + "(" + Collections.SERVICE_ID + "," + Collections.URL + ")");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// no different versions yet
}
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();
}
}
public static void onRenameAccount(@NonNull SQLiteDatabase db, @NonNull String oldName, @NonNull String newName) {
ContentValues values = new ContentValues(1);
values.put(Services.ACCOUNT_NAME, newName);
db.update(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", new String[] { oldName });
}
}
@@ -0,0 +1,224 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteOpenHelper
import android.preference.PreferenceManager
import at.bitfire.davdroid.App
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.StartupDialogFragment
import java.io.Closeable
import java.util.logging.Level
class ServiceDB {
/*object Settings {
@JvmField val _TABLE = "settings"
@JvmField val NAME = "setting"
@JvmField val VALUE = "value"
}*/
object Services {
@JvmField val _TABLE = "services"
@JvmField val ID = "_id"
@JvmField val ACCOUNT_NAME = "accountName"
@JvmField val SERVICE = "service"
@JvmField val PRINCIPAL = "principal"
// allowed values for SERVICE column
@JvmField val SERVICE_CALDAV = "caldav"
@JvmField val SERVICE_CARDDAV = "carddav"
}
object HomeSets {
@JvmField val _TABLE = "homesets"
@JvmField val ID = "_id"
@JvmField val SERVICE_ID = "serviceID"
@JvmField val URL = "url"
}
object Collections {
@JvmField val _TABLE = "collections"
@JvmField val ID = "_id"
@JvmField val TYPE = "type"
@JvmField val SERVICE_ID = "serviceID"
@JvmField val URL = "url"
@JvmField val READ_ONLY = "readOnly"
@JvmField val DISPLAY_NAME = "displayName"
@JvmField val DESCRIPTION = "description"
@JvmField val COLOR = "color"
@JvmField val TIME_ZONE = "timezone"
@JvmField val SUPPORTS_VEVENT = "supportsVEVENT"
@JvmField val SUPPORTS_VTODO = "supportsVTODO"
@JvmField val SOURCE = "source"
@JvmField val SYNC = "sync"
}
companion object {
@JvmStatic
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
val values = ContentValues(1)
values.put(Services.ACCOUNT_NAME, newName)
db.updateWithOnConflict(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName), SQLiteDatabase.CONFLICT_REPLACE)
}
}
class OpenHelper(
val context: Context
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
companion object {
val DATABASE_NAME = "services.db"
val DATABASE_VERSION = 3
}
override fun onConfigure(db: SQLiteDatabase) {
setWriteAheadLoggingEnabled(true)
db.setForeignKeyConstraintsEnabled(true)
}
override fun onCreate(db: SQLiteDatabase) {
Logger.log.info("Creating database " + db.path)
db.execSQL("CREATE TABLE ${Services._TABLE}(" +
"${Services.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
"${Services.ACCOUNT_NAME} TEXT NOT NULL," +
"${Services.SERVICE} TEXT NOT NULL," +
"${Services.PRINCIPAL} TEXT NULL)")
db.execSQL("CREATE UNIQUE INDEX services_account ON ${Services._TABLE} (${Services.ACCOUNT_NAME},${Services.SERVICE})")
db.execSQL("CREATE TABLE ${HomeSets._TABLE}(" +
"${HomeSets.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
"${HomeSets.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
"${HomeSets.URL} TEXT NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON ${HomeSets._TABLE}(${HomeSets.SERVICE_ID},${HomeSets.URL})")
db.execSQL("CREATE TABLE ${Collections._TABLE}(" +
"${Collections.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
"${Collections.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
"${Collections.TYPE} TEXT NOT NULL," +
"${Collections.URL} TEXT NOT NULL," +
"${Collections.READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
"${Collections.DISPLAY_NAME} TEXT NULL," +
"${Collections.DESCRIPTION} TEXT NULL," +
"${Collections.COLOR} INTEGER NULL," +
"${Collections.TIME_ZONE} TEXT NULL," +
"${Collections.SUPPORTS_VEVENT} INTEGER NULL," +
"${Collections.SUPPORTS_VTODO} INTEGER NULL," +
"${Collections.SOURCE} TEXT NULL," +
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
for (upgradeFrom in oldVersion until newVersion) {
val upgradeTo = oldVersion + 1
Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo")
try {
val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java)
upgradeProc.invoke(this, db)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't upgrade database", e)
}
}
}
@Suppress("unused")
private fun upgrade_2_3(db: SQLiteDatabase) {
val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
try {
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
when (cursor.getString(0)) {
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
}
}
}
db.execSQL("DROP TABLE settings")
} finally {
edit.apply()
}
}
@Suppress("unused")
private fun upgrade_1_2(db: SQLiteDatabase) {
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL")
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" +
"SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " +
"FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" +
")",
arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK))
}
fun dump(sb: StringBuilder) {
val db = readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
while (cursorTables.moveToNext()) {
val table = cursorTables.getString(0)
sb.append(table).append("\n")
db.query(table, null, null, null, null, null, null).use { cursor ->
// print columns
val cols = cursor.columnCount
sb.append("\t| ")
for (i in 0 .. cols-1)
sb .append(" ")
.append(cursor.getColumnName(i))
.append(" |")
sb.append("\n")
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ")
for (i in 0 .. cols-1) {
sb.append(" ")
try {
val value = cursor.getString(i)
if (value != null)
sb.append(value
.replace("\r", "<CR>")
.replace("\n", "<LF>"))
else
sb.append("<null>")
} catch (e: SQLiteException) {
sb.append("<unprintable>")
}
sb.append(" |")
}
sb.append("\n")
}
sb.append("----------\n")
}
}
db.endTransaction()
}
}
}
}
@@ -1,83 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.support.annotation.Nullable;
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() && !cursor.isNull(0))
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 int getInt(String name, int 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() && !cursor.isNull(0))
return cursor.isNull(0) ? defaultValue : cursor.getInt(0);
else
return defaultValue;
}
public void putInt(String name, int value) {
ContentValues values = new ContentValues(2);
values.put(ServiceDB.Settings.NAME, name);
values.put(ServiceDB.Settings.VALUE, value);
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
@Nullable
public String getString(String name, @Nullable String 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.getString(0);
else
return defaultValue;
}
public void putString(String name, @Nullable String value) {
ContentValues values = new ContentValues(2);
values.put(ServiceDB.Settings.NAME, name);
values.put(ServiceDB.Settings.VALUE, value);
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 });
}
}
@@ -1,22 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
import android.provider.ContactsContract.RawContacts;
public class UnknownProperties {
public static final String CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties";
public static final String
MIMETYPE = RawContacts.Data.MIMETYPE,
RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID,
UNKNOWN_PROPERTIES = RawContacts.Data.DATA1;
}
@@ -0,0 +1,28 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model;
import android.provider.ContactsContract.RawContacts;
object UnknownProperties {
@JvmField
val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
@JvmField
val MIMETYPE = RawContacts.Data.MIMETYPE
@JvmField
val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
@JvmField
val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
}
@@ -1,319 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcel;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.support.annotation.NonNull;
import java.io.FileNotFoundException;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.davdroid.App;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class LocalAddressBook extends AndroidAddressBook implements LocalCollection {
protected static final String
SYNC_STATE_CTAG = "ctag",
SYNC_STATE_URL = "url";
private final Bundle syncState = new Bundle();
/**
* Whether contact groups (LocalGroup resources) are included in query results for
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
* {@link #getWithoutFileName()}.
*/
public boolean includeGroups = true;
public LocalAddressBook(Account account, ContentProviderClient provider) {
super(account, provider, LocalGroup.Factory.INSTANCE, LocalContact.Factory.INSTANCE);
}
@NonNull
public LocalContact findContactByUID(String uid) throws ContactsStorageException, FileNotFoundException {
LocalContact[] contacts = (LocalContact[])queryContacts(LocalContact.COLUMN_UID + "=?", new String[] { uid });
if (contacts.length == 0)
throw new FileNotFoundException();
return contacts[0];
}
@Override
@NonNull
public LocalResource[] getAll() throws ContactsStorageException {
List<LocalResource> all = new LinkedList<>();
Collections.addAll(all, (LocalResource[])queryContacts(null, null));
if (includeGroups)
Collections.addAll(all, (LocalResource[])queryGroups(null, null));
return all.toArray(new LocalResource[all.size()]);
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
*/
@Override
@NonNull
public LocalResource[] getDeleted() throws ContactsStorageException {
List<LocalResource> deleted = new LinkedList<>();
Collections.addAll(deleted, getDeletedContacts());
if (includeGroups)
Collections.addAll(deleted, getDeletedGroups());
return deleted.toArray(new LocalResource[deleted.size()]);
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
*/
public int verifyDirty() throws ContactsStorageException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
App.log.severe("verifyDirty() should not be called on Android <7");
int reallyDirty = 0;
for (LocalContact contact : getDirtyContacts()) {
try {
int lastHash = contact.getLastHashCode(),
currentHash = contact.dataHashCode();
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
App.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact);
contact.resetDirty();
} else {
App.log.log(Level.FINE, "Contact data has changed from hash " + lastHash + " to " + currentHash, contact);
reallyDirty++;
}
} catch(FileNotFoundException e) {
throw new ContactsStorageException("Couldn't calculate hash code", e);
}
}
if (includeGroups)
reallyDirty += getDirtyGroups().length;
return reallyDirty;
}
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
*/
@Override
@NonNull
public LocalResource[] getDirty() throws ContactsStorageException {
List<LocalResource> dirty = new LinkedList<>();
Collections.addAll(dirty, getDirtyContacts());
if (includeGroups)
Collections.addAll(dirty, getDirtyGroups());
return dirty.toArray(new LocalResource[dirty.size()]);
}
/**
* Returns an array of local contacts which don't have a file name yet.
*/
@Override
@NonNull
public LocalResource[] getWithoutFileName() throws ContactsStorageException {
List<LocalResource> nameless = new LinkedList<>();
Collections.addAll(nameless, (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null));
if (includeGroups)
Collections.addAll(nameless, (LocalGroup[])queryGroups(AndroidGroup.COLUMN_FILENAME + " IS NULL", null));
return nameless.toArray(new LocalResource[nameless.size()]);
}
@NonNull
public LocalContact[] getDeletedContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DELETED + "!= 0", null);
}
@NonNull
public LocalContact[] getDirtyContacts() throws ContactsStorageException {
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0", null);
}
@NonNull
public LocalGroup[] getDeletedGroups() throws ContactsStorageException {
return (LocalGroup[])queryGroups(Groups.DELETED + "!= 0", null);
}
@NonNull
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0", null);
}
@NonNull LocalContact[] getByGroupMembership(long groupID) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = provider.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
new String[] { RawContacts.Data.RAW_CONTACT_ID },
"(" + GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?) OR (" + CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?)",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupID), CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupID) },
null);
Set<Long> ids = new HashSet<>();
while (cursor != null && cursor.moveToNext())
ids.add(cursor.getLong(0));
LocalContact[] contacts = new LocalContact[ids.size()];
int i = 0;
for (Long id : ids)
contacts[i++] = new LocalContact(this, id, null, null);
return contacts;
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't query contacts", e);
}
}
public void deleteAll() throws ContactsStorageException {
try {
provider.delete(syncAdapterURI(RawContacts.CONTENT_URI), null, null);
provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't delete all local contacts and groups", e);
}
}
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws ContactsStorageException on contact provider errors
*/
public long findOrCreateGroup(@NonNull String title) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID },
Groups.TITLE + "=?", new String[] { title },
null);
if (cursor != null && cursor.moveToNext())
return cursor.getLong(0);
ContentValues values = new ContentValues();
values.put(Groups.TITLE, title);
Uri uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values);
return ContentUris.parseId(uri);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find local contact group", e);
}
}
public void removeEmptyGroups() throws ContactsStorageException {
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
for (LocalGroup group : (LocalGroup[])queryGroups(null, null))
if (group.getMembers().length == 0) {
App.log.log(Level.FINE, "Deleting group", group);
group.delete();
}
}
public void removeGroups() throws ContactsStorageException {
try {
provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't remove all groups", e);
}
}
// SYNC STATE
@SuppressWarnings("ParcelClassLoader,Recycle")
protected void readSyncState() throws ContactsStorageException {
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
byte[] raw = getSyncState();
syncState.clear();
if (raw != null) {
parcel.unmarshall(raw, 0, raw.length);
parcel.setDataPosition(0);
syncState.putAll(parcel.readBundle());
}
}
@SuppressWarnings("Recycle")
protected void writeSyncState() throws ContactsStorageException {
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
parcel.writeBundle(syncState);
setSyncState(parcel.marshall());
}
public String getURL() throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
return syncState.getString(SYNC_STATE_URL);
}
}
public void setURL(String url) throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
syncState.putString(SYNC_STATE_URL, url);
writeSyncState();
}
}
@Override
public String getCTag() throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
return syncState.getString(SYNC_STATE_CTAG);
}
}
@Override
public void setCTag(String cTag) throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
syncState.putString(SYNC_STATE_CTAG, cTag);
writeSyncState();
}
}
// HELPERS
public static void onRenameAccount(@NonNull ContentResolver resolver, @NonNull String oldName, @NonNull String newName) throws RemoteException {
@Cleanup("release") ContentProviderClient client = resolver.acquireContentProviderClient(ContactsContract.AUTHORITY);
if (client != null) {
ContentValues values = new ContentValues(1);
values.put(RawContacts.ACCOUNT_NAME, newName);
client.update(RawContacts.CONTENT_URI, values, RawContacts.ACCOUNT_NAME + "=?", new String[]{oldName});
}
}
}
@@ -0,0 +1,359 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.os.Build
import android.os.Bundle
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.util.Base64
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
class LocalAddressBook(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalResource> {
companion object {
val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
val USER_DATA_URL = "url"
val USER_DATA_READ_ONLY = "read_only"
val USER_DATA_CTAG = "ctag"
@JvmStatic
@Throws(ContactsStorageException::class)
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
val accountManager = AccountManager.get(context)
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
throw ContactsStorageException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, account, provider)
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
return addressBook
}
@JvmStatic
@Throws(ContactsStorageException::class)
fun find(context: Context, provider: ContentProviderClient, mainAccount: Account?): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
val result = LinkedList<LocalAddressBook>()
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
.forEach { result += it }
return result
}
@JvmStatic
fun accountName(mainAccount: Account, info: CollectionInfo): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
sb .append(" (")
.append(mainAccount.name)
.append(" ")
.append(hash)
.append(")")
return sb.toString()
}
@JvmStatic
fun initialUserData(mainAccount: Account, url: String): Bundle {
val bundle = Bundle(3)
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
bundle.putString(USER_DATA_URL, url)
return bundle
}
}
/**
* Whether contact groups (LocalGroup resources) are included in query results for
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
* {@link #getWithoutFileName()}.
*/
var includeGroups = true
/* operations on the collection (address book) itself */
@Throws(ContactsStorageException::class)
fun update(info: CollectionInfo) {
val newAccountName = accountName(getMainAccount(), info)
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, {
try {
// update raw contacts to new account name
provider?.let { provider ->
val values = ContentValues(1)
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
}
} catch(e: RemoteException) {
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
}
}, null)
account = future.result
}
Constants.log.info("Address book read-only? = ${info.readOnly}")
setReadOnly(info.readOnly)
// make sure it will still be synchronized when contacts are updated
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
@Throws(ContactsStorageException::class)
fun delete() {
val accountManager = AccountManager.get(context)
try {
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, null, null, null)
else
@Suppress("deprecation")
accountManager.removeAccount(account, null, null)
} catch(e: Exception) {
throw ContactsStorageException("Couldn't remove address book", e)
}
}
/* operations on members (contacts/groups) */
@Throws(ContactsStorageException::class, FileNotFoundException::class)
fun findContactByUID(uid: String): LocalContact {
val contacts = queryContacts("${AndroidContact.COLUMN_UID}=?", arrayOf(uid))
if (contacts.isEmpty())
throw FileNotFoundException()
return contacts.first()
}
@Throws(ContactsStorageException::class)
override fun getAll(): List<LocalResource> {
val all = LinkedList<LocalResource>()
all.addAll(queryContacts(null, null))
if (includeGroups)
all.addAll(queryGroups(null, null))
return all
}
/**
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
*/
@Throws(ContactsStorageException::class)
override fun getDeleted(): List<LocalResource> {
val deleted = LinkedList<LocalResource>()
deleted.addAll(getDeletedContacts())
if (includeGroups)
deleted.addAll(getDeletedGroups())
return deleted
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
*/
@Throws(ContactsStorageException::class)
fun verifyDirty(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
var reallyDirty = 0
for (contact in getDirtyContacts())
try {
val lastHash = contact.getLastHashCode()
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
}
} catch(e: FileNotFoundException) {
throw ContactsStorageException("Couldn't calculate hash code", e)
}
if (includeGroups)
reallyDirty += getDirtyGroups().size
return reallyDirty
}
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
*/
@Throws(ContactsStorageException::class)
override fun getDirty(): List<LocalResource> {
val dirty = LinkedList<LocalResource>()
dirty.addAll(getDirtyContacts())
if (includeGroups)
dirty.addAll(getDirtyGroups())
return dirty
}
/**
* Returns an array of local contacts which don't have a file name yet.
*/
@Throws(ContactsStorageException::class)
override fun getWithoutFileName(): List<LocalResource> {
val nameless = LinkedList<LocalResource>()
nameless.addAll(queryContacts("${AndroidContact.COLUMN_FILENAME} IS NULL", null))
if (includeGroups)
nameless.addAll(queryGroups("${AndroidGroup.COLUMN_FILENAME} IS NULL", null))
return nameless
}
@Throws(ContactsStorageException::class)
fun getDeletedContacts() = queryContacts("${RawContacts.DELETED} != 0", null)
@Throws(ContactsStorageException::class)
fun getDirtyContacts() = queryContacts("${RawContacts.DIRTY} != 0", null)
@Throws(ContactsStorageException::class)
fun getDeletedGroups() = queryGroups("${Groups.DELETED} != 0", null)
@Throws(ContactsStorageException::class)
fun getDirtyGroups() = queryGroups("${Groups.DIRTY} != 0", null)
@Throws(ContactsStorageException::class)
fun getByGroupMembership(groupID: Long): List<LocalContact> {
try {
val ids = HashSet<Long>()
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
null)?.use { cursor ->
while (cursor.moveToNext())
ids += cursor.getLong(0)
}
return ids.map { id -> LocalContact(this, id, null, null) }
} catch (e: RemoteException) {
throw ContactsStorageException("Couldn't query contacts", e)
}
}
/* special group operations */
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws ContactsStorageException on contact provider errors
*/
@Throws(ContactsStorageException::class)
fun findOrCreateGroup(title: String): Long {
try {
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getLong(0)
}
val values = ContentValues(1)
values.put(Groups.TITLE, title)
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
return ContentUris.parseId(uri)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't find local contact group", e)
}
}
@Throws(ContactsStorageException::class)
fun removeEmptyGroups() {
// find groups without members
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
Logger.log.log(Level.FINE, "Deleting group", group)
group.delete()
}
}
@Throws(ContactsStorageException::class)
fun removeGroups() {
try {
provider!!.delete(syncAdapterURI(Groups.CONTENT_URI), null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't remove all groups", e)
}
}
// SETTINGS
@Throws(ContactsStorageException::class)
fun getMainAccount(): Account {
val accountManager = AccountManager.get(context)
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw ContactsStorageException("Address book doesn't exist anymore")
}
fun setMainAccount(mainAccount: Account) {
val accountManager = AccountManager.get(context)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
}
@Throws(ContactsStorageException::class)
fun getURL() =
AccountManager.get(context).getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
fun setURL(url: String) =
AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
fun getReadOnly() =
AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
fun setReadOnly(readOnly: Boolean) =
AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override fun getCTag(): String? =
AccountManager.get(context).getUserData(account, USER_DATA_CTAG)
override fun setCTag(cTag: String?) =
AccountManager.get(context).setUserData(account, USER_DATA_CTAG, cTag)
}
@@ -1,255 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Reminders;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import net.fortuna.ical4j.model.component.VTimeZone;
import org.apache.commons.lang3.StringUtils;
import java.io.FileNotFoundException;
import java.util.LinkedList;
import java.util.List;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.ical4android.AndroidCalendar;
import at.bitfire.ical4android.AndroidCalendarFactory;
import at.bitfire.ical4android.BatchOperation;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.DateUtils;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class LocalCalendar extends AndroidCalendar implements LocalCollection {
public static final int defaultColor = 0xFF8bc34a; // light green 500
public static final String COLUMN_CTAG = Calendars.CAL_SYNC1;
static String[] BASE_INFO_COLUMNS = new String[] {
Events._ID,
Events._SYNC_ID,
LocalEvent.COLUMN_ETAG
};
@Override
protected String[] eventBaseInfoColumns() {
return BASE_INFO_COLUMNS;
}
protected LocalCalendar(Account account, ContentProviderClient provider, long id) {
super(account, provider, LocalEvent.Factory.INSTANCE, id);
}
public static Uri create(@NonNull Account account, @NonNull ContentProviderClient provider, @NonNull CollectionInfo info) throws CalendarStorageException {
ContentValues values = valuesFromCollectionInfo(info, true);
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name);
values.put(Calendars.ACCOUNT_TYPE, account.type);
values.put(Calendars.OWNER_ACCOUNT, account.name);
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1);
values.put(Calendars.SYNC_EVENTS, 1);
return create(account, provider, values);
}
public void update(CollectionInfo info, boolean updateColor) throws CalendarStorageException {
update(valuesFromCollectionInfo(info, updateColor));
}
private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) {
ContentValues values = new ContentValues();
values.put(Calendars.NAME, info.url);
values.put(Calendars.CALENDAR_DISPLAY_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url));
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor);
if (info.readOnly)
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ);
else {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER);
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1);
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1);
}
if (!TextUtils.isEmpty(info.timeZone)) {
VTimeZone timeZone = DateUtils.parseVTimeZone(info.timeZone);
if (timeZone != null && timeZone.getTimeZoneId() != null)
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(timeZone.getTimeZoneId().getValue()));
}
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(new int[] { Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY }, ","));
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(new int[] { CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE }, ", "));
return values;
}
@Override
public LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException {
return (LocalEvent[])queryEvents(Events.ORIGINAL_ID + " IS NULL", null);
}
@Override
public LocalEvent[] getDeleted() throws CalendarStorageException {
return (LocalEvent[])queryEvents(Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NULL", null);
}
@Override
public LocalEvent[] getWithoutFileName() throws CalendarStorageException {
return (LocalEvent[])queryEvents(Events._SYNC_ID + " IS NULL AND " + Events.ORIGINAL_ID + " IS NULL", null);
}
@Override
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
List<LocalResource> dirty = new LinkedList<>();
// get dirty events which are required to have an increased SEQUENCE value
for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "!=0 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 if (event.weAreOrganizer)
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(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
));
// remove exception
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
));
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(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(Events.DIRTY, 1)
));
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
));
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];
}
}
}
@@ -0,0 +1,238 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.CalendarContract.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.ical4android.*
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
companion object {
val defaultColor = 0xFF8bc34a.toInt() // light green 500
val COLUMN_CTAG = Calendars.CAL_SYNC1
val BASE_INFO_COLUMNS = arrayOf(
Events._ID,
Events._SYNC_ID,
LocalEvent.COLUMN_ETAG
)
@Throws(CalendarStorageException::class)
fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri {
val values = valuesFromCollectionInfo(info, true)
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
values.put(Calendars.ACCOUNT_NAME, account.name)
values.put(Calendars.ACCOUNT_TYPE, account.type)
values.put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
val uri = create(account, provider, values)
return uri
}
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url)
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(Calendars.CALENDAR_COLOR, info.color ?: defaultColor)
if (info.readOnly)
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
else {
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
}
info.timeZone?.let { tzData ->
try {
val timeZone = DateUtils.parseVTimeZone(tzData)
timeZone.timeZoneId?.let { tzId ->
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
}
} catch(e: IllegalArgumentException) {
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
values.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}")
values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}")
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
return values
}
}
override fun eventBaseInfoColumns() = BASE_INFO_COLUMNS
@Throws(CalendarStorageException::class)
fun update(info: CollectionInfo, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
@Throws(CalendarStorageException::class)
override fun getAll(): List<LocalEvent> =
queryEvents("${Events.ORIGINAL_ID} IS NULL", null)
@Throws(CalendarStorageException::class)
override fun getDeleted() =
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
@Throws(CalendarStorageException::class)
override fun getWithoutFileName() =
queryEvents("${Events._SYNC_ID} IS NULL AND ${Events.ORIGINAL_ID} IS NULL", null)
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun getDirty(): List<LocalEvent> {
val dirty = LinkedList<LocalEvent>()
// get dirty events which are required to have an increased SEQUENCE value
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
val event = localEvent.event!!
val sequence = event.sequence
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (localEvent.weAreOrganizer)
event.sequence = sequence!! + 1
dirty += localEvent
}
return dirty
}
@Throws(CalendarStorageException::class)
override fun getCTag(): String? =
try {
provider.query(calendarSyncURI(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getString(0)
}
null
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
}
@Throws(CalendarStorageException::class)
override fun setCTag(cTag: String?) {
try {
val values = ContentValues(1)
values.put(COLUMN_CTAG, cTag)
provider.update(calendarSyncURI(), values, null, null)
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
}
}
@Throws(CalendarStorageException::class)
fun processDirtyExceptions() {
try {
// process deleted exceptions
Logger.log.info("Processing deleted exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found deleted exception, removing; then re-scheduling original event")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val batch = BatchOperation(provider)
// get original event's SEQUENCE
provider.query(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
// re-schedule original event and set it to DIRTY
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
))
}
// remove exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
}
// process dirty exceptions
Logger.log.info("Processing dirty exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
val batch = BatchOperation(provider)
// original event to DIRTY
batch.enqueue(BatchOperation.Operation (
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(Events.DIRTY, 1)
))
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(BatchOperation.Operation (
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
.withValue(Events.DIRTY, 0)
))
batch.commit()
}
}
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't process locally modified exception", e)
}
}
object Factory: AndroidCalendarFactory<LocalCalendar> {
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
LocalCalendar(account, provider, id)
}
}
@@ -1,26 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import java.io.FileNotFoundException;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
public interface LocalCollection {
LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException;
LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException;
LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException, FileNotFoundException;
LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException;
String getCTag() throws CalendarStorageException, ContactsStorageException;
void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException;
}
@@ -0,0 +1,35 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import java.io.FileNotFoundException
interface LocalCollection<out T: LocalResource> {
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getDeleted(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getWithoutFileName(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
fun getDirty(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getAll(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getCTag(): String?
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun setCTag(cTag: String?)
}
@@ -1,284 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.database.Cursor;
import android.os.Build;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.RawContacts.Data;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.FileNotFoundException;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.model.UnknownProperties;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidContactFactory;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.Ezvcard;
import lombok.Cleanup;
public class LocalContact extends AndroidContact implements LocalResource {
static {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION;
}
public static final String COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3;
protected final Set<Long>
cachedGroupMemberships = new HashSet<>(),
groupMemberships = new HashSet<>();
protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id, fileName, eTag);
}
public LocalContact(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
super(addressBook, contact, fileName, eTag);
}
public void resetDirty() throws ContactsStorageException {
ContentValues values = new ContentValues(1);
values.put(ContactsContract.RawContacts.DIRTY, 0);
try {
addressBook.provider.update(rawContactSyncURI(), values, null, null);
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't clear dirty flag", e);
}
}
public void clearDirty(String eTag) throws ContactsStorageException {
try {
ContentValues values = new ContentValues(3);
values.put(COLUMN_ETAG, eTag);
values.put(ContactsContract.RawContacts.DIRTY, 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
int hashCode = dataHashCode();
values.put(COLUMN_HASHCODE, hashCode);
App.log.finer("Clearing dirty flag with eTag = " + eTag + ", contact hash = " + hashCode);
}
addressBook.provider.update(rawContactSyncURI(), values, null, null);
this.eTag = eTag;
} catch (FileNotFoundException|RemoteException e) {
throw new ContactsStorageException("Couldn't clear dirty flag", e);
}
}
public void prepareForUpload() throws ContactsStorageException {
try {
final String uid = UUID.randomUUID().toString();
final String newFileName = uid + ".vcf";
ContentValues values = new ContentValues(2);
values.put(COLUMN_FILENAME, newFileName);
values.put(COLUMN_UID, uid);
addressBook.provider.update(rawContactSyncURI(), values, null, null);
fileName = newFileName;
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't update UID", e);
}
}
@Override
protected void populateData(String mimeType, ContentValues row) {
switch (mimeType) {
case CachedGroupMembership.CONTENT_ITEM_TYPE:
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID));
break;
case GroupMembership.CONTENT_ITEM_TYPE:
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID));
break;
case UnknownProperties.CONTENT_ITEM_TYPE:
contact.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES);
break;
}
}
@Override
protected void insertDataRows(BatchOperation batch) throws ContactsStorageException {
super.insertDataRows(batch);
if (contact.unknownProperties != null) {
final BatchOperation.Operation op;
final ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
if (id == null) {
op = new BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0);
} else {
op = new BatchOperation.Operation(builder);
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id);
}
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, contact.unknownProperties);
batch.enqueue(op);
}
}
/**
* Calculates a hash code from the contact's data (VCard) and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
* @return hash code of contact data (including group memberships)
*/
protected int dataHashCode() throws FileNotFoundException, ContactsStorageException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
App.log.severe("dataHashCode() should not be called on Android <7");
// reset contact so that getContact() reads from database
contact = null;
// groupMemberships is filled by getContact()
int dataHash = getContact().hashCode(),
groupHash = groupMemberships.hashCode();
App.log.finest("Calculated data hash = " + dataHash + ", group memberships hash = " + groupHash);
return dataHash ^ groupHash;
}
public void updateHashCode(@Nullable BatchOperation batch) throws ContactsStorageException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
App.log.severe("updateHashCode() should not be called on Android <7");
ContentValues values = new ContentValues(1);
try {
int hashCode = dataHashCode();
App.log.fine("Storing contact hash = " + hashCode);
values.put(COLUMN_HASHCODE, hashCode);
if (batch == null)
addressBook.provider.update(rawContactSyncURI(), values, null, null);
else {
ContentProviderOperation.Builder builder = ContentProviderOperation
.newUpdate(rawContactSyncURI())
.withValues(values);
batch.enqueue(new BatchOperation.Operation(builder));
}
} catch(FileNotFoundException|RemoteException e) {
throw new ContactsStorageException("Couldn't store contact checksum", e);
}
}
public int getLastHashCode() throws ContactsStorageException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
App.log.severe("getLastHashCode() should not be called on Android <7");
try {
@Cleanup Cursor c = addressBook.provider.query(rawContactSyncURI(), new String[] { COLUMN_HASHCODE }, null, null, null);
if (c == null || !c.moveToNext() || c.isNull(0))
return 0;
return c.getInt(0);
} catch(RemoteException e) {
throw new ContactsStorageException("Could't read last hash code", e);
}
}
public void addToGroup(BatchOperation batch, long groupID) {
assertID();
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
));
groupMemberships.add(groupID);
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
));
cachedGroupMemberships.add(groupID);
}
public void removeGroupMemberships(BatchOperation batch) {
assertID();
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newDelete(dataSyncURI())
.withSelection(
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
new String[] { String.valueOf(id), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE }
)
.withYieldAllowed(true)
));
groupMemberships.clear();
cachedGroupMemberships.clear();
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
* whether a membership has been deleted/added when a raw contact is dirty.
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getCachedGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return cachedGroupMemberships;
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@NonNull
public Set<Long> getGroupMemberships() throws ContactsStorageException, FileNotFoundException {
getContact();
return groupMemberships;
}
// factory
static class Factory extends AndroidContactFactory {
static final Factory INSTANCE = new Factory();
@Override
public LocalContact newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
return new LocalContact(addressBook, id, fileName, eTag);
}
@Override
public LocalContact newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
return new LocalContact(addressBook, contact, fileName, eTag);
}
@Override
public LocalContact[] newArray(int size) {
return new LocalContact[size];
}
}
}
@@ -0,0 +1,284 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.os.Build
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.UnknownProperties
import at.bitfire.vcard4android.*
import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.*
class LocalContact: AndroidContact, LocalResource {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
}
val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
private val cachedGroupMemberships = HashSet<Long>()
private val groupMemberships = HashSet<Long>()
constructor(addressBook: AndroidAddressBook<LocalContact,*>, id: Long, fileName: String?, eTag: String?):
super(addressBook, id, fileName, eTag)
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
super(addressBook, contact, fileName, eTag)
@Throws(ContactsStorageException::class)
fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DELETED, 0)
try {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear deleted flag", e)
}
}
@Throws(ContactsStorageException::class)
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
try {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear dirty flag", e)
}
}
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(3)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.eTag = eTag
} catch(e: Exception) {
throw ContactsStorageException("Couldn't clear dirty flag", e)
}
}
@Throws(ContactsStorageException::class)
override fun prepareForUpload() {
try {
val uid = UUID.randomUUID().toString()
val newFileName = uid + ".vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
fileName = newFileName
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't update UID", e)
}
}
@Throws(ContactsStorageException::class)
override fun populateData(mimeType: String, row: ContentValues) {
when (mimeType) {
CachedGroupMembership.CONTENT_ITEM_TYPE ->
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID))
GroupMembership.CONTENT_ITEM_TYPE ->
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID))
UnknownProperties.CONTENT_ITEM_TYPE ->
try {
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
} catch(e: FileNotFoundException) {
throw ContactsStorageException("Couldn't fetch data rows", e)
}
}
}
@Throws(ContactsStorageException::class)
override fun insertDataRows(batch: BatchOperation) {
super.insertDataRows(batch)
try {
contact!!.unknownProperties?.let { unknownProperties ->
val op: BatchOperation.Operation
val builder = ContentProviderOperation.newInsert(dataSyncURI())
if (id == null)
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
else {
op = BatchOperation.Operation(builder)
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
}
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
batch.enqueue(op)
}
} catch(e: FileNotFoundException) {
throw ContactsStorageException("Couldn't insert data rows", e)
}
}
/**
* Calculates a hash code from the contact's data (VCard) and group memberships.
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
* @return hash code of contact data (including group memberships)
*/
@Throws(FileNotFoundException::class, ContactsStorageException::class)
internal fun dataHashCode(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
// reset contact so that getContact() reads from database
contact = null
// groupMemberships is filled by getContact()
val dataHash = contact!!.hashCode()
val groupHash = groupMemberships.hashCode()
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
return dataHash xor groupHash
}
@Throws(ContactsStorageException::class)
fun updateHashCode(batch: BatchOperation?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
val values = ContentValues(1)
try {
val hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
values.put(COLUMN_HASHCODE, hashCode)
if (batch == null)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
else {
val builder = ContentProviderOperation
.newUpdate(rawContactSyncURI())
.withValues(values)
batch.enqueue(BatchOperation.Operation(builder))
}
} catch(e: Exception) {
throw ContactsStorageException("Couldn't store contact checksum", e)
}
}
@Throws(ContactsStorageException::class)
fun getLastHashCode(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
try {
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
}
return 0
} catch(e: RemoteException) {
throw ContactsStorageException("Could't read last hash code", e)
}
}
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
))
groupMemberships.add(groupID)
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
))
cachedGroupMemberships.add(groupID)
}
fun removeGroupMemberships(batch: BatchOperation) {
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(dataSyncURI())
.withSelection(
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
)
.withYieldAllowed(true)
))
groupMemberships.clear()
cachedGroupMemberships.clear()
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
* whether a membership has been deleted/added when a raw contact is dirty.
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@Throws(FileNotFoundException::class, ContactsStorageException::class)
fun getCachedGroupMemberships(): Set<Long> {
contact
return cachedGroupMemberships
}
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws ContactsStorageException on contact provider errors
* @throws FileNotFoundException if the current contact can't be found
*/
@Throws(FileNotFoundException::class, ContactsStorageException::class)
fun getGroupMemberships(): Set<Long> {
contact
return groupMemberships
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, id: Long, fileName: String?, eTag: String?) =
LocalContact(addressBook, id, fileName, eTag)
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, contact: Contact, fileName: String?, eTag: String?) =
LocalContact(addressBook, contact, fileName, eTag)
}
}
@@ -1,166 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.annotation.TargetApi;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.database.Cursor;
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.App;
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.Cleanup;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@TargetApi(17)
@ToString(of={ "fileName","eTag" }, callSuper=true)
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 boolean weAreOrganizer = true;
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);
if (Build.VERSION.SDK_INT >= 17)
weAreOrganizer = values.getAsInteger(Events.IS_ORGANIZER) != 0;
else {
String organizer = values.getAsString(Events.ORGANIZER);
weAreOrganizer = organizer == null || organizer.equals(calendar.account.name);
}
}
@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 prepareForUpload() throws CalendarStorageException {
try {
String uid = null;
@Cleanup Cursor c = calendar.provider.query(eventSyncURI(), new String[] { COLUMN_UID }, null, null, null);
if (c.moveToNext())
uid = c.getString(0);
if (uid == null)
uid = App.getUidGenerator().generateUid().getValue();
final 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];
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.os.Build
import android.provider.CalendarContract
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.BuildConfig
import at.bitfire.ical4android.*
import net.fortuna.ical4j.model.property.ProdId
import java.io.FileNotFoundException
import java.util.*
class LocalEvent: AndroidEvent, LocalResource {
companion object {
init {
iCalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
}
val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
val COLUMN_UID = if (Build.VERSION.SDK_INT >= 17) Events.UID_2445 else Events.SYNC_DATA2
val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
}
override var fileName: String? = null
override var eTag: String? = null
var weAreOrganizer = true
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
}
private constructor(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?): super(calendar, id, baseInfo) {
baseInfo?.let {
fileName = it.getAsString(Events._SYNC_ID)
eTag = it.getAsString(COLUMN_ETAG)
}
}
/* process LocalEvent-specific fields */
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun populateEvent(row: ContentValues) {
super.populateEvent(row)
val event = requireNotNull(event)
fileName = row.getAsString(Events._SYNC_ID)
eTag = row.getAsString(COLUMN_ETAG)
event.uid = row.getAsString(COLUMN_UID)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
if (Build.VERSION.SDK_INT >= 17) {
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
} else {
val organizer = row.getAsString(Events.ORGANIZER)
weAreOrganizer = organizer == null || organizer == calendar.account.name
}
}
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
super.buildEvent(recurrence, builder)
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(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 */
@Throws(CalendarStorageException::class)
override fun prepareForUpload() {
try {
var uid: String? = null
calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0)
}
if (uid == null)
uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName)
values.put(COLUMN_UID, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName
event!!.uid = uid
} catch(e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
}
}
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(2)
values.put(CalendarContract.Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SEQUENCE, event!!.sequence);
calendar.provider.update(eventSyncURI(), values, null, null)
this.eTag = eTag
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
}
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun newInstance(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?) =
LocalEvent(calendar, id, baseInfo)
override fun newInstance(calendar: AndroidCalendar<*>, event: Event) =
LocalEvent(calendar, event, null, null)
}
}
@@ -1,260 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.os.Build;
import android.os.Parcel;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.RawContacts.Data;
import org.apache.commons.lang3.ArrayUtils;
import java.io.FileNotFoundException;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import at.bitfire.dav4android.Constants;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidGroup;
import at.bitfire.vcard4android.AndroidGroupFactory;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.CachedGroupMembership;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
import lombok.ToString;
@ToString(callSuper=true)
public class LocalGroup extends AndroidGroup implements LocalResource {
/** marshalled list of member UIDs, as sent by server */
public static final String COLUMN_PENDING_MEMBERS = Groups.SYNC3;
public LocalGroup(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id, fileName, eTag);
}
public LocalGroup(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
super(addressBook, contact, fileName, eTag);
}
@Override
public void clearDirty(String eTag) throws ContactsStorageException {
assertID();
ContentValues values = new ContentValues(2);
values.put(Groups.DIRTY, 0);
values.put(COLUMN_ETAG, this.eTag = eTag);
update(values);
// update cached group memberships
BatchOperation batch = new BatchOperation(addressBook.provider);
// delete cached group memberships
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
new String[] { CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) }
)
));
// insert updated cached group memberships
for (long member : getMembers())
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
.withYieldAllowed(true)
));
batch.commit();
}
@Override
public void prepareForUpload() throws ContactsStorageException {
final String uid = UUID.randomUUID().toString();
final String newFileName = uid + ".vcf";
ContentValues values = new ContentValues(2);
values.put(COLUMN_FILENAME, newFileName);
values.put(COLUMN_UID, uid);
update(values);
fileName = newFileName;
}
@Override
protected ContentValues contentValues() {
ContentValues values = super.contentValues();
@Cleanup("recycle") Parcel members = Parcel.obtain();
members.writeStringList(contact.members);
values.put(COLUMN_PENDING_MEMBERS, members.marshall());
return values;
}
/**
* Marks all members of the current group as dirty.
*/
public void markMembersDirty() throws ContactsStorageException {
assertID();
BatchOperation batch = new BatchOperation(addressBook.provider);
for (long member : getMembers())
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
.withYieldAllowed(true)
));
batch.commit();
}
/**
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
* are (if possible) applied, keeping cached memberships in sync.
* @param addressBook address book to take groups from
* @throws ContactsStorageException on contact provider errors
*/
public static void applyPendingMemberships(LocalAddressBook addressBook) throws ContactsStorageException {
try {
@Cleanup Cursor cursor = addressBook.provider.query(
addressBook.syncAdapterURI(Groups.CONTENT_URI),
new String[] { Groups._ID, COLUMN_PENDING_MEMBERS },
COLUMN_PENDING_MEMBERS + " IS NOT NULL", new String[] {},
null
);
BatchOperation batch = new BatchOperation(addressBook.provider);
while (cursor != null && cursor.moveToNext()) {
long id = cursor.getLong(0);
Constants.log.fine("Assigning members to group " + id);
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
Set<Long> changeContactIDs = new HashSet<>();
// delete all memberships and cached memberships for this group
for (LocalContact contact : addressBook.getByGroupMembership(id)) {
contact.removeGroupMemberships(batch);
changeContactIDs.add(contact.getId());
}
// extract list of member UIDs
List<String> members = new LinkedList<>();
byte[] raw = cursor.getBlob(1);
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
parcel.unmarshall(raw, 0, raw.length);
parcel.setDataPosition(0);
parcel.readStringList(members);
// insert memberships
for (String uid : members) {
Constants.log.fine("Assigning member: " + uid);
try {
LocalContact member = addressBook.findContactByUID(uid);
member.addToGroup(batch, id);
changeContactIDs.add(member.getId());
} catch(FileNotFoundException e) {
Constants.log.log(Level.WARNING, "Group member not found: " + uid, e);
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
for (Long contactID : changeContactIDs) {
LocalContact contact = new LocalContact(addressBook, contactID, null, null);
contact.updateHashCode(batch);
}
// remove pending memberships
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.withYieldAllowed(true)
));
batch.commit();
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't get pending memberships", e);
}
}
// helpers
private void assertID() {
if (id == null)
throw new IllegalStateException("Group has not been saved yet");
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws ContactsStorageException on contact provider errors
*/
protected long[] getMembers() throws ContactsStorageException {
assertID();
List<Long> members = new LinkedList<>();
try {
@Cleanup Cursor cursor = addressBook.provider.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
new String[] { Data.RAW_CONTACT_ID },
GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) },
null
);
while (cursor != null && cursor.moveToNext())
members.add(cursor.getLong(0));
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't list group members", e);
}
return ArrayUtils.toPrimitive(members.toArray(new Long[members.size()]));
}
// factory
static class Factory extends AndroidGroupFactory {
static final Factory INSTANCE = new Factory();
@Override
public LocalGroup newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
return new LocalGroup(addressBook, id, fileName, eTag);
}
@Override
public LocalGroup newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
return new LocalGroup(addressBook, contact, fileName, eTag);
}
@Override
public LocalGroup[] newArray(int size) {
return new LocalGroup[size];
}
}
}
@@ -0,0 +1,255 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.os.Build
import android.os.Parcel
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.dav4android.Constants
import at.bitfire.vcard4android.*
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
class LocalGroup: AndroidGroup, LocalResource {
companion object {
/** marshaled list of member UIDs, as sent by server */
val COLUMN_PENDING_MEMBERS = Groups.SYNC3
/**
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
* are (if possible) applied, keeping cached memberships in sync.
* @param addressBook address book to take groups from
* @throws ContactsStorageException on contact provider errors
*/
@JvmStatic
@Throws(ContactsStorageException::class)
fun applyPendingMemberships(addressBook: LocalAddressBook) {
try {
addressBook.provider!!.query(
addressBook.syncAdapterURI(Groups.CONTENT_URI),
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
null
)?.use { cursor ->
val batch = BatchOperation(addressBook.provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
Constants.log.fine("Assigning members to group $id")
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
// delete all memberships and cached memberships for this group
for (contact in addressBook.getByGroupMembership(id)) {
contact.removeGroupMemberships(batch)
changeContactIDs.add(contact.id!!)
}
// extract list of member UIDs
val members = LinkedList<String>()
val raw = cursor.getBlob(1)
val parcel = Parcel.obtain()
try {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
parcel.readStringList(members)
} finally {
parcel.recycle()
}
// insert memberships
for (uid in members) {
Constants.log.fine("Assigning member: $uid")
try {
val member = addressBook.findContactByUID(uid)
member.addToGroup(batch, id)
changeContactIDs.add(member.id!!)
} catch(e: FileNotFoundException) {
Constants.log.log(Level.WARNING, "Group member not found: $uid", e)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
changeContactIDs
.map { LocalContact(addressBook, it, null, null) }
.forEach { it.updateHashCode(batch) }
// remove pending memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.withYieldAllowed(true)
))
batch.commit()
}
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't get pending memberships", e)
}
}
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?):
super(addressBook, id, fileName, eTag)
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?):
super(addressBook, contact, fileName, eTag)
@Throws(ContactsStorageException::class)
fun resetDeleted() {
val uri = ContentUris.withAppendedId(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), requireNotNull(id))
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
try {
addressBook.provider!!.update(uri, values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear deleted flag", e)
}
}
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
val id = requireNotNull(id)
val values = ContentValues(2)
values.put(Groups.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
this.eTag = eTag
update(values)
// update cached group memberships
val batch = BatchOperation(addressBook.provider!!)
// delete cached group memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
)
))
// insert updated cached group memberships
for (member in getMembers())
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
.withYieldAllowed(true)
))
batch.commit()
}
@Throws(ContactsStorageException::class)
override fun prepareForUpload() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
update(values)
fileName = newFileName
}
@Throws(FileNotFoundException::class, ContactsStorageException::class)
override fun contentValues(): ContentValues {
val values = super.contentValues()
val members = Parcel.obtain()
try {
members.writeStringList(contact!!.members)
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
} finally {
members.recycle()
}
return values
}
/**
* Marks all members of the current group as dirty.
*/
@Throws(ContactsStorageException::class)
fun markMembersDirty() {
val batch = BatchOperation(addressBook.provider!!)
for (member in getMembers())
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
.withYieldAllowed(true)
))
batch.commit()
}
// helpers
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws ContactsStorageException on contact provider errorst
*/
@Throws(ContactsStorageException::class)
internal fun getMembers(): List<Long> {
val id = requireNotNull(id)
val members = LinkedList<Long>()
try {
addressBook.provider!!.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(Data.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
null
)?.use { cursor ->
while (cursor.moveToNext())
members.add(cursor.getLong(0))
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't list group members", e)
}
return members
}
// factory
object Factory: AndroidGroupFactory<LocalGroup> {
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?) =
LocalGroup(addressBook, id, fileName, eTag)
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?) =
LocalGroup(addressBook, contact, fileName, eTag)
}
}
@@ -1,26 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
public interface LocalResource {
Long getId();
String getFileName();
String getETag();
int delete() throws CalendarStorageException, ContactsStorageException;
void prepareForUpload() throws CalendarStorageException, ContactsStorageException;
void clearDirty(String eTag) throws CalendarStorageException, ContactsStorageException;
}
@@ -0,0 +1,30 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
interface LocalResource {
val id: Long?
var fileName: String?
var eTag: String?
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun delete(): Int
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun prepareForUpload()
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun clearDirty(eTag: String?)
}
@@ -1,142 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.content.ContentProviderOperation;
import android.content.ContentValues;
import android.os.RemoteException;
import android.provider.CalendarContract.Events;
import android.support.annotation.NonNull;
import net.fortuna.ical4j.model.property.ProdId;
import org.dmfs.provider.tasks.TaskContract.Tasks;
import java.io.FileNotFoundException;
import java.text.ParseException;
import at.bitfire.davdroid.App;
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;
import lombok.ToString;
@ToString(of={ "fileName","eTag" }, callSuper=true)
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 prepareForUpload() throws CalendarStorageException {
try {
final String uid = App.getUidGenerator().generateUid().getValue();
final 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];
}
}
}
@@ -0,0 +1,117 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.provider.CalendarContract.Events
import at.bitfire.ical4android.*
import org.dmfs.provider.tasks.TaskContract.Tasks
import java.io.FileNotFoundException
import java.text.ParseException
import java.util.*
class LocalTask: AndroidTask, LocalResource {
companion object {
val COLUMN_ETAG = Tasks.SYNC1
val COLUMN_UID = Tasks.SYNC2
val COLUMN_SEQUENCE = Tasks.SYNC3
}
override var fileName: String? = null
override var eTag: String? = null
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?): super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
}
private constructor(taskList: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?): super(taskList, id) {
baseInfo?.let {
fileName = it.getAsString(Events._SYNC_ID)
eTag = it.getAsString(COLUMN_ETAG)
}
}
/* process LocalTask-specific fields */
@Throws(ParseException::class)
override fun populateTask(values: ContentValues) {
super.populateTask(values)
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
val task = requireNotNull(task)
task.uid = values.getAsString(COLUMN_UID)
task.sequence = values.getAsInteger(COLUMN_SEQUENCE)
}
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
super.buildTask(builder, update)
val task = requireNotNull(task)
builder .withValue(Tasks._SYNC_ID, fileName)
.withValue(COLUMN_UID, task.uid)
.withValue(COLUMN_SEQUENCE, task.sequence)
.withValue(COLUMN_ETAG, eTag)
}
/* custom queries */
@Throws(CalendarStorageException::class)
override fun prepareForUpload() {
try {
val uid = UUID.randomUUID().toString()
val newFileName = uid + ".ics"
val values = ContentValues(2)
values.put(Tasks._SYNC_ID, newFileName)
values.put(COLUMN_UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = newFileName
task!!.uid = uid
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
}
}
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(2)
values.put(Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SEQUENCE, task!!.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null)
this.eTag = eTag
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e)
}
}
object Factory: AndroidTaskFactory<LocalTask> {
override fun newInstance(calendar: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?) =
LocalTask(calendar, id, baseInfo)
override fun newInstance(calendar: AndroidTaskList<*>, task: Task) =
LocalTask(calendar, task, null, null)
}
}
@@ -1,175 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import org.dmfs.provider.tasks.TaskContract.TaskLists;
import org.dmfs.provider.tasks.TaskContract.Tasks;
import java.io.FileNotFoundException;
import at.bitfire.davdroid.DavUtils;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.ical4android.AndroidTaskList;
import at.bitfire.ical4android.AndroidTaskListFactory;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
public class LocalTaskList extends AndroidTaskList implements LocalCollection {
public static final int defaultColor = 0xFFC3EA6E; // "DAVdroid green"
public static final String COLUMN_CTAG = TaskLists.SYNC_VERSION;
static String[] BASE_INFO_COLUMNS = new String[] {
Tasks._ID,
Tasks._SYNC_ID,
LocalTask.COLUMN_ETAG
};
@Override
protected String[] taskBaseInfoColumns() {
return BASE_INFO_COLUMNS;
}
protected LocalTaskList(Account account, TaskProvider provider, long id) {
super(account, provider, LocalTask.Factory.INSTANCE, id);
}
public static Uri create(Account account, TaskProvider provider, CollectionInfo info) throws CalendarStorageException {
ContentValues values = valuesFromCollectionInfo(info, true);
values.put(TaskLists.OWNER, account.name);
values.put(TaskLists.SYNC_ENABLED, 1);
values.put(TaskLists.VISIBLE, 1);
return create(account, provider, values);
}
public void update(CollectionInfo info, boolean updateColor) throws CalendarStorageException {
update(valuesFromCollectionInfo(info, updateColor));
}
private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) {
ContentValues values = new ContentValues();
values.put(TaskLists._SYNC_ID, info.url);
values.put(TaskLists.LIST_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url));
if (withColor)
values.put(TaskLists.LIST_COLOR, info.color != null ? info.color : defaultColor);
return values;
}
@Override
public LocalTask[] getAll() throws CalendarStorageException {
return (LocalTask[])queryTasks(null, null);
}
@Override
public LocalTask[] getDeleted() throws CalendarStorageException {
return (LocalTask[])queryTasks(Tasks._DELETED + "!=0", null);
}
@Override
public LocalTask[] getWithoutFileName() throws CalendarStorageException {
return (LocalTask[])queryTasks(Tasks._SYNC_ID + " IS NULL", null);
}
@Override
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
LocalTask[] tasks = (LocalTask[])queryTasks(Tasks._DIRTY + "!=0", null);
if (tasks != null)
for (LocalTask task : tasks) {
if (task.getTask().sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.getTask().sequence = 0;
else
task.getTask().sequence++;
}
return tasks;
}
@Override
@SuppressWarnings("Recycle")
public String getCTag() throws CalendarStorageException {
try {
@Cleanup Cursor cursor = provider.client.query(taskListSyncUri(), new String[] { COLUMN_CTAG }, null, null, null);
if (cursor != null && cursor.moveToNext())
return cursor.getString(0);
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
}
return null;
}
@Override
public void setCTag(String cTag) throws CalendarStorageException {
try {
ContentValues values = new ContentValues(1);
values.put(COLUMN_CTAG, cTag);
provider.client.update(taskListSyncUri(), values, null, null);
} catch (RemoteException e) {
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
}
}
// helpers
public static boolean tasksProviderAvailable(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
return context.getPackageManager().resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null;
else {
@Cleanup TaskProvider provider = TaskProvider.acquire(context.getContentResolver(), TaskProvider.ProviderName.OpenTasks);
return 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];
}
}
// HELPERS
public static void onRenameAccount(@NonNull ContentResolver resolver, @NonNull String oldName, @NonNull String newName) throws RemoteException {
@Cleanup("release") ContentProviderClient client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority);
if (client != null) {
ContentValues values = new ContentValues(1);
values.put(Tasks.ACCOUNT_NAME, newName);
client.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, Tasks.ACCOUNT_NAME + "=?", new String[]{oldName});
}
}
}
@@ -0,0 +1,164 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.AndroidTaskListFactory
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.TaskProvider
import org.dmfs.provider.tasks.TaskContract.TaskLists
import org.dmfs.provider.tasks.TaskContract.Tasks
import java.io.FileNotFoundException
class LocalTaskList private constructor(
account: Account,
provider: TaskProvider,
id: Long
): AndroidTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
companion object {
val defaultColor = 0xFFC3EA6E.toInt() // "DAVdroid green"
val COLUMN_CTAG = TaskLists.SYNC_VERSION
val BASE_INFO_COLUMNS = arrayOf(
Tasks._ID,
Tasks._SYNC_ID,
LocalTask.COLUMN_ETAG
)
@JvmStatic
fun tasksProviderAvailable(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
else {
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
provider?.use { return true }
return false
}
}
@JvmStatic
@Throws(CalendarStorageException::class)
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
val values = valuesFromCollectionInfo(info, true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
values.put(TaskLists.VISIBLE, 1)
return create(account, provider, values)
}
@JvmStatic
@Throws(Exception::class)
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
var client: ContentProviderClient? = null
try {
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
client?.use {
val values = ContentValues(1)
values.put(Tasks.ACCOUNT_NAME, newName)
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
client?.close()
else
client?.release()
}
}
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url)
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
if (withColor)
values.put(TaskLists.LIST_COLOR, info.color ?: defaultColor)
return values
}
}
override fun taskBaseInfoColumns() = BASE_INFO_COLUMNS
@Throws(CalendarStorageException::class)
fun update(info: CollectionInfo, updateColor: Boolean) {
update(valuesFromCollectionInfo(info, updateColor));
}
@Throws(CalendarStorageException::class)
override fun getAll() = queryTasks(null, null)
@Throws(CalendarStorageException::class)
override fun getDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
@Throws(CalendarStorageException::class)
override fun getWithoutFileName() = queryTasks("${Tasks._SYNC_ID} IS NULL", null)
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun getDirty(): List<LocalTask> {
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
for (localTask in tasks) {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else
task.sequence = sequence + 1
}
return tasks
}
@Throws(CalendarStorageException::class)
override fun getCTag(): String? =
try {
provider.client.query(taskListSyncUri(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getString(0)
}
null
} catch(e: Exception) {
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
}
@Throws(CalendarStorageException::class)
override fun setCTag(cTag: String?) {
try {
val values = ContentValues(1)
values.put(COLUMN_CTAG, cTag)
provider.client.update(taskListSyncUri(), values, null, null)
} catch (e: Exception) {
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
}
}
object Factory: AndroidTaskListFactory<LocalTaskList> {
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
LocalTaskList(account, provider, id)
}
}
@@ -0,0 +1,74 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.settings
import at.bitfire.davdroid.App
open class DefaultsProvider(
private val allowOverride: Boolean = true
): Provider {
open val booleanDefaults = mapOf<String, Boolean>(
Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(App.OVERRIDE_PROXY, false)
)
open val intDefaults = mapOf<String, Int>(
Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
)
open val longDefaults = mapOf<String, Long>()
open val stringDefaults = mapOf<String, String>(
Pair(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
)
override fun close() {
}
override fun forceReload() {
}
private fun hasKey(key: String) =
booleanDefaults.containsKey(key) ||
intDefaults.containsKey(key) ||
longDefaults.containsKey(key) ||
stringDefaults.containsKey(key)
override fun has(key: String): Pair<Boolean, Boolean> {
val has = hasKey(key)
return Pair(has, allowOverride || !has)
}
override fun getBoolean(key: String) =
Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key))
override fun getInt(key: String) =
Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key))
override fun getLong(key: String) =
Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key))
override fun getString(key: String) =
Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key))
override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key))
override fun putBoolean(key: String, value: Boolean?) = false
override fun putInt(key: String, value: Int?) = false
override fun putLong(key: String, value: Long?) = false
override fun putString(key: String, value: String?) = false
override fun remove(key: String) = false
}
@@ -0,0 +1,38 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.settings
import java.io.Closeable
interface Provider: Closeable {
fun forceReload()
fun has(key: String): Pair<Boolean, Boolean>
fun getBoolean(key: String): Pair<Boolean?, Boolean>
fun getInt(key: String): Pair<Int?, Boolean>
fun getLong(key: String): Pair<Long?, Boolean>
fun getString(key: String): Pair<String?, Boolean>
fun isWritable(key: String): Pair<Boolean, Boolean>
fun putBoolean(key: String, value: Boolean?): Boolean
fun putInt(key: String, value: Int?): Boolean
fun putLong(key: String, value: Long?): Boolean
fun putString(key: String, value: String?): Boolean
fun remove(key: String): Boolean
interface Observer {
fun onReload()
}
}
@@ -0,0 +1,276 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.settings
import android.annotation.TargetApi
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import at.bitfire.davdroid.log.Logger
import java.io.Closeable
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
class Settings: Service(), Provider.Observer {
private val providers = LinkedList<Provider>()
private val observers = LinkedList<WeakReference<ISettingsObserver>>()
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
override fun onCreate() {
Logger.log.info("Initializing Settings service")
// always add a defaults provider first
providers.add(DefaultsProvider())
// always add a provider for local preferences
providers.add(SharedPreferencesProvider(this))
}
override fun onDestroy() {
Logger.log.info("Shutting down Settings service")
providers.forEach { it.close() }
providers.clear()
}
override fun onTrimMemory(level: Int) {
stopSelf()
}
fun forceReload() {
providers.forEach { it.forceReload() }
}
override fun onReload() {
observers.forEach {
Handler(Looper.getMainLooper()).post {
it.get()?.onSettingsChanged()
}
}
}
private fun has(key: String): Boolean {
Logger.log.fine("Looking for setting $key")
var result = false
for (provider in providers)
try {
val (value, further) = provider.has(key)
Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further")
if (value) {
result = true
break
}
if (!further)
break
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e)
}
Logger.log.fine("Looking for setting $key -> $result")
return result
}
private fun<T> getValue(key: String, reader: (Provider) -> Pair<T?, Boolean>): T? {
Logger.log.fine("Looking up setting $key")
var result: T? = null
for (provider in providers)
try {
val (value, further) = reader(provider)
Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further")
value?.let { result = it }
if (!further)
break
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
}
Logger.log.fine("Looked up setting $key -> $result")
return result
}
fun getBoolean(key: String) =
getValue(key, { provider -> provider.getBoolean(key) })
fun getInt(key: String) =
getValue(key, { provider -> provider.getInt(key) })
fun getLong(key: String) =
getValue(key, { provider -> provider.getLong(key) })
fun getString(key: String) =
getValue(key, { provider -> provider.getString(key) })
fun isWritable(key: String): Boolean {
for (provider in providers) {
val (value, further) = provider.isWritable(key)
if (value)
return true
if (!further)
return false
}
return false
}
private fun<T> putValue(key: String, value: T?, writer: (Provider) -> Boolean): Boolean {
Logger.log.fine("Trying to write setting $key = $value")
for (provider in providers) {
val (writable, further) = provider.isWritable(key)
Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further")
if (writable)
return try {
writer(provider)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e)
false
}
if (!further)
return false
}
return false
}
fun putBoolean(key: String, value: Boolean?) =
putValue(key, value, { provider -> provider.putBoolean(key, value) })
fun putInt(key: String, value: Int?) =
putValue(key, value, { provider -> provider.putInt(key, value) })
fun putLong(key: String, value: Long?) =
putValue(key, value, { provider -> provider.putLong(key, value) })
fun putString(key: String, value: String?) =
putValue(key, value, { provider -> provider.putString(key, value) })
fun remove(key: String): Boolean {
var deleted = false
providers.forEach { deleted = deleted || it.remove(key) }
return deleted
}
val binder = object: ISettings.Stub() {
override fun forceReload() =
this@Settings.forceReload()
override fun has(key: String) =
this@Settings.has(key)
override fun getBoolean(key: String, defaultValue: Boolean) =
this@Settings.getBoolean(key) ?: defaultValue
override fun getInt(key: String, defaultValue: Int) =
this@Settings.getInt(key) ?: defaultValue
override fun getLong(key: String, defaultValue: Long) =
this@Settings.getLong(key) ?: defaultValue
override fun getString(key: String, defaultValue: String?) =
this@Settings.getString(key) ?: defaultValue
override fun isWritable(key: String) =
this@Settings.isWritable(key)
override fun remove(key: String) =
this@Settings.remove(key)
override fun putBoolean(key: String, value: Boolean) =
this@Settings.putBoolean(key, value)
override fun putString(key: String, value: String?) =
this@Settings.putString(key, value)
override fun putInt(key: String, value: Int) =
this@Settings.putInt(key, value)
override fun putLong(key: String, value: Long) =
this@Settings.putLong(key, value)
override fun registerObserver(observer: ISettingsObserver) {
observers += WeakReference(observer)
}
override fun unregisterObserver(observer: ISettingsObserver) {
observers.removeAll { it.get() == observer }
}
}
override fun onBind(intent: Intent?) = binder
class Stub(
delegate: ISettings,
private val context: Context,
private val serviceConn: ServiceConnection
): ISettings by delegate, Closeable {
override fun close() {
try {
context.unbindService(serviceConn)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't unbind Settings service", e)
}
}
}
companion object {
fun getInstance(context: Context): Stub? {
if (Looper.getMainLooper().thread == Thread.currentThread())
throw IllegalStateException("Must not be called from main thread")
var service: ISettings? = null
val serviceLock = Object()
val serviceConn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
synchronized(serviceLock) {
service = ISettings.Stub.asInterface(binder)
serviceLock.notify()
}
}
override fun onServiceDisconnected(name: ComponentName) {
service = null
}
}
if (!context.bindService(Intent(context, Settings::class.java), serviceConn, Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT))
return null
synchronized(serviceLock) {
if (service == null)
try {
serviceLock.wait()
} catch(e: InterruptedException) {
}
if (service == null) {
try {
context.unbindService(serviceConn)
} catch (e: IllegalArgumentException) {
}
return null
}
}
return Stub(service!!, context, serviceConn)
}
}
}
@@ -0,0 +1,120 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.settings
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.preference.PreferenceManager
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
class SharedPreferencesProvider(
context: Context
): Provider {
companion object {
private val META_VERSION = "version"
private val CURRENT_VERSION = 0
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
init {
val meta = context.getSharedPreferences("meta", MODE_PRIVATE)
val version = meta.getInt(META_VERSION, -1)
if (version == -1) {
// first call, check whether to migrate from SQLite database (DAVdroid <1.9)
firstCall(context)
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
}
}
override fun close() {
}
override fun forceReload() {
}
override fun has(key: String) =
Pair(preferences.contains(key), true)
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): Pair<T?, Boolean> {
if (preferences.contains(key))
return Pair(
try { reader(preferences) } catch(e: ClassCastException) { null },
true)
return Pair(null, true)
}
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
getValue(key, { preferences -> preferences.getBoolean(key, /* will never be used: */ false) })
override fun getInt(key: String): Pair<Int?, Boolean> =
getValue(key, { preferences -> preferences.getInt(key, /* will never be used: */ -1) })
override fun getLong(key: String): Pair<Long?, Boolean> =
getValue(key, { preferences -> preferences.getLong(key, /* will never be used: */ -1) })
override fun getString(key: String): Pair<String?, Boolean> =
getValue(key, { preferences -> preferences.getString(key, /* will never be used: */ null) })
override fun isWritable(key: String) =
Pair(true, true)
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
if (value == null)
return remove(key)
else {
Logger.log.fine("Writing setting $key = $value")
val edit = preferences.edit()
writer(edit, value)
edit.apply()
return true
}
}
override fun putBoolean(key: String, value: Boolean?) =
putValue(key, value, { editor, v -> editor.putBoolean(key, v) })
override fun putInt(key: String, value: Int?) =
putValue(key, value, { editor, v -> editor.putInt(key, v) })
override fun putLong(key: String, value: Long?) =
putValue(key, value, { editor, v -> editor.putLong(key, v) })
override fun putString(key: String, value: String?) =
putValue(key, value, { editor, v -> editor.putString(key, v) })
override fun remove(key: String): Boolean {
Logger.log.fine("Removing setting $key")
preferences.edit()
.remove(key)
.apply()
return true
}
private fun firstCall(context: Context) {
// remove possible artifacts from DAVdroid <1.9
val edit = preferences.edit()
edit.remove("override_proxy")
edit.remove("proxy_host")
edit.remove("proxy_port")
edit.remove("log_to_external_storage")
edit.apply()
// open ServiceDB to upgrade it and possibly migrate settings
ServiceDB.OpenHelper(context).use { it.readableDatabase }
}
}
@@ -1,89 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import at.bitfire.davdroid.ui.setup.LoginActivity;
public class AccountAuthenticatorService extends Service {
private AccountAuthenticator accountAuthenticator;
@Override
public void onCreate() {
accountAuthenticator = new AccountAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT))
return accountAuthenticator.getIBinder();
return null;
}
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
final Context context;
public AccountAuthenticator(Context context) {
super(context);
this.context = context;
}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
String[] requiredFeatures, Bundle options) throws NetworkErrorException {
Intent intent = new Intent(context, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
return null;
}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public String getAuthTokenLabel(String authTokenType) {
return null;
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
return null;
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
}
}
@@ -0,0 +1,52 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import at.bitfire.davdroid.ui.setup.LoginActivity
class AccountAuthenticatorService: Service() {
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountAuthenticator = AccountAuthenticator(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
val intent = Intent(context, LoginActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle(1)
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}
@@ -0,0 +1,24 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.content.ContentProvider
import android.content.ContentValues
import android.net.Uri
class AddressBookProvider: ContentProvider() {
override fun onCreate() = false
override fun insert(p0: Uri?, p1: ContentValues?) = null
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?) = 0
override fun getType(p0: Uri?) = null
}
@@ -0,0 +1,135 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.database.DatabaseUtils
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.vcard4android.ContactsStorageException
import java.util.logging.Level
class AddressBooksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
protected class AddressBooksSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return
}
try {
val accountSettings = AccountSettings(context, settings, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
updateLocalAddressBooks(contactsProvider, account)
val accountManager = AccountManager.get(context)
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
}
Logger.log.info("Address book sync complete")
}
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteAddressBooks(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
}
// enumerate remote and local address books
val service = getService()
val remote = remoteAddressBooks(service)
// delete/update local address books
for (addressBook in LocalAddressBook.find(context, provider, account)) {
val url = addressBook.getURL()
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch(e: ContactsStorageException) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local address books
for ((_, info) in remote) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, provider, account, info)
}
}
}
}
}
@@ -1,252 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
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.ByteArrayOutputStream;
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.CalendarData;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.AccountSettings;
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;
/**
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
*/
public class CalendarSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 20;
public CalendarSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult result, LocalCalendar calendar) throws InvalidAccountException {
super(context, account, settings, extras, authority, result, "calendar/" + calendar.getId());
localCollection = calendar;
}
@Override
protected int notificationId() {
return Constants.NOTIFICATION_CALENDAR_SYNC;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_calendar, account.name);
}
@Override
protected boolean prepare() {
collectionURL = HttpUrl.parse(localCalendar().getName());
davCollection = new DavCalendar(httpClient, collectionURL);
return true;
}
@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;
App.log.log(Level.FINE, "Preparing upload of event " + local.getFileName(), local.getEvent());
ByteArrayOutputStream os = new ByteArrayOutputStream();
local.getEvent().write(os);
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.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
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.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);
}
currentDavResource = null;
}
@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
final DavResource remote = bunch[0];
currentDavResource = remote;
ResponseBody body = remote.get("text/calendar");
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME);
if (eTag == null || StringUtils.isEmpty(eTag.eTag))
throw new DavException("Received CalDAV GET response without ETag for " + remote.location);
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.eTag, stream, charset);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.multiget(urls.toArray(new HttpUrl[urls.size()]));
// process multiget results
for (final DavResource remote : davCollection.members) {
currentDavResource = remote;
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);
}
}
currentDavResource = null;
}
}
// 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);
currentLocalResource = localEvent;
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);
currentLocalResource = localEvent;
localEvent.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName);
currentLocalResource = null;
}
}
@@ -0,0 +1,209 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.*
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
*/
class CalendarSyncManager(
context: Context,
account: Account,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
val localCalendar: LocalCalendar
): SyncManager(context, account, settings, extras, authority, syncResult, "calendar/${localCalendar.id}") {
val MAX_MULTIGET = 20
init {
localCollection = localCalendar
}
override fun notificationId() = Constants.NOTIFICATION_CALENDAR_SYNC
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_calendar, account.name)!!
override fun prepare(): Boolean {
collectionURL = HttpUrl.parse(localCalendar.name ?: return false) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
return true
}
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun prepareDirty() {
super.prepareDirty()
localCalendar.processDirtyExceptions()
}
override fun prepareUpload(resource: LocalResource): RequestBody {
if (resource is LocalEvent) {
val event = requireNotNull(resource.event)
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
val os = ByteArrayOutputStream()
event.write(os)
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
)
} else
throw IllegalArgumentException("resource must be a LocalEvent")
}
override fun listRemote() {
// calculate time range limits
var limitStart: Date? = null
settings.getTimeRangePastDays()?.let { pastDays ->
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_MONTH, -pastDays)
limitStart = calendar.time
}
// fetch list of remote VEVENTs and build hash table to index file name
val calendar = davCalendar()
currentDavResource = calendar
calendar.calendarQuery("VEVENT", limitStart, null)
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
for (iCal in davCollection.members) {
val fileName = iCal.fileName()
Logger.log.fine("Found remote VEVENT: $fileName")
remoteResources[fileName] = iCal
}
currentDavResource = null
}
override fun downloadRemote() {
Logger.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
// download new/updated iCalendars from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
val body = remote.get("text/calendar")
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = remote.properties[GetETag.NAME] as GetETag?
if (eTag == null || eTag.eTag.isNullOrEmpty())
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
body.charStream()?.use { reader ->
processVEvent(remote.fileName(), eTag.eTag!!, reader)
}
} else {
// multiple contacts, use multi-get
val calendar = davCalendar()
currentDavResource = calendar
calendar.multiget(bunch.map { it.location })
// process multiget results
for (remote in davCollection.members) {
currentDavResource = remote
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without event data")
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
}
}
currentDavResource = null
}
}
// helpers
private fun davCalendar() = davCollection as DavCalendar
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
val events: List<Event>
try {
events = Event.fromReader(reader)
} catch (e: InvalidCalendarException) {
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
return
}
if (events.size == 1) {
val newData = events.first()
// delete local event, if it exists
val localEvent = localResources[fileName] as LocalEvent?
currentLocalResource = localEvent
if (localEvent != null) {
Logger.log.info("Updating $fileName in local calendar")
localEvent.eTag = eTag
localEvent.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding $fileName to local calendar")
val newEvent = LocalEvent(localCalendar, newData, fileName, eTag)
currentLocalResource = newEvent
newEvent.add()
syncResult.stats.numInserts++
}
} else
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
currentLocalResource = null
}
}
@@ -1,149 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.provider.CalendarContract;
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.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.Services;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.ical4android.CalendarStorageException;
import lombok.Cleanup;
public class CalendarsSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new SyncAdapter(this);
}
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
public SyncAdapter(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);
try {
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
updateLocalCalendars(provider, account, settings);
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, settings, extras, authority, syncResult, calendar);
syncManager.performSync();
}
} catch(CalendarStorageException|SQLiteException e) {
App.log.log(Level.SEVERE, "Couldn't prepare 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, AccountSettings settings) throws CalendarStorageException {
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
// enumerate remote and local calendars
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
Map<String, CollectionInfo> remote = remoteCalendars(db, service);
LocalCalendar[] local = (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null);
boolean updateColors = settings.getManageCalendarColors();
// 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, updateColors);
// 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);
}
} finally {
dbHelper.close();
}
}
@Nullable
Long getService(@NonNull SQLiteDatabase db, @NonNull 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(@NonNull SQLiteDatabase db, 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;
}
}
}
@@ -0,0 +1,127 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.database.DatabaseUtils
import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.AndroidCalendar
import java.util.logging.Level
class CalendarsSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = SyncAdapter(this)
protected class SyncAdapter(
context: Context
): SyncAdapterService.SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, settings, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
if (accountSettings.getEventColors())
AndroidCalendar.insertColors(provider, account)
else
AndroidCalendar.removeColors(provider, account)
updateLocalCalendars(provider, account, accountSettings)
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, calendar).use {
it.performSync()
}
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e)
}
Logger.log.info("Calendar sync complete")
}
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteCalendars(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
arrayOf(service.toString()), null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
}
// enumerate remote and local calendars
val service = getService()
val remote = remoteCalendars(service)
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let { url ->
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local calendars
for ((_, info) in remote) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
}
}
}
}
@@ -1,115 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.support.annotation.NonNull;
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.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class ContactsSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new ContactsSyncAdapter(this);
}
private static class ContactsSyncAdapter extends SyncAdapter {
public 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);
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
if (service != null) {
CollectionInfo remote = remoteAddressBook(db, service);
if (remote != null)
try {
ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, 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, deleting local contacts");
LocalAddressBook localAddressBook = new LocalAddressBook(account, provider);
try {
localAddressBook.deleteAll();
} catch(ContactsStorageException ignored) {
}
}
} else
App.log.info("No CardDAV service found in DB");
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
} finally {
dbHelper.close();
}
App.log.info("Address book sync complete");
}
@Nullable
private Long getService(@NonNull SQLiteDatabase db, @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(@NonNull SQLiteDatabase db, 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;
}
}
}
@@ -0,0 +1,75 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.ISettings
import java.util.logging.Level
class ContactsSyncAdapterService: SyncAdapterService() {
companion object {
val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
override fun syncAdapter() = ContactsSyncAdapter(this)
protected class ContactsSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val addressBook = LocalAddressBook(context, account, provider)
val accountSettings = AccountSettings(context, settings, addressBook.getMainAccount())
// handle group method change
val groupMethod = accountSettings.getGroupMethod().name
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
if (previousGroupMethod != groupMethod) {
Logger.log.info("Group method changed, deleting all local contacts/groups")
// delete all local contacts and groups so that they will be downloaded again
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
// reset CTag
addressBook.setCTag(null)
}
}
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
return
Logger.log.info("Synchronizing address book: ${addressBook.getURL()}")
Logger.log.info("Taking settings from: ${addressBook.getMainAccount()}")
ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
it.performSync()
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
}
Logger.log.info("Contacts sync complete")
}
}
}
@@ -1,553 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.os.Build;
import android.os.Bundle;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Groups;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import org.apache.commons.codec.Charsets;
import org.apache.commons.collections4.SetUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
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.Set;
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.ResourceType;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.BuildConfig;
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.BatchOperation;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import ezvcard.VCardVersion;
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;
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remoted contacts.</li>
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
public class ContactsSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 10;
final private ContentProviderClient provider;
final private CollectionInfo remote;
private boolean hasVCard4;
private GroupMethod groupMethod;
public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, CollectionInfo remote) throws InvalidAccountException {
super(context, account, settings, extras, authority, result, "addressBook");
this.provider = provider;
this.remote = remote;
}
@Override
protected int notificationId() {
return Constants.NOTIFICATION_CONTACTS_SYNC;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_contacts, account.name);
}
@Override
protected boolean prepare() throws ContactsStorageException {
// prepare local address book
localCollection = new LocalAddressBook(account, provider);
LocalAddressBook localAddressBook = localAddressBook();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
int reallyDirty = localAddressBook.verifyDirty(),
deleted = localAddressBook.getDeleted().length;
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
App.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed");
return false;
}
}
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.deleteAll();
localAddressBook.setURL(remote.url);
}
// set up Contacts Provider Settings
ContentValues values = new ContentValues(2);
values.put(ContactsContract.Settings.SHOULD_SYNC, 1);
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
localAddressBook.updateSettings(values);
collectionURL = HttpUrl.parse(url);
davCollection = new DavAddressBook(httpClient, collectionURL);
return true;
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
// prepare remote address book
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData)davCollection.properties.get(SupportedAddressData.NAME);
hasVCard4 = supportedAddressData != null && supportedAddressData.hasVCard4();
App.log.info("Server advertises VCard/4 support: " + hasVCard4);
groupMethod = settings.getGroupMethod();
App.log.info("Contact group method: " + groupMethod);
localAddressBook().includeGroups = groupMethod == GroupMethod.GROUP_VCARDS;
}
@Override
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
super.prepareDirty();
LocalAddressBook addressBook = localAddressBook();
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
// groups with DELETED=1: set all members to dirty, then remove group
for (LocalGroup group : addressBook.getDeletedGroups()) {
App.log.fine("Finally removing group " + group);
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty();
group.delete();
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (LocalGroup group : addressBook.getDirtyGroups()) {
App.log.fine("Marking members of modified group " + group + " as dirty");
group.markMembersDirty();
group.clearDirty(null);
}
} else {
/* groups as separate VCards: there are group contacts and individual contacts */
// mark groups with changed members as dirty
BatchOperation batch = new BatchOperation(addressBook.provider);
for (LocalContact contact : addressBook.getDirtyContacts())
try {
App.log.fine("Looking for changed group memberships of contact " + contact.getFileName());
Set<Long> cachedGroups = contact.getCachedGroupMemberships(),
currentGroups = contact.getGroupMemberships();
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
App.log.fine("Marking group as dirty: " + groupID);
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
));
}
} catch(FileNotFoundException ignored) {
}
batch.commit();
}
}
@Override
protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException {
final Contact contact;
if (resource instanceof LocalContact) {
LocalContact local = ((LocalContact)resource);
contact = local.getContact();
if (groupMethod == GroupMethod.CATEGORIES) {
// add groups as CATEGORIES
for (long groupID : local.getGroupMemberships()) {
try {
@Cleanup Cursor c = provider.query(
localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
new String[] { Groups.TITLE },
null, null,
null
);
if (c != null && c.moveToNext()) {
String title = c.getString(0);
if (!TextUtils.isEmpty(title))
contact.categories.add(title);
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e);
}
}
}
} else if (resource instanceof LocalGroup)
contact = ((LocalGroup)resource).getContact();
else
throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup");
App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact);
ByteArrayOutputStream os = new ByteArrayOutputStream();
contact.write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, groupMethod, os);
return RequestBody.create(
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
final DavAddressBook addressBook = davAddressBook();
currentDavResource = addressBook;
// fetch list of remote VCards and build hash table to index file name
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME);
remoteResources = new HashMap<>(davCollection.members.size());
for (DavResource vCard : davCollection.members) {
// ignore member collections
ResourceType type = (ResourceType)vCard.properties.get(ResourceType.NAME);
if (type != null && type.types.contains(ResourceType.COLLECTION))
continue;
String fileName = vCard.fileName();
App.log.fine("Found remote VCard: " + fileName);
remoteResources.put(fileName, vCard);
}
currentDavResource = null;
}
@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
final DavResource remote = bunch[0];
currentDavResource = remote;
ResponseBody body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5");
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME);
if (eTag == null || StringUtils.isEmpty(eTag.eTag))
throw new DavException("Received CardDAV GET response without ETag for " + remote.location);
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.eTag, stream, charset, downloader);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
final DavAddressBook addressBook = davAddressBook();
currentDavResource = addressBook;
addressBook.multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
// process multi-get results
for (final DavResource remote : davCollection.members) {
currentDavResource = remote;
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);
}
}
currentDavResource = null;
}
}
@Override
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
if (groupMethod == GroupMethod.CATEGORIES) {
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
// remove empty groups
App.log.info("Removing empty groups");
localAddressBook().removeEmptyGroups();
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
App.log.info("Assigning memberships of downloaded contact groups");
LocalGroup.applyPendingMemberships(localAddressBook());
}
}
// helpers
private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; }
private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; }
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
App.log.info("Processing CardDAV resource " + fileName);
Contact[] contacts = Contact.fromStream(stream, charset, downloader);
if (contacts.length == 0) {
App.log.warning("Received VCard without data, ignoring");
return;
} else if (contacts.length > 1)
App.log.warning("Received multiple VCards, using first one");
final Contact newData = contacts[0];
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
groupMethod = GroupMethod.GROUP_VCARDS;
App.log.warning("Received group VCard although group method is CATEGORIES. Deleting all groups; new group method: " + groupMethod);
localAddressBook().removeGroups();
settings.setGroupMethod(groupMethod);
}
// update local contact, if it exists
LocalResource local = localResources.get(fileName);
currentLocalResource = local;
if (local != null) {
App.log.log(Level.INFO, "Updating " + fileName + " in local address book", newData);
if (local instanceof LocalGroup && newData.group) {
// update group
LocalGroup group = (LocalGroup)local;
group.eTag = eTag;
group.updateFromServer(newData);
syncResult.stats.numUpdates++;
} else if (local instanceof LocalContact && !newData.group) {
// update contact
LocalContact contact = (LocalContact)local;
contact.eTag = eTag;
contact.update(newData);
syncResult.stats.numUpdates++;
} else {
// group has become an individual contact or vice versa
try {
local.delete();
local = null;
} catch(CalendarStorageException e) {
// CalendarStorageException is not used by LocalGroup and LocalContact
}
}
}
if (local == null) {
if (newData.group) {
App.log.log(Level.INFO, "Creating local group", newData);
LocalGroup group = new LocalGroup(localAddressBook(), newData, fileName, eTag);
currentLocalResource = group;
group.create();
local = group;
} else {
App.log.log(Level.INFO, "Creating local contact", newData);
LocalContact contact = new LocalContact(localAddressBook(), newData, fileName, eTag);
currentLocalResource = contact;
contact.create();
local = contact;
}
syncResult.stats.numInserts++;
}
if (groupMethod == GroupMethod.CATEGORIES && local instanceof LocalContact) {
// VCard3: update group memberships from CATEGORIES
LocalContact contact = (LocalContact)local;
currentLocalResource = contact;
BatchOperation batch = new BatchOperation(provider);
App.log.log(Level.FINE, "Removing contact group memberships");
contact.removeGroupMemberships(batch);
for (String category : contact.getContact().categories) {
long groupID = localAddressBook().findOrCreateGroup(category);
App.log.log(Level.FINE, "Adding membership in group " + category + " (" + groupID + ")");
contact.addToGroup(batch, groupID);
}
batch.commit();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && local instanceof LocalContact)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
((LocalContact)local).updateHashCode(null);
currentLocalResource = null;
}
// 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(context);
// 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;
}
}
}
@@ -0,0 +1,515 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.os.Build
import android.os.Bundle
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.Groups
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.dav4android.DavAddressBook
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.*
import at.bitfire.davdroid.log.Logger
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.vcard4android.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import ezvcard.VCardVersion
import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.*
import java.util.*
import java.util.logging.Level
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remote contacts.</li>
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
class ContactsSyncManager(
context: Context,
account: Account,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
private val localAddressBook: LocalAddressBook
): SyncManager(context, account, settings, extras, authority, syncResult, "addressBook") {
companion object {
private val MAX_MULTIGET = 10
}
private val readOnly = localAddressBook.getReadOnly()
private var numDiscarded = 0
private var hasVCard4 = false
private val groupMethod = settings.getGroupMethod()
init {
localCollection = localAddressBook
}
override fun notificationId() = Constants.NOTIFICATION_CONTACTS_SYNC
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_contacts, account.name)!!
override fun prepare(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localAddressBook.verifyDirty()
val deleted = localAddressBook.getDeleted().size
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
return false
}
}
// set up Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
localAddressBook.updateSettings(values)
collectionURL = HttpUrl.parse(localAddressBook.getURL()) ?: return false
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
return true
}
override fun queryCapabilities() {
// prepare remote address book
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME)
(davCollection.properties[SupportedAddressData.NAME] as SupportedAddressData?)?.let {
hasVCard4 = it.hasVCard4()
}
Logger.log.info("Server advertises VCard/4 support: $hasVCard4")
Logger.log.info("Contact group method: $groupMethod")
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
}
override fun processLocallyDeleted() {
if (readOnly) {
for (group in localAddressBook.getDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
group.resetDeleted()
numDiscarded++
}
for (contact in localAddressBook.getDeletedContacts()) {
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
contact.resetDeleted()
numDiscarded++
}
if (numDiscarded > 0)
notifyDiscardedChange()
} else
// mirror deletions to remote collection (DELETE)
super.processLocallyDeleted()
}
override fun prepareDirty() {
// generate UID/file name for newly created contacts
super.prepareDirty()
if (readOnly) {
for (group in localAddressBook.getDirtyGroups()) {
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
group.clearDirty(null)
numDiscarded++
}
for (contact in localAddressBook.getDirtyContacts()) {
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
contact.clearDirty(null)
numDiscarded++
}
if (numDiscarded > 0)
notifyDiscardedChange()
} else {
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
// groups with DELETED=1: set all members to dirty, then remove group
for (group in localAddressBook.getDeletedGroups()) {
Logger.log.fine("Finally removing group $group")
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty()
group.delete()
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (group in localAddressBook.getDirtyGroups()) {
Logger.log.fine("Marking members of modified group $group as dirty")
group.markMembersDirty()
group.clearDirty(null)
}
} else {
/* groups as separate VCards: there are group contacts and individual contacts */
// mark groups with changed members as dirty
val batch = BatchOperation(localAddressBook.provider!!)
for (contact in localAddressBook.getDirtyContacts())
try {
Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}")
val cachedGroups = contact.getCachedGroupMemberships()
val currentGroups = contact.getGroupMemberships()
for (groupID in cachedGroups disjunct currentGroups) {
Logger.log.fine("Marking group as dirty: $groupID")
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
))
}
} catch(e: FileNotFoundException) {
}
batch.commit()
}
}
}
private fun notifyDiscardedChange() {
val notification = NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_delete_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(context.getString(R.string.sync_contacts_read_only_address_book))
.setContentText(context.resources.getQuantityString(R.plurals.sync_contacts_local_contact_changes_discarded, numDiscarded, numDiscarded))
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setLocalOnly(true)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify("discarded_${account.name}", 0, notification)
}
override fun prepareUpload(resource: LocalResource): RequestBody {
val contact: Contact
if (resource is LocalContact) {
contact = resource.contact!!
if (groupMethod == GroupMethod.CATEGORIES) {
// add groups as CATEGORIES
for (groupID in resource.getGroupMemberships()) {
try {
provider.query(
localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
arrayOf(Groups.TITLE), null, null, null
)?.use { cursor ->
if (cursor.moveToNext()) {
val title = cursor.getString(0)
if (!title.isNullOrEmpty())
contact.categories.add(title)
}
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't find group for adding CATEGORIES", e)
}
}
}
} else if (resource is LocalGroup)
contact = resource.contact!!
else
throw IllegalArgumentException("resource must be a LocalContact or a LocalGroup")
Logger.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact)
val os = ByteArrayOutputStream()
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
return RequestBody.create(
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
)
}
override fun listRemote() {
val addressBook = davAddressBook()
currentDavResource = addressBook
// fetch list of remote VCards and build hash table to index file name
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
for (vCard in davCollection.members) {
// ignore member collections
val type = vCard.properties[ResourceType.NAME] as ResourceType?
if (type != null && type.types.contains(ResourceType.COLLECTION))
continue
val fileName = vCard.fileName()
Logger.log.fine("Found remote VCard: $fileName")
remoteResources[fileName] = vCard
}
currentDavResource = null
}
override fun downloadRemote() {
Logger.log.info("Downloading ${toDownload.size} contacts ($MAX_MULTIGET at once)")
// prepare downloader which may be used to download external resource like contact photos
val downloader = ResourceDownloader(collectionURL)
// download new/updated VCards from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = remote.properties[GetETag.NAME] as GetETag?
if (eTag == null || eTag.eTag.isNullOrEmpty())
throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVCard(remote.fileName(), eTag.eTag!!, reader, downloader)
}
} else {
// multiple contacts, use multi-get
val addressBook = davAddressBook()
currentDavResource = addressBook
addressBook.multiget(bunch.map { it.location }, hasVCard4)
// process multi-get results
for (remote in davCollection.members) {
currentDavResource = remote
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val addressData = remote.properties[AddressData.NAME] as AddressData?
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
}
}
currentDavResource = null
}
}
override fun postProcess() {
if (groupMethod == GroupMethod.CATEGORIES) {
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
// remove empty groups
Logger.log.info("Removing empty groups")
localAddressBook.removeEmptyGroups()
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
Logger.log.info("Assigning memberships of downloaded contact groups")
LocalGroup.applyPendingMemberships(localAddressBook)
}
}
// helpers
private fun davAddressBook() = davCollection as DavAddressBook
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
Logger.log.info("Processing CardDAV resource $fileName")
val contacts = Contact.fromReader(reader, downloader)
if (contacts.isEmpty()) {
Logger.log.warning("Received VCard without data, ignoring")
return
} else if (contacts.size > 1)
Logger.log.warning("Received multiple VCards, using first one")
val newData = contacts.first()
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
Logger.log.warning("Received group VCard although group method is CATEGORIES. Saving as regular contact")
newData.group = false
}
// update local contact, if it exists
var local = localResources[fileName]
currentLocalResource = local
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
if (local is LocalGroup && newData.group) {
// update group
local.eTag = eTag
local.updateFromServer(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
// group has become an individual contact or vice versa
local.delete()
local = null
}
}
if (local == null) {
if (newData.group) {
Logger.log.log(Level.INFO, "Creating local group", newData)
val group = LocalGroup(localAddressBook, newData, fileName, eTag)
currentLocalResource = group
group.create()
local = group
} else {
Logger.log.log(Level.INFO, "Creating local contact", newData)
val contact = LocalContact(localAddressBook, newData, fileName, eTag)
currentLocalResource = contact
contact.create()
local = contact
}
syncResult.stats.numInserts++
}
if (groupMethod == GroupMethod.CATEGORIES && local is LocalContact) {
// VCard3: update group memberships from CATEGORIES
currentLocalResource = local
val batch = BatchOperation(provider)
Logger.log.log(Level.FINE, "Removing contact group memberships")
local.removeGroupMemberships(batch)
for (category in local.contact!!.categories) {
val groupID = localAddressBook.findOrCreateGroup(category)
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
local.addToGroup(batch, groupID)
}
batch.commit()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O && local is LocalContact)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
local.updateHashCode(null)
currentLocalResource = null
}
// downloader helper class
private inner class ResourceDownloader(
val baseUrl: HttpUrl
): Contact.Downloader {
override fun download(url: String, accepts: String): ByteArray? {
val httpUrl = HttpUrl.parse(url)
if (httpUrl == null) {
Logger.log.log(Level.SEVERE, "Invalid external resource URL", url)
return null
}
val host = httpUrl.host()
if (host == null) {
Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url)
return null
}
// authenticate only against a certain host, and only upon request
val username = settings.username()
val password = settings.password()
val builder = if (username != null && password != null)
HttpClient.Builder(context, baseUrl.host(), username, password)
else
HttpClient.Builder(context)
// allow redirects
builder.followRedirects(true)
val client = builder.build()
try {
val response = client.okHttpClient.newCall(Request.Builder()
.get()
.url(httpUrl)
.build()).execute()
if (response.isSuccessful)
return response.body()?.bytes()
else
Logger.log.warning("Couldn't download external resource")
} catch(e: IOException) {
Logger.log.log(Level.SEVERE, "Couldn't download external resource", e)
} finally {
client.close()
}
return null
}
}
}
@@ -0,0 +1,54 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import at.bitfire.davdroid.ui.AccountsActivity
class NullAuthenticatorService: Service() {
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountAuthenticator = AccountAuthenticator(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
val intent = Intent(context, AccountsActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle(1)
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}
@@ -1,115 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Notification;
import android.app.PendingIntent;
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.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Bundle;
import android.os.IBinder;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.ui.PermissionsActivity;
//import com.android.vending.billing.IInAppBillingService;
public abstract class SyncAdapterService extends Service {
abstract protected AbstractThreadedSyncAdapter syncAdapter();
@Nullable
@Override
public IBinder onBind(Intent intent) {
return syncAdapter().getSyncAdapterBinder();
}
public static abstract class SyncAdapter extends AbstractThreadedSyncAdapter {
public SyncAdapter(Context context) {
super(context, false);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
App.log.log(Level.INFO, "Sync for " + authority + " has been initiated.", extras.keySet().toArray());
// required for dav4android (ServiceLoader)
Thread.currentThread().setContextClassLoader(getContext().getClassLoader());
}
@Override
public void onSecurityException(Account account, Bundle extras, String authority, SyncResult syncResult) {
App.log.log(Level.WARNING, "Security exception when opening content provider for " + authority);
syncResult.databaseError = true;
Intent intent = new Intent(getContext(), PermissionsActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Notification notify = new NotificationCompat.Builder(getContext())
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(getContext()))
.setContentTitle(getContext().getString(R.string.sync_error_permissions))
.setContentText(getContext().getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build();
NotificationManagerCompat nm = NotificationManagerCompat.from(getContext());
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify);
}
protected boolean checkSyncConditions(@NonNull AccountSettings settings) {
if (settings.getSyncWifiOnly()) {
ConnectivityManager cm = (ConnectivityManager)getContext().getSystemService(CONNECTIVITY_SERVICE);
NetworkInfo network = cm.getActiveNetworkInfo();
if (network == null) {
App.log.info("No network available, stopping");
return false;
}
if (network.getType() != ConnectivityManager.TYPE_WIFI || !network.isConnected()) {
App.log.info("Not on connected WiFi, stopping");
return false;
}
String onlySSID = settings.getSyncWifiOnlySSID();
if (onlySSID != null) {
onlySSID = "\"" + onlySSID + "\"";
WifiManager wifi = (WifiManager)getContext().getApplicationContext().getSystemService(WIFI_SERVICE);
WifiInfo info = wifi.getConnectionInfo();
if (info == null || !onlySSID.equals(info.getSSID())) {
App.log.info("Connected to wrong WiFi network (" + info.getSSID() + ", required: " + onlySSID + "), ignoring");
return false;
}
}
}
return true;
}
}
}
@@ -0,0 +1,120 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.PendingIntent
import android.app.Service
import android.content.*
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.PermissionsActivity
import java.util.*
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
companion object {
val runningSyncs = Collections.synchronizedSet(mutableSetOf<Pair<String, Account>>())
}
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
abstract class SyncAdapter(
context: Context
): AbstractThreadedSyncAdapter(context, false) {
abstract fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
// prevent multiple syncs of the same authority to be run for the same account
val currentSync = Pair(authority, account)
if (!runningSyncs.add(currentSync)) {
Logger.log.warning("There's already another $authority sync running for $account, aborting")
return
}
try {
// required for dav4android (ServiceLoader)
Thread.currentThread().contextClassLoader = context.classLoader
// load app settings
Settings.getInstance(context).use { settings ->
if (settings == null) {
syncResult.databaseError = true
Logger.log.severe("Couldn't connect to Settings service, aborting sync")
return
}
sync(settings, account, extras, authority, provider, syncResult)
}
Logger.log.info("Sync for $authority complete")
} finally {
runningSyncs -= currentSync
}
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
syncResult.databaseError = true
val intent = Intent(context, PermissionsActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val notify = NotificationCompat.Builder(context)
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(context.getString(R.string.sync_error_permissions))
.setContentText(context.getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
val nm = NotificationManagerCompat.from(context)
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify)
}
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
if (settings.getSyncWifiOnly()) {
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetworkInfo
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
Logger.log.info("Not on connected WiFi, stopping")
return false
}
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val info = wifi.connectionInfo
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
Logger.log.info("Connected to wrong WiFi network (${info.ssid}), ignoring")
return false
}
}
}
return true
}
}
}
@@ -1,484 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.annotation.TargetApi;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
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.LocalResource;
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;
abstract public class SyncManager {
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_POST_PROCESSING = 10,
SYNC_PHASE_SAVE_SYNC_STATE = 11;
protected final NotificationManagerCompat notificationManager;
protected final String uniqueCollectionId;
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;
/** state information for debug info (local resource) */
protected LocalResource currentLocalResource;
/** state information for debug info (remote resource) */
protected DavResource currentDavResource;
/** 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(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult syncResult, String uniqueCollectionId) throws InvalidAccountException {
this.context = context;
this.account = account;
this.settings = settings;
this.extras = extras;
this.authority = authority;
this.syncResult = syncResult;
// create HttpClient with given logger
httpClient = HttpClient.create(context, account);
// dismiss previous error notifications
this.uniqueCollectionId = uniqueCollectionId;
notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(uniqueCollectionId, notificationId());
}
protected abstract int notificationId();
protected abstract String getSyncErrorTitle();
@TargetApi(21)
public void performSync() {
int syncPhase = SYNC_PHASE_PREPARE;
try {
App.log.info("Preparing synchronization");
if (!prepare()) {
App.log.info("No reason to synchronize, aborting");
return;
}
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_POST_PROCESSING;
App.log.info("Post-processing");
postProcess();
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_THROWABLE, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
if (currentLocalResource != null)
detailsIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, currentLocalResource.toString());
if (currentDavResource != null)
detailsIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, currentDavResource.toString());
}
// to make the PendingIntent unique
detailsIntent.setData(Uri.parse("uri://" + getClass().getName() + "/" + uniqueCollectionId));
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
builder .setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR);
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(uniqueCollectionId, notificationId(), builder.build());
}
}
/** Prepares synchronization (for instance, allocates necessary resources).
* @return whether actual synchronization is required / can be made. true = synchronization
* shall be continued, false = synchronization can be skipped */
abstract protected boolean 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 (final LocalResource local : localList) {
if (Thread.interrupted())
return;
currentLocalResource = local;
final String fileName = local.getFileName();
if (!TextUtils.isEmpty(fileName)) {
App.log.info(fileName + " has been deleted locally -> deleting from server");
final DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
currentDavResource = remote;
try {
remote.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++;
currentLocalResource = null;
currentDavResource = null;
}
}
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
App.log.info("Looking for contacts/groups without file name");
for (final LocalResource local : localCollection.getWithoutFileName()) {
currentLocalResource = local;
App.log.fine("Found local record #" + local.getId() + " without file name; generating file name/UID if necessary");
local.prepareForUpload();
currentLocalResource = null;
}
}
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 (final LocalResource local : localCollection.getDirty()) {
if (Thread.interrupted())
return;
currentLocalResource = local;
final String fileName = local.getFileName();
final DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
currentDavResource = remote;
// 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);
currentLocalResource = null;
currentDavResource = null;
}
}
/**
* 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()) {
final DavResource remote = remoteResources.get(localName);
currentDavResource = remote;
if (remote == null) {
App.log.info(localName + " is not on server anymore, deleting");
final LocalResource local = localResources.get(localName);
currentLocalResource = local;
local.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);
currentDavResource = null;
currentLocalResource = null;
}
}
// 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;
/**
* For post-processing of entries, for instance assigning groups.
*/
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
}
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);
}
}
@@ -0,0 +1,468 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.net.Uri
import android.os.Bundle
import android.support.v4.app.NotificationManagerCompat
import android.support.v7.app.NotificationCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.*
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.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalResource
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.RequestBody
import java.io.Closeable
import java.io.IOException
import java.util.*
import java.util.logging.Level
abstract class SyncManager(
val context: Context,
val account: Account,
val settings: AccountSettings,
val extras: Bundle,
val authority: String,
val syncResult: SyncResult,
val uniqueCollectionId: String
): Closeable {
val SYNC_PHASE_PREPARE = 0
val SYNC_PHASE_QUERY_CAPABILITIES = 1
val SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2
val SYNC_PHASE_PREPARE_DIRTY = 3
val SYNC_PHASE_UPLOAD_DIRTY = 4
val SYNC_PHASE_CHECK_SYNC_STATE = 5
val SYNC_PHASE_LIST_LOCAL = 6
val SYNC_PHASE_LIST_REMOTE = 7
val SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8
val SYNC_PHASE_DOWNLOAD_REMOTE = 9
val SYNC_PHASE_POST_PROCESSING = 10
val SYNC_PHASE_SAVE_SYNC_STATE = 11
protected val notificationManager = NotificationManagerCompat.from(context)!!
protected lateinit var localCollection: LocalCollection<*>
protected val httpClient = HttpClient.Builder(context, accountSettings = settings).build()
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: DavResource
/** state information for debug info (local resource) */
protected var currentLocalResource: LocalResource? = null
/** state information for debug info (remote resource) */
protected var currentDavResource: DavResource? = null
/** remote CTag at the time of {@link #listRemote()} */
protected var remoteCTag: String? = null
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
protected lateinit var localResources: MutableMap<String, LocalResource>
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
protected lateinit var remoteResources: MutableMap<String, DavResource>
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
protected val toDownload = mutableSetOf<DavResource>()
companion object {
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
protected abstract fun notificationId(): Int
protected abstract fun getSyncErrorTitle(): String
fun performSync() {
// dismiss previous error notifications
notificationManager.cancel(uniqueCollectionId, notificationId())
var syncPhase = SYNC_PHASE_PREPARE
try {
Logger.log.info("Preparing synchronization")
if (!prepare()) {
Logger.log.info("No reason to synchronize, aborting")
return
}
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES
Logger.log.info("Querying capabilities")
queryCapabilities()
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED
Logger.log.info("Processing locally deleted entries")
processLocallyDeleted()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_PREPARE_DIRTY
Logger.log.info("Locally preparing dirty entries")
prepareDirty()
syncPhase = SYNC_PHASE_UPLOAD_DIRTY
Logger.log.info("Uploading dirty entries")
uploadDirty()
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE
Logger.log.info("Checking sync state")
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL
Logger.log.info("Listing local resources")
listLocal()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_LIST_REMOTE
Logger.log.info("Listing remote resources")
listRemote()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE
Logger.log.info("Comparing local/remote entries")
compareLocalRemote()
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE
Logger.log.info("Downloading remote entries")
downloadRemote()
syncPhase = SYNC_PHASE_POST_PROCESSING
Logger.log.info("Post-processing")
postProcess()
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE
Logger.log.info("Saving sync state")
saveSyncState()
} else
Logger.log.info("Remote collection didn't change, skipping remote sync")
} catch(e: IOException) {
Logger.log.log(Level.WARNING, "I/O exception during sync, trying again later", e)
syncResult.stats.numIoExceptions++
} catch(e: ServiceUnavailableException) {
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
syncResult.stats.numIoExceptions++
e.retryAfter?.let { retryAfter ->
// how many seconds to wait? getTime() returns ms, so divide by 1000
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
}
} catch(e: Throwable) {
val messageString: Int
when (e) {
is UnauthorizedException -> {
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
messageString = R.string.sync_error_unauthorized
syncResult.stats.numAuthExceptions++
}
is HttpException, is DavException -> {
Logger.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e)
messageString = R.string.sync_error_http_dav
syncResult.stats.numParseExceptions++
}
is CalendarStorageException, is ContactsStorageException -> {
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
messageString = R.string.sync_error_local_storage
syncResult.databaseError = true
}
else -> {
Logger.log.log(Level.SEVERE, "Unknown sync error", e)
messageString = R.string.sync_error
syncResult.stats.numParseExceptions++
}
}
val detailsIntent: Intent
if (e is UnauthorizedException) {
detailsIntent = Intent(context, AccountSettingsActivity::class.java)
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
} else {
detailsIntent = Intent(context, DebugInfoActivity::class.java)
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
currentLocalResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, it.toString()) }
currentDavResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, it.toString()) }
}
// to make the PendingIntent unique
detailsIntent.data = Uri.parse("uri://${javaClass.name}/$uniqueCollectionId")
val builder = NotificationCompat.Builder(context)
builder .setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
try {
val phases = context.resources.getStringArray(R.array.sync_error_phases)
val message = context.getString(messageString, phases[syncPhase])
builder.setContentText(message)
} catch (ex: IndexOutOfBoundsException) {
// should never happen
}
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build())
}
}
override fun close() {
httpClient.close()
}
/** Prepares synchronization (for instance, allocates necessary resources).
* @return whether actual synchronization is required / can be made. true = synchronization
* shall be continued, false = synchronization can be skipped */
abstract protected fun prepare(): Boolean
abstract protected fun queryCapabilities()
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected open fun processLocallyDeleted() {
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
// but only if they don't have changed on the server. Then finally remove them from the local address book.
val localList = localCollection.getDeleted()
for (local in localList) {
if (Thread.interrupted())
return
currentLocalResource = local
val fileName = local.fileName
if (fileName != null) {
Logger.log.info("$fileName has been deleted locally -> deleting from server")
val remote = DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
currentDavResource = remote
try {
remote.delete(local.eTag)
} catch (e: HttpException) {
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
}
} else
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
syncResult.stats.numDeletes++
currentLocalResource = null
currentDavResource = null
}
}
protected open fun prepareDirty() {
// assign file names and UIDs to new contacts so that we can use the file name as an index
Logger.log.info("Looking for contacts/groups without file name")
for (local in localCollection.getWithoutFileName()) {
currentLocalResource = local
Logger.log.fine("Found local record #${local.id} without file name; generating file name/UID if necessary")
local.prepareForUpload()
currentLocalResource = null
}
}
abstract protected fun prepareUpload(resource: LocalResource): RequestBody
/**
* 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 fun uploadDirty() {
// upload dirty contacts
for (local in localCollection.getDirty()) {
if (Thread.interrupted())
return
currentLocalResource = local
val fileName = local.fileName
val remote = DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
currentDavResource = remote
// generate entity to upload (VCard, iCal, whatever)
val body = prepareUpload(local)
try {
if (local.eTag == null) {
Logger.log.info("Uploading new record $fileName")
remote.put(body, null, true)
} else {
Logger.log.info("Uploading locally modified record $fileName")
remote.put(body, local.eTag, false)
}
} catch(e: ConflictException) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
} catch(e: PreconditionFailedException) {
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
}
val newETag = remote.properties[GetETag.NAME] as GetETag?
val eTag: String?
if (newETag != null) {
eTag = newETag.eTag
Logger.log.fine("Received new ETag=$eTag after uploading")
} else {
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
eTag = null
}
local.clearDirty(eTag)
currentLocalResource = null
currentDavResource = null
}
}
/**
* 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 fun checkSyncState(): Boolean {
// check CTag (ignore on manual sync)
(davCollection.properties[GetCTag.NAME] as GetCTag?)?.let { remoteCTag = it.cTag }
val localCTag = if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
Logger.log.info("Manual sync, ignoring CTag")
null
} else
localCollection.getCTag()
return if (remoteCTag != null && remoteCTag == localCTag) {
Logger.log.info("Remote collection didn't change (CTag=$remoteCTag), no need to query children")
false
} else
true
}
/**
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
*/
protected fun listLocal() {
// fetch list of local contacts and build hash table to index file name
val localList = localCollection.getAll()
val resources = HashMap<String, LocalResource>(localList.size)
for (resource in localList) {
Logger.log.fine("Found local resource: ${resource.fileName}")
resource.fileName?.let { resources[it] = resource }
}
localResources = resources
Logger.log.info("Found ${localResources.size} local resources")
}
/**
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
*/
abstract protected fun listRemote()
/**
* 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 fun compareLocalRemote() {
/* 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.clear()
for ((name,local) in localResources) {
val remote = remoteResources[name]
currentDavResource = remote
if (remote == null) {
Logger.log.info("$name is not on server anymore, deleting")
currentLocalResource = local
local.delete()
syncResult.stats.numDeletes++
} else {
// contact is still on server, check whether it has been updated remotely
val getETag = remote.properties[GetETag.NAME] as GetETag?
if (getETag == null || getETag.eTag == null)
throw DavException("Server didn't provide ETag")
val localETag = local.eTag
val remoteETag = getETag.eTag
if (remoteETag == localETag) {
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
syncResult.stats.numSkippedEntries++
} else {
Logger.log.info("$name 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(name)
currentDavResource = null
currentLocalResource = null
}
}
// add all unseen (= remotely added) remote contacts
if (remoteResources.isNotEmpty()) {
Logger.log.info("New resources have been found on the server: ${remoteResources.keys.joinToString(", ")}")
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 fun downloadRemote()
/**
* For post-processing of entries, for instance assigning groups.
*/
protected open fun postProcess() {}
protected fun saveSyncState() {
/* 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. */
Logger.log.info("Saving CTag=$remoteCTag")
localCollection.setCTag(remoteCTag)
}
}
@@ -1,155 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.dmfs.provider.tasks.TaskContract;
import java.util.LinkedHashMap;
import java.util.Map;
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.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
public class TasksSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new SyncAdapter(this);
}
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
public SyncAdapter(Context context) {
super(context);
}
@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");
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
updateLocalTaskLists(provider, account, settings);
for (LocalTaskList taskList : (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, TaskContract.TaskLists.SYNC_ENABLED + "!=0", null)) {
App.log.info("Synchronizing task list #" + taskList.getId() + " [" + taskList.getSyncId() + "]");
TasksSyncManager syncManager = new TasksSyncManager(getContext(), account, settings, 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);
}
App.log.info("Task sync complete");
}
private void updateLocalTaskLists(TaskProvider provider, Account account, AccountSettings settings) throws CalendarStorageException {
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
// enumerate remote and local task lists
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
Map<String, CollectionInfo> remote = remoteTaskLists(db, service);
LocalTaskList[] local = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
boolean updateColors = settings.getManageCalendarColors();
// 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, updateColors);
// 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);
}
} finally {
dbHelper.close();
}
}
@Nullable
Long getService(@NonNull SQLiteDatabase db, @NonNull 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(@NonNull SQLiteDatabase db, 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;
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.database.DatabaseUtils
import android.os.Bundle
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.ISettings
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import org.dmfs.provider.tasks.TaskContract
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
class TasksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = SyncAdapter(this)
protected class SyncAdapter(
context: Context
): SyncAdapterService.SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val taskProvider = TaskProvider.fromProviderClient(provider)
val settings = AccountSettings(context, settings, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
updateLocalTaskLists(taskProvider, account, settings)
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
TasksSyncManager(context, account, settings, extras, authority, syncResult, taskProvider, taskList).use {
it.performSync()
}
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
}
Logger.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
fun getService() =
db.query(Services._TABLE, arrayOf(Services.ID),
"${Services.ACCOUNT_NAME}=? AND ${Services.SERVICE}=?",
arrayOf(account.name, Services.SERVICE_CALDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}",
arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
}
// enumerate remote and local task lists
val service = getService()
val remote = remoteTaskLists(service)
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let { url ->
val info = remote[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local task lists
for ((_,info) in remote) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
}
}
}
}
@@ -1,233 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
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.ByteArrayOutputStream;
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.DavCalendar;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.CalendarData;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.AccountSettings;
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, AccountSettings settings, Bundle extras, String authority, TaskProvider provider, SyncResult result, LocalTaskList taskList) throws InvalidAccountException {
super(context, account, settings, extras, authority, result, "taskList/" + taskList.getId());
this.provider = provider;
localCollection = taskList;
}
@Override
protected int notificationId() {
return Constants.NOTIFICATION_TASK_SYNC;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_tasks, account.name);
}
@Override
protected boolean prepare() {
collectionURL = HttpUrl.parse(localTaskList().getSyncId());
davCollection = new DavCalendar(httpClient, collectionURL);
return true;
}
@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;
App.log.log(Level.FINE, "Preparing upload of task " + local.getFileName(), local.getTask() );
ByteArrayOutputStream os = new ByteArrayOutputStream();
local.getTask().write(os);
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// fetch list of remote VTODOs and build hash table to index file name
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.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);
}
currentDavResource = null;
}
@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
final DavResource remote = bunch[0];
currentDavResource = remote;
ResponseBody body = remote.get("text/calendar");
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME);
if (eTag == null || StringUtils.isEmpty(eTag.eTag))
throw new DavException("Received CalDAV GET response without ETag for " + remote.location);
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.eTag, stream, charset);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.multiget(urls.toArray(new HttpUrl[urls.size()]));
// process multiget results
for (final DavResource remote : davCollection.members) {
currentDavResource = remote;
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);
}
}
currentDavResource = null;
}
}
// 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);
currentLocalResource = localTask;
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);
currentLocalResource = localTask;
localTask.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring " + fileName);
}
}
@@ -0,0 +1,192 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.*
import java.util.logging.Level
class TasksSyncManager(
context: Context,
account: Account,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: TaskProvider,
val localTaskList: LocalTaskList
): SyncManager(context, account, settings, extras, authority, syncResult, "taskList/${localTaskList.id}") {
val MAX_MULTIGET = 30
init {
localCollection = localTaskList
}
override fun notificationId() = Constants.NOTIFICATION_TASK_SYNC
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_tasks, account.name)!!
override fun prepare(): Boolean {
collectionURL = HttpUrl.parse(localTaskList.syncId ?: return false) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
return true
}
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun prepareUpload(resource: LocalResource): RequestBody {
if (resource is LocalTask) {
val task = requireNotNull(resource.task)
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
val os = ByteArrayOutputStream()
task.write(os)
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
)
} else
throw IllegalArgumentException("resource must be a LocalTask")
}
override fun listRemote() {
// fetch list of remote VTODOs and build hash table to index file name
val calendar = davCalendar()
currentDavResource = calendar
calendar.calendarQuery("VTODO", null, null)
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
for (vCard in davCollection.members) {
val fileName = vCard.fileName()
Logger.log.fine("Found remote VTODO: $fileName")
remoteResources[fileName] = vCard
}
currentDavResource = null
}
override fun downloadRemote() {
Logger.log.info("Downloading ${toDownload.size} tasks ($MAX_MULTIGET at once)")
// download new/updated iCalendars from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
val body = remote.get("text/calendar")
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = remote.properties[GetETag.NAME] as GetETag?
if (eTag == null || eTag.eTag.isNullOrEmpty())
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVTodo(remote.fileName(), eTag.eTag!!, reader)
}
} else {
// multiple contacts, use multi-get
val calendar = davCalendar()
currentDavResource = calendar
calendar.multiget(bunch.map { it.location })
// process multiget results
for (remote in davCollection.members) {
currentDavResource = remote
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
}
}
currentDavResource = null
}
}
// helpers
private fun davCalendar() = davCollection as DavCalendar
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
val tasks: List<Task>
try {
tasks = Task.fromReader(reader)
} catch (e: InvalidCalendarException) {
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
return
}
if (tasks.size == 1) {
val newData = tasks.first()
// update local task, if it exists
val localTask = localResources[fileName] as LocalTask?
currentLocalResource = localTask
if (localTask != null) {
Logger.log.info("Updating $fileName in local tasklist")
localTask.eTag = eTag
localTask.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.info("Adding $fileName to local task list")
val newTask = LocalTask(localTaskList, newData, fileName, eTag)
currentLocalResource = newTask
newTask.add()
syncResult.stats.numInserts++
}
} else
Logger.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
}
}
@@ -1,217 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.TabLayout;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.AsyncTaskLoader;
import android.support.v4.content.Loader;
import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.text.Html;
import android.text.Spanned;
import android.text.util.Linkify;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Level;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.R;
import ezvcard.Ezvcard;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
public class AboutActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_about);
setSupportActionBar((Toolbar)findViewById(R.id.toolbar));
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
ViewPager viewPager = (ViewPager)findViewById(R.id.viewpager);
viewPager.setAdapter(new TabsAdapter(getSupportFragmentManager()));
TabLayout tabLayout = (TabLayout)findViewById(R.id.tabs);
tabLayout.setupWithViewPager(viewPager);
}
@RequiredArgsConstructor
private static class ComponentInfo {
final String title, version, website, copyright;
final int licenseInfo;
final String licenseTextFile;
}
private final static ComponentInfo components[] = {
new ComponentInfo(
"DAVdroid", BuildConfig.VERSION_NAME, "https://davdroid.bitfire.at",
DateFormatUtils.format(BuildConfig.buildTime, "yyyy") + " Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
R.string.about_license_info_no_warranty, "gpl-3.0-standalone.html"
), new ComponentInfo(
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
), new ComponentInfo(
"Apache Commons", null, "http://commons.apache.org/",
"Apache Software Foundation", R.string.about_license_info_no_warranty, "apache2.html"
), new ComponentInfo(
"dnsjava", null, "http://dnsjava.org/",
"Brian Wellington", R.string.about_license_info_no_warranty, "bsd.html"
), new ComponentInfo(
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
), new ComponentInfo(
"ical4j", "2.x", "https://ical4j.github.io/",
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
), new ComponentInfo(
"OkHttp", null, "https://square.github.io/okhttp/",
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
), new ComponentInfo(
"Project Lombok", null, "https://projectlombok.org/",
"The Project Lombok Authors", R.string.about_license_info_no_warranty, "mit.html"
)
};
private static class TabsAdapter extends FragmentPagerAdapter {
public TabsAdapter(FragmentManager fm) {
super(fm);
}
@Override
public int getCount() {
return components.length;
}
@Override
public CharSequence getPageTitle(int position) {
return components[position].title;
}
@Override
public Fragment getItem(int position) {
return ComponentFragment.instantiate(position);
}
}
public static class ComponentFragment extends Fragment implements LoaderManager.LoaderCallbacks<Spanned> {
private static final String
KEY_POSITION = "position",
KEY_FILE_NAME = "fileName";
public static ComponentFragment instantiate(int position) {
ComponentFragment frag = new ComponentFragment();
Bundle args = new Bundle(1);
args.putInt(KEY_POSITION, position);
frag.setArguments(args);
return frag;
}
@Nullable
@Override
@SuppressLint("SetTextI18n")
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
ComponentInfo info = components[getArguments().getInt(KEY_POSITION)];
View v = inflater.inflate(R.layout.about_component, container, false);
TextView tv = (TextView)v.findViewById(R.id.title);
tv.setText(info.title + (info.version != null ? (" " + info.version) : ""));
tv = (TextView)v.findViewById(R.id.website);
tv.setAutoLinkMask(Linkify.WEB_URLS);
tv.setText(info.website);
tv = (TextView)v.findViewById(R.id.copyright);
tv.setText("© " + info.copyright);
tv = (TextView)v.findViewById(R.id.license_info);
tv.setText(info.licenseInfo);
// load and format license text
Bundle args = new Bundle(1);
args.putString(KEY_FILE_NAME, info.licenseTextFile);
getLoaderManager().initLoader(0, args, this);
return v;
}
@Override
public Loader<Spanned> onCreateLoader(int id, Bundle args) {
return new LicenseLoader(getContext(), args.getString(KEY_FILE_NAME));
}
@Override
public void onLoadFinished(Loader<Spanned> loader, Spanned license) {
if (getView() != null) {
TextView tv = (TextView)getView().findViewById(R.id.license_text);
if (tv != null) {
tv.setAutoLinkMask(Linkify.EMAIL_ADDRESSES | Linkify.WEB_URLS);
tv.setText(license);
}
}
}
@Override
public void onLoaderReset(Loader<Spanned> loader) {
}
}
private static class LicenseLoader extends AsyncTaskLoader<Spanned> {
final String fileName;
Spanned content;
LicenseLoader(Context context, String fileName) {
super(context);
this.fileName = fileName;
}
@Override
protected void onStartLoading() {
if (content == null)
forceLoad();
else
deliverResult(content);
}
@Override
public Spanned loadInBackground() {
App.log.fine("Loading license file " + fileName);
try {
@Cleanup InputStream is = getContext().getResources().getAssets().open(fileName);
byte[] raw = IOUtils.toByteArray(is);
return content = Html.fromHtml(new String(raw));
} catch (IOException e) {
App.log.log(Level.SEVERE, "Couldn't read license file", e);
return null;
}
}
}
}
@@ -0,0 +1,219 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.v4.app.FragmentPagerAdapter
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import android.support.v7.app.AppCompatActivity
import android.text.Html
import android.text.Spanned
import android.text.util.Linkify
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import ezvcard.Ezvcard
import kotlinx.android.synthetic.main.about_component.view.*
import kotlinx.android.synthetic.main.activity_about.*
import org.apache.commons.io.IOUtils
import java.io.IOException
import java.util.*
import java.util.logging.Level
class AboutActivity: AppCompatActivity() {
private class ComponentInfo(
val title: String?,
val version: String?,
val website: String,
val copyright: String,
val licenseInfo: Int?,
val licenseTextFile: String?
)
private lateinit var components: Array<ComponentInfo>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
components = arrayOf(
ComponentInfo(
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
null, null
), ComponentInfo(
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
), ComponentInfo(
"Apache Commons", null, "http://commons.apache.org/",
"Apache Software Foundation", R.string.about_license_info_no_warranty, "apache2.html"
), ComponentInfo(
"dnsjava", null, "http://dnsjava.org/",
"Brian Wellington", R.string.about_license_info_no_warranty, "bsd.html"
), ComponentInfo(
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
), ComponentInfo(
"ical4j", "2.x", "https://ical4j.github.io/",
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
), ComponentInfo(
"OkHttp", null, "https://square.github.io/okhttp/",
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
), ComponentInfo(
"Project Lombok", null, "https://projectlombok.org/",
"The Project Lombok Authors", R.string.about_license_info_no_warranty, "mit.html"
)
)
setContentView(R.layout.activity_about)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
viewpager.adapter = TabsAdapter(supportFragmentManager)
tabs.setupWithViewPager(viewpager)
}
private inner class TabsAdapter(
fm: FragmentManager
): FragmentPagerAdapter(fm) {
override fun getCount() = components.size
override fun getPageTitle(position: Int) =
components[position].title ?: getString(R.string.app_name)!!
override fun getItem(position: Int) = ComponentFragment.instantiate(position)
}
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
companion object {
val KEY_POSITION = "position"
val KEY_FILE_NAME = "fileName"
fun instantiate(position: Int): ComponentFragment {
val frag = ComponentFragment()
val args = Bundle(1)
args.putInt(KEY_POSITION, position)
frag.arguments = args
return frag
}
}
private val licenseFragmentLoader = ServiceLoader.load(ILicenseFragment::class.java)!!.firstOrNull()
@SuppressLint("SetTextI18n")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val info = (activity as AboutActivity).components[arguments.getInt(KEY_POSITION)]
val v = inflater.inflate(R.layout.about_component, container, false)
var title = info.title ?: getString(R.string.app_name)
info.version?.let { title += " ${info.version}" }
v.title.text = title
v.website.autoLinkMask = Linkify.WEB_URLS
v.website.text = info.website
v.copyright.text = "© ${info.copyright}"
if (info.licenseInfo == null && info.licenseTextFile == null) {
// No license text, so this must be the app's tab. Show the license fragment here, if available.
licenseFragmentLoader?.let { factory ->
fragmentManager.beginTransaction()
.add(R.id.license_fragment, factory.getFragment())
.commit()
}
v.license_terms.visibility = View.GONE
} else {
// show license info
if (info.licenseInfo == null)
v.license_info.visibility = View.GONE
else
v.license_info.setText(info.licenseInfo)
// load and format license text
if (info.licenseTextFile == null) {
v.license_header.visibility = View.GONE
v.license_text.visibility = View.GONE
} else {
val args = Bundle(1)
args.putString(KEY_FILE_NAME, info.licenseTextFile)
loaderManager.initLoader(0, args, this)
}
}
return v
}
override fun onCreateLoader(id: Int, args: Bundle) =
LicenseLoader(context, args.getString(KEY_FILE_NAME))
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
view?.let { v ->
v.license_text.autoLinkMask = Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS
v.license_text.text = license
}
}
override fun onLoaderReset(loader: Loader<Spanned>) {}
}
class LicenseLoader(
context: Context,
val fileName: String
): AsyncTaskLoader<Spanned>(context) {
var content: Spanned? = null
override fun onStartLoading() {
if (content == null)
forceLoad()
else
deliverResult(content)
}
override fun loadInBackground(): Spanned? {
Logger.log.fine("Loading license file $fileName")
try {
context.resources.assets.open(fileName).use {
content = Html.fromHtml(IOUtils.toString(it, Charsets.UTF_8))
return content
}
} catch(e: IOException) {
Logger.log.log(Level.SEVERE, "Couldn't read license file", e)
return null
}
}
}
interface ILicenseFragment {
fun getFragment(): Fragment
}
}
@@ -1,688 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.app.Dialog;
import android.app.LoaderManager;
import android.content.AsyncTaskLoader;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.Loader;
import android.content.ServiceConnection;
import android.content.SyncStatusObserver;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.provider.CalendarContract;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.design.widget.Snackbar;
import android.support.v4.app.DialogFragment;
import android.support.v7.app.AlertDialog;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.CardView;
import android.support.v7.widget.Toolbar;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.TextView;
import org.apache.commons.lang3.BooleanUtils;
import java.io.IOException;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.cert4android.CustomCertManager;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.DavService;
import at.bitfire.davdroid.R;
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 at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
import static android.content.ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE;
public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
public static final String EXTRA_ACCOUNT = "account";
private Account account;
private AccountInfo accountInfo;
ListView listCalDAV, listCardDAV;
Toolbar tbCardDAV, tbCalDAV;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
account = getIntent().getParcelableExtra(EXTRA_ACCOUNT);
setTitle(account.name);
setContentView(R.layout.activity_account);
Drawable icMenu = Build.VERSION.SDK_INT >= 21 ? getDrawable(R.drawable.ic_menu_light) :
getResources().getDrawable(R.drawable.ic_menu_light);
// CardDAV toolbar
tbCardDAV = (Toolbar)findViewById(R.id.carddav_menu);
tbCardDAV.setOverflowIcon(icMenu);
tbCardDAV.inflateMenu(R.menu.carddav_actions);
tbCardDAV.setOnMenuItemClickListener(this);
// CalDAV toolbar
tbCalDAV = (Toolbar)findViewById(R.id.caldav_menu);
tbCalDAV.setOverflowIcon(icMenu);
tbCalDAV.inflateMenu(R.menu.caldav_actions);
tbCalDAV.setOnMenuItemClickListener(this);
// load CardDAV/CalDAV collections
getLoaderManager().initLoader(0, getIntent().getExtras(), this);
}
@Override
protected void onPause() {
super.onPause();
CustomCertManager certManager = ((App)getApplicationContext()).getCertManager();
if (certManager != null)
certManager.appInForeground = false;
}
@Override
protected void onResume() {
super.onResume();
CustomCertManager certManager = ((App)getApplicationContext()).getCertManager();
if (certManager != null)
certManager.appInForeground = true;
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.activity_account, menu);
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
MenuItem itemRename = menu.findItem(R.id.rename_account);
// renameAccount is available for API level 21+
itemRename.setVisible(Build.VERSION.SDK_INT >= 21);
return super.onPrepareOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.sync_now:
requestSync();
break;
case R.id.settings:
Intent intent = new Intent(this, AccountSettingsActivity.class);
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account);
startActivity(intent);
break;
case R.id.rename_account:
RenameAccountFragment.newInstance(account).show(getSupportFragmentManager(), null);
break;
case R.id.delete_account:
new AlertDialog.Builder(AccountActivity.this)
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.account_delete_confirmation_title)
.setMessage(R.string.account_delete_confirmation_text)
.setNegativeButton(android.R.string.no, null)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
deleteAccount();
}
})
.show();
break;
default:
return super.onOptionsItemSelected(item);
}
return true;
}
@Override
public boolean onMenuItemClick(MenuItem item) {
Intent intent;
switch (item.getItemId()) {
case R.id.refresh_address_books:
if (accountInfo != null && accountInfo.carddav != null) {
intent = new Intent(this, DavService.class);
intent.setAction(DavService.ACTION_REFRESH_COLLECTIONS);
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, accountInfo.carddav.id);
startService(intent);
}
break;
case R.id.create_address_book:
intent = new Intent(this, CreateAddressBookActivity.class);
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account);
startActivity(intent);
break;
case R.id.refresh_calendars:
if (accountInfo != null && accountInfo.caldav != null) {
intent = new Intent(this, DavService.class);
intent.setAction(DavService.ACTION_REFRESH_COLLECTIONS);
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, accountInfo.caldav.id);
startService(intent);
}
break;
case R.id.create_calendar:
intent = new Intent(this, CreateCalendarActivity.class);
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account);
startActivity(intent);
break;
}
return false;
}
private AdapterView.OnItemClickListener onItemClickListener = new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
final ListView list = (ListView)parent;
final ArrayAdapter<CollectionInfo> adapter = (ArrayAdapter)list.getAdapter();
final CollectionInfo info = adapter.getItem(position);
boolean nowChecked = !info.selected;
if (list.getChoiceMode() == AbsListView.CHOICE_MODE_SINGLE)
// clear all other checked items
for (int i = adapter.getCount()-1; i >= 0; i--)
adapter.getItem(i).selected = false;
OpenHelper dbHelper = new OpenHelper(AccountActivity.this);
try {
SQLiteDatabase db = dbHelper.getWritableDatabase();
db.beginTransactionNonExclusive();
if (list.getChoiceMode() == AbsListView.CHOICE_MODE_SINGLE) {
// disable all other collections
ContentValues values = new ContentValues(1);
values.put(Collections.SYNC, 0);
db.update(Collections._TABLE, values, Collections.SERVICE_ID + "=?", new String[] { String.valueOf(info.serviceID) });
}
ContentValues values = new ContentValues(1);
values.put(Collections.SYNC, nowChecked ? 1 : 0);
db.update(Collections._TABLE, values, Collections.ID + "=?", new String[] { String.valueOf(info.id) });
db.setTransactionSuccessful();
db.endTransaction();
info.selected = nowChecked;
adapter.notifyDataSetChanged();
} finally {
dbHelper.close();
}
}
};
private AdapterView.OnItemLongClickListener onItemLongClickListener = new AdapterView.OnItemLongClickListener() {
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
final ListView list = (ListView)parent;
final ArrayAdapter<CollectionInfo> adapter = (ArrayAdapter)list.getAdapter();
final CollectionInfo info = adapter.getItem(position);
PopupMenu popup = new PopupMenu(AccountActivity.this, view);
popup.inflate(R.menu.account_collection_operations);
popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.delete_collection:
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null);
break;
}
return true;
}
});
popup.show();
// long click was handled
return true;
}
};
/* LOADERS AND LOADED DATA */
protected static class AccountInfo {
ServiceInfo carddav, caldav;
public static class ServiceInfo {
long id;
boolean refreshing;
boolean hasHomeSets;
List<CollectionInfo> collections;
}
}
@Override
public Loader<AccountInfo> onCreateLoader(int id, Bundle args) {
return new AccountLoader(this, account);
}
public void reload() {
getLoaderManager().restartLoader(0, getIntent().getExtras(), this);
}
@Override
public void onLoadFinished(Loader<AccountInfo> loader, final AccountInfo info) {
accountInfo = info;
CardView card = (CardView)findViewById(R.id.carddav);
if (info.carddav != null) {
ProgressBar progress = (ProgressBar)findViewById(R.id.carddav_refreshing);
progress.setVisibility(info.carddav.refreshing ? View.VISIBLE : View.GONE);
listCardDAV = (ListView)findViewById(R.id.address_books);
listCardDAV.setEnabled(!info.carddav.refreshing);
listCardDAV.setAlpha(info.carddav.refreshing ? 0.5f : 1);
tbCardDAV.getMenu().findItem(R.id.create_address_book).setEnabled(info.carddav.hasHomeSets);
AddressBookAdapter adapter = new AddressBookAdapter(this);
adapter.addAll(info.carddav.collections);
listCardDAV.setAdapter(adapter);
listCardDAV.setOnItemClickListener(onItemClickListener);
listCardDAV.setOnItemLongClickListener(onItemLongClickListener);
} else
card.setVisibility(View.GONE);
card = (CardView)findViewById(R.id.caldav);
if (info.caldav != null) {
ProgressBar progress = (ProgressBar)findViewById(R.id.caldav_refreshing);
progress.setVisibility(info.caldav.refreshing ? View.VISIBLE : View.GONE);
listCalDAV = (ListView)findViewById(R.id.calendars);
listCalDAV.setEnabled(!info.caldav.refreshing);
listCalDAV.setAlpha(info.caldav.refreshing ? 0.5f : 1);
tbCalDAV.getMenu().findItem(R.id.create_calendar).setEnabled(info.caldav.hasHomeSets);
final CalendarAdapter adapter = new CalendarAdapter(this);
adapter.addAll(info.caldav.collections);
listCalDAV.setAdapter(adapter);
listCalDAV.setOnItemClickListener(onItemClickListener);
listCalDAV.setOnItemLongClickListener(onItemLongClickListener);
} else
card.setVisibility(View.GONE);
}
@Override
public void onLoaderReset(Loader<AccountInfo> loader) {
if (listCardDAV != null)
listCardDAV.setAdapter(null);
if (listCalDAV != null)
listCalDAV.setAdapter(null);
}
private static class AccountLoader extends AsyncTaskLoader<AccountInfo> implements DavService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
private final Account account;
private DavService.InfoBinder davService;
private Object syncStatusListener;
public AccountLoader(Context context, Account account) {
super(context);
this.account = account;
}
@Override
protected void onStartLoading() {
syncStatusListener = ContentResolver.addStatusChangeListener(SYNC_OBSERVER_TYPE_ACTIVE, this);
getContext().bindService(new Intent(getContext(), DavService.class), this, Context.BIND_AUTO_CREATE);
}
@Override
protected void onStopLoading() {
davService.removeRefreshingStatusListener(this);
getContext().unbindService(this);
if (syncStatusListener != null)
ContentResolver.removeStatusChangeListener(syncStatusListener);
}
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
davService = (DavService.InfoBinder)service;
davService.addRefreshingStatusListener(this, false);
forceLoad();
}
@Override
public void onServiceDisconnected(ComponentName name) {
davService = null;
}
@Override
public void onDavRefreshStatusChanged(long id, boolean refreshing) {
forceLoad();
}
@Override
public void onStatusChanged(int which) {
forceLoad();
}
@Override
public AccountInfo loadInBackground() {
AccountInfo info = new AccountInfo();
@Cleanup OpenHelper dbHelper = new OpenHelper(getContext());
SQLiteDatabase db = dbHelper.getReadableDatabase();
@Cleanup Cursor cursor = db.query(
Services._TABLE,
new String[] { Services.ID, Services.SERVICE },
Services.ACCOUNT_NAME + "=?", new String[] { account.name },
null, null, null);
while (cursor.moveToNext()) {
long id = cursor.getLong(0);
String service = cursor.getString(1);
if (Services.SERVICE_CARDDAV.equals(service)) {
info.carddav = new AccountInfo.ServiceInfo();
info.carddav.id = id;
info.carddav.refreshing = (davService != null && davService.isRefreshing(id)) || ContentResolver.isSyncActive(account, ContactsContract.AUTHORITY);
info.carddav.hasHomeSets = hasHomeSets(db, id);
info.carddav.collections = readCollections(db, id);
} else if (Services.SERVICE_CALDAV.equals(service)) {
info.caldav = new AccountInfo.ServiceInfo();
info.caldav.id = id;
info.caldav.refreshing = (davService != null && davService.isRefreshing(id)) ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority);
info.caldav.hasHomeSets = hasHomeSets(db, id);
info.caldav.collections = readCollections(db, id);
}
}
return info;
}
private boolean hasHomeSets(@NonNull SQLiteDatabase db, long service) {
@Cleanup Cursor cursor = db.query(ServiceDB.HomeSets._TABLE, null, ServiceDB.HomeSets.SERVICE_ID + "=?",
new String[] { String.valueOf(service) }, null, null, null);
return cursor.getCount() > 0;
}
private List<CollectionInfo> readCollections(@NonNull SQLiteDatabase db, long service) {
List<CollectionInfo> collections = new LinkedList<>();
@Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?",
new String[] { String.valueOf(service) }, null, null, Collections.SUPPORTS_VEVENT + " DESC," + Collections.DISPLAY_NAME);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
collections.add(CollectionInfo.fromDB(values));
}
return collections;
}
}
/* LIST ADAPTERS */
public static class AddressBookAdapter extends ArrayAdapter<CollectionInfo> {
public AddressBookAdapter(Context context) {
super(context, R.layout.account_carddav_item);
}
@Override
public View getView(int position, View v, ViewGroup parent) {
if (v == null)
v = LayoutInflater.from(getContext()).inflate(R.layout.account_carddav_item, parent, false);
final CollectionInfo info = getItem(position);
RadioButton checked = (RadioButton)v.findViewById(R.id.checked);
checked.setChecked(info.selected);
TextView tv = (TextView)v.findViewById(R.id.title);
tv.setText(TextUtils.isEmpty(info.displayName) ? info.url : info.displayName);
tv = (TextView)v.findViewById(R.id.description);
if (TextUtils.isEmpty(info.description))
tv.setVisibility(View.GONE);
else {
tv.setVisibility(View.VISIBLE);
tv.setText(info.description);
}
tv = (TextView)v.findViewById(R.id.read_only);
tv.setVisibility(info.readOnly ? View.VISIBLE : View.GONE);
return v;
}
}
public static class CalendarAdapter extends ArrayAdapter<CollectionInfo> {
public CalendarAdapter(Context context) {
super(context, R.layout.account_caldav_item);
}
@Override
public View getView(final int position, View v, ViewGroup parent) {
if (v == null)
v = LayoutInflater.from(getContext()).inflate(R.layout.account_caldav_item, parent, false);
final CollectionInfo info = getItem(position);
CheckBox checked = (CheckBox)v.findViewById(R.id.checked);
checked.setChecked(info.selected);
View vColor = v.findViewById(R.id.color);
if (info.color != null) {
vColor.setVisibility(View.VISIBLE);
vColor.setBackgroundColor(info.color);
} else
vColor.setVisibility(View.GONE);
TextView tv = (TextView)v.findViewById(R.id.title);
tv.setText(TextUtils.isEmpty(info.displayName) ? info.url : info.displayName);
tv = (TextView)v.findViewById(R.id.description);
if (TextUtils.isEmpty(info.description))
tv.setVisibility(View.GONE);
else {
tv.setVisibility(View.VISIBLE);
tv.setText(info.description);
}
tv = (TextView)v.findViewById(R.id.read_only);
tv.setVisibility(info.readOnly ? View.VISIBLE : View.GONE);
tv = (TextView)v.findViewById(R.id.events);
tv.setVisibility(BooleanUtils.isTrue(info.supportsVEVENT) ? View.VISIBLE : View.GONE);
tv = (TextView)v.findViewById(R.id.tasks);
tv.setVisibility(BooleanUtils.isTrue(info.supportsVTODO) ? View.VISIBLE : View.GONE);
return v;
}
}
/* DIALOG FRAGMENTS */
public static class RenameAccountFragment extends DialogFragment {
private final static String ARG_ACCOUNT = "account";
static RenameAccountFragment newInstance(@NonNull Account account) {
RenameAccountFragment fragment = new RenameAccountFragment();
Bundle args = new Bundle(1);
args.putParcelable(ARG_ACCOUNT, account);
fragment.setArguments(args);
return fragment;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
final Account oldAccount = getArguments().getParcelable(ARG_ACCOUNT);
final EditText editText = new EditText(getContext());
editText.setText(oldAccount.name);
return new AlertDialog.Builder(getContext())
.setTitle(R.string.account_rename)
.setMessage(R.string.account_rename_new_name)
.setView(editText)
.setPositiveButton(R.string.account_rename_rename, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
final String newName = editText.getText().toString();
if (newName.equals(oldAccount.name))
return;
final AccountManager accountManager = AccountManager.get(getContext());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
accountManager.renameAccount(oldAccount, newName,
new AccountManagerCallback<Account>() {
@Override
public void run(AccountManagerFuture<Account> future) {
App.log.info("Updating account name references");
// cancel running synchronization
ContentResolver.cancelSync(oldAccount, null);
// update account name references in database
@Cleanup OpenHelper dbHelper = new OpenHelper(getContext());
ServiceDB.onRenameAccount(dbHelper.getWritableDatabase(), oldAccount.name, newName);
// update account_name of local contacts
try {
LocalAddressBook.onRenameAccount(getContext().getContentResolver(), oldAccount.name, newName);
} catch(RemoteException e) {
App.log.log(Level.SEVERE, "Couldn't propagate new account name to contacts provider");
}
// calendar provider doesn't allow changing account_name of Events
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(getContext().getContentResolver(), oldAccount.name, newName);
} catch(RemoteException e) {
App.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider");
}
// synchronize again
requestSync(new Account(newName, oldAccount.type));
}
}, null
);
getActivity().finish();
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
}
})
.create();
}
}
/* USER ACTIONS */
private void deleteAccount() {
AccountManager accountManager = AccountManager.get(this);
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, this, new AccountManagerCallback<Bundle>() {
@Override
public void run(AccountManagerFuture<Bundle> future) {
try {
if (future.getResult().getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
finish();
} catch(OperationCanceledException|IOException|AuthenticatorException e) {
App.log.log(Level.SEVERE, "Couldn't remove account", e);
}
}
}, null);
else
accountManager.removeAccount(account, new AccountManagerCallback<Boolean>() {
@Override
public void run(AccountManagerFuture<Boolean> future) {
try {
if (future.getResult())
finish();
} catch (OperationCanceledException|IOException|AuthenticatorException e) {
App.log.log(Level.SEVERE, "Couldn't remove account", e);
}
}
}, null);
}
protected static void requestSync(Account account) {
String authorities[] = {
ContactsContract.AUTHORITY,
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.authority
};
for (String authority : authorities) {
Bundle extras = new Bundle();
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras);
}
}
private void requestSync() {
requestSync(account);
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show();
}
}
@@ -0,0 +1,688 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Dialog
import android.app.LoaderManager
import android.content.*
import android.content.pm.PackageManager
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.design.widget.Snackbar
import android.support.v4.app.ActivityCompat
import android.support.v4.app.DialogFragment
import android.support.v4.content.ContextCompat
import android.support.v7.app.AlertDialog
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.view.*
import android.widget.*
import at.bitfire.davdroid.DavService
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.ContactsStorageException
import kotlinx.android.synthetic.main.account_caldav_item.view.*
import kotlinx.android.synthetic.main.activity_account.*
import java.util.*
import java.util.logging.Level
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
companion object {
@JvmField val EXTRA_ACCOUNT = "account"
private fun requestSync(context: Context, account: Account) {
val authorities = arrayOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.authority
)
for (authority in authorities) {
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
}
}
}
lateinit var account: Account
private var accountInfo: AccountInfo? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
title = account.name
setContentView(R.layout.activity_account)
val icMenu = if (Build.VERSION.SDK_INT >= 21)
getDrawable(R.drawable.ic_menu_light)
else
resources.getDrawable(R.drawable.ic_menu_light)
// CardDAV toolbar
carddav_menu.overflowIcon = icMenu
carddav_menu.inflateMenu(R.menu.carddav_actions)
carddav_menu.setOnMenuItemClickListener(this)
// CalDAV toolbar
caldav_menu.overflowIcon = icMenu
caldav_menu.inflateMenu(R.menu.caldav_actions)
caldav_menu.setOnMenuItemClickListener(this)
// Webcal toolbar
webcal_menu.overflowIcon = icMenu
webcal_menu.inflateMenu(R.menu.webcal_actions)
webcal_menu.setOnMenuItemClickListener(this)
// load CardDAV/CalDAV collections
loaderManager.initLoader(0, null, this)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (grantResults.any { it == PackageManager.PERMISSION_GRANTED })
// we've got additional permissions; try to load everything again
reload()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.activity_account, menu)
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val itemRename = menu.findItem(R.id.rename_account)
// renameAccount is available for API level 21+
itemRename.isVisible = Build.VERSION.SDK_INT >= 21
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.sync_now ->
requestSync()
R.id.settings -> {
val intent = Intent(this, AccountSettingsActivity::class.java)
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
R.id.rename_account ->
RenameAccountFragment.newInstance(account).show(supportFragmentManager, null)
R.id.delete_account -> {
AlertDialog.Builder(this)
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.account_delete_confirmation_title)
.setMessage(R.string.account_delete_confirmation_text)
.setNegativeButton(android.R.string.no, null)
.setPositiveButton(android.R.string.yes, { _, _ ->
deleteAccount()
})
.show()
}
else ->
return super.onOptionsItemSelected(item)
}
return true
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.refresh_address_books ->
accountInfo?.carddav?.let { carddav ->
val intent = Intent(this, DavService::class.java)
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, carddav.id)
startService(intent)
}
R.id.create_address_book -> {
val intent = Intent(this, CreateAddressBookActivity::class.java)
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
R.id.refresh_calendars ->
accountInfo?.caldav?.let { caldav ->
val intent = Intent(this, DavService::class.java)
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, caldav.id)
startService(intent)
}
R.id.create_calendar -> {
val intent = Intent(this, CreateCalendarActivity::class.java)
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
}
return false
}
private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ ->
if (!view.isEnabled)
return@OnItemClickListener
val list = parent as ListView
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
val info = adapter.getItem(position)
val nowChecked = !info.selected
OpenHelper(this@AccountActivity).use { dbHelper ->
val db = dbHelper.writableDatabase
db.beginTransactionNonExclusive()
val values = ContentValues(1)
values.put(Collections.SYNC, if (nowChecked) 1 else 0)
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
db.setTransactionSuccessful()
db.endTransaction()
}
info.selected = nowChecked
adapter.notifyDataSetChanged()
if (nowChecked && info.type == CollectionInfo.Type.ADDRESS_BOOK && info.readOnly)
Snackbar.make(parent, R.string.account_read_only_address_book_selected, Snackbar.LENGTH_LONG).show()
}
private val onItemLongClickListener = AdapterView.OnItemLongClickListener { parent, view, position, _ ->
val list = parent as ListView
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
val info = adapter.getItem(position)
val popup = PopupMenu(this@AccountActivity, view)
popup.inflate(R.menu.account_collection_operations)
popup.setOnMenuItemClickListener({ item ->
when (item.itemId) {
R.id.delete_collection ->
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null);
}
true
})
popup.show()
// long click was handled
true
}
private val webcalOnItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
val info = parent.getItemAtPosition(position) as CollectionInfo
var uri = Uri.parse(info.source)
val nowChecked = !info.selected
if (nowChecked) {
// subscribe to Webcal feed
when {
uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build()
uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build()
}
val intent = Intent(Intent.ACTION_VIEW, uri)
if (packageManager.resolveActivity(intent, 0) != null)
startActivity(intent)
else {
val snack = Snackbar.make(parent, R.string.account_no_webcal_handler_found, Snackbar.LENGTH_LONG)
val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid"))
if (packageManager.resolveActivity(installIntent, 0) != null)
snack.setAction(R.string.account_install_icsdroid, {
startActivity(installIntent)
})
snack.show()
}
} else {
// unsubscribe from Webcal feed
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
provider.delete(CalendarContract.Calendars.CONTENT_URI, "${CalendarContract.Calendars.NAME}=?", arrayOf(info.source))
reload()
} finally {
provider.release()
}
}
}
}
/* LOADERS AND LOADED DATA */
class AccountInfo {
var carddav: ServiceInfo? = null
var caldav: ServiceInfo? = null
class ServiceInfo {
var id: Long? = null
var refreshing = false
var hasHomeSets = false
var collections = listOf<CollectionInfo>()
}
}
override fun onCreateLoader(id: Int, args: Bundle?) =
AccountLoader(this, account)
fun reload() {
loaderManager.restartLoader(0, null, this)
}
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
accountInfo = info
carddav.visibility = info?.carddav?.let { carddav ->
carddav_refreshing.visibility = if (carddav.refreshing) View.VISIBLE else View.GONE
address_books.isEnabled = !carddav.refreshing
address_books.alpha = if (carddav.refreshing) 0.5f else 1f
carddav_menu.menu.findItem(R.id.create_address_book).isEnabled = carddav.hasHomeSets
val adapter = AddressBookAdapter(this)
adapter.addAll(carddav.collections)
address_books.adapter = adapter
address_books.onItemClickListener = onItemClickListener
address_books.onItemLongClickListener = onItemLongClickListener
View.VISIBLE
} ?: View.GONE
caldav.visibility = info?.caldav?.let { caldav ->
caldav_refreshing.visibility = if (caldav.refreshing) View.VISIBLE else View.GONE
calendars.isEnabled = !caldav.refreshing
calendars.alpha = if (caldav.refreshing) 0.5f else 1f
caldav_menu.menu.findItem(R.id.create_calendar).isEnabled = caldav.hasHomeSets
val adapter = CalendarAdapter(this)
adapter.addAll(caldav.collections.filter { it.type == CollectionInfo.Type.CALENDAR })
calendars.adapter = adapter
calendars.onItemClickListener = onItemClickListener
calendars.onItemLongClickListener = onItemLongClickListener
View.VISIBLE
} ?: View.GONE
webcal.visibility = info?.caldav?.let {
val collections = it.collections.filter { it.type == CollectionInfo.Type.WEBCAL }
val adapter = CalendarAdapter(this)
adapter.addAll(collections)
webcals.adapter = adapter
webcals.onItemClickListener = webcalOnItemClickListener
if (collections.isNotEmpty())
View.VISIBLE
else
View.GONE
} ?: View.GONE
}
override fun onLoaderReset(loader: Loader<AccountInfo>) {
address_books?.adapter = null
calendars?.adapter = null
}
class AccountLoader(
val activity: AccountActivity,
val account: Account
): AsyncTaskLoader<AccountInfo>(activity), DavService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
private var davService: DavService.InfoBinder? = null
private lateinit var syncStatusListener: Any
override fun onStartLoading() {
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
context.bindService(Intent(context, DavService::class.java), this, Context.BIND_AUTO_CREATE)
}
override fun onStopLoading() {
davService?.removeRefreshingStatusListener(this)
context.unbindService(this)
ContentResolver.removeStatusChangeListener(syncStatusListener)
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
davService = service as DavService.InfoBinder
service.addRefreshingStatusListener(this, false)
forceLoad()
}
override fun onServiceDisconnected(name: ComponentName) {
davService = null
}
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
forceLoad()
override fun onStatusChanged(which: Int) =
forceLoad()
override fun loadInBackground(): AccountInfo {
val info = AccountInfo()
OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(
Services._TABLE,
arrayOf(Services.ID, Services.SERVICE),
"${Services.ACCOUNT_NAME}=?", arrayOf(account.name),
null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
when (cursor.getString(1)) {
Services.SERVICE_CARDDAV -> {
val carddav = AccountInfo.ServiceInfo()
info.carddav = carddav
carddav.id = id
carddav.refreshing =
davService?.isRefreshing(id) ?: false ||
ContentResolver.isSyncActive(account, context.getString(R.string.address_books_authority))
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, null)
try {
if (account == addressBook.getMainAccount())
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
} catch(e: ContactsStorageException) {
}
}
carddav.hasHomeSets = hasHomeSets(db, id)
carddav.collections = readCollections(db, id)
}
Services.SERVICE_CALDAV -> {
val caldav = AccountInfo.ServiceInfo()
info.caldav = caldav
caldav.id = id
caldav.refreshing =
davService?.isRefreshing(id) ?: false ||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
caldav.hasHomeSets = hasHomeSets(db, id)
caldav.collections = readCollections(db, id)
}
}
}
}
}
return info
}
private fun hasHomeSets(db: SQLiteDatabase, service: Long): Boolean {
db.query(ServiceDB.HomeSets._TABLE, null, "${ServiceDB.HomeSets.SERVICE_ID}=?",
arrayOf(service.toString()), null, null, null)?.use { cursor ->
return cursor.count > 0
}
return false
}
private fun readCollections(db: SQLiteDatabase, service: Long): List<CollectionInfo> {
val collections = LinkedList<CollectionInfo>()
db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()),
null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
collections.add(CollectionInfo(values))
}
}
// Webcal: check whether calendar is already subscribed by ICSdroid
// (or any other app that stores the URL in Calendars.NAME)
val webcalCollections = collections.filter { it.type == CollectionInfo.Type.WEBCAL }
if (webcalCollections.isNotEmpty()) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
for (info in webcalCollections) {
provider.query(CalendarContract.Calendars.CONTENT_URI, null,
"${CalendarContract.Calendars.NAME}=?", arrayOf(info.source), null)?.use { cursor ->
if (cursor.moveToNext())
info.selected = true
}
}
} finally {
provider.release()
}
}
else
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CALENDAR), 0)
}
return collections
}
}
/* LIST ADAPTERS */
class AddressBookAdapter(
context: Context
): ArrayAdapter<CollectionInfo>(context, R.layout.account_carddav_item) {
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_carddav_item, parent, false)
val info = getItem(position)
val checked: CheckBox = v.findViewById(R.id.checked)
checked.isChecked = info.selected
var tv: TextView = v.findViewById(R.id.title)
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
tv = v.findViewById(R.id.description)
if (info.description.isNullOrBlank())
tv.visibility = View.GONE
else {
tv.visibility = View.VISIBLE
tv.text = info.description
}
v.findViewById<ImageView>(R.id.read_only).visibility =
if (info.readOnly) View.VISIBLE else View.GONE
return v
}
}
class CalendarAdapter(
context: Context
): ArrayAdapter<CollectionInfo>(context, R.layout.account_caldav_item) {
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_caldav_item, parent, false)
val info = getItem(position)
val enabled = info.selected || info.supportsVEVENT || info.supportsVTODO
v.isEnabled = enabled
v.checked.isEnabled = enabled
val checked: CheckBox = v.findViewById(R.id.checked)
checked.isChecked = info.selected
val vColor: View = v.findViewById(R.id.color)
vColor.visibility = info.color?.let {
vColor.setBackgroundColor(it)
View.VISIBLE
} ?: View.GONE
var tv: TextView = v.findViewById(R.id.title)
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
tv = v.findViewById(R.id.description)
if (info.description.isNullOrBlank())
tv.visibility = View.GONE
else {
tv.visibility = View.VISIBLE
tv.text = info.description
}
v.findViewById<ImageView>(R.id.read_only).visibility =
if (info.readOnly) View.VISIBLE else View.GONE
v.findViewById<ImageView>(R.id.events).visibility =
if (info.supportsVEVENT) View.VISIBLE else View.GONE
v.findViewById<ImageView>(R.id.tasks).visibility =
if (info.supportsVTODO) View.VISIBLE else View.GONE
return v
}
}
/* DIALOG FRAGMENTS */
class RenameAccountFragment: DialogFragment() {
companion object {
val ARG_ACCOUNT = "account"
fun newInstance(account: Account): RenameAccountFragment {
val fragment = RenameAccountFragment()
val args = Bundle(1)
args.putParcelable(ARG_ACCOUNT, account)
fragment.arguments = args
return fragment
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val oldAccount: Account = arguments.getParcelable(ARG_ACCOUNT)
val editText = EditText(activity)
editText.setText(oldAccount.name)
return AlertDialog.Builder(activity)
.setTitle(R.string.account_rename)
.setMessage(R.string.account_rename_new_name)
.setView(editText)
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { dialog, which ->
val newName = editText.text.toString()
if (newName == oldAccount.name)
return@OnClickListener
val accountManager = AccountManager.get(activity)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
accountManager.renameAccount(oldAccount, newName, { _ ->
Logger.log.info("Updating account name references")
// cancel maybe running synchronization
ContentResolver.cancelSync(oldAccount, null)
for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book)))
ContentResolver.cancelSync(addrBookAccount, null)
// update account name references in database
OpenHelper(activity).use { dbHelper ->
ServiceDB.onRenameAccount(dbHelper.getWritableDatabase(), oldAccount.name, newName)
}
// update main account of address book accounts
try {
for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book))) {
val provider = activity.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
try {
if (provider != null) {
val addressBook = LocalAddressBook(activity, addrBookAccount, provider)
if (oldAccount == addressBook.getMainAccount())
addressBook.setMainAccount(Account(newName, oldAccount.type))
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider?.close()
else
provider?.release()
}
}
} catch(e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
}
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again)
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(activity.contentResolver, oldAccount.name, newName)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e)
}
// synchronize again
requestSync(activity, Account(newName, oldAccount.type))
}, null)
activity.finish()
})
.setNegativeButton(android.R.string.cancel, { _, _ -> })
.create()
}
}
/* USER ACTIONS */
private fun deleteAccount() {
val accountManager = AccountManager.get(this)
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, this, { future ->
try {
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
finish()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
else
accountManager.removeAccount(account, { future ->
try {
if (future.result)
finish()
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
}
private fun requestSync() {
requestSync(this, account)
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
}
}
@@ -1,135 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.OnAccountsUpdateListener;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.app.ListFragment;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.AsyncTaskLoader;
import android.support.v4.content.Loader;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.TextView;
import at.bitfire.davdroid.AccountsChangedReceiver;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
public class AccountListFragment extends ListFragment implements LoaderManager.LoaderCallbacks<Account[]>, AdapterView.OnItemClickListener {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
setListAdapter(new AccountListAdapter(getContext()));
return inflater.inflate(R.layout.account_list, container, false);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
getLoaderManager().initLoader(0, getArguments(), this);
ListView list = getListView();
list.setOnItemClickListener(this);
list.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE);
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Account account = (Account)getListAdapter().getItem(position);
Intent intent = new Intent(getContext(), AccountActivity.class);
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account);
startActivity(intent);
}
// loader
@Override
public Loader<Account[]> onCreateLoader(int id, Bundle args) {
return new AccountLoader(getContext());
}
@Override
public void onLoadFinished(Loader<Account[]> loader, Account[] accounts) {
AccountListAdapter adapter = (AccountListAdapter)getListAdapter();
adapter.clear();
adapter.addAll(accounts);
}
@Override
public void onLoaderReset(Loader<Account[]> loader) {
((AccountListAdapter)getListAdapter()).clear();
}
private static class AccountLoader extends AsyncTaskLoader<Account[]> implements OnAccountsUpdateListener {
private final AccountManager accountManager;
public AccountLoader(Context context) {
super(context);
accountManager = AccountManager.get(context);
}
@Override
protected void onStartLoading() {
AccountsChangedReceiver.registerListener(this, true);
}
@Override
protected void onStopLoading() {
AccountsChangedReceiver.unregisterListener(this);
}
@Override
public void onAccountsUpdated(Account[] accounts) {
forceLoad();
}
@Override
@SuppressLint("MissingPermission")
public Account[] loadInBackground() {
return accountManager.getAccountsByType(Constants.ACCOUNT_TYPE);
}
}
// list adapter
static class AccountListAdapter extends ArrayAdapter<Account> {
public AccountListAdapter(Context context) {
super(context, R.layout.account_list_item);
}
@Override
public View getView(int position, View v, ViewGroup parent) {
if (v == null)
v = LayoutInflater.from(getContext()).inflate(R.layout.account_list_item, parent, false);
Account account = getItem(position);
TextView tv = (TextView)v.findViewById(R.id.account_name);
tv.setText(account.name);
return v;
}
}
}
@@ -0,0 +1,103 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.ListFragment
import android.app.LoaderManager
import android.content.AsyncTaskLoader
import android.content.Context
import android.content.Intent
import android.content.Loader
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.AdapterView
import android.widget.ArrayAdapter
import at.bitfire.davdroid.AccountsChangedReceiver
import at.bitfire.davdroid.R
import kotlinx.android.synthetic.main.account_list_item.view.*
class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>> {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
listAdapter = AccountListAdapter(activity)
return inflater.inflate(R.layout.account_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loaderManager.initLoader(0, arguments, this)
listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE
listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
val account = listAdapter.getItem(position) as Account
val intent = Intent(activity, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
}
// loader
override fun onCreateLoader(id: Int, args: Bundle?) =
AccountLoader(activity)
override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {
val adapter = listAdapter as AccountListAdapter
adapter.clear()
adapter.addAll(*accounts)
}
override fun onLoaderReset(loader: Loader<Array<Account>>) {
(listAdapter as AccountListAdapter).clear()
}
class AccountLoader(
context: Context
): AsyncTaskLoader<Array<Account>>(context), OnAccountsUpdateListener {
override fun onStartLoading() =
AccountsChangedReceiver.registerListener(this, true)
override fun onStopLoading() =
AccountsChangedReceiver.unregisterListener(this)
override fun onAccountsUpdated(accounts: Array<Account>?) =
forceLoad()
override fun loadInBackground(): Array<Account> =
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
}
// list adapter
class AccountListAdapter(
context: Context
): ArrayAdapter<Account>(context, R.layout.account_list_item) {
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val account = getItem(position)
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
v.account_name.text = account.name
return v
}
}
}
@@ -1,319 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui;
import android.accounts.Account;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncStatusObserver;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.ContactsContract;
import android.support.v4.app.LoaderManager;
import android.support.v4.app.NavUtils;
import android.support.v4.content.AsyncTaskLoader;
import android.support.v4.content.Loader;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.preference.EditTextPreference;
import android.support.v7.preference.ListPreference;
import android.support.v7.preference.Preference;
import android.support.v7.preference.PreferenceFragmentCompat;
import android.support.v7.preference.SwitchPreferenceCompat;
import android.text.TextUtils;
import android.view.MenuItem;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.ical4android.TaskProvider;
import at.bitfire.vcard4android.GroupMethod;
public class AccountSettingsActivity extends AppCompatActivity {
public final static String EXTRA_ACCOUNT = "account";
private Account account;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
account = getIntent().getParcelableExtra(EXTRA_ACCOUNT);
setTitle(getString(R.string.settings_title, account.name));
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
if (savedInstanceState == null)
getSupportFragmentManager().beginTransaction()
.replace(android.R.id.content, AccountSettingsFragment.instantiate(this, AccountSettingsFragment.class.getName(), getIntent().getExtras()))
.commit();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
Intent intent = new Intent(this, AccountActivity.class);
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account);
NavUtils.navigateUpTo(this, intent);
return true;
} else
return false;
}
public static class AccountSettingsFragment extends PreferenceFragmentCompat implements LoaderManager.LoaderCallbacks<AccountSettings> {
Account account;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
account = getArguments().getParcelable(EXTRA_ACCOUNT);
getLoaderManager().initLoader(0, getArguments(), this);
}
@Override
public void onCreatePreferences(Bundle bundle, String s) {
addPreferencesFromResource(R.xml.settings_account);
}
@Override
public Loader<AccountSettings> onCreateLoader(int id, Bundle args) {
return new AccountSettingsLoader(getContext(), (Account)args.getParcelable(EXTRA_ACCOUNT));
}
@Override
public void onLoadFinished(Loader<AccountSettings> loader, final AccountSettings settings) {
if (settings == null) {
getActivity().finish();
return;
}
// category: authentication
final EditTextPreference prefUserName = (EditTextPreference)findPreference("username");
prefUserName.setSummary(settings.username());
prefUserName.setText(settings.username());
prefUserName.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.username((String)newValue);
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
final EditTextPreference prefPassword = (EditTextPreference)findPreference("password");
prefPassword.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.password((String)newValue);
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
// category: synchronization
final ListPreference prefSyncContacts = (ListPreference)findPreference("sync_interval_contacts");
final Long syncIntervalContacts = settings.getSyncInterval(ContactsContract.AUTHORITY);
if (syncIntervalContacts != null) {
prefSyncContacts.setValue(syncIntervalContacts.toString());
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSyncContacts.setSummary(R.string.settings_sync_summary_manually);
else
prefSyncContacts.setSummary(getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60));
prefSyncContacts.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.setSyncInterval(ContactsContract.AUTHORITY, Long.parseLong((String)newValue));
return false;
}
});
} else {
prefSyncContacts.setEnabled(false);
prefSyncContacts.setSummary(R.string.settings_sync_summary_not_available);
}
final ListPreference prefSyncCalendars = (ListPreference)findPreference("sync_interval_calendars");
final Long syncIntervalCalendars = settings.getSyncInterval(CalendarContract.AUTHORITY);
if (syncIntervalCalendars != null) {
prefSyncCalendars.setValue(syncIntervalCalendars.toString());
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSyncCalendars.setSummary(R.string.settings_sync_summary_manually);
else
prefSyncCalendars.setSummary(getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60));
prefSyncCalendars.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.setSyncInterval(CalendarContract.AUTHORITY, Long.parseLong((String)newValue));
return false;
}
});
} else {
prefSyncCalendars.setEnabled(false);
prefSyncCalendars.setSummary(R.string.settings_sync_summary_not_available);
}
final ListPreference prefSyncTasks = (ListPreference)findPreference("sync_interval_tasks");
final Long syncIntervalTasks = settings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority);
if (syncIntervalTasks != null) {
prefSyncTasks.setValue(syncIntervalTasks.toString());
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSyncTasks.setSummary(R.string.settings_sync_summary_manually);
else
prefSyncTasks.setSummary(getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60));
prefSyncTasks.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Long.parseLong((String)newValue));
return false;
}
});
} else {
prefSyncTasks.setEnabled(false);
prefSyncTasks.setSummary(R.string.settings_sync_summary_not_available);
}
final SwitchPreferenceCompat prefWifiOnly = (SwitchPreferenceCompat)findPreference("sync_wifi_only");
prefWifiOnly.setChecked(settings.getSyncWifiOnly());
prefWifiOnly.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object wifiOnly) {
settings.setSyncWiFiOnly((Boolean)wifiOnly);
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
final EditTextPreference prefWifiOnlySSID = (EditTextPreference)findPreference("sync_wifi_only_ssid");
final String onlySSID = settings.getSyncWifiOnlySSID();
prefWifiOnlySSID.setText(onlySSID);
if (onlySSID != null)
prefWifiOnlySSID.setSummary(getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID));
else
prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off);
prefWifiOnlySSID.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
String ssid = (String)newValue;
settings.setSyncWifiOnlySSID(!TextUtils.isEmpty(ssid) ? ssid : null);
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
// category: CardDAV
final ListPreference prefGroupMethod = (ListPreference)findPreference("contact_group_method");
if (syncIntervalContacts != null) {
prefGroupMethod.setValue(settings.getGroupMethod().name());
prefGroupMethod.setSummary(prefGroupMethod.getEntry());
prefGroupMethod.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object o) {
String name = (String)o;
settings.setGroupMethod(GroupMethod.valueOf(name));
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
} else
prefGroupMethod.setEnabled(false);
// category: CalDAV
final EditTextPreference prefTimeRangePastDays = (EditTextPreference)findPreference("time_range_past_days");
if (syncIntervalCalendars != null) {
Integer pastDays = settings.getTimeRangePastDays();
if (pastDays != null) {
prefTimeRangePastDays.setText(pastDays.toString());
prefTimeRangePastDays.setSummary(getResources().getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays));
} else {
prefTimeRangePastDays.setText(null);
prefTimeRangePastDays.setSummary(R.string.settings_sync_time_range_past_none);
}
prefTimeRangePastDays.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
int days;
try {
days = Integer.parseInt((String)newValue);
} catch(NumberFormatException ignored) {
days = -1;
}
settings.setTimeRangePastDays(days < 0 ? null : days);
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
} else
prefTimeRangePastDays.setEnabled(false);
final SwitchPreferenceCompat prefManageColors = (SwitchPreferenceCompat)findPreference("manage_calendar_colors");
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
prefManageColors.setChecked(settings.getManageCalendarColors());
prefManageColors.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.setManageCalendarColors((Boolean)newValue);
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}
});
} else
prefManageColors.setEnabled(false);
}
@Override
public void onLoaderReset(Loader<AccountSettings> loader) {
}
}
private static class AccountSettingsLoader extends AsyncTaskLoader<AccountSettings> implements SyncStatusObserver {
final Account account;
Object listenerHandle;
public AccountSettingsLoader(Context context, Account account) {
super(context);
this.account = account;
}
@Override
protected void onStartLoading() {
forceLoad();
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this);
}
@Override
protected void onStopLoading() {
ContentResolver.removeStatusChangeListener(listenerHandle);
}
@Override
public AccountSettings loadInBackground() {
AccountSettings settings;
try {
settings = new AccountSettings(getContext(), account);
} catch(InvalidAccountException e) {
return null;
}
return settings;
}
@Override
public void onStatusChanged(int which) {
forceLoad();
}
}
}
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more