Compare commits

..
Author SHA1 Message Date
Ricki Hirner 770825677b Fetch translations from Transifex 2017-08-22 15:16:54 +02:00
Ricki Hirner b1b8651f9f Version bump to 1.7.1 2017-08-22 15:16:04 +02:00
Ricki Hirner 2691a784e8 Add donation startup fragment again 2017-08-22 14:58:11 +02:00
Ricki Hirner 69cc0bdd25 ProGuard: keep ThreeTen; update build tools and Kotlin version 2017-08-22 14:44:38 +02:00
Ricki Hirner f99ee40e01 Fetch translations from Transifex 2017-08-19 12:16:52 +02:00
Ricki Hirner e8262da7cb Version bump to 1.7 2017-08-19 12:16:26 +02:00
Ricki Hirner b0cee17707 Fix migration 2017-08-15 16:11:38 +02:00
Ricki Hirner fc24d0ff6c Fetch translations from Transifex 2017-08-15 15:52:23 +02:00
Ricki Hirner 7cdc0ee2e9 Update Kotlin, vcard4android 2017-08-15 15:51:55 +02:00
Ricki Hirner a1a00d962e Handle conflict when renaming accounts 2017-08-10 17:39:39 +02:00
Ricki Hirner 3b89410b3e WiFi restriction: allow multiple SSIDs (comma-separated) 2017-08-08 13:28:04 +02:00
Ricki Hirner f94d2f7ac3 Add support for event colors 2017-08-07 22:50:19 +02:00
Ricki Hirner a88a418feb Scroll to external log settings when tapping notification 2017-08-05 15:50:50 +02:00
Ricki Hirner 3f1e58d513 Version bump to 1.6.5 (160) 2017-08-05 13:30:02 +02:00
Ricki Hirner 2740a1bd3c Fetch lib translations from Transifex 2017-08-05 13:15:10 +02:00
Ricki Hirner 03f8176b07 Fetch translations from Transifex 2017-08-05 13:13:25 +02:00
Ricki Hirner 5c10a6aa2a Minor optimizations 2017-08-05 13:12:47 +02:00
Ricki Hirner 2b1fd66da0 Fix DavResourceFinder 2017-08-03 23:51:39 +02:00
Ricki Hirner 555de288a0 Fix device rotate crash bug, cert4android race condition 2017-08-03 20:47:28 +02:00
Ricki Hirner f467c0449b Fix custom certificate socket factory 2017-08-02 17:58:57 +02:00
Ricki Hirner bedc1742c1 Remove TextUtils dependency; tests 2017-08-02 17:31:51 +02:00
Ricki Hirner 9ad5406712 Tests 2017-08-02 16:35:28 +02:00
Ricki Hirner 6f536ba3a6 Rewrite last UI classes to Kotlin; allow cancellation of resource detection 2017-08-02 16:28:59 +02:00
Ricki Hirner c12d569410 Rewrite most UI classes to Kotlin 2017-08-01 15:00:06 +02:00
Ricki Hirner d04690fcd9 Fix addAccount authenticator crash 2017-07-31 17:48:18 +02:00
Ricki Hirner 7b96af094d Rewrite some UI classes to Kotlin 2017-07-31 17:38:28 +02:00
Ricki Hirner c6424ec3e6 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 f590571563 Allow to remove all WiFi SSID restrictions when "sync only on WiFi" is active 2017-07-30 14:55:49 +02:00
Ricki Hirner c5346325fb cert4android: fetch translations from Transifex 2017-07-27 16:51:08 +02:00
Ricki Hirner 81c5bab3cb Fetch translations from Transifex 2017-07-27 16:49:22 +02:00
Ricki Hirner d5078c9d5c Version bump to 1.6.4 2017-07-27 16:48:06 +02:00
Ricki Hirner 236fbdc936 cert4android, vcard4android updates 2017-07-27 16:47:52 +02:00
Ricki Hirner 4d96ad5c3e Improve debug info 2017-07-27 16:47:52 +02:00
Ricki Hirner 3aeadf3b67 Some minor fixes 2017-07-20 17:26:50 +02:00
Ricki Hirner a0a088724b Rewrite LoginCredentials to Kotlin 2017-07-19 22:05:58 +02:00
Ricki Hirner a10c5b7656 Fix: SSLSocket doesn't implement Closeable in Android 4.1 2017-07-19 19:49:49 +02:00
Ricki Hirner b9b5a244b0 Update copyright 2017-07-19 19:32:46 +02:00
Ricki Hirner 7d8ae2a2c4 Rewrite syncadapter package to Kotlin 2017-07-19 19:11:02 +02:00
Ricki Hirner 017c472523 Rewrite all remaining classes except syncadapter and ui package 2017-07-16 15:30:58 +02:00
Ricki Hirner a502750e95 Rewrite DavService to Kotlin 2017-07-16 01:07:58 +02:00
Ricki Hirner 5f91c793af Rewrite resource package to Kotlin 2017-07-14 20:58:29 +02:00
Ricki Hirner a564ddf79b Rewrite some classes to Kotlin 2017-07-10 21:09:15 +02:00
Ricki Hirner bc6fb6b3aa Rewrite model package to Kotlin 2017-07-10 01:31:45 +02:00
Ricki Hirner b70c55fcf0 Rewrite log package to Kotlin 2017-07-08 14:31:23 +02:00
Ricki Hirner 71c3221cb3 Use x86 emulator 2017-07-07 00:14:05 +02:00
Ricki Hirner b7cb66296a Fetch translations from Transifex 2017-07-06 12:10:08 +02:00
Ricki Hirner be24e5db47 Version bump to 1.6.3 2017-07-06 12:07:27 +02:00
Ricki Hirner d44914700f More logging (ical4j) + ProGuard 2017-07-06 12:06:54 +02:00
Ricki Hirner 7d2486d4ed 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 4cc0968d9c Minor bug/NPE fixes 2017-07-04 15:01:06 +02:00
Ricki Hirner 3565428721 Fetch translations from Transifex 2017-07-03 18:54:12 +02:00
Ricki Hirner 65be56fa57 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 d8ddad5b73 Improve preview release startup fragment 2017-07-02 14:42:01 +02:00
Ricki Hirner 2ad3bc92ba 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 a9a8cb981f vcard4android: Kotlin 2017-06-25 16:06:15 +02:00
Ricki Hirner bf0783e624 Update gradle plugin 2017-06-21 13:00:08 +02:00
Ricki Hirner 819e709613 SDK/build tools level 26 2017-06-21 00:03:44 +02:00
Ricki Hirner 14bd8c0bb8 Version bump to 1.6.2 2017-06-20 15:18:16 +02:00
Ricki Hirner 51c7f8f5f2 Update libs; update Commons Lang to 3.6 2017-06-20 15:17:30 +02:00
Ricki Hirner 714d63b78c Lib updates, okhttp/3.8.1 2017-06-20 15:17:30 +02:00
Ricki Hirner eab494d3fa ical4android: Kotlin 2017-06-20 15:17:30 +02:00
Ricki Hirner 1c5e16b093 Version bump to 1.6.1.1 (151) 2017-06-16 18:57:06 +02:00
Ricki Hirner 2bcd6e4686 Lib updates 2017-06-16 17:54:41 +02:00
Ricki Hirner 029319c6c8 Fetch translations from Transifex 2017-06-09 16:33:38 +02:00
Ricki Hirner e07a55870c Version bump to 1.6.1 2017-06-09 16:33:06 +02:00
Ricki Hirner cd299ec83a dav4android: Kotlin, cert4android: handle InterruptedException 2017-06-09 00:41:34 +02:00
Ricki Hirner d764a48a06 Birthdays without years, again 2017-06-05 14:35:54 +02:00
Ricki Hirner 88b6dee6b0 Fetch translations from Transifex 2017-06-03 19:27:49 +02:00
Ricki Hirner 131c633549 Version bump to 1.6 2017-06-03 19:27:06 +02:00
Ricki Hirner f3c9e406f9 Improve resource detection
* scan unrequested responses for useful auto-detection information
* cert4android: Kotlin
2017-06-03 19:26:43 +02:00
Ricki Hirner 9e23cdffb5 Support for birthdays without year 2017-06-02 00:38:23 +02:00
Ricki Hirner a776b65d66 Remove "ical4android" from iCal PRODID (same format as for VCard) 2017-05-14 13:36:49 +02:00
Ricki Hirner 76e83e1269 okhttp 3.8.0 ProGuard settings 2017-05-14 13:01:13 +02:00
Ricki Hirner a1d6c69fad Fetch translations from Transifex 2017-05-14 12:18:50 +02:00
Ricki Hirner 2248ba6508 Version bump to 1.5.2 2017-05-14 12:18:02 +02:00
Ricki Hirner d6ee73444e Remove "vcard4android" from VCard PRODID to avoid folding for better compatibility 2017-05-14 12:11:58 +02:00
Ricki Hirner 0a472905f7 Upgrade to okhttp/3.8.0 2017-05-14 11:33:42 +02:00
Ricki Hirner 2f7debb42f Use UUIDs for newly generated event/task UIDs (RFC 7986 5.3 UID Property) 2017-04-27 13:36:10 +02:00
Ricki Hirner 370367724c Fetch translations from Transifex 2017-04-25 14:11:47 +02:00
Ricki Hirner 4dcde1140d Version bump to 1.5.1-ose 2017-04-25 14:11:22 +02:00
Ricki Hirner 64ad708316 Use untranslated User-Agent string
* refactoring: don't use global string variables
2017-04-24 23:00:11 +02:00
Ricki Hirner d4e3864410 Check sync conditions for contacts sync, too 2017-04-23 18:10:01 +02:00
Ricki Hirner 3b71808534 Remove unnecessary InvalidAccountException 2017-04-22 21:37:19 +02:00
Ricki Hirner 3132ffbd7f Allow null values for IS_ORGANIZER 2017-04-18 23:54:11 +02:00
Ricki Hirner d553e1840c README updates 2017-04-16 21:58:30 +02:00
Ricki Hirner f368456ee6 Version bump to 1.5.0.3 2017-04-16 15:51:51 +02:00
Ricki Hirner 6feb45eaa9 Fetch translations from Transifex 2017-04-16 15:49:02 +02:00
Ricki Hirner 88baa19d86 Upgrade libraries 2017-04-16 14:31:50 +02:00
Ricki Hirner de5903b375 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 ed0eefc971 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 33510ff203 Version bump to 1.5.0.2 2017-04-02 19:23:38 +02:00
Ricki Hirner 05ded45711 Fix some inconsistencies 2017-04-02 19:19:38 +02:00
Ricki Hirner 3fab975a28 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 e767dcbf38 Account settings: restart loader after sync interval update
* debug info: add signature
2017-03-29 12:39:59 +02:00
Ricki Hirner 0d193bfba1 Hotfix: don't crash on empty address book displayName 2017-03-27 11:43:14 +02:00
Ricki Hirner f1a4253c88 Version bump to 1.5 2017-03-26 19:30:16 +02:00
Ricki Hirner 199d8b8eae Fetch translations from Transifex 2017-03-26 19:30:13 +02:00
Ricki Hirner 41dae8bcb3 Remove all references to Constants.ACCOUNT_TYPE (now a to string resource) 2017-03-26 19:13:51 +02:00
Ricki Hirner 956698208a Improve address book details in debug info 2017-03-26 19:07:26 +02:00
Ricki Hirner ea925e132b Add more debug information
* power saving status
* permissions
* address book accounts
2017-03-25 20:13:07 +01:00
Ricki Hirner c87371ab1d 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 666ddc972b Version bump to 1.5-beta1 2017-03-19 20:44:07 +01:00
Ricki Hirner 9817594da1 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 dfec72ce6b 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 46c5d7201d Unify action bar icon colors 2017-03-12 13:47:52 +01:00
Ricki Hirner 949f78f1ad Fix maven 2017-03-03 13:25:51 +01:00
Ricki Hirner 675536f1d5 Update translations from Transifex 2017-03-03 12:52:51 +01:00
Ricki Hirner e5aedec9e1 Update gradle 2017-03-03 12:51:12 +01:00
Ricki Hirner aa13ccf35f Version bump to 1.4.1 2017-03-02 23:44:43 +01:00
Ricki Hirner cc25babc27 Fetch translations from Transifex 2017-03-02 23:44:43 +01:00
Ricki Hirner 58fd2d02d7 AccountsActivity: show message when global sync is disabled 2017-03-02 23:44:39 +01:00
Ricki Hirner 353094d268 Gradle update to 3.4 2017-02-27 17:39:22 +01:00
Ricki Hirner 5e819e44a4 Fetch translations from Transifex 2017-02-17 13:55:08 +01:00
Ricki Hirner 0080cf683c Version bump to 1.4.0.3 2017-02-17 13:54:09 +01:00
Ricki Hirner ba4e7a6499 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 526c0e2381 Version bump to 1.4.0.2 2017-02-12 19:01:43 +01:00
Ricki Hirner f7b31d219a Improve Android 7 workaround behavior in combination with CATEGORIES/VCard4 contact groups 2017-02-12 18:59:41 +01:00
Ricki Hirner 47639c4511 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 6dd310637e Android 7 workaround: update hash after group membership operations 2017-02-10 18:06:28 +01:00
Ricki Hirner e46e85c7b2 Version bump to 1.4.0.1 2017-02-06 11:59:06 +01:00
Ricki Hirner 8f8e0f9ce8 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 5fd771e43d Version bump to 1.4.0 2017-02-05 17:21:09 +01:00
Ricki Hirner 9b45a6b7c6 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 faaaa330a7 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 712467d933 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 cb0041bfed Upgrade to okhttp 3.6.0 2017-01-30 22:27:54 +01:00
Ricki Hirner 9ae61f6233 Version bump to 1.3.8 2017-01-29 20:11:15 +01:00
Ricki Hirner 6045a988b4 Fetch translations from Transifex (fixes crash in Spanish version) 2017-01-29 19:18:57 +01:00
Ricki Hirner c034789069 Add information about current local and remote resource to debug info 2017-01-29 19:17:53 +01:00
Ricki Hirner d98787a5bc Upgrade to gradle 3.3 2017-01-29 19:17:53 +01:00
Ricki Hirner 3c1b9a7288 Use isAlwaysSyncable for contacts/calendars again because of buggy Android firmwares 2017-01-09 21:28:28 +01:00
Ricki Hirner 35cd11299d Version bump to 1.3.7 2017-01-08 23:35:41 +01:00
Ricki Hirner 34ba52979c Change authentication restriction to domains instead of host names 2017-01-08 19:09:34 +01:00
Ricki Hirner a14dfe5d39 Delete local contacts when no CardDAV collection is selected 2017-01-06 15:53:23 +01:00
Ricki Hirner 232eaa1d6d Version bump to 1.3.6 2017-01-01 12:43:47 +01:00
Ricki Hirner d40af10b1f Fetch translations from Transifex 2017-01-01 12:26:02 +01:00
Ricki Hirner 364d79535a Update to ez-vcard 0.10.1
* fix REV and PREF problems
2017-01-01 01:13:03 +01:00
Ricki Hirner d30077657c 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 4b8c44fe5b 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 88b5a26c3b Don't show warning on AccountSettings version updates 2016-12-30 14:28:28 +01:00
Ricki Hirner d5807e44af Update to SDK level 25 2016-12-30 02:58:54 +01:00
Ricki Hirner c8ddec9651 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 0a70d91883 Fetch translations from Transifex 2016-12-23 15:54:20 +01:00
Ricki Hirner 7a4fc19814 Version bump to 1.3.5 2016-12-23 15:51:56 +01:00
Ricki Hirner ef92753ac6 Address book selection changed: update URL as soon as possible 2016-12-19 18:58:35 +01:00
Ricki Hirner 91d4e38227 Update gradle to 3.2.1; ical4android/vcard4android updates 2016-12-18 22:02:44 +01:00
Ricki Hirner ca38cbe935 Update okhttp to 3.5.0 2016-12-02 15:01:52 +01:00
Ricki Hirner 29068389c7 Log group assignments more verbosely 2016-11-25 21:40:40 +01:00
Ricki Hirner d2df3c9027 Rename account: don't crash when content providers are not accessible 2016-11-17 19:59:23 +01:00
Ricki Hirner d34efb103d Version bump to 1.3.4.1 2016-11-14 18:48:26 +01:00
Ricki Hirner 2fd0ac2dd0 Fetch translations from Transifex 2016-11-14 18:41:48 +01:00
Ricki Hirner 4f796669f6 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 fb7f974987 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 900f1fd857 Fetch translations from Transifex 2016-11-13 20:34:11 +01:00
Ricki Hirner e0a5748da3 Update build tools to 25.0.0, fix WiFiManager leak 2016-11-13 20:22:10 +01:00
Ricki Hirner c714785f47 Fetch translations from Transifex
ical4android: fix for events without dtend/duration
2016-11-06 17:36:59 +01:00
Ricki Hirner fa17659ce8 Fetch translations from Transifex 2016-11-04 12:02:08 +01:00
Ricki Hirner 8d4eeb763b Version bump to 1.3.4
* library updates
2016-11-04 12:01:24 +01:00
Ricki Hirner 3c1e9302df Add app-wide HTTP proxy setting 2016-10-30 22:21:54 +01:00
Ricki Hirner f2d5fe32c5 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 08639b2e89 Version bump to 1.3.3.1 2016-10-21 19:52:55 +02:00
Ricki Hirner 5b50aa024e Fetch translations from Transifex 2016-10-21 19:50:09 +02:00
Ricki Hirner d8fb9c8373 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 b3dda2c112 ProGuard update; signing config 2016-10-18 12:36:41 +02:00
Ricki Hirner ae8bf4d49e Use string resource for logging file provider authority; vcard4android update 2016-10-17 23:57:53 +02:00
Ricki Hirner 8e5ca5a72d 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 2e78e1e746 Add useless ProGuard rule 2016-10-15 16:51:35 +02:00
Ricki Hirner fb4321f35f Fetch translations from Transifex 2016-10-14 21:19:39 +02:00
Ricki Hirner 7d7e2584ed Version bump to 1.3.3 2016-10-14 21:00:29 +02:00
Ricki Hirner 2b9dfc668f dav4android: always use UTF-8 for Basic/Digest auth credentials 2016-10-13 15:23:11 +02:00
Ricki Hirner 118d873cdf ical4android: ignore invalid DUE < DTSTART for tasks 2016-10-12 17:03:46 +02:00
Ricki Hirner e7e30e9f2f Remove VCard RFC6868 setting (always enabled now; setting not needed for Posteo compatibility anymore) 2016-10-12 16:45:26 +02:00
Ricki Hirner 520df3654d Gitlab CI: install OpenTasks before tests 2016-10-12 13:04:21 +02:00
Ricki Hirner f0fe8e7620 vcard4android: ez-vcard 0.10.0 2016-10-11 23:36:30 +02:00
Ricki Hirner 783fead048 Test adaptions 2016-10-11 00:28:22 +02:00
Ricki Hirner bd8470e7eb Switch to JUnit4 2016-10-10 21:03:18 +02:00
Ricki Hirner ec0e792f12 Add Gitlab CI 2016-10-10 20:18:37 +02:00
Ricki Hirner 593d907e3a Improve tests 2016-10-07 14:39:21 +02:00
Ricki Hirner 2c5b93ce43 Fix NPE in "is refreshing progress bar" 2016-10-07 14:39:18 +02:00
Ricki Hirner 68d48bb0f2 Version bump to 1.3.2.2 2016-10-05 11:14:41 +02:00
Ricki Hirner 3acc05b605 Enable verbose logging of allow loggers (for instance, okhttp) / dav4android update 2016-10-04 23:42:03 +02:00
Ricki Hirner 3813c1e0c8 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 4e87186dd8 Version bump to 1.3.2 2016-10-03 20:57:14 +02:00
Ricki Hirner 5e0a3e8b9f Fetch translations from Transifex 2016-10-03 20:43:01 +02:00
Ricki Hirner 3bd96955cc Avoid "no transaction" exception 2016-10-03 20:11:56 +02:00
Ricki Hirner 3952181df2 Minimal layout change 2016-10-03 12:13:59 +02:00
Ricki Hirner 054dba6ff8 Show progress bar when synchronization is active 2016-09-26 23:07:35 +02:00
Ricki Hirner 68781fd96e Increase SEQUENCE only when we're ORGANIZER 2016-09-26 23:07:35 +02:00
Ricki Hirner 00a400569a Query/use CalDAV email address as account name, if available 2016-09-26 23:07:35 +02:00
Ricki Hirner 08b1886f9e Always increase SEQUENCE 2016-09-26 23:07:35 +02:00
Ricki Hirner d2d7ce8fe3 lint: don't keep references to Context in static fields 2016-09-26 23:07:35 +02:00
Ricki Hirner 1354afebf7 Version bump to 1.3.1
* some cert4android tests
2016-09-18 17:39:07 +02:00
Ricki Hirner 08c8390202 Import strings from Transifex 2016-09-18 16:50:27 +02:00
Ricki Hirner d688eb5196 Always use PROPFIND instead of REPORT addressbook-query 2016-09-18 16:43:11 +02:00
Ricki Hirner b757cba521 README changes 2016-09-02 12:22:45 +02:00
Ricki Hirner 4fea7880e7 Fetch translations from Transifex 2016-09-02 12:13:22 +02:00
Ricki Hirner 9555c066bd 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 290ce7ad80 Version bump to 1.3
* vcard4android: fix bug concerning generated formatted postal addresses
2016-09-02 00:55:44 +02:00
Ricki Hirner 55ad06e328 New launcher logo 2016-09-02 00:55:39 +02:00
Ricki Hirner 19ab4a14ce 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 ac940b3a12 Accept intent extras for LoginActivity 2016-08-13 23:14:33 +02:00
Ricki Hirner 7416c62c97 Fetch translations from Transifex 2016-08-06 00:13:16 +02:00
Ricki Hirner eb70f8690f 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 bab84d7d0f 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 ae145d897e Fetch translations from Transifex 2016-08-02 19:30:20 +02:00
Ricki Hirner d675fb961e 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 967bc50256 Avoid sync error when OpenTasks is not installed 2016-08-01 21:54:56 +02:00
Ricki Hirner 2617063835 Clean up launcher icon
* clean up launcher icon
* update dependencies
2016-08-01 21:15:55 +02:00
Ricki Hirner 7fbdf96546 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 f6cb92dd6d 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 b49bdda7e8 Fix NPE, add intent to view FAQ to upgrade notification 2016-07-11 12:55:55 +02:00
Ricki Hirner 19b54748cd 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 41ce609237 Support X-ABLabel for custom types
* vcard4android: support X-ABLabel for custom types
2016-07-01 22:10:20 +02:00
Ricki Hirner aafcc36c4d 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 2496a3bf05 Add standard and gplay product flavor 2016-06-24 00:06:43 +02:00
Ricki Hirner 54e6426dc4 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 4542da7d89 Version bump to 1.1.1
* fetch translations from Transifex
2016-06-21 21:05:18 +02:00
Ricki Hirner 977409511a Handle cookies correctly using a name/domain/path MultiKeyMap 2016-06-21 20:51:52 +02:00
Ricki Hirner ad8c832819 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 389af2b738 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 be2e15e463 Merge branch 'master' into vcard4-groups 2016-06-12 15:52:07 +02:00
Ricki Hirner c7c13520f9 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 317144630c 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 34bc27fa79 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 210735a500 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 b30733c64b 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 91234a688f Upgrade to okhttp 3.3.1 2016-05-30 12:02:04 +02:00
Ricki Hirner 5675e544b5 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 42a261b84e 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 0d1825cbf3 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 9b8fc983cd 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 3bde3758fc Contact/event/task upload: use streams directly without extra byte[] array 2016-05-23 15:10:35 +02:00
Ricki Hirner fd1f59d124 Logs: show which contact/event/task is being prepared for upload 2016-05-23 14:28:27 +02:00
Ricki Hirner 9886507b7d 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 144643d6af Work around unexpected IllegalArgumentException when user enters garbage host name 2016-04-30 12:39:19 +02:00
Ricki Hirner 14875f63ea 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 28e567cf78 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 7997606550 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 fb0552de46 Find collections when they're identical with their home set 2016-04-18 00:04:18 +02:00
Ricki Hirner 03c15a6924 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 c3b2929f88 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 eb2537a278 Version bump to 1.0.7-beta1
* fetch translations from Transifex
2016-04-10 20:43:01 +02:00
Ricki Hirner 0b9727cca6 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 61231b4233 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 59252d7471 Fetch translations from Transifex
* fetch translations
* minor changes (lint)
2016-04-07 08:38:09 +02:00
Ricki Hirner 6ffa6fa9a7 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 03ee9a037b Various tests 2016-04-05 23:25:18 +02:00
Ricki Hirner 7ab13d648e 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 25c54cce62 SyncManager notifications: create a unique notification for every synced collection 2016-04-01 17:45:57 +02:00
Ricki Hirner f0e45c71f5 Add account setting: manage calendar colors 2016-03-31 20:07:36 +02:00
Ricki Hirner fa528a64e9 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 c6aed90c96 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 2280f899ee 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 a283cbbae5 Add account info when creating calendars
* add ACCOUNT_NAME and ACCOUNT_TYPE when creating calendars
* close TaskProvider when checking for its presence
* when TaskProvider is not available/accessible, explicitly disallow task sync at account creation
  to prevent further crashes
* try to handle OutOfMemoryError
* version 1.0_2
2016-03-30 16:11:44 +02:00
Ricki Hirner bb95a25b91 Fix NPE for synchronization while CalDAV/CardDAV services is not available in DB 2016-03-30 16:07:41 +02:00
Ricki Hirner f1ccd01708 Fetch translations from Transifex
* version 1.0_1 for commercial stores
2016-03-29 15:39:10 +02:00
Ricki Hirner c498225064 Resource detection: fix NPE 2016-03-29 15:19:53 +02:00
Ricki Hirner 879b137cfc Version bump to 1.0
* fetch translations from Transifex
2016-03-27 15:55:05 +02:00
Ricki Hirner 84379f7ee1 Verbose logging of resource detection
* enable verbose logging of resource detection
* dav4android: prevent leaking connections
2016-03-25 16:27:43 +01:00
Ricki Hirner a594fd3d14 Handle invalid accounts where accounts are used
* add InvalidAccountException for invalid (=not existent/invalid settings version) accounts
* handle invalid accounts properly
* HttpClient: add constructors without Account when authentication is not needed
* drop upgrade compatibility for accounts without version (version<1)
2016-03-25 15:23:54 +01:00
Ricki Hirner 100b78a6a4 Version bump to 1.0-rc1
* fix migration bug (doesn't set read-only flag)
* unify progress dialogs
* improve debug info report styling
2016-03-24 21:03:10 +01:00
Ricki Hirner 758711acb2 Import translations from Transifex 2016-03-24 19:32:45 +01:00
Ricki Hirner c90b6075db Re-initialize logger in :sync process, too (IPC using broadcast)
* re-initialize logger in :sync process after changing the settings (IPC using broadcast)
* move settings from SharedPreferences (which is not multi-process-safe) to ServiceDB
* logger: show exception details
* settings: show debug info
2016-03-24 19:10:30 +01:00
Ricki Hirner 7109915e6e Minor refactoring (lint) 2016-03-24 17:43:35 +01:00
Ricki Hirner e8cf9fd5ab Implement AboutActivity (license information) 2016-03-24 13:48:43 +01:00
Ricki Hirner 3a49815220 Show notifications on refresh errors
* show notifications on DAV service refresh errors
* add Twitter to navigation drawer
2016-03-23 22:01:52 +01:00
Ricki Hirner 96881bd986 Improve resource detection
* honour calendar-proxy-read/write-for property
* ignore errors when quering member groups for home sets
* remove home sets and collections from the service database not only on 404, but 403, 404 and 410
* fix crash bug when <displayname> was defined, but empty
2016-03-23 14:46:13 +01:00
Ricki Hirner c08a0bdc43 Respect read-only flag of collections
* handle read-only information properly
* don't show (clear-text) password in account settings
2016-03-23 12:30:49 +01:00
Ricki Hirner 773b2ee992 SSLSocketFactoryCompat: fix typo 2016-03-23 11:30:42 +01:00
Ricki Hirner c2181c55d3 Translation fix 2016-03-20 21:03:25 +01:00
Ricki Hirner 8449684dd2 Version bump to 1.0-beta1
* fetch translations from Transifex
* minor changes (lint/strings)
2016-03-20 18:31:11 +01:00
Ricki Hirner 28e7c91658 Initiate DAV service refresh after migration
* initiate DAV service refresh after migration
* minor refactoring of sync adapter classes
* minor UI changes
2016-03-20 17:41:05 +01:00
Ricki Hirner 51867c5f3f Notification for external file logging
* Show notificatin when external file logging is active
* Use column name constants for ServiceDB access
2016-03-20 11:41:08 +01:00
Ricki Hirner 1786b73ac6 Provide settings migration v0.9 -> v1.0 2016-03-19 22:30:07 +01:00
Ricki Hirner 1df3ddbe74 Startup dialogs
* add startup dialogs (F-Droid: donations, Play Store: DRM bug, OpenTasks not installed)
* allow to reset hints/startup dialogs
* AccountSettings: fragment as inner class of activity
2016-03-19 11:22:30 +01:00
Ricki Hirner 5ee8d76b34 Add SQLite dump to debug report 2016-03-18 19:02:27 +01:00
Ricki Hirner 5723225475 App settings UI 2016-03-18 17:24:46 +01:00
Ricki Hirner f73f6ca43c Account management: Create address book (similar to create calendar) 2016-03-18 15:40:05 +01:00
Ricki Hirner 753c4b05a5 Allow time-range filtering of events (to the past)
* add account setting + GUI: restrict time range in the past
* add support for restricted time range VEVENT synchronization
* fix bug in handling changed exceptions of recurring events
2016-03-16 18:23:52 +01:00
Ricki Hirner 2e34fa686d Minor refactoring 2016-02-24 23:21:25 +01:00
Ricki Hirner a735564bc1 Use java.util.logging instead of sl4fj 2016-02-24 23:08:19 +01:00
Ricki Hirner 552f6b6936 Refactoring
* move AccountSettings up to package context
* HttpClient: take authentication from AccountSettings in the constructor
* App: provide global instance of MemorizingTrustManager
* App: provide global Java logger, optionally with verbose and external file logging
* LoginCredentials: moved from inner-class into setup package
2016-02-24 15:56:30 +01:00
Ricki Hirner 50f7006e59 Refactoring
* make DavResourceFinder.Configuration really serializable
2016-02-24 12:29:07 +01:00
Ricki Hirner 6ac5fe0204 Show debug info on management errors 2016-02-23 23:10:44 +01:00
Ricki Hirner 19bfe5c5f2 Create/delete calendars 2016-02-23 18:42:50 +01:00
Ricki Hirner 212cd8ddb0 Proof of concept: create remote address books, delete remote collections 2016-02-22 14:33:55 +01:00
Ricki Hirner c30195d9ba AccountActivity changes
* CalDAV/CardDAV resource list views now always contain all elements without scrolling (NonScrollingListView)
* synchronization action in activity now overriddes system sync settings
2016-02-21 14:15:55 +01:00
Ricki Hirner 3ca063416e Fix crash bug caused by leaking OnAccountsUpdateListener 2016-02-19 14:15:32 +01:00
Ricki Hirner 940d622402 Upgrade to okhttp/3.1.2 + tests 2016-02-19 13:16:34 +01:00
Ricki Hirner 814abc60ed Service detection, account settings
* service detection: detect group memberships and query them for homesets
* account settings
* request account synchronization
2016-01-23 18:44:40 +01:00
Ricki Hirner 220ba4b151 Improved service detection + GUI
* DavService: query group-membership principals for home sets, too
* working collection selection
* contacts sync according to selected address book
2016-01-23 00:04:48 +01:00
Ricki Hirner 777e124b54 Selectable calendars 2016-01-20 21:12:37 +01:00
Ricki Hirner f32493986b Update local calendars according to ServiceDB at sync 2016-01-20 15:22:58 +01:00
Ricki Hirner 5025a61cd1 Update local task lists according to ServiceDB at sync 2016-01-20 15:01:17 +01:00
Ricki Hirner 89a516bfd1 DavService: refresh collections 2016-01-20 00:39:10 +01:00
Ricki Hirner af71ed8bc5 Collections refresh 2016-01-19 20:04:25 +01:00
Ricki Hirner fc29988dc6 Add DavService for long-running operations 2016-01-19 13:51:52 +01:00
Ricki Hirner 77c947da14 Add account details activity (AccountActivity) 2016-01-18 14:59:19 +01:00
Ricki Hirner ff901ce91f Service database
* HttpClient: authentication that is limited to a host name is never preemptive
* DavResourceFinder: service configuration == null means that this service is not available
* new SQLite database for CalDAV/CardDAV services
* added AccountDetailsFragment, which asks for account name and then finishes account creation
* updated AccountListFragment
2016-01-17 17:10:30 +01:00
Ricki Hirner 85a6b68a56 Rewrite initial configuration detection
* HttpClient: add Accept-Language header
* HttpClient: fix MemoryCookieStore NullPointerException
* DavResourceFinder: check for home sets, too
2016-01-17 00:34:26 +01:00
Ricki Hirner 89050d88c6 Upgrade to okhttp3 2016-01-16 21:34:41 +01:00
Ricki Hirner ba0350c83d New initial server configuration detection
* separate initial server configuration (= principal and/or a certain collection) detection from collection refresh (to be done)
* GUI: LoginActivity
2016-01-16 00:53:05 +01:00
Ricki Hirner 515969c4b8 Initial changes for new GUI 2016-01-15 01:07:56 +01:00
Ricki Hirner 9a8d29e774 Append trailing slashes to Web URLs; okhttp upgrade 2016-01-08 17:57:51 +01:00
Ricki Hirner 2880b05b5d README updates 2016-01-04 01:43:53 +01:00
Ricki Hirner d6cff63f2d Version bump to 0.9.1.3 2016-01-03 01:47:32 +01:00
Ricki Hirner be6aa1b6a2 Upgrade to okhttp/2.7.1 2016-01-02 10:59:52 +01:00
Ricki Hirner 9ec4a4015d Increase timeout values
* increase timeout values because some servers are known to be very slow
2015-12-06 13:45:15 +01:00
Ricki Hirner 9dbc32d30b BuildConfig: use build time instead of current time for timestamp 2015-11-27 14:04:24 +01:00
Ricki Hirner b63fc70cfb README changes; fix EXDATE bug
* ical4android: process EXDATEs when there are no explicit exceptions
2015-11-27 12:31:15 +01:00
Ricki Hirner 0142e63257 Show open-source information when MainActivity is created 2015-11-24 18:36:25 +01:00
Ricki Hirner aaa7d71ae3 Version bump to 0.9.1.2
* debug info: send report inline up to 8000 characters, as attachment otherwise
* ical4android: fix bug which locally deleted tasks by mistake
2015-11-24 17:59:47 +01:00
Ricki Hirner 4adf3001ac New upstream libraries, task sync bug fix
* use OkHttp 2.6.0, slf4j-android 1.7.13, and ez-vcard 0.9.8
* ical4android: don't delete all tasks instead of single one
2015-11-23 09:07:35 +01:00
Ricki Hirner 5ccdafa074 ContactsSyncManager: URL fix
* ContactsSyncManager: don't try to download external resources which do not have a valid URL
2015-11-20 10:12:48 +01:00
Ricki Hirner fce2b85991 Increase version code……………………………………………………………………………. 2015-11-16 13:24:44 +01:00
Ricki Hirner e5ebf10dc0 Version 0.9.1.1
* resource detection: ignore 404 errors when trying context paths
* work around crash when edit field is changed while there is no acitivity (???!)
* dav4android: fix calendar-multiget request
2015-11-16 13:15:57 +01:00
Ricki Hirner 0f0acd62a3 Optimize soft keyboard handling, make resource detection dialog not cancelable 2015-11-09 11:31:40 +01:00
Ricki Hirner 2414b42867 Add basic support for cookies
* add basic support for cookies (doesn't work for URLs with ports: https://code.google.com/p/android/issues/detail?id=193475)
* MemorizingTrustManager: log reason for inaccessible key store files
2015-11-08 18:51:19 +01:00
Ricki Hirner 243dac9952 Merge branch 'master' of https://gitlab.com/bitfireAT/davdroid 2015-11-07 22:03:54 +01:00
Ricki Hirner 12248b8bb9 Version 0.9.1-beta1
* CalendarSyncManager/TaskSyncManager: only set calendar name and color on sync when data is available
* DavResourceFinder: test getCurrentUserPrincipal
* dav4android: use java.util.ServiceLoader, resilience against multi-status with <propstat> without <status> + test
* ical4android: always set HAS_ATTENDEE_DATA to 1
* vcard4android: small fixes
* merge translations from Transifex
2015-11-07 22:03:19 +01:00
Ricki Hirner d872bd06e5 * CalendarSyncManager/TaskSyncManager: only set calendar name and color on sync when data is available
* DavResourceFinder: test getCurrentUserPrincipal
* dav4android: use java.util.ServiceLoader, resilience against multi-status with <propstat> without <status> + test
* ical4android: always set HAS_ATTENDEE_DATA to 1
* vcard4android: small fixes
* merge translations from Transifex
2015-11-07 21:09:59 +01:00
Ricki Hirner 065aa3fc84 Version bump to 0.9.1
* filter ":" and "/" from external log file names
2015-11-07 15:35:22 +01:00
Ricki Hirner 20ee4e03f3 Various improvements
* ContactsSyncManager: gracefully handle photo URLs without host name
* MainActivity: cache installer package name
* dav4android: use java.util.ServiceLoader to load DAV property factories
2015-11-07 15:18:23 +01:00
Ricki Hirner 241e15404f Amend DebugInfoActivity
* write report to temporary file in external cache dir before sending
* don't delete the report file onActivityResult (because services like the email service may access it asynchronously)
* don't show label of installer (just the package name), because some use ambiguous strings like "App Store" etc.
* show sync. settings for all accounts again
2015-11-07 14:10:17 +01:00
Ricki Hirner 4a00ba647d Fix crash bug when external log file can't be created 2015-10-28 14:10:03 +01:00
Ricki Hirner 8d00814eaf Update .gitmodules to publically accessible URLs 2015-10-24 12:08:51 +02:00
Ricki Hirner c665744c31 Version 0.9.0.4
* ical4android: treat empty-string task location and URL as null values
* vcard4android: ignore raw contact data without MIMETYPE
* gracefully ignore when server doesn't sent Content-Type in GET responses
* merge translations from Transifex
2015-10-24 00:36:22 +02:00
Ricki Hirner 2ef278c336 vcard4android: ignore raw contact data rows without MIMETYPE 2015-10-23 02:28:23 +02:00
Ricki Hirner 34de8431ae Fallback to PROPFIND when REPORT addressbook-query returns 400, 403, 500 or 501
* increase max. log line length to 80 characters
2015-10-21 14:40:03 +02:00
Ricki Hirner 9d19d9757c Merge translations from Transifex and bump version to 0.9.0.3 2015-10-21 02:15:10 +02:00
Ricki Hirner 81d13576e8 Minor bug fixes and improvements
* Contacts sync: if REPORT addressbook-query doesn't work, don't ignore other exceptions than HTTP 40x errors
* dav4android: Digest auth improvements (e.g. for OS X Calendar Server)
* vcard4android: better support for exotic IMPP handles and names
2015-10-21 02:06:29 +02:00
Ricki Hirner 6f429328ef Version bump to 0.9.0.2 2015-10-20 13:28:34 +02:00
Ricki Hirner 6465d83da4 Better handling of contacts without N/FN 2015-10-20 13:23:01 +02:00
Ricki Hirner 0f5f39a9fe Lower target SDK to 22 (pre-M) to fixes crashes on Android 6; new permissions model will be implemented later 2015-10-20 12:56:48 +02:00
Ricki Hirner 3e2459c85c 2 bug fixes
* ical4android: enumerate (=synchronize) all task lists and not only the first one
* fix crash bugs when activating external logging without external storage
2015-10-20 12:04:31 +02:00
Ricki Hirner 8f52bf160e Version bump to 0.9.0.1
* with minor fixes
2015-10-19 19:04:01 +02:00
Ricki Hirner 661276450c SSLSocketFactoryCompatTest 2015-10-19 16:55:01 +02:00
Ricki Hirner c93a89348e Handle event/task sequence == null (meaning it was created locally and not sequence has yet been assigned) 2015-10-19 16:44:37 +02:00
Ricki Hirner 93464ccf8c Enable TLSv1.1 and TLSv1.2 (if available) for Android <5 again 2015-10-19 15:16:44 +02:00
Ricki Hirner 3646a561c6 Remove Robohydra (obsoleted by okhttp-mockwebserver) 2015-10-19 13:18:21 +02:00
Ricki Hirner da9410c1b5 Fix lint warnings, don't require external storage permission for SDK >18 2015-10-19 11:57:43 +02:00
Ricki Hirner 82f80fed1c Resource detection fixes
* check TXT records for <service>._tcp.domain.tld instead of domain.tld
* duplicate log to ADB for successful resource detection
2015-10-19 01:12:06 +02:00
Ricki Hirner 94770fb0c8 Version 0.9 ready!
* fix lint warnings
* line-break too long messages of network trace logs
* DebugInfoActivity "send": attach log file instead if sending it as plain text
* revert to ez-vcard 0.9.6 because of https://github.com/mangstadt/ez-vcard/issues/33
* German translations
2015-10-19 00:19:29 +02:00
Ricki Hirner 9ddcec5624 Changed source strings 2015-10-18 19:36:03 +02:00
Ricki Hirner 4b5cb30762 Log resource detection results to viewable string
* new StringLogger
* DavResourceFinder: log to StringLogger; if no collections are found, logs can be views
* DebugInfoActivity: show passed logs
* script to fetch translations from Transifex
* increase version to 0.9-beta2
2015-10-18 17:30:26 +02:00
Ricki Hirner 58f05986c9 Synchronization logging to external file
* use ExternalFileLogger to log synchronization, if enabled in Settings
* new settings: log to external file / log verbose
* DavResource: check for well-known even if service type of user-given URL can't be determined
* remove oblsete testing assets
2015-10-18 16:20:26 +02:00
Ricki Hirner dd50f10c58 Merge translations from Transifex 2015-10-17 22:42:45 +02:00
Ricki Hirner d3c1688407 Improve DavResourceFinder
* check whether user-given URL actually provides CalDAV/CardDAV before trusting the current-user-principal
  as there may be different principals for CalDAV and CardDAV (if both services are completely separated)
2015-10-17 19:13:16 +02:00
Ricki Hirner 80231dd44b Sync manager optimization
* allow cancellation of synchronization within appropriate time
* sync error notification: use loader, show all accounts, show whether JB Workaround is installed, reorder
2015-10-17 11:33:35 +02:00
Ricki Hirner 4ecca76a95 Group support (VCard 3 CATEGORIES) with vcard4android
* VCard 3-style group support (CATEGORIES)
* sync error notification improvements
* some tests
2015-10-16 23:06:35 +02:00
Ricki Hirner 410a04dc11 Support Basic and Digest auth 2015-10-16 19:30:50 +02:00
Ricki Hirner 7fc01503d5 New collection/service discovery: CalDAV+CardDAV 2015-10-16 12:40:44 +02:00
Ricki Hirner 18542adb2c New resource detection
* new resource detection: only CalDAV yet
2015-10-16 03:27:56 +02:00
Ricki Hirner e34abf291e Improve error/account settings notifications
* move address book settings from account user data to ContactsContract.SyncState
* remove "VCard4 capable?" setting (as it's detected at every sync)
* show user notification when updating settings version or when Android version was increased
* improve stack trace in DebugInfoActivity
* get rid of Guava (use Commons again)
2015-10-15 15:36:55 +02:00
Ricki Hirner 20bc5af4a3 Resource detection, bug fixes
* resource detection is subject to change yet
* don't use UID_2445 for Android <= 4.1
* more useful sync error notification messages
* handle 401 Unauthorized and show account info when notification is tapped
2015-10-15 13:46:19 +02:00
Ricki Hirner f344bd3c28 Tasks with new sync logic 2015-10-15 00:49:15 +02:00
Ricki Hirner 419d732195 Process recurring events, exceptions etc. 2015-10-14 21:45:19 +02:00
Ricki Hirner 0c819c842b Basic implementation of calendar sync. with common SyncManager 2015-10-14 18:20:51 +02:00
Ricki Hirner d348f54deb Remove legacy calendar/task/WebDAV code 2015-10-14 13:38:18 +02:00
Ricki Hirner c2e9b27831 New DebugInfoActivity
* DebugInfoActivity shows and allows to share sync exceptions
* log sync phase
2015-10-14 12:23:02 +02:00
Ricki Hirner 808958a69b README changes 2015-10-13 11:27:33 +02:00
Ricki Hirner bd77a5be63 Integrate MemorizingTrustManager by Georg Lukas 2015-10-13 02:34:24 +02:00
Ricki Hirner ab34def8b0 Contacts sync logic
* download external resources (contact images)
* improve ETag handling
* contacts: set UNGROUPED_VISIBLE to 1
2015-10-12 14:16:26 +02:00
Ricki Hirner d024cdb495 Contact synchronization logic
* use VERSION_CODE and buildTime from BuildConfig
* new HTTP User-Agent, VCard PRODID values
* contact sync: store CTag in SyncState
* sync logic: upload contacts, check CTag, multiget
2015-10-12 01:59:05 +02:00
Ricki Hirner 4f7f3b851a New sync logic for ContactsSyncAdapter, using dav4android and vcard4android 2015-10-11 22:34:03 +02:00
Ricki Hirner 7f4b4855a0 First implementation of CardDAV sync with dav4android and vcard4android
* try to get rid of Apache Commons
2015-10-10 23:30:38 +02:00
Ricki Hirner bc2d1ba96d Resource detection with dav4android
* handle authentication (only Basic auth yet)
* rewrite DavResourceFinder to use dav4android
2015-10-10 15:47:44 +02:00
Ricki Hirner 0bc1a8178a First use of dav4android for resource detection
* replaced Apache httplib by gradle version because it will be removed completely anyway
2015-10-10 02:15:53 +02:00
Ricki Hirner d0b928a93d Make well-known URLs work again when user enters an initial context path 2015-09-22 12:19:39 +02:00
Ricki Hirner b0163e16cd Merge branch 'french_translations' of https://github.com/callmemagnus/davdroid 2015-09-15 17:53:23 +02:00
Ricki Hirner 98899ab27b Fix UI crash bug 2015-09-15 17:37:36 +02:00
Magnus Anderssen e4e1053f77 Added missing french translations 2015-09-06 21:22:43 +02:00
rfc2822 bcd2e8d4da Merge pull request #628 from gjtoth/master
Hungarian translation updated.
2015-09-06 13:16:02 +02:00
Ricki Hirner f7700ba8aa Update README 2015-09-05 01:35:02 +02:00
Ricki Hirner a198309df5 Version update to 0.8.4.1
* minor (crash) bug fixes
* updated translations
2015-08-31 16:31:12 +02:00
Gábor J.Tóth c1a26fbbb7 Hungarian translation updated. 2015-08-28 17:46:24 +02:00
Ricki Hirner 5bf3aad575 Version bump to 0.8.4 2015-08-25 22:06:33 +02:00
Ricki Hirner 97ae121331 Exception handling, verbose TLS logs
* handle IllegalArgumentException in Tasks provider (show LocalStorageException notification) (closes #601)
* add more verbose TLS cipher logs (see #608)
2015-08-25 22:04:45 +02:00
Ricki Hirner 31f5be01b4 ical4j update, clean up XML requests
* ical4j update to 2.0-beta1 (fixes #509, fixes #606)
* only run sync adapters in :sync process, set thread context class loaders appropriately
* remove "class" attribute from XML requests (fixes #615)
2015-08-25 21:18:29 +02:00
Ricki Hirner d7fff8a760 Handle attendees and reminders for exceptions of recurring events 2015-08-10 11:54:05 +02:00
Ricki Hirner faeb3b7dd0 Refactoring
* VEvent: don't set LAST-MODIFIED to sync time (should be last modification time which is not available)
* ignore 403 Forbidden when uploading (can happen on certain scheduling conditions)
2015-08-10 00:33:26 +02:00
Ricki Hirner fc1874af85 Remove unnecessary getters/setters
* remove getters/setters for protected fields when they're only accessed from package scope
* version bump to 0.8.3
2015-08-09 20:02:37 +02:00
Ricki Hirner be80b6fde8 Improve ATTENDEE/ORGANIZER handling 2015-08-08 15:39:58 +02:00
Ricki Hirner 072c763dec Process Content-Type character set information (fixes #594) 2015-08-06 15:57:06 +02:00
Ricki Hirner 6ad74c79f0 Improve event exception handling (always convert RECURRENCE-ID DATE-TIME to DATE when master event is all-day) 2015-08-06 14:11:39 +02:00
Ricki Hirner 01d1b1a6c0 Send used VTIMEZONEs with VTODOs 2015-08-03 16:54:10 +02:00
Ricki Hirner 1c461e9d13 Refactoring
* WebDavResource: properties in separate subclass
* improve time zone handling
* always provide task list color
2015-08-03 15:53:19 +02:00
Ricki Hirner 5ec4dbb9e7 Send charset information with MIME type when uploading VCard/3.0 resources 2015-08-02 16:35:02 +02:00
Ricki Hirner 3225a4bbc1 Detect VCard/4 support per sync, too 2015-08-02 16:24:00 +02:00
rfc2822 ece6be0f9d Merge pull request #593 from svetlemodry/master
Czech translation update
2015-08-02 14:47:04 +02:00
Jaroslav Lichtblau 40c6643b41 Czech translation update
for davdroid
2015-08-02 12:33:41 +02:00
Ricki Hirner b3afe48179 Added uninstall warning in "Install Tasks app" string (fixes #589) 2015-08-02 09:29:28 +02:00
Ricki Hirner abf04e14d2 Update collection properties (name, color) on every sync 2015-08-02 08:57:03 +02:00
Ricki Hirner 5b7947034a Convert RDate/ExDate properties <-> Android RDATE/EXDATE strings more precisely (+ tests) 2015-08-01 13:25:35 +02:00
Ricki Hirner 26d9f7284a Version bump to 0.8.2 2015-07-29 21:59:36 +02:00
Ricki Hirner 7c1b787410 VEVENT exceptions always get master UID
* make sure that VEVENT exceptions always the the UID of the master event (fixes #523)
2015-07-28 18:19:41 +02:00
Ricki Hirner 41bae221f0 Asset downloader: send credentials when URI authority is the same, even if the default port is explicitly given 2015-07-28 16:23:15 +02:00
Ricki Hirner 243483a957 Improved iCal generation
* move shared code to new iCalendar class
* generate UIDs and file names with "_" instead of "@" to reduce encoding problems (closes #585)
* tasks: validate "start date" and "completed at" time zones
2015-07-28 15:29:54 +02:00
Ricki Hirner 9d76d57af8 Fix problem of recent commit with deleting local records 2015-07-28 15:04:57 +02:00
Ricki Hirner 44bdd4d0ed Merge branch 'master' of github.com:bitfireAT/davdroid 2015-07-28 14:48:32 +02:00
rfc2822 40bffb78b0 Merge pull request #580 from oskarjakiela/master
Add Polish translation
2015-07-28 14:48:02 +02:00
Oskar S. Jakieła 5951414b25 Add Polish translation 2015-07-20 21:12:53 +02:00
Ricki Hirner dcd86c7d86 Small refactoring 2015-07-18 15:15:36 +02:00
Ricki Hirner 92966a5c57 Null-pointer check for SIP address types (fixes #506) 2015-07-18 00:52:44 +02:00
Ricki Hirner ad733ebff1 Handle 409 Conflict status codes (fixes #563) 2015-07-07 00:25:49 +02:00
Ricki Hirner 59088086fd Version bump to 0.8.1
* use slf4j-android as it's required by ical4j/2
* disable i18n lint warnings
* retain ServerInfo when activity is re-created (fixes #543)
2015-07-06 23:48:35 +02:00
Ricki Hirner 0b56d2a966 Add trailing slash to sample URL (closes #522) 2015-07-06 00:27:00 +02:00
Ricki Hirner ed2a0419ad Specify encoding details of member names passed to WebDavResource (fixes #482) 2015-07-05 23:51:53 +02:00
rfc2822 c6950b1c16 Merge pull request #504 from svetlemodry/master
Czech translation for davdroid
2015-07-05 22:18:06 +02:00
Ricki Hirner a796a1e9b3 Library updates
* use ical4j/2.0.x instead of 1.0.x (thanks @benfortuna)
* use Apache Commons 3.x instead of 2.x
* code optimizations
2015-06-14 20:35:28 +02:00
Ricki Hirner c8cfbd6b07 Check for null values of Events.ORIGINAL_ALL_DAY (should fix #551)
* fix indentation
2015-06-14 12:39:36 +02:00
Ricki Hirner 654af1eec5 Check for null values in relations (should fix #547) 2015-06-14 12:07:04 +02:00
Ricki Hirner 534953fe4c Check for null values in StructuredPostal.TYPE (should fix #549) 2015-06-14 12:00:34 +02:00
Ricki Hirner 18c08bc9dd Don't disable per-session cookie management + test (closes #525) 2015-06-12 00:41:21 +02:00
Ricki Hirner 81d7813614 Set _DIRTY=0 for new tasks explicitly (fixes #524) 2015-06-11 23:58:03 +02:00
rfc2822 b7f0a9efa9 Merge pull request #519 from pejakm/srup
Update Serbian translation
2015-05-27 23:46:29 +02:00
Mladen Pejaković 915ed7199b Update Serbian translation 2015-05-27 23:20:36 +02:00
Ricki Hirner 2665f6c4e6 Add missing files (fixes #517) 2015-05-27 15:51:31 +02:00
rfc2822 13ec5a93ae Merge pull request #518 from phy25/values-zh-CN
Translations in zh-rcn for v0.8.0
2015-05-27 15:07:23 +02:00
phy25 a160d56643 Translations in zh-rcn for v0.7. 2015-05-27 20:22:27 +08:00
Ricki Hirner c3f7c1b97e Extra icon for tasks in "Select collections" fragment 2015-05-27 12:04:06 +02:00
Ricki Hirner bc7e58232e Version bump to 0.8.0
* update to Lombok 1.16.4 and dnsjava 2.1.7
* optimize imports and copyrights
* delete Note data class (will be implemented later)
2015-05-27 11:21:31 +02:00
Ricki Hirner f3e83922f7 Version bump to 0.8.0-beta3
* don't offer to install Tasks from InstallAppsFragment, show instructions instead
* fix crash when displaying "todo lists" heading in "selection collections" fragment (fixes #512, fixes #513)
* use Android SDK build tools v22.0.1
2015-05-27 10:13:03 +02:00
Ricki Hirner af011a65db Sync tasks
* remove VJOURNAL/notes sync (will be implemented later)
* setup: add "install Tasks app" fragment
* version bump to 0.8.0-beta1
* use Tasks instead of Mirakel
* handle task list colors
* allow independent selection of calendar/task sync for the same CalDAV calendar
* minor refactoring (don't use return value of Builder)
* handle more task fields and time zones
* sync interval setting for tasks
2015-05-25 19:54:16 +02:00
Ricki Hirner aa7e582bc9 Sync notes and tasks 2015-05-22 03:06:30 +02:00
Jaroslav Lichtblau 03517584f2 Czech translation for davdroid
updated
2015-05-17 11:48:40 +02:00
Ricki Hirner 5f3c6045d8 Implement remote filters to fetch only CalDAV resources with useful components (VEVENT for now) 2015-05-15 23:35:27 +02:00
Ricki Hirner cd513683f5 Version bump to 0.7.7
* SettingsActivity: up navigation
* tests
2015-05-15 14:47:03 +02:00
Ricki Hirner 011dd15c98 Handle Android "INTEGER (boolean)" values which are read as CharSequences correctly (fixes #503) 2015-05-15 02:31:53 +02:00
rfc2822 8fc86dd12c Merge pull request #498 from dehart/master
Added dutch translation file
2015-05-12 18:42:59 +02:00
Michael de Hart 4e690a02ad Added dutch translation file 2015-05-12 14:56:29 +02:00
Ricki Hirner a3ebd72321 Version bump to 0.7.6
* additional test
* minor code optimizations
2015-05-09 13:29:28 +02:00
Ricki Hirner 87df8f880d Process multiple RDATE/EXDATE values (see #340, see #495) 2015-05-09 11:55:35 +02:00
Ricki Hirner 97633c5204 EXDATE processing
* don't ignore the time zone of EXDATEs (fixes #495)
2015-05-08 17:46:10 +02:00
Ricki Hirner 33958ab548 Better reminder (VALARM) handling
* handle WEEKS in duration correctly (fixes #398)
* handle positive and negative TRIGGER duration values correctly
2015-05-03 22:44:15 +02:00
Ricki Hirner f19d528739 Use EntityEvent to populate entities from local DB 2015-05-03 21:56:31 +02:00
Ricki Hirner 365e04154b Use RawContactsEntity to query raw contact data 2015-05-03 17:09:20 +02:00
Ricki Hirner c707b1eb9d RDATE processing
* don't ignore the time zone of RDATEs (see #340)
2015-05-02 11:39:25 +02:00
Ricki Hirner 5e9fe92520 New target SDK: API level 22 (Android 5.1) 2015-05-02 10:55:31 +02:00
Ricki Hirner f1eabb6227 Support relations the VCard 4.0 way (closes #278) 2015-05-01 02:47:08 +02:00
Ricki Hirner b5c99265c3 Version bump to 0.7.5
* account settings: show whether CardDAV server supports VCard 4.0
* CardDAV GET: ask for VCard 3.0 or VCard 4.0 (preferred) contacts
* CardDAV multiget: ask for VCard 4.0 contacts if the server supports it
* CardDAV PUT: send VCard 4.0 contacts if the server supports it
* import Apache httpclient-android rev. 1652769 correctly (hopefully fixes #491)
2015-05-01 00:36:12 +02:00
Ricki Hirner f738f74dea Use source version of apache-httpclient
We can't use a repository version because there's no release yet which
contains rev. 1652769. However, this revision is necessary to get SNI with Android <4.2, too.
To avoid packaging a pre-compiled jar, this source lib has been added as a subproject:

apache-httpclient, branch 4.3.5-android, revision 1652769

Fixes #491.
2015-04-30 14:48:28 +02:00
Ricki Hirner fb33767e57 Process <status> in multi-get responses without <propstat> (see #475) 2015-04-29 20:04:56 +02:00
Ricki Hirner 8afc55dff3 Merge branch 'master' of github.com:bitfireAT/davdroid 2015-04-29 00:25:49 +02:00
Ricki Hirner 9a63dd4693 Version bump to 0.7.3 2015-04-29 00:25:07 +02:00
Ricki Hirner 2683012ec3 Test parsing recurring events with exceptions
* adapted tests
* use org.apache.httpcomponents:httpclient-android:4.3.5.1 again because it seems to contain all necessary fixes
2015-04-29 00:21:15 +02:00
rfc2822 a79a59f409 Merge pull request #490 from FabianoK/master
Portugues Brazilian translation
2015-04-28 22:29:49 +02:00
Fabiano Sardenberg Kuss - COCOE/COASC/COSAM 81eabf5961 Portugues Brazilian translation 2015-04-28 17:16:52 -03:00
Ricki Hirner 495cdf7c7e Synchronize exceptions of recurring events to the Calendar storage (server to client)
* Event class finds and processes exceptions of recurring events
* workaround for iCloud and other services that provide RECURRENCE-ID as DATETIME even if the original event is an all-day event
* VEvents are generated with all time zone definitions (including time zone definitions of exceptions)
2015-04-28 13:50:06 +02:00
Ricki Hirner f6eee6c910 Handle dirty flag of exceptions of recurring events
* mark events as dirty when its exceptions are dirty/deleted
* when (mass-)deleting events, delete corresponding exceptions too
2015-04-28 11:08:22 +02:00
Ricki Hirner a405d07baf Sync recurring event exceptions to CalDAV server
* added SQL filter possibility to generic LocalCollection
* added exceptions of recurring events to Event
* process exceptions of recurring events in LocalCalendar
2015-04-28 01:36:01 +02:00
rfc2822 2696e64a83 Merge pull request #486 from astalavister/master
Russain language added
2015-04-25 21:36:16 +02:00
astalavister 6d6835c3b7 Russian language translation v. 0.7 2015-04-24 12:41:53 +08:00
astalavister 0eb6a56ef1 Russian language translation v. 0.7 2015-04-24 12:32:01 +08:00
Ricki Hirner df335335d2 Generate VCARD N property for prefix- and suffix-only contacts, too (closes #469) 2015-03-29 15:13:10 +02:00
Ricki Hirner 35011445e0 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 08789bbb2c Improve time-zone detection in VEVENTs 2015-03-29 14:57:12 +02:00
Ricki Hirner 001b445222 Merge branch 'master' of github.com:bitfireAT/davdroid 2015-03-29 14:44:34 +02:00
rfc2822 7d5ed0bd11 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 392e9f963e 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 8d5f815be5 Time zone guessing improved, fixes 'Assuming time zone Etc/GMT for Etc/GMT-2' 2015-03-28 10:15:40 +01:00
rfc2822 100aa665f5 Merge pull request #471 from xphnx/patch-3
Update spanish translation
2015-03-23 16:04:21 +01:00
xphnx caf7adb18a Update spanish translation 2015-03-23 15:27:27 +01:00
rfc2822 13b8c86ce3 Merge pull request #467 from pejakm/srtr
Update Serbian translation
2015-03-13 18:33:37 +01:00
Mladen Pejaković a15185372d Update Serbian translation 2015-03-13 15:47:18 +01:00
rfc2822 03d6dfef02 Merge pull request #466 from gjtoth/master
Updated Hungarian translation
2015-03-12 23:24:03 +01:00
Gábor J.Tóth a021b973fe Updated Hungarian translation. 2015-03-12 18:48:02 +01:00
Ricki Hirner 7e592d7647 Bug fixes
* show "Settings" in Android Settings/Accounts again
2015-03-12 18:25:10 +01:00
Ricki Hirner 38a4f3f53c 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 986213dda5 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 d98a9d3673 Czech translation update for v0.7 2015-03-12 18:25:09 +01:00
phy25 12cb0ff8fe Translations in zh-rcn for v0.7. 2015-03-12 18:25:09 +01:00
Ricki Hirner 3a8f17cc2e 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 9b52e51e4e 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 be235c39b0 Bug fixes
* show "Settings" in Android Settings/Accounts again
2015-03-11 14:56:39 +01:00
Ricki Hirner 1acd2e1a55 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 23dba581e1 Fix Settings rendering issues
* fix Settings rendering issues (fixes #459)
* version bump to 0.7.1
2015-03-11 14:01:43 +01:00
rfc2822 b29756aff2 Merge pull request #461 from phy25/values-zh-CN
Translations in zh-rcn for v0.7
2015-03-11 12:20:32 +01:00
rfc2822 4e2809910c Merge pull request #462 from svetlemodry/master
Czech translation update for v0.7
2015-03-11 12:20:16 +01:00
Jaroslav Lichtblau 9394c10d29 Czech translation update for v0.7 2015-03-09 16:34:06 +01:00
phy25 6d55de7c1d Translations in zh-rcn for v0.7. 2015-03-09 20:28:52 +08:00
Ricki Hirner 933f99b563 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 aeca582a7c 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
R Hirner 6c998c31c3 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
R Hirner 1a796ade60 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
R Hirner 9e082d930b 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
R Hirner a7115ad39c 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
R Hirner 4eb03f78f5 Version bump to 0.6.10 2015-01-01 21:51:28 +01:00
R Hirner a6a7420935 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
R Hirner 3cba163c3b Handle absolute URIs in resource detection (+ tests) 2015-01-01 18:53:03 +01:00
R Hirner ca0ad612a7 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
R Hirner 897ede7582 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
R Hirner 6f5748c464 Handle resources with "%" and other special characters in file name more accurately (fixes #401) 2014-12-27 22:42:57 +01:00
R Hirner 0423e00ffd 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
R Hirner ac46b8f45f Mention used Apache HttpClient Android port 2014-12-26 18:57:51 +01:00
R Hirner 142e229ff3 Handle relative URIs with ":" in path names correctly 2014-12-26 18:53:22 +01:00
R Hirner e65a1b5f16 Minor code tweaks (by lint) 2014-12-21 11:24:00 +01:00
R Hirner bfbd4569ed Temporarily use patched version of HttpClient-Android 4.3.5 to avoid Basic Authorization bug 2014-12-21 10:50:53 +01:00
R Hirner a52e3507e7 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
R Hirner 49f7bbd8da version bump to 0.6.9
* fix i18n
2014-12-20 23:38:37 +01:00
R Hirner 8a75552a4c 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
R Hirner 5ce03abad6 Re-enable tests again 2014-12-20 23:33:02 +01:00
R Hirner a7e8221e26 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
R Hirner 0cc23e3ecf Merge with origin/master 2014-12-20 21:15:08 +01:00
R Hirner 661738c866 Merge remote-tracking branch 'origin/master' into android-studio
Conflicts:
	.gitignore
2014-12-20 20:40:28 +01:00
R Hirner d56175652c 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
rfc2822 760632302d 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
rfc2822 5b21cb4938 Use https links for davdroid.bitfire.at 2014-12-05 21:26:34 +01:00
rfc2822 03a64a43a3 Mark as compatible with Android 5.0 2014-12-01 01:17:01 +01:00
rfc2822 1ad25a748a Merge branch 'master' of github.com:bitfireAT/davdroid 2014-11-30 21:07:49 +01:00
rfc2822 51ac34790c 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
rfc2822 bc8d63f233 Bug fixes
* fix a bug in handling TXT records (hopefully fixes #383)
* fix invalid translation strings
2014-11-30 18:09:31 +01:00
rfc2822 9452204f5c Bug fixes
* fix a bug in handling TXT records (hopefully fixes #383)
* fix invalid translation strings
2014-11-29 22:52:51 +01:00
rfc2822 e98f0bd1ed Merge pull request #376 from pokoli/update_catalan_translation
Update catalan translation
2014-11-22 22:20:40 +01:00
Sergi Almacellas Abellana dba35a5296 Update catalan translation 2014-11-22 18:20:19 +01:00
rfc2822 751e2eb5a4 Merge pull request #375 from gjtoth/master
Updated Hungarian translation.
2014-11-22 11:51:46 +01:00
jtg 4bf613c28b Updated Hungarian translation. 2014-11-22 00:54:32 +01:00
rfc2822 3e87f045d5 Merge pull request #372 from svetlemodry/master
Updated Czech translation
2014-11-19 13:03:12 +01:00
Jaroslav Lichtblau 3f1740bc1c Updated Czech translation 2014-11-18 20:50:22 +01:00
rfc2822 c8da282db0 Improve handling of unknown timezone definitions (should fix #333) 2014-11-15 13:49:38 +01:00
rfc2822 6f0b9421c1 Don't require # for calendar colors (see #136, closes #238) 2014-11-15 12:58:38 +01:00
rfc2822 4b250dcb66 Merge pull request #364 from phy25/values-zh-CN
Updated the Chinese Simplified translations
2014-11-14 20:39:19 +01:00
rfc2822 b6386c1b95 Merge pull request #365 from pejakm/srupd
Updated Serbian translations
2014-11-14 20:39:09 +01:00
Mladen Pejaković 378214f62a Oops... 2014-11-14 19:59:11 +01:00
Mladen Pejaković c10fc6dfd1 [Translations] Update Serbian 2014-11-14 19:54:55 +01:00
phy25 cc1a1741f9 Updated the Chinese Simplified translations according to the simplified strings.xml. 2014-11-14 01:02:19 +08:00
rfc2822 83eecc0c72 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
rfc2822 cfc71542f5 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
rfc2822 487509cb0c Add license info for dnsjava and some copyright notes 2014-11-08 17:57:14 +01:00
rfc2822 31f3fc80ef Merge branch 'master' of github.com:rfc2822/davdroid
Conflicts:
	res/values-hu/strings.xml
2014-11-08 17:38:27 +01:00
rfc2822 2e6a3efd25 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
rfc2822 2f5622edaf CalDAV/CardDAV service discovery with SRV/TXT records
* Structural changes in the strings file (for translations)
2014-11-08 16:33:34 +01:00
rfc2822 d73f4c5937 Merge pull request #354 from gjtoth/master
Refined Hungarian translation.
2014-11-05 20:34:50 +01:00
jtg 4c2e66d44c Refined Hungarian translation. 2014-11-05 15:44:50 +01:00
rfc2822 8d4c353d8c Initial support for SRV/TXT service discovery 2014-11-04 23:04:24 +01:00
rfc2822 7bfcb2d8ad Merge pull request #347 from gjtoth/master
Attribution for translations added to the Hungarian version.
2014-11-04 09:06:25 +01:00
rfc2822 e3a7c7092e Version bump to 0.6.6 2014-11-03 20:24:10 +01:00
rfc2822 65376c2d42 Show Website icon in main activity only if there's enough room 2014-11-03 19:39:55 +01:00
rfc2822 58efd9ba03 Messing around with trailing slash again and again + tests (fixes #349) 2014-11-03 19:30:07 +01:00
jtg fd4e5921ea Merge remote-tracking branch 'upstream/master' 2014-11-01 21:37:28 +01:00
jtg 6d9fc8734f Attribution for translations added. 2014-11-01 21:31:11 +01:00
rfc2822 7501f87d03 Merge pull request #346 from gjtoth/master
Hungarian translation added. Thanks to @gjtoth
2014-11-01 21:06:38 +01:00
jtg 4205bf34fd Hungarian translation added. 2014-11-01 19:58:34 +01:00
rfc2822 25c1a1ad51 Mention Chinese translator 2014-10-31 17:36:59 +01:00
rfc2822 69c94c8d93 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
rfc2822 e9901f38f5 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
rfc2822 b8a728bdb9 Add TLS cipher info to tests (see #344) 2014-10-31 15:39:12 +01:00
rfc2822 1ec1db3045 Ensure trailing slashes are always used for collections + tests 2014-10-31 15:29:00 +01:00
rfc2822 b2bd53f36f Merge branch 'master' of github.com:rfc2822/davdroid 2014-10-27 15:18:58 +01:00
rfc2822 3601678fe8 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
rfc2822 7cdf71fcdc 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 97ec7ca721 Added Chinese Simplified translation. 2014-10-18 19:41:15 +08:00
rfc2822 9e49de1116 Allow adb backups (not Google cloud backups!) for DAVdroid because of user request 2014-10-17 15:33:00 +02:00
rfc2822 7e20bcd6f5 Merge branch 'master' of github.com:rfc2822/davdroid 2014-10-10 13:00:36 +02:00
rfc2822 428c09c390 Send appropriate Accept/Content-Type headers + tests (closes #328) 2014-10-10 12:59:53 +02:00
rfc2822 c1bd4e2156 Merge pull request #329 from pejakm/srupd
Serbian translation fixes and updates
2014-10-07 10:02:59 +02:00
Mladen Pejaković 0b5aa52642 Serbian translation fixes and updates 2014-10-03 20:30:20 +02:00
rfc2822 ebe13cef5e Setup: always show 401 Unauthorized errors
* also increase version code and version to 0.6.3
2014-10-02 17:05:20 +02:00
rfc2822 5e5da95e23 Version bump to 0.6.3
Add translator info
2014-10-02 15:37:36 +02:00
rfc2822 07ec3c1a0a Don't fail to detect resources if principal is not available for CalDAV/CardDAV 2014-10-02 12:04:29 +02:00
rfc2822 b8e6ff4627 Merge pull request #318 from svetlemodry/master
Czech translation – thanks to @svetlemodry
2014-09-30 18:07:50 +02:00
rfc2822 732fe8151b Merge pull request #324 from pejakm/srtr
[Translations] Add Serbian – thanks to @srtr
2014-09-30 18:07:17 +02:00
Mladen Pejaković aeaff0a0e6 Serbian translation fixes 2014-09-25 22:19:54 +02:00
Mladen Pejaković 7a3867b95c [Translations] Add Serbian 2014-09-25 16:30:08 +02:00
Jaroslav Lichtblau 6b52d03002 typo/missing string fix 2014-09-11 12:48:51 +02:00
Jaroslav Lichtblau a286b485e3 Czech translation file
czech translation of davdroid text strings
2014-09-11 12:44:07 +02:00
rfc2822 88972aed1d Set STARRED as integer, not boolean (should fix #294) 2014-08-10 18:56:19 +02:00
rfc2822 225ffc07cf Version bump to 0.6.2 2014-08-09 10:29:09 +02:00
rfc2822 1235a5e45a 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
rfc2822 bdee53b5ab 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
rfc2822 2c79ae20e5 Move resource detection to separate class + tests 2014-07-27 13:01:54 +02:00
rfc2822 75e5a59948 Don't check capabilities on principal URL but on calendar/address book home set (fixes #286) 2014-07-25 00:04:35 +02:00
rfc2822 02d72e4f51 Version bump to 0.6.1; show exceptions from resource detection (see #284, see #286) 2014-07-24 23:12:48 +02:00
rfc2822 9a809c9761 Add group support via VCard CATEGORIES (closes #48) 2014-07-24 23:12:43 +02:00
rfc2822 d712238700 Handle redirections to relative URLs correctly (see #282) + tests; minor GUI change 2014-07-20 16:08:02 +02:00
rfc2822 1cde020619 Version bump to 0.6 (drop -alpha suffix) 2014-07-19 00:44:28 +02:00
rfc2822 4be031e274 Add support for well-known URIs (RFC 5785) - closes #148 2014-07-19 00:39:07 +02:00
rfc2822 cf40cb2ebc Handle HTTP redirections (fixes #83) 2014-07-18 22:31:45 +02:00
rfc2822 1678873885 Don't crash when Calendar Provider is not available at account creation
(fixes #276)
2014-07-12 13:24:49 +02:00
rfc2822 e78151a244 Allow only one anniversary to comply with VCard 4 (fixes #277) 2014-07-12 13:08:00 +02:00
rfc2822 b7cd8baa05 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
rfc2822 3cab688782 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
rfc2822 7e7d36584c 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
rfc2822 334dfb13d4 Ignore crappy unknown properties with illegal syntax (see issue #118) 2014-05-09 15:55:05 +02:00
rfc2822 13cfe3ce98 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
rfc2822 3e4efe8b82 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
rfc2822 ec94fe61fd Make server URL, user name and password horizontally scrollable (fixes
#241)
2014-05-08 15:57:43 +02:00
rfc2822 624f33c746 Support HTTP(S) proxies (closes #232) 2014-05-07 22:53:22 +02:00
rfc2822 f69f449b44 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
rfc2822 0965c8d3a3 Version bump to 0.5.12 2014-04-05 12:46:00 +02:00
rfc2822 b8df91e12d 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
rfc2822 f298d9dcb6 don't close httpClient while syncing 2014-04-04 21:18:11 +02:00
rfc2822 1e2051038c 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
rfc2822 4c05dd1e45 Version bump to 0.5.11-alpha 2014-03-29 22:42:53 +01:00
rfc2822 65f7053c02 SNI support for Android <4.2, too (see issue #9) 2014-03-29 17:03:29 +01:00
rfc2822 f92d114c01 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 475cd87720 Escape reserved caracters on catalan translation to do not break the build 2014-03-26 20:58:33 +01:00
rfc2822 75f00735d6 Merge pull request #213 from pokoli/catalan_translation
Add catalan translations – thanks @pokoli
2014-03-26 19:59:12 +01:00
Sergi Almacellas Abellana 01dc0a1877 Add catalan translations 2014-03-26 19:50:41 +01:00
rfc2822 d22a2633af Version bump to 0.5.10.2-alpha 2014-03-25 19:17:12 +01:00
rfc2822 cba27426b7 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
rfc2822 c5905b44ed 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 195eb19840 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
rfc2822 84211564bc Add SNI support for Android 4.2+ for new API again (fixes #205) 2014-03-23 22:19:59 +01:00
rfc2822 a02b8a1b1e Adapted tests 2014-03-23 13:01:46 +01:00
rfc2822 e2f154c963 Upgrade to HttpComponents 4.3.2, version bump to 0.5.10 2014-03-23 01:12:32 +01:00
rfc2822 e0b21abc35 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
rfc2822 ea94fbee72 Show JB Workaround info when installed from Google Play 2014-03-16 16:07:40 +01:00
rfc2822 e38feed08b Check base URL validity with new URI() instead of new URL() (fixes #184) 2014-03-15 13:39:02 +01:00
rfc2822 e9a4a4f1ef Correct icon sizes to match Android iconography; translations 2014-03-15 12:32:45 +01:00
rfc2822 b1ffdbfa95 Fix a bug that deletes entities remotely even if they have changed remotely (closes #197) 2014-03-14 23:14:32 +01:00
rfc2822 8a02601cfc 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
rfc2822 a12942c606 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 70973dcc0a French translation that compiles 2014-02-27 15:57:01 +01:00
unknown a511b16211 French translation 2014-02-27 15:57:01 +01:00
xphnx 46fb5df191 Update strings.xml 2014-02-27 15:57:00 +01:00
xphnx 72e31a7330 Update strings.xml 2014-02-27 15:57:00 +01:00
rfc2822 2a6cfc5346 Bump version to 0.5.8.1, ignore VCards without VEVENTs (fixes #178) 2014-02-27 15:56:56 +01:00
rfc2822 6c4acad758 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
rfc2822 c7fe069b1f 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
rfc2822 972da39e4a 0.5.7-alpha1: crash bug fix 2014-02-02 19:21:51 +01:00
rfc2822 c5a4958d3a Merge pull request #165 from xphnx/patch-1
Spanish translation
2014-02-02 19:21:02 +01:00
xphnx a7aec7b4a9 Update and rename strings.xml to strings-es.xml
Spanish translation
2014-01-28 18:47:01 +01:00
rfc2822 ea31f5a621 Fix crash when ROLE/TITLE but no ORG value set (fixes #163) 2014-01-26 12:50:50 +01:00
rfc2822 ea06c4a7a1 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
rfc2822 fee6431981 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
rfc2822 9b992c54ca Fix regression (8a651f135b) causing unnecessary traffic (closes #162, closes #147) 2014-01-25 11:11:48 +01:00
rfc2822 4073880185 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
rfc2822 b3f145f6a8 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
rfc2822 4f1488ece5 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
rfc2822 4dc65d0144 Version bump to 0.5.5-alpha 2014-01-06 05:48:56 +01:00
rfc2822 43cc5c0e7b 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
rfc2822 983214d23b 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
rfc2822 520ec21d9e Use (final) static wherever possible for method signature clarity/performance 2013-12-31 19:13:22 +01:00
rfc2822 e667de8992 Allow vCard4 fields (ANNIVERSARY) in vCard3, too (closes #131). 2013-12-31 16:55:29 +01:00
rfc2822 8be6fdedd9 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
rfc2822 df012efe78 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
rfc2822 558075f1bb Minor improvements
* generalise davdroid.ArrayUtils.partition; tests
* lower multi-get limit to 35
2013-12-25 15:15:23 +01:00
rfc2822 4505a5958d 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
rfc2822 385e7b5e13 Fetch resources for uploading one-by-one to save memory 2013-12-24 12:49:18 +01:00
rfc2822 64068f7007 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
rfc2822 f4bb3639e6 throw RecordNotFoundExceptions only when specific records are requested 2013-12-21 20:54:54 +01:00
rfc2822 8a651f135b 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
rfc2822 6cfaad35b1 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
rfc2822 ec4fedf04e Version bump to 0.5.2-alpha 2013-12-11 17:08:10 +01:00
rfc2822 d30715f813 avoid NullPointerExceptions when contact data rows with custom types have no LABEL 2013-12-11 15:48:21 +01:00
rfc2822 3af25754c4 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
rfc2822 15e8e20bad 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
rfc2822 b35273a6d5 Version bump to 0.5.1; fix crash bug (fixes #108) 2013-12-08 14:00:48 +01:00
rfc2822 33b8aa8e05 Ignore invalid IM addresses (fixes #105), Lombok 1.12.2 2013-12-08 13:31:59 +01:00
rfc2822 eaa080cfe8 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
rfc2822 8c79c64d75 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
rfc2822 87b71b0b38 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
rfc2822 725c815e32 Fix crash bug when ORGANIZER has no URI scheme 2013-12-03 20:34:50 +01:00
rfc2822 1986837be8 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
rfc2822 44cf628dd6 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
rfc2822 3cbfcbee9b CalDAV: ignore collections without VEVENT support (for instance, VTODO collections) 2013-11-30 00:06:16 +01:00
rfc2822 8f7b48748b add DURATION iCal->Android, collection refactoring 2013-11-26 22:20:00 +01:00
rfc2822 4f9bd5bebd version bump to 0.4.3-alpha 2013-11-24 13:21:59 +01:00
rfc2822 0f09881eaf treat 404 on DELETE as success (fixes #94) 2013-11-24 13:20:44 +01:00
rfc2822 759e32b89b 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
rfc2822 55d9dc4357 security fix: validate certificates and not only host names (thanks Günter) 2013-11-24 03:07:50 +01:00
rfc2822 37ff088b78 version bump to 0.4.2
* imports calendar default time zone on account creation
2013-11-22 15:11:39 +01:00
rfc2822 af698c584b 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
rfc2822 1fc2679bd4 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
rfc2822 66d6ec34d1 Fix invalid URLs one more time 2013-11-17 13:04:57 +01:00
rfc2822 b802ed07d9 Preserve order in multiget requests (thanks Marten, fixes #65) 2013-11-15 11:21:03 +01:00
rfc2822 6889559fef SNI support (fixes #9); only available for API level >= 17 (Android 4.2+) 2013-11-15 01:05:18 +01:00
rfc2822 8e8bc1c772 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 e7e7759f41 fix NumberFormatException when parsing "ff" alpha value into a Byte 2013-11-13 22:09:14 +01:00
rfc2822 1e00ed217f import Apple calendar-color with r,g,b,a (see issue #31) 2013-11-12 22:17:46 +01:00
rfc2822 f8f10be3ba don't decode URI pathes (fixes #58), don't expect GET/POST/PUT on collections 2013-11-12 18:39:05 +01:00
rfc2822 5aa77e51a2 Version bump to 0.3.8 (version code 12) 2013-11-09 12:45:09 +01:00
rfc2822 c1f57a4470 Importing calendar colors from <calendar:color xmlns="http://apple.com/ns/ical/"> element (see #31) 2013-11-09 12:43:21 +01:00
rfc2822 477a3c89f7 avoid NullPointerException (hint from ANR) 2013-11-09 10:54:29 +01:00
rfc2822 ab517bd87d Always append trailing slash to address-book/calendar home set to avoid redirections 2013-11-09 01:36:09 +01:00
rfc2822 c753dcaa8e 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
rfc2822 362f0036be refactoring, RoboHydra tests 2013-11-09 01:04:35 +01:00
rfc2822 0626f1ecdc basic WebDAV tests 2013-11-03 01:40:24 +01:00
rfc2822 f85dbe934b Version bump to 0.3.7 (version code 11) 2013-11-02 16:18:17 +01:00
rfc2822 603758fcbc support for structured addresses (closes #29); bug fixes 2013-11-02 16:09:48 +01:00
rfc2822 fb671a67a4 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
rfc2822 ff4fe77fe3 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
rfc2822 da4443da62 Version bump to 0.3.6
* show app version in main activity
2013-10-30 17:27:44 +01:00
rfc2822 a45aed6914 don't follow redirects (see issue #52) 2013-10-30 17:20:04 +01:00
rfc2822 dbc89cb4fe better vcard parsing error logging, always append trailing slash to collection URLs 2013-10-30 15:14:19 +01:00
rfc2822 b6611131db 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
rfc2822 e3ba460991 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
rfc2822 68bd43fe6a 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
rfc2822 b2ab24afd2 Don't delete resources without file name (fixes #33) 2013-10-27 00:14:55 +02:00
rfc2822 a01c531784 Use correct caching for address book/calendar selection ListView 2013-10-27 00:03:41 +02:00
rfc2822 42929ddcf1 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
rfc2822 03a85da6e3 Commit after every 100 resource downloads to avoid TransactionTooLargeException 2013-10-26 22:37:45 +02:00
rfc2822 d15478b863 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
rfc2822 5db8bcb9d8 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
rfc2822 c87fb7bedd fix potential crash bug when server returns no collection members 2013-10-16 18:22:59 +02:00
rfc2822 59ded55ac1 version bump to 0.3.3-alpha 2013-10-16 00:18:00 +02:00
rfc2822 db1f2666e8 fixes issue #11 2013-10-16 00:09:29 +02:00
rfc2822 6ee010905e add contacts.xml and birthday (couldn't test it) 2013-10-15 23:06:01 +02:00
rfc2822 b9a13bf7de disable strict parsing of XML response for PROPFIND and multiget 2013-10-15 23:06:01 +02:00
rfc2822 7ab740d443 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
rfc2822 9a9d9709c9 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
rfc2822 6727987051 fix annoying bug that marked contacts as starred 2013-10-11 21:50:22 +02:00
rfc2822 5d3ef61b77 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
rfc2822 62ed38ee4f 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
rfc2822 c012512de1 refactoring
* ical4j-vcard instead of ez-vcard
* removed Guava dependencies in favor of Apache Commons IO
2013-10-06 13:48:53 +02:00
rfc2822 495b854bf2 Refactoring
* move common code to super-classes
* new icons
2013-10-05 20:34:26 +02:00
rfc2822 3729951b52 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
rfc2822 350171c20c biweekly 0.3 -> 0.3.1, incomplete time zone support for events, Android-compatible Lombok 2013-09-27 18:19:54 +02:00
rfc2822 90cf92bc16 new Android test project 2013-09-18 21:11:42 +02:00
rfc2822 f92a3d77b5 first commit 2013-09-18 20:03:36 +02:00
315 changed files with 9985 additions and 14703 deletions

No files matched your search

+4 -13
View File
@@ -1,8 +1,10 @@
image: registry.gitlab.com/bitfireat/davx5-ose:latest
image: registry.gitlab.com/bitfireat/davdroid:latest
before_script:
- git submodule update --init --recursive
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
- emulator64-x86 -avd 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:
@@ -10,20 +12,9 @@ cache:
test:
script:
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
- ./gradlew app:check app:connectedCheck
- ./gradlew check mergeAndroidReports
artifacts:
paths:
- app/build/outputs/lint-results-debug.html
- app/build/reports
- build/reports
pages:
script:
- ./gradlew app:dokka
- mkdir public && mv app/build/dokka public
artifacts:
paths:
- public
only:
- master-ose
+6 -6
View File
@@ -1,12 +1,12 @@
[submodule "dav4jvm"]
path = dav4jvm
url = https://gitlab.com/bitfireAT/dav4jvm.git
[submodule "dav4android"]
path = dav4android
url = ../dav4android.git
[submodule "ical4android"]
path = ical4android
url = https://gitlab.com/bitfireAT/ical4android.git
url = ../ical4android.git
[submodule "vcard4android"]
path = vcard4android
url = https://gitlab.com/bitfireAT/vcard4android.git
url = ../vcard4android.git
[submodule "cert4android"]
path = cert4android
url = https://gitlab.com/bitfireAT/cert4android.git
url = ../cert4android.git
+20 -19
View File
@@ -1,37 +1,38 @@
![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png)
DAVx⁵
[![build status](https://gitlab.com/bitfireAT/davdroid/badges/master/build.svg)](https://gitlab.com/bitfireAT/davdroid/commits/master)
DAVdroid
========
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
comprehensive information about DAVx⁵.
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) for
comprehensive information about DAVdroid.
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
DAVdroid is licensed under the [GPLv3 License](LICENSE).
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter /
[davdroid-announce](https://davdroid.bitfire.at/download/newsletter/) mailing list
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums)
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
or [purchasing it](https://www.davx5.com/download).**
**If you want to support DAVdroid, please consider [donating to DAVdroid](https://davdroid.bitfire.at/donate/)
or [purchasing it](https://davdroid.bitfire.at/download/).**
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
Parts of DAVx⁵ have been outsourced into these libraries:
Parts of DAVdroid have been outsourced into these libraries:
* [cert4android](https://gitlab.com/bitfireAT/cert4android) custom certificate management
* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) WebDAV/CalDav/CardDAV framework
* [dav4android](https://gitlab.com/bitfireAT/dav4android) WebDAV/CalDav/CardDAV framework
* [ical4android](https://gitlab.com/bitfireAT/ical4android) iCalendar processing and Calendar Provider access
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) vCard processing and Contacts Provider access
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) VCard processing and Contacts Provider access
USED THIRD-PARTY LIBRARIES
==========================
Those libraries are used by DAVx⁵ (alphabetically):
Those libraries are used by DAVdroid (alphabetically):
* [Color Picker](https://github.com/jaredrummler/ColorPicker) [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE)
* [dnsjava](http://www.xbill.org/dnsjava/) [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
* [ez-vcard](https://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)
* [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)
* [Project Lombok](http://projectlombok.org/) [MIT License](http://opensource.org/licenses/mit-license.php)
+47 -63
View File
@@ -9,41 +9,27 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.dokka-android'
android {
compileSdkVersion 29
buildToolsVersion '29.0.1'
compileSdkVersion 26
buildToolsVersion '26.0.1'
defaultConfig {
applicationId "at.bitfire.davdroid"
versionCode 301
minSdkVersion 16
targetSdkVersion 25
versionCode 164
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
buildConfigField "boolean", "customCerts", "true"
minSdkVersion 19 // Android 4.4
targetSdkVersion 28 // Android 9.0
multiDexEnabled true // >64k methods for Android 4.4
buildConfigField "String", "userAgent", "\"DAVx5\""
// when using this, make sure that notification icons are real bitmaps
vectorDrawables.useSupportLibrary = true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
dataBinding.enabled = true
flavorDimensions "distribution"
productFlavors {
standard {
versionName "2.5.5-beta1-ose"
versionName "1.7.1-ose"
buildConfigField "boolean", "customCerts", "true"
}
}
@@ -59,8 +45,13 @@ android {
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'GradleDependency'
disable 'IconColors'
disable 'IconLauncherShape'
disable 'IconMissingDensityFolder'
disable 'ImpliedQuantity', 'MissingQuantity'
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable 'Recycle' // doesn't understand Lombok's @Cleanup
disable 'RtlEnabled'
disable 'RtlHardcoded'
disable 'Typos'
@@ -71,54 +62,47 @@ android {
}
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
}
dependencies {
implementation project(':cert4android')
implementation project(':ical4android')
implementation project(':vcard4android')
compile project(':cert4android')
compile project(':dav4android')
compile project(':ical4android')
compile project(':vcard4android')
implementation 'androidx.multidex:multidex:2.0.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}"
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.fragment:fragment-ktx:1.1.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
implementation 'androidx.paging:paging-runtime-ktx:2.1.0'
implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha02'
implementation 'com.google.android:flexbox:1.1.0'
implementation 'com.google.android.material:material:1.1.0-alpha10'
//noinspection GradleDynamicVersion
compile 'com.android.support:appcompat-v7:26.+'
//noinspection GradleDynamicVersion
compile 'com.android.support:cardview-v7:26.+'
//noinspection GradleDynamicVersion
compile 'com.android.support:design:26.+'
//noinspection GradleDynamicVersion
compile 'com.android.support:preference-v14:26.+'
def room_version = '2.1.0'
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
compile 'com.github.yukuku:ambilwarna:2.0.1'
implementation(':dav4jvm') {
exclude group: 'org.ogce', module: 'xpp3' // Android comes with its own XmlPullParser
}
implementation 'com.jaredrummler:colorpicker:1.1.0'
implementation 'com.mikepenz:aboutlibraries:6.2.3'
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
implementation 'commons-io:commons-io:2.6'
implementation 'dnsjava:dnsjava:2.1.9'
implementation 'org.apache.commons:commons-collections4:4.4'
implementation 'org.apache.commons:commons-lang3:3.9'
compile 'com.squareup.okhttp3:logging-interceptor:3.8.1'
compile 'commons-io:commons-io:2.5'
compile 'dnsjava:dnsjava:2.1.8'
compile 'org.apache.commons:commons-lang3:3.6'
compile 'org.apache.commons:commons-collections4:4.1'
// for tests
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'junit:junit:4.12'
androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
//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.8.1'
testImplementation 'junit:junit:4.12'
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
testCompile 'junit:junit:4.12'
testCompile 'com.squareup.okhttp3:mockwebserver:3.8.1'
}
+7 -20
View File
@@ -1,8 +1,8 @@
# ProGuard usage for DAVx⁵:
# ProGuard usage for DAVdroid:
# shrinking yes (main reason for using ProGuard)
# optimization yes
# obfuscation no (DAVx⁵ is open-source)
# obfuscation no (DAVdroid is open-source)
# preverification no
-dontobfuscate
@@ -12,39 +12,26 @@
-allowaccessmodification
-dontpreverify
# Kotlin
-dontwarn kotlin.**
# https://github.com/material-components/material-components-android/issues/387
-keep class com.google.android.material.tabs.** {*;}
# Apache Commons
-dontwarn javax.script.**
# ez-vcard
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
# ical4j: ignore unused dynamic libraries
-dontwarn aQute.**
-dontwarn groovy.** # Groovy-based ContentBuilder not used
-dontwarn javax.cache.** # no JCache support in Android
-dontwarn net.fortuna.ical4j.model.**
-dontwarn org.codehaus.groovy.**
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
# okhttp
-dontwarn javax.annotation.**
-dontwarn okio.**
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn org.conscrypt.**
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.ParametersAreNonnullByDefault
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
# DAVx⁵ + libs
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
# DAVdroid + libs
-keep class at.bitfire.** { *; } # all DAVdroid code is required
-1
View File
@@ -1 +0,0 @@
espressoTest
@@ -1,74 +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 okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class HttpClientTest {
lateinit var server: MockWebServer
lateinit var httpClient: HttpClient
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
server = MockWebServer()
server.start(30000)
}
@After
fun tearDown() {
server.shutdown()
httpClient.close()
}
@Test
fun testCookies() {
val url = server.url("/test")
// set cookie for root path (/) and /test path in first response
server.enqueue(MockResponse()
.setResponseCode(200)
.addHeader("Set-Cookie", "cookie1=1; path=/")
.addHeader("Set-Cookie", "cookie2=2")
.setBody("Cookie set"))
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertNull(server.takeRequest().getHeader("Cookie"))
// cookie should be sent with second request
// second response lets first cookie expire and overwrites second cookie
server.enqueue(MockResponse()
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
.addHeader("Set-Cookie", "cookie2=2a")
.setResponseCode(200))
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
server.enqueue(MockResponse()
.setResponseCode(200))
httpClient.okHttpClient.newCall(Request.Builder()
.get().url(url)
.build()).execute()
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
}
}
@@ -0,0 +1,57 @@
/*
* 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 org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.net.Socket;
import javax.net.ssl.SSLSocket;
import at.bitfire.cert4android.CustomCertManager;
import okhttp3.mockwebserver.MockWebServer;
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 {
SSLSocketFactoryCompat factory;
MockWebServer server = new MockWebServer();
@Before
public void startServer() throws Exception {
factory = new SSLSocketFactoryCompat(new CustomCertManager(getTargetContext().getApplicationContext(), true));
server.start();
}
@After
public void stopServer() throws Exception {
server.shutdown();
}
@Test
public void testUpgradeTLS() throws IOException {
Socket s = factory.createSocket(server.getHostName(), server.getPort());
assertTrue(s instanceof SSLSocket);
SSLSocket ssl = (SSLSocket)s;
assertFalse(contains(ssl.getEnabledProtocols(), "SSLv3"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.1"));
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.2"));
}
}
@@ -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.model;
import android.content.ContentValues;
import org.junit.Test;
import java.io.IOException;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class CollectionInfoTest {
MockWebServer server = new MockWebServer();
@Test
public void testFromDavResource() throws IOException, HttpException, DavException {
// r/w address book
server.enqueue(new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
DavResource dav = new DavResource(HttpClient.create(null), server.url("/"));
dav.propfind(0, ResourceType.NAME);
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()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
dav = new DavResource(HttpClient.create(null), server.url("/"));
dav.propfind(0, ResourceType.NAME);
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
public void testFromDB() {
ContentValues values = new ContentValues();
values.put(Collections.ID, 1);
values.put(Collections.SERVICE_ID, 1);
values.put(Collections.URL, "http://example.com");
values.put(Collections.READ_ONLY, 1);
values.put(Collections.DISPLAY_NAME, "display name");
values.put(Collections.DESCRIPTION, "description");
values.put(Collections.COLOR, 0xFFFF0000);
values.put(Collections.TIME_ZONE, "tzdata");
values.put(Collections.SUPPORTS_VEVENT, 1);
values.put(Collections.SUPPORTS_VTODO, 1);
values.put(Collections.SYNC, 1);
CollectionInfo info = new CollectionInfo(values);
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());
}
}
@@ -1,135 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model
import android.content.ContentValues
import androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class CollectionTest {
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
}
@After
fun shutDown() {
httpClient.close()
}
@Test
@SmallTest
fun testFromDavResponseAddressBook() {
// r/w address book
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
assertTrue(info.privWriteContent)
assertTrue(info.privUnbind)
assertNull(info.supportsVEVENT)
assertNull(info.supportsVTODO)
assertNull(info.supportsVJOURNAL)
assertEquals("My Contacts", info.displayName)
assertEquals("My Contacts Description", info.description)
}
@Test
@SmallTest
fun testFromDavResponseCalendar() {
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
assertFalse(info.privWriteContent)
assertFalse(info.privUnbind)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timezone)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
@SmallTest
fun testFromDavResponseWebcal() {
// Webcal subscription
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
"<response>" +
" <href>/webcal1</href>" +
" <propstat><prop>" +
" <displayname>Sample Subscription</displayname>" +
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_WEBCAL, info.type)
assertEquals("Sample Subscription", info.displayName)
assertEquals(HttpUrl.get("https://example.com/1.ics"), info.source)
}
}
@@ -1,64 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.HttpUrl
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class DaoToolsTest {
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().context
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
@After
fun closeDb() {
db.close()
}
@Test
fun testSyncAll() {
val serviceDao = db.serviceDao()
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
service.id = serviceDao.insertOrReplace(service)
val homeSetDao = db.homeSetDao()
val entry1 = HomeSet(id=1, serviceId=service.id, url=HttpUrl.get("https://example.com/1"))
val entry3 = HomeSet(id=3, serviceId=service.id, url=HttpUrl.get("https://example.com/3"))
val oldItems = listOf(
entry1,
HomeSet(id=2, serviceId=service.id, url=HttpUrl.get("https://example.com/2")),
entry3
)
homeSetDao.insert(oldItems)
val newItems = mutableMapOf<HttpUrl, HomeSet>()
newItems[entry1.url] = entry1
// no id, because identity is given by the url
val updated = HomeSet(id=0, serviceId=service.id,
url=HttpUrl.get("https://example.com/2"), displayName="Updated Entry")
newItems[updated.url] = updated
val created = HomeSet(id=4, serviceId=service.id, url=HttpUrl.get("https://example.com/4"))
newItems[created.url] = created
DaoTools(homeSetDao).syncAll(oldItems, newItems) { it.url }
val afterSync = homeSetDao.getByService(service.id)
assertEquals(afterSync.size, 3)
assertFalse(afterSync.contains(entry3))
assertTrue(afterSync.contains(entry1))
assertTrue(afterSync.contains(updated))
assertTrue(afterSync.contains(created))
}
}
@@ -1,38 +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.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class DefaultsSettingsProviderTest {
private val provider: SettingsProvider = DefaultsProvider()
@Test
fun testHas() {
assertEquals(Pair(false, true), provider.has("notExisting"))
assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY))
}
@Test
fun testGet() {
assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST))
assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT))
}
@Test
fun testPutRemove() {
assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY))
assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true))
assertFalse(provider.remove(Settings.OVERRIDE_PROXY))
}
}
@@ -1,34 +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.settings
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SettingsTest {
lateinit var settings: Settings
@Before
fun initialize() {
settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
}
@Test
fun testHas() {
assertFalse(settings.has("notExisting"))
// provided by DefaultsProvider
assertTrue(settings.has(Settings.OVERRIDE_PROXY))
}
}
@@ -0,0 +1,200 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui.setup;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.net.URI;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressbookHomeSet;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.log.Logger;
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
import okhttp3.OkHttpClient;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import static android.support.test.InstrumentationRegistry.getTargetContext;
import static junit.framework.TestCase.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class DavResourceFinderTest {
MockWebServer server = new MockWebServer();
DavResourceFinder finder;
OkHttpClient client;
LoginCredentials credentials;
private static final String
PATH_NO_DAV = "/nodav",
PATH_CALDAV = "/caldav",
PATH_CARDDAV = "/carddav",
PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav",
SUBPATH_PRINCIPAL = "/principal",
SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks",
SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts";
@Before
public void initServerAndClient() throws Exception {
server.setDispatcher(new TestDispatcher());
server.start();
credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
finder = new DavResourceFinder(getTargetContext(), credentials);
client = HttpClient.create(null);
client = HttpClient.addAuthentication(client, credentials.getUserName(), credentials.getPassword());
}
@After
public void stopServer() throws Exception {
server.shutdown();
}
@Test
public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException {
ServiceInfo info;
// before dav.propfind(), no info is available
DavResource dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(0, info.getCollections().size());
assertEquals(0, info.getHomeSets().size());
// recognize home set
dav.propfind(0, AddressbookHomeSet.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
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.propfind(0, ResourceType.NAME);
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
assertEquals(1, info.getCollections().size());
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.getCollections().keySet().iterator().next());
assertEquals(0, info.getHomeSets().size());
}
@Test
public void testProvidesService() throws IOException {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV));
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV));
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV));
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV));
}
@Test
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
);
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
assertEquals(
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
);
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
}
// mock server
public class TestDispatcher extends Dispatcher {
@Override
public MockResponse dispatch(RecordedRequest rq) throws InterruptedException {
if (!checkAuth(rq)) {
MockResponse authenticate = new MockResponse().setResponseCode(401);
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"");
return authenticate;
}
String path = rq.getPath();
if ("OPTIONS".equalsIgnoreCase(rq.getMethod())) {
String dav = null;
if (path.startsWith(PATH_CALDAV))
dav = "calendar-access";
else if (path.startsWith(PATH_CARDDAV))
dav = "addressbook";
else if (path.startsWith(PATH_CALDAV_AND_CARDDAV))
dav = "calendar-access, addressbook";
MockResponse response = new MockResponse().setResponseCode(200);
if (dav != null)
response.addHeader("DAV", dav);
return response;
} else if ("PROPFIND".equalsIgnoreCase(rq.getMethod())) {
String props = null;
switch (path) {
case PATH_CALDAV:
case PATH_CARDDAV:
props = "<current-user-principal><href>" + path + SUBPATH_PRINCIPAL + "</href></current-user-principal>";
break;
case PATH_CARDDAV + SUBPATH_PRINCIPAL:
props = "<CARD:addressbook-home-set>" +
" <href>" + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "</href>" +
"</CARD:addressbook-home-set>";
break;
case PATH_CARDDAV + SUBPATH_ADDRESSBOOK:
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>";
break;
}
Logger.log.info("Sending props: " + props);
return new MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>" + rq.getPath() + "</href>" +
" <propstat><prop>" + props + "</prop></propstat>" +
"</response>" +
"</multistatus>");
}
return new MockResponse().setResponseCode(404);
}
private boolean checkAuth(RecordedRequest rq) {
return "Basic bW9jazoxMjM0NQ==".equals(rq.getHeader("Authorization"));
}
}
}
@@ -1,193 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui.setup
import android.app.Application
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.AddressbookHomeSet
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.URI
class DavResourceFinderTest {
companion object {
private const val PATH_NO_DAV = "/nodav"
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
}
val server = MockWebServer()
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
lateinit var loginModel: LoginModel
@Before
fun initServerAndClient() {
server.setDispatcher(TestDispatcher())
server.start()
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
client = HttpClient.Builder()
.addAuthentication(null, loginModel.credentials!!)
.build()
}
@After
fun stopServer() {
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
// recognize address book
info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
}
@Test
fun testProvidesService() {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
}
@Test
fun testGetCurrentUserPrincipal() {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
}
// mock server
class TestDispatcher: Dispatcher() {
override fun dispatch(rq: RecordedRequest): MockResponse {
if (!checkAuth(rq)) {
val authenticate = MockResponse().setResponseCode(401)
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
return authenticate
}
val path = rq.path
if (rq.method.equals("OPTIONS", true)) {
val dav = when {
path.startsWith(PATH_CALDAV) -> "calendar-access"
path.startsWith(PATH_CARDDAV) -> "addressbook"
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
else -> null
}
val response = MockResponse().setResponseCode(200)
if (dav != null)
response.addHeader("DAV", dav)
return response
} else if (rq.method.equals("PROPFIND", true)) {
val props: String?
when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
props = "<CARD:addressbook-home-set>" +
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>"
else -> props = null
}
Logger.log.info("Sending props: $props")
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>${rq.path}</href>" +
" <propstat><prop>$props</prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
private fun checkAuth(rq: RecordedRequest) =
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
}
}
+112 -67
View File
@@ -1,4 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<manifest package="at.bitfire.davdroid"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
@@ -18,6 +25,13 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<!--
for writing external log files; permission only required for SDK <= 18 because since then,
writing to app-private directory doesn't require extra permissions
-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
@@ -26,10 +40,6 @@
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.LOCATION -->
<!-- required since Android 8.1 to get the WiFi name (for "sync in Wifi only" feature) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- ical4android declares task access permissions -->
<application
@@ -39,76 +49,27 @@
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppThemeExt"
android:theme="@style/AppTheme"
tools:ignore="UnusedAttribute">
<service android:name=".DavService"/>
<activity
android:name=".ui.AccountsActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">
<receiver
android:name=".model.Settings$ReinitSettingsReceiver"
android:exported="false"
android:process=":sync">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="at.bitfire.davdroid.REINIT_SETTINGS"/>
</intent-filter>
</activity>
<activity
android:name=".ui.AboutActivity"
android:label="@string/navigation_drawer_about"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"
</receiver>
<receiver
android:name=".AccountSettings$AppUpdatedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
<action android:name="android.intent.action.PACKAGE_REPLACED" />
<data android:scheme="package" android:path="at.bitfire.davdroid" />
</intent-filter>
</activity>
</receiver>
<activity
android:name=".ui.setup.LoginActivity"
android:label="@string/login_title"
android:parentActivityName=".ui.AccountsActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<activity
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.AccountsActivity"
android:theme="@style/AppTheme.NoActionBar"/>
<activity android:name=".ui.AccountSettingsActivity"/>
<activity android:name=".ui.CreateAddressBookActivity"
android:label="@string/create_addressbook"/>
<activity android:name=".ui.CreateCalendarActivity"
android:label="@string/create_calendar"/>
<activity
android:name=".ui.DebugInfoActivity"
android:parentActivityName=".ui.AppSettingsActivity"
android:exported="true"
android:label="@string/debug_info_title">
<intent-filter>
<action android:name="android.intent.action.BUG_REPORT"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/authority_debug_provider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/debug_paths" />
</provider>
<!-- account type "DAVx⁵" -->
<!-- account type "DAVdroid" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
@@ -123,6 +84,7 @@
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -132,9 +94,11 @@
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"/>
@@ -145,7 +109,7 @@
android:resource="@xml/sync_tasks"/>
</service>
<!-- account type "DAVx⁵ Address book" -->
<!-- account type "DAVdroid Address book" -->
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="false">
@@ -166,6 +130,7 @@
<service
android:name=".syncadapter.AddressBooksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -178,6 +143,7 @@
<service
android:name=".syncadapter.ContactsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
@@ -191,6 +157,85 @@
android:resource="@xml/contacts"/>
</service>
<service
android:name=".DavService"
android:enabled="true">
</service>
<receiver android:name=".AccountsChangedReceiver">
<intent-filter>
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
</intent-filter>
</receiver>
<receiver android:name=".PackageChangedReceiver">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_ADDED"/>
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED"/>
<data android:scheme="package"/>
</intent-filter>
</receiver>
<activity
android:name=".ui.AccountsActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name=".ui.AboutActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.AppSettingsActivity"
android:label="@string/app_settings"
android:parentActivityName=".ui.AccountsActivity"/>
<activity android:name=".ui.PermissionsActivity"
android:label="@string/permissions_title"
android:parentActivityName=".ui.AccountsActivity"/>
<activity
android:name=".ui.setup.LoginActivity"
android:label="@string/login_title"
android:parentActivityName=".ui.AccountsActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<activity
android:name=".ui.AccountActivity"
android:parentActivityName=".ui.AccountsActivity">
</activity>
<activity android:name=".ui.AccountSettingsActivity"/>
<activity android:name=".ui.CreateAddressBookActivity"
android:label="@string/create_addressbook"/>
<activity android:name=".ui.CreateCalendarActivity"
android:label="@string/create_calendar"/>
<activity
android:name=".ui.DebugInfoActivity"
android:exported="true"
android:label="@string/debug_info_title">
</activity>
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="@string/authority_log_provider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/log_paths" />
</provider>
</application>
</manifest>
+153
View File
@@ -0,0 +1,153 @@
<h3>Apache License, Version 2.0, January 2004</h3>
<p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a> </p>
<p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
<p><strong><a name="definitions">1. Definitions</a></strong>.</p>
<p>"License" shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.</p>
<p>"Licensor" shall mean the copyright owner or entity authorized by the
copyright owner that is granting the License.</p>
<p>"Legal Entity" shall mean the union of the acting entity and all other
entities that control, are controlled by, or are under common control with
that entity. For the purposes of this definition, "control" means (i) the
power, direct or indirect, to cause the direction or management of such
entity, whether by contract or otherwise, or (ii) ownership of fifty
percent (50%) or more of the outstanding shares, or (iii) beneficial
ownership of such entity.</p>
<p>"You" (or "Your") shall mean an individual or Legal Entity exercising
permissions granted by this License.</p>
<p>"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation source,
and configuration files.</p>
<p>"Object" form shall mean any form resulting from mechanical transformation
or translation of a Source form, including but not limited to compiled
object code, generated documentation, and conversions to other media types.</p>
<p>"Work" shall mean the work of authorship, whether in Source or Object form,
made available under the License, as indicated by a copyright notice that
is included in or attached to the work (an example is provided in the
Appendix below).</p>
<p>"Derivative Works" shall mean any work, whether in Source or Object form,
that is based on (or derived from) the Work and for which the editorial
revisions, annotations, elaborations, or other modifications represent, as
a whole, an original work of authorship. For the purposes of this License,
Derivative Works shall not include works that remain separable from, or
merely link (or bind by name) to the interfaces of, the Work and Derivative
Works thereof.</p>
<p>"Contribution" shall mean any work of authorship, including the original
version of the Work and any modifications or additions to that Work or
Derivative Works thereof, that is intentionally submitted to Licensor for
inclusion in the Work by the copyright owner or by an individual or Legal
Entity authorized to submit on behalf of the copyright owner. For the
purposes of this definition, "submitted" means any form of electronic,
verbal, or written communication sent to the Licensor or its
representatives, including but not limited to communication on electronic
mailing lists, source code control systems, and issue tracking systems that
are managed by, or on behalf of, the Licensor for the purpose of discussing
and improving the Work, but excluding communication that is conspicuously
marked or otherwise designated in writing by the copyright owner as "Not a
Contribution."</p>
<p>"Contributor" shall mean Licensor and any individual or Legal Entity on
behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.</p>
<p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
terms and conditions of this License, each Contributor hereby grants to You
a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of, publicly
display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.</p>
<p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
and conditions of this License, each Contributor hereby grants to You a
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, use,
offer to sell, sell, import, and otherwise transfer the Work, where such
license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by
combination of their Contribution(s) with the Work to which such
Contribution(s) was submitted. If You institute patent litigation against
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
that the Work or a Contribution incorporated within the Work constitutes
direct or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate as of the
date such litigation is filed.</p>
<p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
distribute copies of the Work or Derivative Works thereof in any medium,
with or without modifications, and in Source or Object form, provided that
You meet the following conditions:</p>
<p>a. You must give any other recipients of the Work or Derivative Works a
copy of this License; and</p>
<p>b. You must cause any modified files to carry prominent notices stating
that You changed the files; and</p>
<p>c. You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices from
the Source form of the Work, excluding those notices that do not pertain to
any part of the Derivative Works; and</p>
<p>d. If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable copy
of the attribution notices contained within such NOTICE file, excluding
those notices that do not pertain to any part of the Derivative Works, in
at least one of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or documentation,
if provided along with the Derivative Works; or, within a display generated
by the Derivative Works, if and wherever such third-party notices normally
appear. The contents of the NOTICE file are for informational purposes only
and do not modify the License. You may add Your own attribution notices
within Derivative Works that You distribute, alongside or as an addendum to
the NOTICE text from the Work, provided that such additional attribution
notices cannot be construed as modifying the License.
<br/>
<br/>
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such
Derivative Works as a whole, provided Your use, reproduction, and
distribution of the Work otherwise complies with the conditions stated in
this License.
</p>
<p><strong><a name="contributions">5. Submission of Contributions</a></strong>. Unless You
explicitly state otherwise, any Contribution intentionally submitted for
inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the
terms of any separate license agreement you may have executed with Licensor
regarding such Contributions.</p>
<p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
permission to use the trade names, trademarks, service marks, or product
names of the Licensor, except as required for reasonable and customary use
in describing the origin of the Work and reproducing the content of the
NOTICE file.</p>
<p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
applicable law or agreed to in writing, Licensor provides the Work (and
each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You
are solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise
of permissions under this License.</p>
<p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
under no legal theory, whether in tort (including negligence), contract, or
otherwise, unless required by applicable law (such as deliberate and
grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a result
of this License or out of the use or inability to use the Work (including
but not limited to damages for loss of goodwill, work stoppage, computer
failure or malfunction, or any and all other commercial damages or losses),
even if such Contributor has been advised of the possibility of such
damages.</p>
<p><strong><a name="additional">9. Accepting Warranty or Additional Liability</a></strong>.
While redistributing the Work or Derivative Works thereof, You may choose
to offer, and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your own behalf
and on Your sole responsibility, not on behalf of any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor
harmless for any liability incurred by, or claims asserted against, such
Contributor by reason of your accepting any such warranty or additional
liability.</p>
<p>END OF TERMS AND CONDITIONS</p>
+28
View File
@@ -0,0 +1,28 @@
<h3>BSD License (3-clause)</h3>
<p>Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:</p>
<p>o Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.</p>
<p>o Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.</p>
<p>o Neither the name of Ben Fortuna nor the names of any other contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.</p>
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+23
View File
@@ -0,0 +1,23 @@
<h3>BSD License</h3>
<p>Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:</p>
<p>1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.</p>
<p>2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.</p>
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
@@ -2,9 +2,9 @@
<p style="text-align: center;">Version 3, 29 June 2007</p>
<p>Copyright &copy; 2007 Free Software Foundation, Inc.
&lt;<a href="http://fsf.org/">http://fsf.org/</a>&gt;</p><p>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.</p>
&lt;<a href="http://fsf.org/">http://fsf.org/</a>&gt;</p><p>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.</p>
<h3><a name="preamble"></a>Preamble</h3>
@@ -42,16 +42,16 @@ know their rights.</p>
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.</p>
<p>For the developers\' and authors\' protection, the GPL clearly explains
that there is no warranty for this free software. For both users\' and
authors\' sake, the GPL requires that modified versions be marked as
<p>For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.</p>
<p>Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users\' freedom to change the software. The systematic
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
@@ -77,7 +77,7 @@ modification follow.</p>
<p>&ldquo;Copyright&rdquo; also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.</p>
<p>&ldquo;The Program&rdquo; refers to any copyrightable work licensed under this
License. Each licensee is addressed as &ldquo;you&rdquo;. &ldquo;Licensees&rdquo; and
&ldquo;recipients&rdquo; may be individuals or organizations.</p>
@@ -135,7 +135,7 @@ produce the work, or an object code interpreter used to run it.</p>
<p>The &ldquo;Corresponding Source&rdquo; for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work\'s
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
@@ -177,7 +177,7 @@ your copyrighted material outside their relationship with you.</p>
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.</p>
<h4><a name="section3"></a>3. Protecting Users\' Legal Rights From Anti-Circumvention Law.</h4>
<h4><a name="section3"></a>3. Protecting Users' Legal Rights From Anti-Circumvention Law.</h4>
<p>No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
@@ -189,13 +189,13 @@ measures.</p>
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work\'s
users, your or third parties\' legal rights to forbid circumvention of
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.</p>
<h4><a name="section4"></a>4. Conveying Verbatim Copies.</h4>
<p>You may convey verbatim copies of the Program\'s source code as you
<p>You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
@@ -240,7 +240,7 @@ works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
&ldquo;aggregate&rdquo; if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation\'s users
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.</p>
@@ -462,7 +462,7 @@ organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party\'s predecessor in interest had or could
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.</p>
@@ -479,9 +479,9 @@ sale, or importing the Program or any portion of it.</p>
<p>A &ldquo;contributor&rdquo; is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor\'s &ldquo;contributor version&rdquo;.</p>
work thus licensed is called the contributor's &ldquo;contributor version&rdquo;.</p>
<p>A contributor\'s &ldquo;essential patent claims&rdquo; are all patent claims
<p>A contributor's &ldquo;essential patent claims&rdquo; are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
@@ -492,7 +492,7 @@ patent sublicenses in a manner consistent with the requirements of
this License.</p>
<p>Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor\'s essential patent claims, to
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.</p>
@@ -513,10 +513,10 @@ patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. &ldquo;Knowingly relying&rdquo; means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient\'s use of the covered work
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.</p>
<p>If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
@@ -544,7 +544,7 @@ or that patent license was granted, prior to 28 March 2007.</p>
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.</p>
<h4><a name="section12"></a>12. No Surrender of Others\' Freedom.</h4>
<h4><a name="section12"></a>12. No Surrender of Others' Freedom.</h4>
<p>If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
@@ -584,7 +584,7 @@ GNU General Public License, you may choose any version ever published
by the Free Software Foundation.</p>
<p>If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy\'s
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.</p>
+7
View File
@@ -0,0 +1,7 @@
<h3>The MIT License (MIT)</h3>
<p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>
<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,479 @@
/*
* 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.content.*
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.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 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"
/** 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() = 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>? =
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() = accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
fun setManageCalendarColors(manage: Boolean) =
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
return if (name != null)
GroupMethod.valueOf(name)
else
GroupMethod.GROUP_VCARDS
}
fun setGroupMethod(method: GroupMethod) {
val name = if (method == GroupMethod.GROUP_VCARDS) null else method.name
accountManager.setUserData(account, KEY_CONTACT_GROUP_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, App.addressBooksAuthority, 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), App.addressBookAccountType)
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, App.addressBooksAuthority, 1)
setSyncInterval(App.addressBooksAuthority, 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()
}
}
class AppUpdatedReceiver: BroadcastReceiver() {
@SuppressLint("UnsafeProtectedBroadcastReceiver,MissingPermission")
override fun onReceive(context: Context, intent: Intent?) {
Logger.log.info("DAVdroid was updated, checking for AccountSettings version")
// peek into AccountSettings to initiate a possible migration
val accountManager = AccountManager.get(context)
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type)))
try {
Logger.log.info("Checking account ${account.name}")
AccountSettings(context, account)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't check for updated account settings", e)
}
}
}
}
@@ -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)
}
}
}
+24 -64
View File
@@ -8,92 +8,52 @@
package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
import androidx.multidex.MultiDexApplication
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import java.util.logging.Level
import kotlin.concurrent.thread
import kotlin.system.exitProcess
@Suppress("unused")
class App: MultiDexApplication(), Thread.UncaughtExceptionHandler {
class App: Application() {
companion object {
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrustSystemCerts"
@JvmField val LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage"
@JvmField val OVERRIDE_PROXY = "overrideProxy"
@JvmField val OVERRIDE_PROXY_HOST = "overrideProxyHost"
@JvmField val OVERRIDE_PROXY_PORT = "overrideProxyPort"
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
lateinit var addressBookAccountType: String
lateinit var addressBooksAuthority: String
@JvmStatic
fun getLauncherBitmap(context: Context): Bitmap? {
val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)
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
}
fun homepageUrl(context: Context) =
Uri.parse(context.getString(R.string.homepage_url)).buildUpon()
.appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
.appendQueryParameter("pk_kwd", context::class.java.simpleName)
.appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
.build()!!
}
override fun onCreate() {
super.onCreate()
Logger.initialize(this)
//if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
Thread.setDefaultUncaughtExceptionHandler(this)
CustomCertificates.reinitCertManager(this)
Logger.reinitLogger(this)
if (BuildConfig.DEBUG)
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build())
if (Build.VERSION.SDK_INT <= 21)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
NotificationUtils.createChannels(this)
// don't block UI for some background checks
thread {
// watch installed/removed apps
val tasksFilter = IntentFilter()
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
tasksFilter.addDataScheme("package")
registerReceiver(PackageChangedReceiver(), tasksFilter)
// check whether a tasks app is currently installed
PackageChangedReceiver.updateTaskSync(this)
}
}
override fun uncaughtException(t: Thread, e: Throwable) {
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
val intent = Intent(this, DebugInfoActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
startActivity(intent)
exitProcess(1)
addressBookAccountType = getString(R.string.account_type_address_book)
addressBooksAuthority = getString(R.string.address_books_authority)
}
}
@@ -1,12 +0,0 @@
package at.bitfire.davdroid
import android.content.ContentProviderClient
import android.os.Build
@Suppress("DEPRECATION")
fun ContentProviderClient.closeCompat() {
if (Build.VERSION.SDK_INT >= 24)
close()
else
release()
}
@@ -5,26 +5,19 @@
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
package at.bitfire.davdroid;
object Constants {
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
// 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
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
/**
* Context label for [org.apache.commons.lang3.exception.ContextedException].
* Context value is the [at.bitfire.davdroid.resource.LocalResource]
* which is related to the exception cause.
*/
const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource"
/**
* Context label for [org.apache.commons.lang3.exception.ContextedException].
* Context value is the [okhttp3.HttpUrl] of the remote resource
* which is related to the exception cause.
*/
const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource"
@JvmField
val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
}
@@ -0,0 +1,41 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.content.Context
import android.os.Build
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.Settings
import okhttp3.internal.tls.OkHostnameVerifier
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLSocketFactory
object CustomCertificates {
@JvmField var certManager: CustomCertManager? = null
var sslSocketFactoryCompat: SSLSocketFactory? = null
var hostnameVerifier: HostnameVerifier? = null
fun reinitCertManager(context: Context) {
if (BuildConfig.customCerts) {
certManager?.close()
ServiceDB.OpenHelper(context).use { dbHelper ->
val settings = Settings(dbHelper.readableDatabase)
val mgr = CustomCertManager(context.applicationContext, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false))
certManager = mgr
hostnameVerifier = mgr.hostnameVerifier(OkHostnameVerifier.INSTANCE)
sslSocketFactoryCompat = SSLSocketFactoryCompat(mgr)
}
}
}
}
@@ -9,53 +9,44 @@
package at.bitfire.davdroid
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ContentResolver
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.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.room.Transaction
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.*
import 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.*
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.settings.AccountSettings
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.davdroid.ui.NotificationUtils
import at.bitfire.vcard4android.ContactsStorageException
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
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: android.app.Service() {
class DavService: Service() {
companion object {
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
const val EXTRA_DAV_SERVICE_ID = "davServiceID"
/** Initialize a forced synchronization. Expects intent data
to be an URI of this format:
contents://<authority>/<account.type>/<account name>
**/
const val ACTION_FORCE_SYNC = "forceSync"
val DAV_COLLECTION_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
)
@JvmField val ACTION_ACCOUNTS_UPDATED = "accountsUpdated"
@JvmField val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
@JvmField val EXTRA_DAV_SERVICE_ID = "davServiceID"
}
private val runningRefresh = HashSet<Long>()
@@ -67,23 +58,13 @@ class DavService: android.app.Service() {
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
when (intent.action) {
ACTION_ACCOUNTS_UPDATED ->
cleanupAccounts()
ACTION_REFRESH_COLLECTIONS ->
if (runningRefresh.add(id)) {
refreshingStatusListeners.forEach { listener ->
listener.get()?.onDavRefreshStatusChanged(id, true)
}
thread { refreshCollections(id) }
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
}
ACTION_FORCE_SYNC -> {
val uri = intent.data!!
val authority = uri.authority!!
val account = Account(
uri.pathSegments[1],
uri.pathSegments[0]
)
forceSync(authority, account)
}
}
}
@@ -104,9 +85,9 @@ class DavService: android.app.Service() {
inner class InfoBinder: Binder() {
fun isRefreshing(id: Long) = runningRefresh.contains(id)
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) {
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
if (callImmediateIfRunning)
if (callImmediate)
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
}
@@ -128,190 +109,203 @@ class DavService: android.app.Service() {
which actually do the work
*/
private fun forceSync(authority: String, account: Account) {
Logger.log.info("Forcing $authority synchronization of $account")
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
@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(App.addressBookAccountType)
.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)
}
}
private fun refreshCollections(serviceId: Long) {
val db = AppDatabase.getInstance(this)
val homeSetDao = db.homeSetDao()
val collectionDao = db.collectionDao()
fun refreshCollections(service: Long) {
OpenHelper(this@DavService).use { dbHelper ->
val db = dbHelper.writableDatabase
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, getString(R.string.account_type))
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 homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
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")
}
/**
* Checks if the given URL defines home sets and adds them to the home set list.
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
*/
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
val related = mutableSetOf<HttpUrl>()
val 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
}
fun findRelated(root: HttpUrl, dav: Response) {
// refresh home sets: calendar-proxy-read/write-for
dav[CalendarProxyReadFor::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
root.resolve(href)?.let { proxyReadFor ->
related += proxyReadFor
}
}
}
dav[CalendarProxyWriteFor::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
root.resolve(href)?.let { proxyWriteFor ->
related += proxyWriteFor
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
}
// refresh home sets: direct group memberships
dav[GroupMembership::class.java]?.let {
for (href in it.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
root.resolve(href)?.let { groupMembership ->
related += groupMembership
}
fun 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)) }
}
}
}
val dav = DavResource(client, url)
when (service.type) {
Service.TYPE_CARDDAV ->
try {
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
response[AddressbookHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
}
}
if (recurse)
findRelated(dav.location, response)
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e)
else
throw e
}
Service.TYPE_CALDAV -> {
try {
dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
response[CalendarHomeSet::class.java]?.let { homeSet ->
for (href in homeSet.hrefs)
dav.location.resolve(href)?.let {
val foundUrl = UrlUtils.withTrailingSlash(it)
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
}
}
if (recurse)
findRelated(dav.location, response)
}
} catch (e: HttpException) {
if (e.code/100 == 4)
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e)
else
throw e
}
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)
}
}
for (resource in related)
queryHomeSets(client, resource, false)
}
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)
}
}
@Transaction
fun saveHomesets() {
DaoTools(homeSetDao).syncAll(
homeSetDao.getByService(serviceId),
homeSets
) { it.url }
}
@Transaction
fun saveCollections() {
DaoTools(collectionDao).syncAll(
collectionDao.getByService(serviceId),
collections
) { it.url }
}
try {
Logger.log.info("Refreshing $serviceType collections of service #$service")
fun saveResults() {
saveHomesets()
saveCollections()
}
try {
Logger.log.info("Refreshing ${service.type} collections of service #$service")
// cancel previous notification
NotificationManagerCompat.from(this)
.cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
HttpClient.Builder(this, AccountSettings(this, account))
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
// create authenticating OkHttpClient (credentials taken from account settings)
val httpClient = HttpClient.create(this@DavService, account)
// refresh home set list (from principal)
service.principal?.let { principalUrl ->
readPrincipal()?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
queryHomeSets(httpClient, principalUrl)
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.forEach { (url, collection) ->
if (collection.sync)
selectedCollections += url
}
collections.values
.filter { it.selected }
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
// now refresh homesets and their member collections
// now refresh collections (taken from home sets)
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val homeSet = itHomeSets.next()
Logger.log.fine("Listing home set ${homeSet.key}")
val homeSetUrl = itHomeSets.next()
Logger.log.fine("Listing home set $homeSetUrl")
val homeSet = DavResource(httpClient, homeSetUrl)
try {
DavResource(httpClient, homeSet.key).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
if (!response.isSuccess())
return@propfind
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
val itCollections = IteratorChain<DavResource>(homeSet.members.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 (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
homeSet.value.displayName = response[DisplayName::class.java]?.displayName
homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
} else {
val info = Collection.fromDavResponse(response) ?: return@propfind
info.serviceId = serviceId
info.confirmed = true
Logger.log.log(Level.FINE, "Found collection", info)
if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type)))
collections[response.href] = info
}
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && info.type == CollectionInfo.Type.CALENDAR))
collections[member.location] = info
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
if (e.status in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
@@ -323,22 +317,18 @@ class DavService: android.app.Service() {
val (url, info) = itCollections.next()
if (!info.confirmed)
try {
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess())
return@propfind
val collection = DavResource(httpClient, url)
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
val info = CollectionInfo(collection)
info.confirmed = true
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.confirmed = true
// remove unusable collections
if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source == null))
itCollections.remove()
}
// remove unusable collections
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && info.type != CollectionInfo.Type.CALENDAR))
itCollections.remove()
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
if (e.status in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
throw e
@@ -347,37 +337,41 @@ class DavService: android.app.Service() {
// restore selections
for (url in selectedCollections)
collections[url]?.let { it.sync = true }
}
collections[url]?.let { it.selected = true }
saveResults()
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)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
val debugIntent = Intent(this, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setSubText(account.name)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
NotificationManagerCompat.from(this)
.notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
} finally {
runningRefresh.remove(serviceId)
refreshingStatusListeners.mapNotNull { it.get() }.forEach {
it.onDavRefreshStatusChanged(serviceId, false)
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) }
}
}
}
}
}
@@ -6,102 +6,33 @@
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
package at.bitfire.davdroid;
import android.accounts.Account
import android.annotation.TargetApi
import android.content.ContentResolver
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.davdroid.log.Logger
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import org.xbill.DNS.*
import java.util.*
/**
* Some WebDAV and related network utility methods
*/
object DavUtils {
@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")
fun lastSegmentOfUrl(url: HttpUrl): String {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
val segments = LinkedList<String>(url.pathSegments())
segments.reverse()
val segments = LinkedList<String>(httpUrl.pathSegments())
Collections.reverse(segments)
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
for (segment in segments)
if (segment.isNotEmpty())
return segment
return "/"
}
fun prepareLookup(context: Context, lookup: Lookup) {
@TargetApi(Build.VERSION_CODES.O)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
The current version of dnsjava relies on these properties to find the default name servers,
so we have to add the servers explicitly (fortunately, there's an Android API to
get the active DNS servers). */
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
if (activeLink != null) {
// get DNS servers of active network link and set them for dnsjava so that it can send SRV queries
val simpleResolvers = activeLink.dnsServers.map {
Logger.log.fine("Using DNS server ${it.hostAddress}")
val resolver = SimpleResolver()
resolver.setAddress(it)
resolver
}
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
lookup.setResolver(resolver)
} else
Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work")
}
}
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
val srvRecords = records?.filterIsInstance(SRVRecord::class.java)
srvRecords?.let {
if (it.size > 1)
Logger.log.warning("Multiple SRV records not supported yet; using first one")
return it.firstOrNull()
}
return null
}
fun pathsFromTXTRecords(records: Array<Record>?): List<String> {
val paths = LinkedList<String>()
records?.filterIsInstance(TXTRecord::class.java)?.forEach { txt ->
@Suppress("UNCHECKED_CAST")
for (segment in txt.strings as List<String>)
if (segment.startsWith("path=")) {
paths.add(segment.substring(5))
break
}
}
return paths
}
fun requestSync(context: Context, account: Account) {
val authorities = arrayOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.authority
)
for (authority in authorities) {
val extras = Bundle(2)
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
ContentResolver.requestSync(account, authority, extras)
}
}
}
@@ -6,79 +6,102 @@
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
package at.bitfire.davdroid;
import android.accounts.Account
import android.content.Context
import android.os.Build
import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.Constants
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4android.BasicDigestAuthHandler
import at.bitfire.dav4android.UrlUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import okhttp3.*
import okhttp3.internal.tls.OkHostnameVerifier
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.Settings
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.*
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
private val certManager: CustomCertManager?
): AutoCloseable {
class HttpClient private constructor() {
companion object {
/** max. size of disk cache (10 MB) */
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
/** [OkHttpClient] singleton to build all clients from */
val sharedClient: OkHttpClient = OkHttpClient.Builder()
// set timeouts
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
private val client = OkHttpClient()
private val userAgentInterceptor = UserAgentInterceptor()
// don't allow redirects by default, because it would break PROPFIND handling
.followRedirects(false)
// add User-Agent to every request
.addNetworkInterceptor(UserAgentInterceptor)
.build()
}
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 close() {
okHttpClient.cache()?.close()
certManager?.close()
}
@JvmStatic
@JvmOverloads
fun create(context: Context?, settings: AccountSettings? = null, logger: java.util.logging.Logger = Logger.log): OkHttpClient {
var builder = defaultBuilder(context, logger)
class Builder(
val context: Context? = null,
accountSettings: AccountSettings? = null,
val logger: java.util.logging.Logger = Logger.log
) {
private var certManager: CustomCertManager? = null
private var certificateAlias: String? = null
private var cache: Cache? = null
// use account settings for authentication
settings?.let {
val userName = it.username()
val password = it.password()
if (userName != null && password != null)
builder = addAuthentication(builder, null, userName, password)
}
private val orig = sharedClient.newBuilder()
return builder.build()
}
@JvmStatic
@Throws(InvalidAccountException::class)
fun create(context: Context, account: Account) =
create(context, AccountSettings(context, account))
private fun defaultBuilder(context: Context?, logger: java.util.logging.Logger): OkHttpClient.Builder {
val builder = client.newBuilder()
// use MemorizingTrustManager to manage self-signed certificates
if (CustomCertificates.sslSocketFactoryCompat != null && CustomCertificates.certManager != null)
builder.sslSocketFactory(CustomCertificates.sslSocketFactoryCompat, CustomCertificates.certManager)
CustomCertificates.hostnameVerifier?.let { builder.hostnameVerifier(it) }
// 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
context?.let {
ServiceDB.OpenHelper(it).use { dbHelper ->
try {
val settings = Settings(dbHelper.readableDatabase)
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)
builder.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy", proxy)
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
}
}
// add User-Agent to every request
builder.addNetworkInterceptor(userAgentInterceptor)
init {
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
orig.cookieJar(MemoryCookieStore())
builder.cookieJar(MemoryCookieStore())
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
@@ -86,157 +109,31 @@ class HttpClient private constructor(
message -> logger.finest(message)
})
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
orig.addInterceptor(loggingInterceptor)
builder.addInterceptor(loggingInterceptor)
}
context?.let {
val settings = Settings.getInstance(context)
// custom proxy support
try {
if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) {
val address = InetSocketAddress(
settings.getString(Settings.OVERRIDE_PROXY_HOST)
?: Settings.OVERRIDE_PROXY_HOST_DEFAULT,
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
)
val proxy = Proxy(Proxy.Type.HTTP, address)
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy", proxy)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
//if (BuildConfig.customCerts)
customCertManager(CustomCertManager(context, true,
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT)))
}
// use account settings for authentication
accountSettings?.let {
addAuthentication(null, it.credentials())
}
return builder
}
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
addAuthentication(host, credentials)
fun addAuthentication(builder: OkHttpClient.Builder, host: String?, username: String, password: String): OkHttpClient.Builder {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password);
return builder
.addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
fun withDiskCache(): Builder {
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
if (dir.exists() && dir.canWrite()) {
val cacheDir = File(dir, "HttpClient")
cacheDir.mkdir()
Logger.log.fine("Using disk cache: $cacheDir")
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
break
}
}
return this
}
fun followRedirects(follow: Boolean): Builder {
orig.followRedirects(follow)
return this
}
fun customCertManager(manager: CustomCertManager) {
certManager = manager
}
fun setForeground(foreground: Boolean): Builder {
certManager?.appInForeground = foreground
return this
}
fun addAuthentication(host: String?, credentials: Credentials): Builder {
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
Credentials.Type.ClientCertificate -> {
certificateAlias = credentials.certificateAlias
}
}
return this
}
fun build(): HttpClient {
val trustManager = certManager ?: {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}()
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
?: OkHostnameVerifier.INSTANCE
var keyManager: KeyManager? = null
certificateAlias?.let { alias ->
try {
val context = requireNotNull(context)
// get provider certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
// create Android KeyStore (performs key operations without revealing secret data to DAVx5)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
// create KeyManager
keyManager = object: X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias
override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }
override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
// HTTP/2 doesn't support client certificates (yet)
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
orig.protocols(listOf(Protocol.HTTP_1_1))
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e)
}
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
orig.hostnameVerifier(hostnameVerifier)
return HttpClient(orig.build(), certManager)
@JvmOverloads
@JvmStatic
fun addAuthentication(client: OkHttpClient, host: String? = null, username: String, password: String): OkHttpClient {
val builder = client.newBuilder()
addAuthentication(builder, host, username, password)
return builder.build()
}
}
private object UserAgentInterceptor: Interceptor {
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
"okhttp/${Constants.okhttpVersion}) Android/${Build.VERSION.RELEASE}"
private class UserAgentInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
@@ -27,7 +27,7 @@ class MemoryCookieStore: CookieJar {
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(storage) {
@@ -9,50 +9,57 @@
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 androidx.annotation.WorkerThread
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
import kotlin.concurrent.thread
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 {
@WorkerThread
@JvmStatic
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("Tasks provider available = $tasksInstalled")
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
val db = AppDatabase.getInstance(context)
db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service ->
val account = Account(service.accountName, context.getString(R.string.account_type))
if (tasksInstalled) {
if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) {
ContentResolver.setIsSyncable(account, OpenTasks.authority, 1)
ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
}
} else
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
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)
}
}
}
}
}
override fun onReceive(context: Context, intent: Intent) {
thread {
updateTaskSync(context)
}
}
}
@@ -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 }
}
}
@@ -18,7 +18,7 @@ import java.util.logging.LogRecord
object LogcatHandler: Handler() {
private const val MAX_LINE_LENGTH = 3000
private val MAX_LINE_LENGTH = 3000
init {
formatter = PlainTextFormatter.LOGCAT
@@ -8,130 +8,97 @@
package at.bitfire.davdroid.log
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Process
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.Settings
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import org.apache.commons.lang3.time.DateFormatUtils
import java.io.File
import java.io.IOException
import java.util.logging.FileHandler
import java.util.logging.Level
import java.util.logging.Logger
@SuppressLint("StaticFieldLeak") // we'll only keep an app context
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
object Logger {
private const val LOG_TO_FILE = "log_to_file"
val log = java.util.logging.Logger.getLogger("davx5")
private lateinit var context: Context
private lateinit var preferences: SharedPreferences
fun initialize(someContext: Context) {
context = someContext.applicationContext
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener(this)
reinitialize()
@JvmField
val log = Logger.getLogger("davdroid")!!
init {
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android")
at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android")
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (key == LOG_TO_FILE) {
log.info("Logging settings changed; re-initializing logger")
reinitialize()
fun reinitLogger(context: Context) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val settings = Settings(dbHelper.getReadableDatabase())
val logToFile = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
log.info("Verbose logging: $logVerbose")
// set logging level according to preferences
val rootLogger = 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
log.addHandler(fileHandler)
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, "log_to_external_storage")
builder .setContentText(dir.path)
.setSubText(context.getString(R.string.logging_to_external_storage_warning))
.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)
}
}
private fun reinitialize() {
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
log.info("Verbose logging: $logVerbose; to file: $logToFile")
// set logging level according to preferences
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
// remove all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.logging_notification_title))
val logDir = debugDir(context) ?: return
val logFile = File(logDir, "davx5-log.txt")
try {
val fileHandler = FileHandler(logFile.toString(), true)
fileHandler.formatter = PlainTextFormatter.DEFAULT
rootLogger.addHandler(fileHandler)
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
builder .setContentText(logDir.path)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(context.getString(R.string.logging_notification_text))
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setOngoing(true)
// add "Share" action
val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile)
log.fine("Now logging to file: $logFile -> $logFileUri")
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs")
shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
shareIntent.type = "text/plain"
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
val chooserIntent = Intent.createChooser(shareIntent, null)
val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_notify,
context.getString(R.string.logging_notification_send_log),
PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT))
builder.addAction(shareAction.build())
} catch(e: IOException) {
log.log(Level.SEVERE, "Couldn't create log file", e)
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
}
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
} else {
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
// delete old logs
debugDir(context)?.deleteRecursively()
}
}
private fun debugDir(context: Context): File? {
val dir = File(context.filesDir, "debug")
if (dir.exists() && dir.isDirectory)
return dir
if (dir.mkdir())
return dir
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
return null
}
}
@@ -8,21 +8,18 @@
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
val logcat: Boolean
): Formatter() {
companion object {
val LOGCAT = PlainTextFormatter(true)
val DEFAULT = PlainTextFormatter(false)
const val MAX_MESSAGE_LENGTH = 20000
@JvmField val LOGCAT = PlainTextFormatter(true)
@JvmField val DEFAULT = PlainTextFormatter(false)
}
override fun format(r: LogRecord): String {
@@ -32,11 +29,13 @@ class PlainTextFormatter private constructor(
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("] ")
if (r.sourceClassName.replaceFirst("\\$.*", "") != r.loggerName) {
val className = shortClassName(r.sourceClassName)
if (className != "ical4android.AndroidAppender")
builder.append("[").append(className).append("] ")
}
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
builder.append(r.message)
r.thrown?.let {
builder .append("\nEXCEPTION ")
@@ -55,7 +54,8 @@ class PlainTextFormatter private constructor(
}
private fun shortClassName(className: String) = className
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
.replace("at.bitfire.davdroid.", "")
.replace("at.bitfire.", "")
.replace(Regex("\\$.*$"), "")
}
@@ -8,8 +8,8 @@
package at.bitfire.davdroid.log
import java.util.logging.Handler
import java.util.logging.LogRecord
import java.util.logging.Handler;
import java.util.logging.LogRecord;
class StringHandler: Handler() {
@@ -1,224 +0,0 @@
package at.bitfire.davdroid.model
import android.content.Context
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import at.bitfire.davdroid.log.Logger
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
Collection::class
], version = 7)
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
abstract fun serviceDao(): ServiceDao
abstract fun homeSetDao(): HomeSetDao
abstract fun collectionDao(): CollectionDao
companion object {
private var INSTANCE: AppDatabase? = null
@Synchronized
fun getInstance(context: Context): AppDatabase {
INSTANCE?.let { return it }
val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(
Migration1_2,
Migration2_3,
Migration3_4,
Migration4_5,
Migration5_6,
Migration6_7
)
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.build()
INSTANCE = db
return db
}
}
fun dump(sb: StringBuilder) {
val db = openHelper.readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
while (cursorTables.moveToNext()) {
val table = cursorTables.getString(0)
sb.append(table).append("\n")
db.query("SELECT * FROM $table").use { cursor ->
// print columns
val cols = cursor.columnCount
sb.append("\t| ")
for (i in 0 until cols)
sb .append(" ")
.append(cursor.getColumnName(i))
.append(" |")
sb.append("\n")
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ")
for (i in 0 until cols) {
sb.append(" ")
try {
val value = cursor.getString(i)
if (value != null)
sb.append(value
.replace("\r", "<CR>")
.replace("\n", "<LF>"))
else
sb.append("<null>")
} catch (e: SQLiteException) {
sb.append("<unprintable>")
}
sb.append(" |")
}
sb.append("\n")
}
sb.append("----------\n")
}
}
db.endTransaction()
}
}
// migrations
object Migration6_7: Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
}
}
object Migration5_6: Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
val sql = arrayOf(
// migrate "services" to "service": rename columns, make id NOT NULL
"CREATE TABLE service(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"accountName TEXT NOT NULL," +
"type TEXT NOT NULL," +
"principal TEXT DEFAULT NULL" +
")",
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
"DROP TABLE services",
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
"CREATE TABLE homeset(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"url TEXT NOT NULL," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
"DROP TABLE homesets",
// migrate "collections" to "collection": rename columns, make id NOT NULL
"CREATE TABLE collection(" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"serviceId INTEGER NOT NULL," +
"type TEXT NOT NULL," +
"url TEXT NOT NULL," +
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
"privUnbind INTEGER NOT NULL DEFAULT 1," +
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
"displayName TEXT DEFAULT NULL," +
"description TEXT DEFAULT NULL," +
"color INTEGER DEFAULT NULL," +
"timezone TEXT DEFAULT NULL," +
"supportsVEVENT INTEGER DEFAULT NULL," +
"supportsVTODO INTEGER DEFAULT NULL," +
"supportsVJOURNAL INTEGER DEFAULT NULL," +
"source TEXT DEFAULT NULL," +
"sync INTEGER NOT NULL DEFAULT 0," +
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
")",
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
"DROP TABLE collections"
)
sql.forEach { db.execSQL(it) }
}
}
object Migration4_5: Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
}
}
object Migration3_4: Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
}
object Migration2_3: Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// We don't have access to the context in a Room migration now, so
// we will just drop those settings from old DAVx5 versions.
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
try {
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
when (cursor.getString(0)) {
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
}
}
}
db.execSQL("DROP TABLE settings")
} finally {
edit.apply()
}*/
}
}
object Migration1_2: Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
db.execSQL("UPDATE collections SET type=(" +
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
"FROM services WHERE _id=collections.serviceID" +
")",
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
}
}
}
@@ -1,157 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.*
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import okhttp3.HttpUrl
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
],
indices = [
Index("serviceId","type")
]
)
data class Collection(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
var serviceId: Long = 0,
var type: String,
var url: HttpUrl,
var privWriteContent: Boolean = true,
var privUnbind: Boolean = true,
var forceReadOnly: Boolean = false,
var displayName: String? = null,
var description: String? = null,
// CalDAV only
var color: Int? = null,
/** timezone definition (full VTIMEZONE) - not a TZID! **/
var timezone: String? = null,
/** whether the collection supports VEVENT; in case of calendars: null means true */
var supportsVEVENT: Boolean? = null,
/** whether the collection supports VTODO; in case of calendars: null means true */
var supportsVTODO: Boolean? = null,
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
var supportsVJOURNAL: Boolean? = null,
/** Webcal subscription source URL */
var source: HttpUrl? = null,
/** whether this collection has been selected for synchronization */
var sync: Boolean = false
): IdEntity() {
companion object {
const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK"
const val TYPE_CALENDAR = "CALENDAR"
const val TYPE_WEBCAL = "WEBCAL"
/**
* Generates a collection entity from a WebDAV response.
* @param dav WebDAV response
* @return null if the response doesn't represent a collection
*/
fun fromDavResponse(dav: Response): Collection? {
val url = UrlUtils.withTrailingSlash(dav.href)
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
when {
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
else -> null
}
} ?: return null
var privWriteContent = true
var privUnbind = true
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
privWriteContent = privilegeSet.mayWriteContent
privUnbind = privilegeSet.mayUnbind
}
var displayName: String? = null
dav[DisplayName::class.java]?.let {
if (!it.displayName.isNullOrEmpty())
displayName = it.displayName
}
var description: String? = null
var color: Int? = null
var timezone: String? = null
var supportsVEVENT: Boolean? = null
var supportsVTODO: Boolean? = null
var supportsVJOURNAL: Boolean? = null
var source: HttpUrl? = null
when (type) {
TYPE_ADDRESSBOOK -> {
dav[AddressbookDescription::class.java]?.let { description = it.description }
}
TYPE_CALENDAR, TYPE_WEBCAL -> {
dav[CalendarDescription::class.java]?.let { description = it.description }
dav[CalendarColor::class.java]?.let { color = it.color }
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
if (type == TYPE_CALENDAR) {
supportsVEVENT = true
supportsVTODO = true
supportsVJOURNAL = true
dav[SupportedCalendarComponentSet::class.java]?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
supportsVJOURNAL = it.supportsJournal
}
} else { // Type.WEBCAL
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull()?.let { rawHref ->
val href = rawHref
.replace("^webcal://".toRegex(), "http://")
.replace("^webcals://".toRegex(), "https://")
HttpUrl.parse(href)
} }
supportsVEVENT = true
}
}
}
return Collection(
type = type,
url = url,
privWriteContent = privWriteContent,
privUnbind = privUnbind,
displayName = displayName,
description = description,
color = color,
timezone = timezone,
supportsVEVENT = supportsVEVENT,
supportsVTODO = supportsVTODO,
supportsVJOURNAL = supportsVJOURNAL,
source = source
)
}
}
// non-persistent properties
@Ignore
var confirmed: Boolean = false
// calculated properties
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
fun readOnly() = forceReadOnly || !privWriteContent
}
@@ -1,43 +0,0 @@
package at.bitfire.davdroid.model
import androidx.lifecycle.LiveData
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface CollectionDao: SyncableDao<Collection> {
@Query("SELECT * FROM collection WHERE id=:id")
fun get(id: Long): Collection?
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url")
fun getByServiceAndSync(serviceId: Long): List<Collection>
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync")
fun observeHasSyncByService(serviceId: Long): LiveData<Boolean>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url")
fun getSyncCalendars(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url")
fun getSyncTaskLists(serviceId: Long): List<Collection>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(collection: Collection)
@Insert
fun insert(collection: Collection)
}
@@ -0,0 +1,140 @@
/*
* 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,
// non-persistent properties
var confirmed: Boolean = false
): Serializable {
enum class Type {
ADDRESS_BOOK,
CALENDAR
}
companion object {
@JvmField
val DAV_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.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
}
}
(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
}
}
}
}
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
id = values.getAsLong(Collections.ID)
serviceID = values.getAsLong(Collections.SERVICE_ID)
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
selected = values.getAsInteger(Collections.SYNC) != 0
}
fun toDB(): ContentValues {
val values = ContentValues()
// Collections.SERVICE_ID is never changed
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.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,16 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.TypeConverter
import okhttp3.HttpUrl
class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
@TypeConverter
fun stringToHttpUrl(url: String?): HttpUrl? =
url?.let { HttpUrl.parse(it) }
}
@@ -1,38 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model
class Credentials(
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null
) {
enum class Type {
UsernamePassword,
ClientCertificate
}
val type: Type
init {
type = when {
!certificateAlias.isNullOrEmpty() ->
Type.ClientCertificate
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
Type.UsernamePassword
else ->
throw IllegalArgumentException("Either username/password or certificate alias must be set")
}
}
override fun toString() =
"Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
}
@@ -1,30 +0,0 @@
package at.bitfire.davdroid.model
import at.bitfire.davdroid.log.Logger
import java.util.logging.Level
class DaoTools<T: IdEntity>(dao: SyncableDao<T>): SyncableDao<T> by dao {
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K) {
Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew))
val remainingNew = allNew.toMutableMap()
allOld.forEach { old ->
val key = selectKey(old)
val matchingNew = remainingNew[key]
if (matchingNew != null) {
// keep this old item, but maybe update it
matchingNew.id = old.id // identity is proven by key
if (matchingNew != old)
update(matchingNew)
// remove from remainingNew
remainingNew -= key
} else {
// this old item is not present anymore, delete it
delete(old)
}
}
insert(remainingNew.values.toList())
}
}
@@ -1,28 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Entity(tableName = "homeset",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
],
indices = [
// index by service; no duplicate URLs per service
Index("serviceId", "url", unique = true)
]
)
data class HomeSet(
@PrimaryKey(autoGenerate = true)
override var id: Long,
var serviceId: Long,
var url: HttpUrl,
var privBind: Boolean = true,
var displayName: String? = null
): IdEntity()
@@ -1,21 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface HomeSetDao: SyncableDao<HomeSet> {
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByService(serviceId: Long): List<HomeSet>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(homeSet: HomeSet): Long
}
@@ -1,5 +0,0 @@
package at.bitfire.davdroid.model
abstract class IdEntity {
abstract var id: Long
}
@@ -1,28 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import okhttp3.HttpUrl
@Entity(tableName = "service",
indices = [
// only one service per type and account
Index("accountName", "type", unique = true)
])
data class Service(
@PrimaryKey(autoGenerate = true)
var id: Long,
var accountName: String,
var type: String,
var principal: HttpUrl?
) {
companion object {
const val TYPE_CALDAV = "caldav"
const val TYPE_CARDDAV = "carddav"
}
}
@@ -0,0 +1,176 @@
/*
* 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 at.bitfire.davdroid.log.Logger
import java.io.Closeable
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 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 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(
context: Context
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
companion object {
val DATABASE_NAME = "services.db"
val DATABASE_VERSION = 1
}
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 ${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 fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// no different versions yet
}
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,35 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndType(accountName: String, type: String): Service?
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
fun getIdByAccountAndType(accountName: String, type: String): Long?
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): Service?
@Query("SELECT * FROM service WHERE type=:type")
fun getByType(type: String): List<Service>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(service: Service): Long
@Query("DELETE FROM service")
fun deleteAll()
@Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)")
fun deleteExceptAccounts(accountNames: Array<String>)
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
fun renameAccount(oldName: String, newName: String)
}
@@ -0,0 +1,94 @@
/*
* 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.BroadcastReceiver
import android.content.ContentValues;
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteDatabase
import at.bitfire.davdroid.CustomCertificates
import at.bitfire.davdroid.log.Logger
class Settings(
val db: SQLiteDatabase
) {
fun getBoolean(name: String, defaultValue: Boolean): Boolean {
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
if (cursor.moveToNext() && !cursor.isNull(0))
return cursor.getInt(0) != 0
}
return defaultValue
}
fun putBoolean(name: String, value: Boolean) {
val values = ContentValues(2)
values.put(ServiceDB.Settings.NAME, name)
values.put(ServiceDB.Settings.VALUE, if (value) 1 else 0)
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
fun getInt(name: String, defaultValue: Int): Int {
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
if (cursor.moveToNext() && !cursor.isNull(0))
return if (cursor.isNull(0)) defaultValue else cursor.getInt(0)
}
return defaultValue
}
fun putInt(name: String, value: Int?) {
val values = ContentValues(2)
values.put(ServiceDB.Settings.NAME, name)
values.put(ServiceDB.Settings.VALUE, value)
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
fun getString(name: String, defaultValue: String?): String? {
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getString(0)
}
return defaultValue
}
fun putString(name: String, value: String?) {
val values = ContentValues(2)
values.put(ServiceDB.Settings.NAME, name)
values.put(ServiceDB.Settings.VALUE, value)
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
fun remove(name: String) {
db.delete(ServiceDB.Settings._TABLE, "${ServiceDB.Settings.NAME}=?", arrayOf(name))
}
class ReinitSettingsReceiver: BroadcastReceiver() {
companion object {
@JvmField val ACTION_REINIT_SETTINGS = "at.bitfire.davdroid.REINIT_SETTINGS"
}
override fun onReceive(context: Context, intent: Intent) {
Logger.log.info("Received broadcast: re-initializing settings (logger/cert manager)")
CustomCertificates.reinitCertManager(context)
Logger.reinitLogger(context)
}
}
}
@@ -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.model
import at.bitfire.dav4jvm.property.SyncToken
import org.json.JSONException
import org.json.JSONObject
data class SyncState(
val type: Type,
val value: String,
/**
* Whether this sync state occurred during an initial sync as described
* in RFC 6578, which means the initial sync is not complete yet.
*/
var initialSync: Boolean? = null
) {
companion object {
private const val KEY_TYPE = "type"
private const val KEY_VALUE = "value"
private const val KEY_INITIAL_SYNC = "initialSync"
fun fromString(s: String?): SyncState? {
if (s == null)
return null
return try {
val json = JSONObject(s)
SyncState(
Type.valueOf(json.getString(KEY_TYPE)),
json.getString(KEY_VALUE),
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
)
} catch (e: JSONException) {
null
}
}
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
}
enum class Type { CTAG, SYNC_TOKEN }
override fun toString(): String {
val json = JSONObject()
json.put(KEY_TYPE, type.name)
json.put(KEY_VALUE, value)
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
return json.toString()
}
}
@@ -1,18 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Update
interface SyncableDao<T: IdEntity> {
@Insert
fun insert(items: List<T>)
@Update
fun update(item: T)
@Delete
fun delete(item: T)
}
@@ -6,16 +6,23 @@
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model
package at.bitfire.davdroid.model;
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts;
object UnknownProperties {
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
@JvmField
val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
const val MIMETYPE = RawContacts.Data.MIMETYPE
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
@JvmField
val MIMETYPE = RawContacts.Data.MIMETYPE
@JvmField
val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
@JvmField
val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
}
@@ -1,17 +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.resource
import at.bitfire.vcard4android.Contact
interface LocalAddress: LocalResource<Contact> {
fun resetDeleted()
}
@@ -18,85 +18,73 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.util.Base64
import at.bitfire.davdroid.App
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
/**
* A local address book. Requires an own Android account, because Android manages contacts per
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
* address book" account for every CardDAV address book. These accounts are bound to a
* DAVx5 main account.
*/
class LocalAddressBook(
private val context: Context,
account: Account,
provider: ContentProviderClient?
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalResource> {
companion object {
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
const val USER_DATA_URL = "url"
const val USER_DATA_READ_ONLY = "read_only"
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_CTAG = "ctag"
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
@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))
val userData = initialUserData(mainAccount, info.url.toString())
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
if (!accountManager.addAccountExplicitly(account, null, userData))
throw IllegalStateException("Couldn't create address book account")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
// Android < 7 seems to lose the initial user data sometimes, so set it a second time
// https://forums.bitfire.at/post/11644
userData.keySet().forEach { key ->
accountManager.setUserData(account, key, userData.getString(key))
}
val account = Account(accountName(mainAccount, info), App.addressBookAccountType)
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)
// initialize Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addressBook.settings = values
addressBook.readOnly = !info.privWriteContent || info.forceReadOnly
return addressBook
}
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.mainAccount == mainAccount }
.toList()
@JvmStatic
@Throws(ContactsStorageException::class)
fun find(context: Context, provider: ContentProviderClient, mainAccount: Account?): List<LocalAddressBook> {
val accountManager = AccountManager.get(context)
fun accountName(mainAccount: Account, info: Collection): String {
val result = LinkedList<LocalAddressBook>()
accountManager.getAccountsByType(App.addressBookAccountType)
.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(info.displayName.let {
if (it.isNullOrEmpty())
DavUtils.lastSegmentOfUrl(info.url)
else
it
})
sb.append(" (${mainAccount.name} $hash)")
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)
@@ -105,174 +93,87 @@ class LocalAddressBook(
return bundle
}
fun mainAccount(context: Context, account: Account): Account =
if (account.type == context.getString(R.string.account_type_address_book)) {
val manager = AccountManager.get(context)
Account(
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME),
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
)
} else
account
}
override val title = account.name!!
/**
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
*
* For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
* Whether contact groups (LocalGroup resources) are included in query results for
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
* {@link #getWithoutFileName()}.
*/
var includeGroups = true
private var _mainAccount: Account? = null
var mainAccount: Account
get() {
_mainAccount?.let { return it }
AccountManager.get(context).let { accountManager ->
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw IllegalStateException("No main account assigned to address book account")
}
}
set(newMainAccount) {
AccountManager.get(context).let { accountManager ->
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
}
_mainAccount = newMainAccount
}
var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
set(state) {
syncState = state?.toString()?.toByteArray()
}
/* operations on the collection (address book) itself */
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider.update(groupsSyncUri(), values, "${Groups.DIRTY}=0", null)
}
return number
}
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider.delete(groupsSyncUri(),
"${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
fun update(info: Collection) {
val newAccountName = accountName(mainAccount, info)
@Throws(ContactsStorageException::class)
fun update(info: CollectionInfo) {
val newAccountName = accountName(getMainAccount(), info)
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
val accountManager = AccountManager.get(context)
val future = accountManager.renameAccount(account, newAccountName, null, null)
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
}
val nowReadOnly = !info.privWriteContent || info.forceReadOnly
if (nowReadOnly != readOnly) {
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
// update address book itself
readOnly = nowReadOnly
// update raw contacts
val rawContactValues = ContentValues(1)
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
// update data rows
val dataValues = ContentValues(1)
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
provider.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
}
// make sure it will still be synchronized when contacts are updated
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
}
@Throws(ContactsStorageException::class)
fun delete() {
val accountManager = AccountManager.get(context)
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, null, null, null)
else
accountManager.removeAccount(account, null, null)
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) */
override fun findByName(name: String): LocalAddress? {
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
return if (includeGroups)
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
else
result
@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 RemoteException on content provider errors
*/
override fun findDeleted() =
if (includeGroups)
findDeletedContacts() + findDeletedGroups()
else
findDeletedContacts()
fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null)
fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", null)
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
* @throws RemoteException on content provider errors
*/
override fun findDirty() =
if (includeGroups)
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts("${RawContacts.DIRTY}!=0", null)
fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null)
@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.
@@ -280,44 +181,89 @@ class LocalAddressBook(
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
* @throws RemoteException on content provider errors
*/
@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 findDirtyContacts()) {
val lastHash = contact.getLastHashCode()
val currentHash = contact.dataHashCode()
if (lastHash == currentHash) {
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
contact.resetDirty()
} else {
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
reallyDirty++
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 += findDirtyGroups().size
reallyDirty += getDirtyGroups().size
return reallyDirty
}
fun getByGroupMembership(groupID: Long): List<LocalContact> {
val ids = HashSet<Long>()
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
null)?.use { cursor ->
while (cursor.moveToNext())
ids += cursor.getLong(0)
}
/**
* 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
}
return ids.map { findContactByID(it) }
/**
* 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)
}
}
@@ -326,23 +272,29 @@ class LocalAddressBook(
/**
* Finds the first group with the given title. If there is no group with this
* title, a new group is created.
* @param title title of the group to look for
* @return id of the group with given title
* @throws RemoteException on content provider errors
* @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 {
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
if (cursor.moveToNext())
return cursor.getLong(0)
}
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) ?: throw RemoteException("Couldn't create contact group")
return ContentUris.parseId(uri)
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 */
@@ -352,4 +304,54 @@ class LocalAddressBook(
}
}
}
@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(): String {
val accountManager = AccountManager.get(context)
return accountManager.getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
}
fun setURL(url: String) {
val accountManager = AccountManager.get(context)
accountManager.setUserData(account, USER_DATA_URL, url)
}
override fun getCTag(): String? {
val accountManager = AccountManager.get(context)
return accountManager.getUserData(account, USER_DATA_CTAG)
}
override fun setCTag(cTag: String?) {
val accountManager = AccountManager.get(context)
accountManager.setUserData(account, USER_DATA_CTAG, cTag)
}
}
@@ -14,16 +14,14 @@ 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.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.BatchOperation
import at.bitfire.ical4android.DateUtils
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.ical4android.*
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
@@ -35,9 +33,19 @@ class LocalCalendar private constructor(
companion object {
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
val defaultColor = 0xFF8bc34a.toInt() // light green 500
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
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.
@@ -48,25 +56,31 @@ class LocalCalendar private constructor(
// 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)
val uri = create(account, provider, values)
// create calendar colors
AndroidCalendar.insertColors(provider, account)
return uri
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
val values = ContentValues()
values.put(Calendars.NAME, info.url.toString())
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 ?: Constants.DAVDROID_GREEN_RGBA)
values.put(Calendars.CALENDAR_COLOR, info.color ?: defaultColor)
if (info.privWriteContent && !info.forceReadOnly) {
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)
} else
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
}
info.timezone?.let { tzData ->
info.timeZone?.let { tzData ->
try {
val timeZone = DateUtils.parseVTimeZone(tzData)
timeZone.timeZoneId?.let { tzId ->
@@ -76,38 +90,36 @@ class LocalCalendar private constructor(
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
values.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}")
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT)
values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}")
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${Attendees.TYPE_OPTIONAL},${Attendees.TYPE_REQUIRED},${Attendees.TYPE_RESOURCE}")
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
return values
}
}
override val title: String
get() = displayName ?: id.toString()
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
set(state) {
val values = ContentValues(1)
values.put(COLUMN_SYNC_STATE, state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
override fun eventBaseInfoColumns() = BASE_INFO_COLUMNS
fun update(info: Collection, updateColor: Boolean) =
@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)
override fun findDeleted() =
@Throws(CalendarStorageException::class)
override fun getDeleted() =
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
override fun findDirty(): List<LocalEvent> {
@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
@@ -124,46 +136,50 @@ class LocalCalendar private constructor(
return dirty
}
override fun findByName(name: String) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
@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)
}
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
return provider.update(eventsSyncURI(), values,
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
@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)
}
}
override fun removeNotDirtyMarked(flags: Int) =
provider.delete(eventsSyncURI(),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
@Throws(CalendarStorageException::class)
fun processDirtyExceptions() {
// process deleted exceptions
Logger.log.info("Processing deleted exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
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)
val batch = BatchOperation(provider)
// get original event's SEQUENCE
provider.query(
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
arrayOf(LocalEvent.COLUMN_SEQUENCE),
null, null, null)?.use { cursor2 ->
if (cursor2.moveToNext()) {
// original event is available
// 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
@@ -173,43 +189,44 @@ class LocalCalendar private constructor(
.withValue(Events.DIRTY, 1)
))
}
// remove exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
// completely remove deleted exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
}
// process dirty exceptions
Logger.log.info("Processing dirty exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
// 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()
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)
}
}
@@ -221,4 +238,4 @@ class LocalCalendar private constructor(
}
}
}
@@ -8,41 +8,28 @@
package at.bitfire.davdroid.resource
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.model.SyncState
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import java.io.FileNotFoundException
interface LocalCollection<out T: LocalResource<*>> {
interface LocalCollection<out T: LocalResource> {
/** collection title (used for user notifications etc.) **/
val title: String
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getDeleted(): List<T>
var lastSyncState: SyncState?
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getWithoutFileName(): List<T>
fun findDeleted(): List<T>
fun findDirty(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
fun getDirty(): List<T>
fun findByName(name: String): T?
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getAll(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getCTag(): String?
/**
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
* and have an [Events.ORIGINAL_ID] of null.
*
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
*
* @return number of marked entries
*/
fun markNotDirty(flags: Int): Int
/**
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
* a given flag combination.
*
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
* all entries with exactly this flag will be removed)
*
* @return number of removed entries
*/
fun removeNotDirtyMarked(flags: Int): Int
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun setCTag(cTag: String?)
}
@@ -23,110 +23,118 @@ import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.*
class LocalContact: AndroidContact, LocalAddress {
class LocalContact: AndroidContact, LocalResource {
companion object {
init {
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
}
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
}
private val cachedGroupMemberships = HashSet<Long>()
private val groupMemberships = HashSet<Long>()
override var flags: Int = 0
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, id: Long, fileName: String?, eTag: String?):
super(addressBook, id, fileName, eTag)
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
super(addressBook, contact, fileName, eTag)
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
fileName = newFileName
}
override fun clearDirty(eTag: String?) {
val values = ContentValues(3)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val hashCode = dataHashCode()
values.put(COLUMN_HASHCODE, hashCode)
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
}
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
this.eTag = eTag
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
}
@Throws(ContactsStorageException::class)
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
try {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear dirty flag", e)
}
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(3)
values.put(COLUMN_ETAG, eTag)
values.put(ContactsContract.RawContacts.DIRTY, 0)
this.flags = flags
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 += row.getAsLong(CachedGroupMembership.GROUP_ID)
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID))
GroupMembership.CONTENT_ITEM_TYPE ->
groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID)
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID))
UnknownProperties.CONTENT_ITEM_TYPE ->
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
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)
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)
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)
}
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)
}
}
@@ -135,6 +143,7 @@ class LocalContact: AndroidContact, LocalAddress {
* 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")
@@ -149,34 +158,44 @@ class LocalContact: AndroidContact, LocalAddress {
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)
val hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
values.put(COLUMN_HASHCODE, hashCode)
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))
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")
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
if (c.moveToNext() && !c.isNull(0))
return c.getInt(0)
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)
}
return 0
}
@@ -187,7 +206,7 @@ class LocalContact: AndroidContact, LocalAddress {
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
))
groupMemberships += groupID
groupMemberships.add(groupID)
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
@@ -196,7 +215,7 @@ class LocalContact: AndroidContact, LocalAddress {
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
))
cachedGroupMemberships += groupID
cachedGroupMemberships.add(groupID)
}
fun removeGroupMemberships(batch: BatchOperation) {
@@ -214,12 +233,13 @@ class LocalContact: AndroidContact, LocalAddress {
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
* 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 FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
* @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
@@ -228,26 +248,26 @@ class LocalContact: AndroidContact, LocalAddress {
/**
* Returns the IDs of all groups the contact is member of.
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
* @throws FileNotFoundException if the current contact can't be found
* @throws RemoteException on contacts provider errors
* @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
}
// data rows
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
builder.withValue(COLUMN_FLAGS, flags)
super.buildContact(builder, update)
}
// factory
object Factory: AndroidContactFactory<LocalContact> {
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
LocalContact(addressBook, values)
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)
}
}
@@ -10,58 +10,68 @@ 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<Event> {
class LocalEvent: AndroidEvent, LocalResource {
companion object {
init {
ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion)
iCalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
}
const val COLUMN_ETAG = Events.SYNC_DATA1
const val COLUMN_FLAGS = Events.SYNC_DATA2
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
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
private set
override var eTag: String? = null
override var flags: Int = 0
private set
var weAreOrganizer = true
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?): super(calendar, event) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
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)
event.uid = row.getAsString(Events.UID_2445)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
fileName = row.getAsString(Events._SYNC_ID)
eTag = row.getAsString(COLUMN_ETAG)
event.uid = row.getAsString(COLUMN_UID)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
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)
@@ -69,11 +79,10 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(Events.UID_2445, event.uid)
builder .withValue(COLUMN_UID, event.uid)
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
.withValue(Events.DIRTY, 0)
.withValue(Events.DELETED, 0)
.withValue(COLUMN_FLAGS, flags)
.withValue(CalendarContract.Events.DIRTY, 0)
.withValue(CalendarContract.Events.DELETED, 0)
if (buildException)
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
@@ -83,48 +92,57 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
}
override fun assignNameAndUID() {
var uid: String? = null
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0)
/* 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)
}
if (uid == null)
uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
val values = ContentValues(2)
values.put(Events._SYNC_ID, newFileName)
values.put(Events.UID_2445, uid)
calendar.provider.update(eventSyncURI(), values, null, null)
fileName = newFileName
event!!.uid = uid
}
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
val values = ContentValues(2)
values.put(Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
calendar.provider.update(eventSyncURI(), values, null, null)
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
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
calendar.provider.update(eventSyncURI(), values, null, null)
this.flags = flags
this.eTag = eTag
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
}
}
object Factory: AndroidEventFactory<LocalEvent> {
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
LocalEvent(calendar, values)
}
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)
}
}
@@ -11,7 +11,6 @@ package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Parcel
import android.os.RemoteException
@@ -20,128 +19,105 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.dav4jvm.Constants
import at.bitfire.dav4android.Constants
import at.bitfire.vcard4android.*
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
class LocalGroup: AndroidGroup, LocalAddress {
class LocalGroup: AndroidGroup, LocalResource {
companion object {
const val COLUMN_FLAGS = Groups.SYNC4
/** marshaled list of member UIDs, as sent by server */
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
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) {
addressBook.provider!!.query(
addressBook.groupsSyncUri(),
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
null
)?.use { cursor ->
val batch = BatchOperation(addressBook.provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
Constants.log.fine("Assigning members to group $id")
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>()
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val changeContactIDs = HashSet<Long>()
// delete all memberships and cached memberships for this group
for (contact in addressBook.getByGroupMembership(id)) {
contact.removeGroupMemberships(batch)
changeContactIDs += contact.id!!
// 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()
}
// extract list of member UIDs
val members = LinkedList<String>()
val raw = cursor.getBlob(1)
val parcel = Parcel.obtain()
try {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
parcel.readStringList(members)
} finally {
parcel.recycle()
}
// insert memberships
for (uid in members) {
Constants.log.fine("Assigning member: $uid")
addressBook.findContactByUID(uid)?.let { member ->
member.addToGroup(batch, id)
changeContactIDs += member.id!!
} ?: Constants.log.warning("Group member not found: $uid")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
changeContactIDs
.map { addressBook.findContactByID(it) }
.forEach { it.updateHashCode(batch) }
// remove pending memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null)
.withYieldAllowed(true)
))
batch.commit()
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't get pending memberships", e)
}
}
}
override var flags: Int = 0
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)
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
override fun contentValues(): ContentValues {
val values = super.contentValues()
values.put(COLUMN_FLAGS, flags)
val members = Parcel.obtain()
try {
members.writeStringList(contact!!.members)
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
} finally {
members.recycle()
}
return values
}
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
update(values)
fileName = newFileName
}
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
val id = requireNotNull(id)
@@ -176,9 +152,38 @@ class LocalGroup: AndroidGroup, LocalAddress {
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!!)
@@ -192,45 +197,31 @@ class LocalGroup: AndroidGroup, LocalAddress {
batch.commit()
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(Groups.DELETED, 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags
}
// helpers
private fun groupSyncUri(): Uri {
val id = requireNotNull(id)
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws RemoteException on contact provider errors
* @throws ContactsStorageException on contact provider errorst
*/
@Throws(ContactsStorageException::class)
internal fun getMembers(): List<Long> {
val id = requireNotNull(id)
val members = LinkedList<Long>()
addressBook.provider!!.query(
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
arrayOf(Data.RAW_CONTACT_ID),
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
null
)?.use { cursor ->
while (cursor.moveToNext())
members += cursor.getLong(0)
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
}
@@ -239,8 +230,13 @@ class LocalGroup: AndroidGroup, LocalAddress {
// factory
object Factory: AndroidGroupFactory<LocalGroup> {
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
LocalGroup(addressBook, values)
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)
}
}
@@ -8,50 +8,23 @@
package at.bitfire.davdroid.resource
import android.net.Uri
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
interface LocalResource<in TData: Any> {
interface LocalResource {
companion object {
/**
* Resource is present on remote server. This flag is used to identify resources
* which are not present on the remote server anymore and can be deleted at the end
* of the synchronization.
*/
const val FLAG_REMOTELY_PRESENT = 1
}
/**
* Unique ID which identifies the resource in the local storage. May be null if the
* resource has not been saved yet.
*/
val id: Long?
val fileName: String?
var fileName: String?
var eTag: String?
val flags: Int
fun assignNameAndUID()
fun clearDirty(eTag: String?)
fun updateFlags(flags: Int)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
* @return content URI of the created row (e.g. event URI)
*/
fun add(): Uri
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData): Uri
/**
* Deletes the data object from the content provider.
* @return number of affected rows
*/
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun delete(): Int
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun prepareForUpload()
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun clearDirty(eTag: String?)
}
@@ -10,92 +10,108 @@ package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import at.bitfire.ical4android.AndroidTask
import at.bitfire.ical4android.AndroidTaskFactory
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.Task
import org.dmfs.tasks.contract.TaskContract.Tasks
import 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<Task> {
class LocalTask: AndroidTask, LocalResource {
companion object {
const val COLUMN_ETAG = Tasks.SYNC1
const val COLUMN_FLAGS = Tasks.SYNC2
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
override var flags = 0
private set
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
: super(taskList, task) {
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?): super(taskList, task) {
this.fileName = fileName
this.eTag = eTag
this.flags = flags
}
private constructor(taskList: AndroidTaskList<*>, values: ContentValues): super(taskList) {
id = values.getAsLong(Tasks._ID)
fileName = values.getAsString(Tasks._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
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)
.withValue(COLUMN_FLAGS, flags)
}
/* custom queries */
override fun assignNameAndUID() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.ics"
@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(Tasks._UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = newFileName
task!!.uid = uid
}
override fun clearDirty(eTag: String?) {
val values = ContentValues(3)
values.put(Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null)
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
val values = ContentValues(2)
values.put(Tasks._SYNC_ID, newFileName)
values.put(COLUMN_UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
}
this.flags = flags
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 fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
LocalTask(taskList, values)
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)
}
}
@@ -9,25 +9,21 @@
package at.bitfire.davdroid.resource
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.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.tasks.contract.TaskContract.TaskLists
import org.dmfs.tasks.contract.TaskContract.Tasks
import java.util.logging.Level
import org.dmfs.provider.tasks.TaskContract.TaskLists
import org.dmfs.provider.tasks.TaskContract.Tasks
import java.io.FileNotFoundException
class LocalTaskList private constructor(
account: Account,
@@ -37,21 +33,30 @@ class LocalTaskList private constructor(
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
try {
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use {
return true
}
} catch (e: Exception) {
// couldn't acquire task provider
}
else {
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
provider?.use { return true }
return false
}
}
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
@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)
@@ -59,7 +64,7 @@ class LocalTaskList private constructor(
return create(account, provider, values)
}
@SuppressLint("Recycle")
@JvmStatic
@Throws(Exception::class)
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
var client: ContentProviderClient? = null
@@ -71,55 +76,47 @@ class LocalTaskList private constructor(
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
}
} finally {
client?.closeCompat()
if (Build.VERSION.SDK_INT >= 24)
client?.close()
else
client?.release()
}
}
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
val values = ContentValues(3)
values.put(TaskLists._SYNC_ID, info.url.toString())
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 ?: Constants.DAVDROID_GREEN_RGBA)
values.put(TaskLists.LIST_COLOR, info.color ?: defaultColor)
return values
}
}
override val title: String
get() = name ?: id.toString()
override var lastSyncState: SyncState?
get() {
try {
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
provider.client.update(taskListSyncUri(), values, null, null)
}
override fun taskBaseInfoColumns() = BASE_INFO_COLUMNS
fun update(info: Collection, updateColor: Boolean) =
update(valuesFromCollectionInfo(info, updateColor))
@Throws(CalendarStorageException::class)
fun update(info: CollectionInfo, updateColor: Boolean) {
update(valuesFromCollectionInfo(info, updateColor));
}
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
@Throws(CalendarStorageException::class)
override fun getAll() = queryTasks(null, null)
override fun findDirty(): List<LocalTask> {
@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)
@@ -132,23 +129,30 @@ class LocalTaskList private constructor(
return tasks
}
override fun findByName(name: String) =
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
@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)
}
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalTask.COLUMN_FLAGS, flags)
return provider.client.update(tasksSyncUri(), values,
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
arrayOf(id.toString()))
@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)
}
}
override fun removeNotDirtyMarked(flags: Int) =
provider.client.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
object Factory: AndroidTaskListFactory<LocalTaskList> {
@@ -157,4 +161,4 @@ class LocalTaskList private constructor(
}
}
}
@@ -1,381 +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.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.os.Bundle
import android.os.Parcel
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.davdroid.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
import java.util.logging.Level
/**
* Manages settings of an account.
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
*/
class AccountSettings(
val context: Context,
val account: Account
) {
companion object {
const val CURRENT_VERSION = 9
const val KEY_SETTINGS_VERSION = "version"
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val WIFI_ONLY_DEFAULT = false
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
/** Time range limitation to the past [in days]
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
< 0 (-1) no limit
>= 0 entries more than n days in the past won't be synchronized
*/
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/* Whether DAVx5 sets the local calendar color to the value from service DB at every sync
value = null (not existing) true (default)
"0" false */
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/* Whether DAVx5 populates and uses CalendarContract.Colors
value = null (not existing) false (default)
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
const val SYNC_INTERVAL_MANUALLY = -1L
fun initialUserData(credentials: Credentials): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
when (credentials.type) {
Credentials.Type.UsernamePassword ->
bundle.putString(KEY_USERNAME, credentials.userName)
Credentials.Type.ClientCertificate ->
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
return bundle
}
}
val accountManager: AccountManager = AccountManager.get(context)
val settings = Settings.getInstance(context)
init {
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
}
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION)
update(version)
}
}
// authentication settings
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
)
fun credentials(credentials: Credentials) {
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
accountManager.setPassword(account, credentials.password)
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
// sync. settings
fun getSyncInterval(authority: String): Long? {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null
return if (ContentResolver.getSyncAutomatically(account, authority))
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
else
SYNC_INTERVAL_MANUALLY
}
fun setSyncInterval(authority: String, seconds: Long) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, authority, false)
} else {
ContentResolver.setSyncAutomatically(account, authority, true)
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
}
}
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT
else
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
settings.getString(KEY_WIFI_ONLY_SSIDS)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
// CalDAV settings
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
return if (strDays != null) {
val days = Integer.valueOf(strDays)
if (days < 0) null else days
} else
DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false
else
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
fun setManageCalendarColors(manage: Boolean) =
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
fun getEventColors() = if (settings.has(KEY_EVENT_COLORS))
settings.getBoolean(KEY_EVENT_COLORS) ?: false
else
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
fun setEventColors(useColors: Boolean) =
accountManager.setUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
// CardDAV settings
fun getGroupMethod(): GroupMethod {
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
if (name != null)
try {
return GroupMethod.valueOf(name)
}
catch (e: IllegalArgumentException) {
}
return GroupMethod.GROUP_VCARDS
}
fun setGroupMethod(method: GroupMethod) {
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
}
// update from previous account settings
private fun update(baseVersion: Int) {
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
val fromVersion = toVersion-1
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
try {
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
updateProc.invoke(this)
Logger.log.info("Account version update successful")
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
}
}
}
@Suppress("unused","FunctionName")
/**
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
* Disable it on those accounts for the future.
*/
private fun update_8_9() {
val db = AppDatabase.getInstance(context)
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) {
Logger.log.info("Disabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
}
}
@Suppress("unused","FunctionName")
@SuppressLint("Recycle")
/**
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
* SEQUENCE and should not be used for the eTag.
*/
private fun update_7_8() {
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
// ETag is now in sync_version instead of sync1
// UID is now in _uid instead of sync2
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account),
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
arrayOf(account.type, account.name), null)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
val uid = cursor.getString(2)
val values = ContentValues(4)
values.put(TaskContract.Tasks._UID, uid)
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
values.putNull(TaskContract.Tasks.SYNC1)
values.putNull(TaskContract.Tasks.SYNC2)
Logger.log.log(Level.FINER, "Updating task $id", values)
provider.client.update(
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
values, null, null)
}
}
}
}
@Suppress("unused")
@SuppressLint("Recycle")
private fun update_6_7() {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
AndroidCalendar.insertColors(provider, account)
} finally {
provider.closeCompat()
}
}
// update allowed WiFi settings key
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID)
accountManager.setUserData(account, "wifi_only_ssid", null)
}
@Suppress("unused")
@SuppressLint("Recycle", "ParcelClassLoader")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
val parcel = Parcel.obtain()
try {
// don't run syncs during the migration
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
ContentResolver.cancelSync(account, null)
// get previous address book settings (including URL)
val raw = ContactsContract.SyncState.get(provider, account)
if (raw == null)
Logger.log.info("No contacts sync state, ignoring account")
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()!!
val url = params.getString("url")?.let { HttpUrl.parse(it) }
if (url == null)
Logger.log.info("No address book URL, ignoring account")
else {
// create new address book
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
Logger.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
Logger.log.info("Moving contacts from $account to $addressBookAccount")
val newAccount = ContentValues(2)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
newAccount,
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
arrayOf(account.name, account.type))
Logger.log.info("$affected contacts moved to new address book")
}
ContactsContract.SyncState.set(provider, account, null)
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
provider.closeCompat()
}
}
// update version number so that further syncs don't repeat the migration
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
}
/* Android 7.1.1 OpenTasks fix */
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context)
}
@Suppress("unused")
private fun update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES)
}
// updates from AccountSettings version 2 and below are not supported anymore
}
@@ -1,78 +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.settings
import android.content.Context
open class DefaultsProvider(
private val allowOverride: Boolean = true
): SettingsProvider {
open val booleanDefaults = mapOf(
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT),
Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT)
)
open val intDefaults = mapOf(
Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT)
)
open val longDefaults = mapOf<String, Long>()
open val stringDefaults = mapOf(
Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT)
)
override fun forceReload() {
}
override fun close() {
}
private fun hasKey(key: String) =
booleanDefaults.containsKey(key) ||
intDefaults.containsKey(key) ||
longDefaults.containsKey(key) ||
stringDefaults.containsKey(key)
override fun has(key: String): Pair<Boolean, Boolean> {
val has = hasKey(key)
return Pair(has, allowOverride || !has)
}
override fun getBoolean(key: String) =
Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key))
override fun getInt(key: String) =
Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key))
override fun getLong(key: String) =
Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key))
override fun getString(key: String) =
Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key))
override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key))
override fun putBoolean(key: String, value: Boolean?) = false
override fun putInt(key: String, value: Int?) = false
override fun putLong(key: String, value: Long?) = false
override fun putString(key: String, value: String?) = false
override fun remove(key: String) = false
class Factory : ISettingsProviderFactory {
override fun getProviders(context: Context) = listOf(DefaultsProvider())
}
}
@@ -1,17 +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.settings
import android.content.Context
interface ISettingsProviderFactory {
fun getProviders(context: Context): List<SettingsProvider>
}
@@ -1,189 +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.settings
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.log.Logger
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
@WorkerThread
class Settings(
appContext: Context
) {
companion object {
// settings keys and default values
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false
const val OVERRIDE_PROXY = "override_proxy"
const val OVERRIDE_PROXY_DEFAULT = false
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
private var singleton: Settings? = null
fun getInstance(context: Context): Settings {
singleton?.let { return it }
val newInstance = Settings(context.applicationContext)
singleton = newInstance
return newInstance
}
}
private val providers = LinkedList<SettingsProvider>()
private val observers = LinkedList<WeakReference<OnChangeListener>>()
init {
val factories = ServiceLoader.load(ISettingsProviderFactory::class.java)
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
factories.forEach { factory ->
providers.addAll(factory.getProviders(appContext))
}
}
fun forceReload() {
providers.forEach {
it.forceReload()
}
onSettingsChanged()
}
/*** OBSERVERS ***/
fun addOnChangeListener(observer: OnChangeListener) {
observers += WeakReference(observer)
}
fun removeOnChangeListener(observer: OnChangeListener) {
observers.removeAll { it.get() == null || it.get() == observer }
}
fun onSettingsChanged() {
observers.mapNotNull { it.get() }.forEach {
it.onSettingsChanged()
}
}
/*** SETTINGS ACCESS ***/
fun has(key: String): Boolean {
Logger.log.fine("Looking for setting $key")
var result = false
for (provider in providers)
try {
val (value, further) = provider.has(key)
Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further")
if (value) {
result = true
break
}
if (!further)
break
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e)
}
Logger.log.fine("Looking for setting $key -> $result")
return result
}
private fun<T> getValue(key: String, reader: (SettingsProvider) -> Pair<T?, Boolean>): T? {
Logger.log.fine("Looking up setting $key")
var result: T? = null
for (provider in providers)
try {
val (value, further) = reader(provider)
Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further")
value?.let { result = it }
if (!further)
break
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
}
Logger.log.fine("Looked up setting $key -> $result")
return result
}
fun getBoolean(key: String) =
getValue(key) { provider -> provider.getBoolean(key) }
fun getInt(key: String) =
getValue(key) { provider -> provider.getInt(key) }
fun getLong(key: String) =
getValue(key) { provider -> provider.getLong(key) }
fun getString(key: String) =
getValue(key) { provider -> provider.getString(key) }
fun isWritable(key: String): Boolean {
for (provider in providers) {
val (value, further) = provider.isWritable(key)
if (value)
return true
if (!further)
return false
}
return false
}
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean {
Logger.log.fine("Trying to write setting $key = $value")
for (provider in providers) {
val (writable, further) = provider.isWritable(key)
Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further")
if (writable)
return try {
writer(provider)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e)
false
}
if (!further)
return false
}
return false
}
fun putBoolean(key: String, value: Boolean?) =
putValue(key, value) { provider -> provider.putBoolean(key, value) }
fun putInt(key: String, value: Int?) =
putValue(key, value) { provider -> provider.putInt(key, value) }
fun putLong(key: String, value: Long?) =
putValue(key, value) { provider -> provider.putLong(key, value) }
fun putString(key: String, value: String?) =
putValue(key, value) { provider -> provider.putString(key, value) }
fun remove(key: String): Boolean {
var deleted = false
providers.forEach { deleted = deleted || it.remove(key) }
return deleted
}
interface OnChangeListener {
fun onSettingsChanged()
}
}
@@ -1,32 +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.settings
interface SettingsProvider {
fun forceReload()
fun close()
fun has(key: String): Pair<Boolean, Boolean>
fun getBoolean(key: String): Pair<Boolean?, Boolean>
fun getInt(key: String): Pair<Int?, Boolean>
fun getLong(key: String): Pair<Long?, Boolean>
fun getString(key: String): Pair<String?, Boolean>
fun isWritable(key: String): Pair<Boolean, Boolean>
fun putBoolean(key: String, value: Boolean?): Boolean
fun putInt(key: String, value: Int?): Boolean
fun putLong(key: String, value: Long?): Boolean
fun putString(key: String, value: String?): Boolean
fun remove(key: String): Boolean
}
@@ -1,132 +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.settings
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.preference.PreferenceManager
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
class SharedPreferencesProvider(
val context: Context
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
companion object {
private const val META_VERSION = "version"
private const val CURRENT_VERSION = 0
}
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
init {
val meta = context.getSharedPreferences("meta", MODE_PRIVATE)
val version = meta.getInt(META_VERSION, -1)
if (version == -1) {
// first call, check whether to migrate from SQLite database (DAVdroid <1.9)
firstCall(context)
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
}
preferences.registerOnSharedPreferenceChangeListener(this)
}
override fun forceReload() {
}
override fun close() {
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
Settings.getInstance(context).onSettingsChanged()
}
override fun has(key: String) =
Pair(preferences.contains(key), true)
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): Pair<T?, Boolean> {
if (preferences.contains(key))
return Pair(
try { reader(preferences) } catch(e: ClassCastException) { null },
true)
return Pair(null, true)
}
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
override fun getInt(key: String): Pair<Int?, Boolean> =
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
override fun getLong(key: String): Pair<Long?, Boolean> =
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
override fun getString(key: String): Pair<String?, Boolean> =
getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) }
override fun isWritable(key: String) =
Pair(first = true, second = true)
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
return if (value == null)
remove(key)
else {
Logger.log.fine("Writing setting $key = $value")
val edit = preferences.edit()
writer(edit, value)
edit.apply()
true
}
}
override fun putBoolean(key: String, value: Boolean?) =
putValue(key, value) { editor, v -> editor.putBoolean(key, v) }
override fun putInt(key: String, value: Int?) =
putValue(key, value) { editor, v -> editor.putInt(key, v) }
override fun putLong(key: String, value: Long?) =
putValue(key, value) { editor, v -> editor.putLong(key, v) }
override fun putString(key: String, value: String?) =
putValue(key, value) { editor, v -> editor.putString(key, v) }
override fun remove(key: String): Boolean {
Logger.log.fine("Removing setting $key")
preferences.edit()
.remove(key)
.apply()
return true
}
private fun firstCall(context: Context) {
// remove possible artifacts from DAVdroid <1.9
val edit = preferences.edit()
edit.remove("override_proxy")
edit.remove("proxy_host")
edit.remove("proxy_port")
edit.remove("log_to_external_storage")
edit.apply()
// open ServiceDB to upgrade it and possibly migrate settings
AppDatabase.getInstance(context)
}
class Factory : ISettingsProviderFactory {
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
}
}
@@ -7,87 +7,26 @@
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.*
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.ui.setup.LoginActivity
import java.util.logging.Level
import kotlin.concurrent.thread
class AccountAuthenticatorService: Service() {
/**
* Account authenticator for the main DAVx5 account type.
*
* Gets started when a DAVx5 account is removed, too, so it also watches for account removals
* and contains the corresponding cleanup code.
*/
class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
companion object {
@WorkerThread
fun cleanupAccounts(context: Context) {
Logger.log.info("Cleaning up orphaned accounts")
val accountManager = AccountManager.get(context)
val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type))
.map { it.name }
// delete orphaned address book accounts
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, null) }
.forEach {
try {
if (!accountNames.contains(it.mainAccount.name))
it.delete()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
}
}
// delete orphaned services in DB
val db = AppDatabase.getInstance(context)
val serviceDao = db.serviceDao()
if (accountNames.isEmpty())
serviceDao.deleteAll()
else
serviceDao.deleteExceptAccounts(accountNames.toTypedArray())
}
}
private lateinit var accountManager: AccountManager
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountManager = AccountManager.get(this)
accountManager.addOnAccountsUpdatedListener(this, null, true)
accountAuthenticator = AccountAuthenticator(this)
}
override fun onDestroy() {
super.onDestroy()
accountManager.removeOnAccountsUpdatedListener(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
override fun onAccountsUpdated(accounts: Array<out Account>?) {
thread {
cleanupAccounts(this)
}
}
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
@@ -12,14 +12,13 @@ import android.content.ContentProvider
import android.content.ContentValues
import android.net.Uri
@Suppress("ImplicitNullableNothingType")
class AddressBookProvider: ContentProvider() {
override fun onCreate() = false
override fun insert(p0: Uri, p1: ContentValues?) = null
override fun query(p0: Uri, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
override fun delete(p0: Uri, p1: String?, p2: Array<out String>?) = 0
override fun getType(p0: Uri) = null
override fun insert(p0: Uri?, p1: ContentValues?) = null
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?) = 0
override fun getType(p0: Uri?) = null
}
@@ -7,94 +7,104 @@
*/
package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.content.pm.PackageManager
import android.database.DatabaseUtils
import android.os.Bundle
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
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.AccountSettings
import at.bitfire.davdroid.ui.account.AccountActivity
import okhttp3.HttpUrl
import at.bitfire.vcard4android.ContactsStorageException
import java.util.logging.Level
class AddressBooksSyncAdapterService : SyncAdapterService() {
class AddressBooksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
class AddressBooksSyncAdapter(
protected class AddressBooksSyncAdapter(
context: Context
) : SyncAdapter(context) {
): SyncAdapter(context) {
override fun sync(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
}
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, account)
val settings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
if (updateLocalAddressBooks(account, syncResult))
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
} catch (e: Exception) {
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(account: Account, syncResult: SyncResult): Boolean {
val db = AppDatabase.getInstance(context)
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getByServiceAndSync(service.id))
remoteAddressBooks[collection.url] = collection
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
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
if (remoteAddressBooks.isEmpty())
Logger.log.info("No contacts permission, but no address book selected for synchronization")
else {
// no contacts permission, but address books should be synchronized -> show notification
val intent = Intent(context, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
notifyPermissions(intent)
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
}
return false
}
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
try {
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return false
}
// enumerate remote and local address books
val service = getService()
val remote = remoteAddressBooks(service)
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = HttpUrl.parse(addressBook.url)!!
val info = remoteAddressBooks[url]
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()
@@ -103,24 +113,20 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch (e: Exception) {
} 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
remoteAddressBooks -= url
remote -= url
}
}
// create new local address books
for ((_, info) in remoteAddressBooks) {
for ((_, info) in remote) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, contactsProvider, account, info)
LocalAddressBook.create(context, provider, account, info)
}
} finally {
contactsProvider?.closeCompat()
}
return true
}
}
@@ -9,27 +9,28 @@
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.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.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.model.SyncState
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
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
@@ -37,130 +38,148 @@ import java.util.*
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles events (VEVENT)
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
*/
class CalendarSyncManager(
context: Context,
account: Account,
accountSettings: AccountSettings,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCalendar: LocalCalendar
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCalendar) {
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(localCollection.name ?: return false) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
// if there are dirty exceptions for events, mark their master events as dirty, too
localCollection.processDirtyExceptions()
collectionURL = HttpUrl.parse(localCalendar.name ?: return false) ?: return false
davCollection = DavCalendar(httpClient, collectionURL)
return true
}
override fun queryCapabilities(): SyncState? =
useRemoteCollection {
var syncState: SyncState? = null
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
}
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState
}
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
SyncAlgorithm.PROPFIND_REPORT
else
SyncAlgorithm.COLLECTION_SYNC
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) {
val event = requireNotNull(resource.event)
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
val os = ByteArrayOutputStream()
event.write(os)
RequestBody.create(
DavCalendar.MIME_ICALENDAR_UTF8,
os.toByteArray()
)
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun listAllRemote(callback: DavResponseCallback) {
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
accountSettings.getTimeRangePastDays()?.let { pastDays ->
settings.getTimeRangePastDays()?.let { pastDays ->
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_MONTH, -pastDays)
limitStart = calendar.time
}
return useRemoteCollection { remote ->
Logger.log.info("Querying events since $limitStart")
remote.calendarQuery("VEVENT", limitStart, null, callback)
// 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(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
if (bunch.size == 1) {
val remote = bunch.first()
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response ->
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
?: throw DavException("Received CalDAV GET response without ETag")
override fun downloadRemote() {
Logger.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
response.body()!!.use {
processVEvent(resource.fileName(), eTag, it.charStream())
}
// 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))
}
}
} else
// multiple iCalendars, use calendar-multi-get
useRemoteCollection {
it.multiget(bunch) { response, _ ->
useRemote(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@useRemote
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without address data")
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
}
}
}
}
override fun postProcess() {
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)
notifyInvalidResource(e, fileName)
return
}
@@ -168,25 +187,24 @@ class CalendarSyncManager(
val newData = events.first()
// delete local event, if it exists
useLocal(localCollection.findByName(fileName)) { local ->
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local calendar", newData)
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.log(Level.INFO, "Adding $fileName to local calendar", newData)
useLocal(LocalEvent(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.add()
}
syncResult.stats.numInserts++
}
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.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
currentLocalResource = null
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_event)
}
@@ -8,93 +8,111 @@
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.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.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
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.AccountSettings
import at.bitfire.ical4android.AndroidCalendar
import okhttp3.HttpUrl
import java.util.logging.Level
class CalendarsSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = CalendarsSyncAdapter(this)
override fun syncAdapter() = SyncAdapter(this)
class CalendarsSyncAdapter(
protected class SyncAdapter(
context: Context
): SyncAdapter(context) {
): SyncAdapterService.SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val accountSettings = AccountSettings(context, account)
val settings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
if (accountSettings.getEventColors())
AndroidCalendar.insertColors(provider, account)
else
AndroidCalendar.removeColors(provider, account)
updateLocalCalendars(provider, account, accountSettings)
updateLocalCalendars(provider, account, settings)
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, calendar).use {
it.performSync()
}
CalendarSyncManager(context, account, settings, extras, authority, syncResult, provider, calendar)
.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) {
val db = AppDatabase.getInstance(context)
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
remoteCalendars[collection.url] = collection
}
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
}
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let {
val url = HttpUrl.parse(it)!!
val info = remoteCalendars[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remoteCalendars -= url
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
}
// create new local calendars
for ((_, info) in remoteCalendars) {
Logger.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
// 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)
}
}
}
@@ -14,58 +14,37 @@ 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.AccountSettings
import java.util.logging.Level
class ContactsSyncAdapterService: SyncAdapterService() {
companion object {
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
}
override fun syncAdapter() = ContactsSyncAdapter(this)
class ContactsSyncAdapter(
protected class ContactsSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val addressBook = LocalAddressBook(context, account, provider)
val accountSettings = AccountSettings(context, addressBook.mainAccount)
// 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 sync state
addressBook.syncState = null
}
}
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
val settings = AccountSettings(context, addressBook.getMainAccount())
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
Logger.log.info("Synchronizing address book: ${addressBook.url}")
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
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()
}
ContactsSyncManager(context, account, settings, extras, authority, syncResult, provider, addressBook)
.performSync()
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
}
Logger.log.info("Contacts sync complete")
}
@@ -12,222 +12,178 @@ 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 at.bitfire.dav4jvm.DavAddressBook
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.dav4android.DavAddressBook
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.*
import at.bitfire.davdroid.settings.AccountSettings
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 ezvcard.io.CannotParseException
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
/**
* Synchronization manager for CardDAV collections; handles contacts and groups.
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:
*
* 1. 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 [uploadDirty]. Empty groups can be deleted without further processing,
* which is done in [postProcess] because groups may become empty after downloading
* updated remote contacts.
*
* 2. 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.
*
* 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
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in [uploadDirty].
*
* 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
* [LocalGroup.COLUMN_PENDING_MEMBERS]. In [postProcess],
* these "pending memberships" are assigned to the actual contacts and then cleaned up.
* <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>
*/
class ContactsSyncManager(
context: Context,
account: Account,
accountSettings: AccountSettings,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
localAddressBook: LocalAddressBook
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, extras, authority, syncResult, localAddressBook) {
val localAddressBook: LocalAddressBook
): SyncManager(context, account, settings, extras, authority, syncResult, "addressBook") {
companion object {
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
private val readOnly = localAddressBook.readOnly
val MAX_MULTIGET = 10
private var hasVCard4 = false
private val groupMethod = accountSettings.getGroupMethod()
private lateinit var groupMethod: GroupMethod
/**
* Used to download images which are referenced by URL
*/
private lateinit var resourceDownloader: ResourceDownloader
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 = localCollection.verifyDirty()
val deleted = localCollection.findDeleted().size
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
}
}
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
// 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)
resourceDownloader = ResourceDownloader(davCollection.location)
collectionURL = HttpUrl.parse(localAddressBook.getURL()) ?: return false
davCollection = DavAddressBook(httpClient, collectionURL)
return true
}
override fun queryCapabilities(): SyncState? {
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")
groupMethod = settings.getGroupMethod()
Logger.log.info("Contact group method: $groupMethod")
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
return useRemoteCollection {
var syncState: SyncState? = null
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[SupportedAddressData::class.java]?.let { supported ->
hasVCard4 = supported.hasVCard4()
}
response[SupportedReportSet::class.java]?.let { supported ->
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState = syncState(response)
}
}
Logger.log.info("Server supports vCard/4: $hasVCard4")
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState
}
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
}
override fun syncAlgorithm() = if (hasCollectionSync)
SyncAlgorithm.COLLECTION_SYNC
else
SyncAlgorithm.PROPFIND_REPORT
override fun prepareDirty() {
super.prepareDirty()
override fun processLocallyDeleted() =
if (readOnly) {
for (group in localCollection.findDeletedGroups()) {
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
useLocal(group) { it.resetDeleted() }
}
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
for (contact in localCollection.findDeletedContacts()) {
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
useLocal(contact) { it.resetDeleted() }
}
false
} else
// mirror deletions to remote collection (DELETE)
super.processLocallyDeleted()
override fun uploadDirty(): Boolean {
if (readOnly) {
for (group in localCollection.findDirtyGroups()) {
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
useLocal(group) { it.clearDirty(null) }
// 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()
}
for (contact in localCollection.findDirtyContacts()) {
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
useLocal(contact) { it.clearDirty(null) }
// 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 {
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
/* groups as separate VCards: there are group contacts and individual contacts */
// groups with DELETED=1: set all members to dirty, then remove group
for (group in localCollection.findDeletedGroups()) {
Logger.log.fine("Finally removing group $group")
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty()
useLocal(group) { it.delete() }
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (group in localCollection.findDirtyGroups()) {
Logger.log.fine("Marking members of modified group $group as dirty")
useLocal(group) {
it.markMembersDirty()
it.clearDirty(null)
// 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) {
}
} else {
/* groups as separate VCards: there are group contacts and individual contacts */
// mark groups with changed members as dirty
val batch = BatchOperation(localCollection.provider!!)
for (contact in localCollection.findDirtyContacts())
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(localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
))
}
} catch(e: FileNotFoundException) {
}
batch.commit()
}
batch.commit()
}
// generate UID/file name for newly created contacts
return super.uploadDirty()
}
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) {
override fun prepareUpload(resource: LocalResource): RequestBody {
val contact: Contact
if (resource is LocalContact) {
contact = resource.contact!!
@@ -235,76 +191,112 @@ class ContactsSyncManager(
if (groupMethod == GroupMethod.CATEGORIES) {
// add groups as CATEGORIES
for (groupID in resource.getGroupMemberships()) {
provider.query(
localCollection.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)
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 LocalContact or LocalGroup")
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)
RequestBody.create(
return RequestBody.create(
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
)
}
override fun listAllRemote(callback: DavResponseCallback) =
useRemoteCollection {
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
}
override fun listRemote() {
val addressBook = davAddressBook()
currentDavResource = addressBook
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} vCards: $bunch")
if (bunch.size == 1) {
val remote = bunch.first()
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response ->
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
?: throw DavException("Received CardDAV GET response without ETag")
// fetch list of remote VCards and build hash table to index file name
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
response.body()!!.use {
processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader)
}
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)
}
}
} else
// multiple vCards, use addressbook-multi-get
useRemoteCollection {
it.multiget(bunch, hasVCard4) { response, _ ->
useRemote(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@useRemote
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val addressData = response[AddressData::class.java]
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)
}
}
}
currentDavResource = null
}
}
override fun postProcess() {
@@ -313,106 +305,104 @@ class ContactsSyncManager(
// remove empty groups
Logger.log.info("Removing empty groups")
localCollection.removeEmptyGroups()
localAddressBook.removeEmptyGroups()
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
Logger.log.info("Assigning memberships of downloaded contact groups")
LocalGroup.applyPendingMemberships(localCollection)
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 = try {
Contact.fromReader(reader, downloader)
} catch (e: CannotParseException) {
Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e)
notifyInvalidResource(e, fileName)
return
}
val contacts = Contact.fromReader(reader, downloader)
if (contacts.isEmpty()) {
Logger.log.warning("Received vCard without data, ignoring")
Logger.log.warning("Received VCard without data, ignoring")
return
} else if (contacts.size > 1)
Logger.log.warning("Received multiple vCards, using first one")
Logger.log.warning("Received multiple VCards, using first one")
val newData = contacts.first()
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact")
newData.group = false
groupMethod = GroupMethod.GROUP_VCARDS
Logger.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
useLocal(localCollection.findByName(fileName)) {
var local = it
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
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.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(newData)
syncResult.stats.numUpdates++
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.flags = LocalResource.FLAG_REMOTELY_PRESENT
local.update(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
}
} 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)
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
group.add()
local = group
}
} else {
Logger.log.log(Level.INFO, "Creating local contact", newData)
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
contact.add()
local = contact
}
}
syncResult.stats.numInserts++
}
if (groupMethod == GroupMethod.CATEGORIES)
(local as? LocalContact)?.let { localContact ->
// VCard3: update group memberships from CATEGORIES
val batch = BatchOperation(provider)
Logger.log.log(Level.FINE, "Removing contact group memberships")
localContact.removeGroupMemberships(batch)
for (category in localContact.contact!!.categories) {
val groupID = localCollection.findOrCreateGroup(category)
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
localContact.addToGroup(batch, groupID)
}
batch.commit()
}
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
(local as? LocalContact)?.updateHashCode(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
}
@@ -429,15 +419,27 @@ class ContactsSyncManager(
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
}
var resourceClient = HttpClient.create(context)
// authenticate only against a certain host, and only upon request
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
val username = settings.username()
val password = settings.password()
if (username != null && password != null)
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), username, password)
// allow redirects
builder.followRedirects(true)
resourceClient = resourceClient.newBuilder()
.followRedirects(true)
.build()
val client = builder.build()
try {
val response = client.okHttpClient.newCall(Request.Builder()
val response = resourceClient.newCall(Request.Builder()
.get()
.url(httpUrl)
.build()).execute()
@@ -448,14 +450,9 @@ class ContactsSyncManager(
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
}
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_contact)
}
@@ -5,6 +5,7 @@
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
@@ -26,7 +27,7 @@ class NullAuthenticatorService: Service() {
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
@@ -8,40 +8,27 @@
package at.bitfire.davdroid.syncadapter
import android.Manifest
import android.accounts.Account
import android.app.PendingIntent
import android.app.Service
import android.content.*
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
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.AccountSettings
import at.bitfire.davdroid.ui.AccountSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.account.AccountActivity
import java.lang.ref.WeakReference
import at.bitfire.davdroid.ui.PermissionsActivity
import java.util.*
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
companion object {
/** Keep a list of running syncs to block multiple calls at the same time,
* like run by some devices. Weak references are used for the case that a thread
* is terminated and the `finally` block which cleans up [runningSyncs] is not
* executed. */
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
}
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
@@ -55,40 +42,31 @@ abstract class SyncAdapterService: Service() {
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)
synchronized(runningSyncs) {
if (runningSyncs.any { it.get() == currentSync }) {
Logger.log.warning("There's already another $authority sync running for $account, aborting")
return
}
runningSyncs += WeakReference(currentSync)
}
// required for dav4android (ServiceLoader)
Thread.currentThread().contextClassLoader = context.classLoader
try {
// required for dav4jvm (ServiceLoader)
Thread.currentThread().contextClassLoader = context.classLoader
sync(account, extras, authority, provider, syncResult)
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
sync(account, extras, authority, provider, syncResult)
} finally {
synchronized(runningSyncs) {
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
}
}
Logger.log.info("Sync for $currentSync finished")
Logger.log.info("Sync for $authority complete")
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
syncResult.databaseError = true
val intent = Intent(context, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
val intent = Intent(context, PermissionsActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
notifyPermissions(intent)
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 {
@@ -101,17 +79,6 @@ abstract class SyncAdapterService: Service() {
}
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
// getting the WiFi name requires location permission (and active location services) since Android 8.1
// see https://issuetracker.google.com/issues/70633700
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
val intent = Intent(context, AccountSettingsActivity::class.java)
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, settings.account)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
notifyPermissions(intent)
}
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val info = wifi.connectionInfo
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
@@ -123,18 +90,6 @@ abstract class SyncAdapterService: Service() {
return true
}
protected fun notifyPermissions(intent: Intent) {
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(context.getString(R.string.sync_error_permissions))
.setContentText(context.getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -8,28 +8,19 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.app.PendingIntent
import android.content.*
import android.content.pm.PackageManager
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.database.DatabaseUtils
import android.os.Bundle
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.davdroid.R
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
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.AccountSettings
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import org.dmfs.tasks.contract.TaskContract
import org.dmfs.provider.tasks.TaskContract
import java.util.logging.Level
/**
@@ -37,101 +28,96 @@ import java.util.logging.Level
*/
class TasksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = TasksSyncAdapter(this)
override fun syncAdapter() = SyncAdapter(this)
class TasksSyncAdapter(
protected class SyncAdapter(
context: Context
): SyncAdapter(context) {
): SyncAdapterService.SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val taskProvider = TaskProvider.fromProviderClient(context, provider)
// make sure account can be seen by OpenTasks
if (Build.VERSION.SDK_INT >= 26)
AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE)
val accountSettings = AccountSettings(context, account)
val taskProvider = TaskProvider.fromProviderClient(provider)
val settings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
updateLocalTaskLists(taskProvider, account, accountSettings)
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, accountSettings, extras, authority, syncResult, taskList).use {
it.performSync()
}
TasksSyncManager(context, account, settings, extras, authority, syncResult, taskProvider, taskList)
.performSync()
}
} catch (e: TaskProvider.ProviderTooOldException) {
val nm = NotificationManagerCompat.from(context)
val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(context.getString(R.string.sync_error_opentasks_too_old))
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setCategory(NotificationCompat.CATEGORY_ERROR)
try {
val icon = context.packageManager.getApplicationIcon(e.provider.packageName)
if (icon is BitmapDrawable)
notify.setLargeIcon(icon.bitmap)
} catch(ignored: PackageManager.NameNotFoundException) {}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}"))
if (intent.resolveActivity(context.packageManager) != null)
notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setAutoCancel(true)
nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build())
syncResult.databaseError = true
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
syncResult.databaseError = true
}
Logger.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
val db = AppDatabase.getInstance(context)
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
if (service != null)
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
remoteTaskLists[collection.url] = collection
}
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
}
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let {
val url = HttpUrl.parse(it)!!
val info = remoteTaskLists[url]
if (info == null) {
Logger.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
Logger.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remoteTaskLists -= url
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
}
// create new local task lists
for ((_,info) in remoteTaskLists) {
Logger.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
// 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)
}
}
}
@@ -12,135 +12,158 @@ import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.CalendarData
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.SyncToken
import at.bitfire.davdroid.DavUtils
import at.bitfire.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.model.SyncState
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
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
/**
* Synchronization manager for CalDAV collections; handles tasks (VTODO)
*/
class TasksSyncManager(
context: Context,
account: Account,
accountSettings: AccountSettings,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCollection: LocalTaskList
): SyncManager<LocalTask, LocalTaskList, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCollection) {
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(localCollection.syncId ?: return false) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
collectionURL = HttpUrl.parse(localTaskList.syncId ?: return false) ?: return false
davCollection = DavCalendar(httpClient, collectionURL)
return true
}
override fun queryCapabilities() =
useRemoteCollection {
var syncState: SyncState? = null
it.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF)
syncState = syncState(response)
}
syncState
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource) {
val task = requireNotNull(resource.task)
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
val os = ByteArrayOutputStream()
task.write(os)
RequestBody.create(
DavCalendar.MIME_ICALENDAR_UTF8,
os.toByteArray()
)
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun listAllRemote(callback: DavResponseCallback) {
useRemoteCollection { remote ->
Logger.log.info("Querying tasks")
remote.calendarQuery("VTODO", null, null, callback)
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
}
}
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
if (bunch.size == 1) {
val remote = bunch.first()
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response ->
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
?: throw DavException("Received CalDAV GET response without ETag")
response.body()!!.use {
processVTodo(resource.fileName(), eTag, it.charStream())
}
}
}
} else
// multiple iCalendars, use calendar-multi-get
useRemoteCollection {
it.multiget(bunch) { response, _ ->
useRemote(response) {
if (!response.isSuccess()) {
Logger.log.warning("Received non-successful multiget response for ${response.href}")
return@useRemote
}
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without address data")
processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
}
}
}
}
override fun postProcess() {
}
// 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)
notifyInvalidResource(e, fileName)
return
}
@@ -148,25 +171,22 @@ class TasksSyncManager(
val newData = tasks.first()
// update local task, if it exists
useLocal(localCollection.findByName(fileName)) { local ->
if (local != null) {
Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData)
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData)
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
it.add()
}
syncResult.stats.numInserts++
}
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.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
Logger.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
}
override fun notifyInvalidResourceTitle(): String =
context.getString(R.string.sync_invalid_task)
}
}
@@ -8,61 +8,83 @@
package at.bitfire.davdroid.ui
import android.app.Application
import android.os.Build
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.util.DisplayMetrics
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import at.bitfire.davdroid.App
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 com.mikepenz.aboutlibraries.LibsBuilder
import kotlinx.android.synthetic.main.about.*
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.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.thread
import java.io.IOException
import java.util.logging.Level
class AboutActivity: AppCompatActivity() {
companion object {
const val pixelsHtml = "<font color=\"#fff433\">■</font>" +
"<font color=\"#ffffff\">■</font>" +
"<font color=\"#9b59d0\">■</font>" +
"<font color=\"#000000\">■</font>"
}
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, false)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.about_davdroid, menu)
return true
}
fun showWebsite(item: MenuItem) {
UiUtils.launchUri(this, App.homepageUrl(this))
tabs.setupWithViewPager(viewpager)
}
@@ -70,68 +92,101 @@ class AboutActivity: AppCompatActivity() {
fm: FragmentManager
): FragmentPagerAdapter(fm) {
override fun getCount() = 2
override fun getCount() = components.size
override fun getPageTitle(position: Int): String =
when (position) {
0 -> getString(R.string.app_name)
else -> getString(R.string.about_libraries)
}
override fun getPageTitle(position: Int) =
components[position].title ?: getString(R.string.app_name)!!
override fun getItem(position: Int) =
when (position) {
0 -> AppFragment()
else -> LibsBuilder()
.withAutoDetect(false)
.withFields(R.string::class.java.fields)
.withLicenseShown(true)
.supportFragment()
}!!
override fun getItem(position: Int) = ComponentFragment.instantiate(position)
}
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
class AppFragment: Fragment() {
companion object {
val KEY_POSITION = "position"
val KEY_FILE_NAME = "fileName"
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
inflater.inflate(R.layout.about, container, false)!!
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
app_name.text = getString(R.string.app_name)
app_version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
build_time.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH))
pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
if (true /* open-source version */) {
warranty.setText(R.string.about_license_info_no_warranty)
val model = ViewModelProviders.of(this).get(LicenseModel::class.java)
model.htmlText.observe(this, Observer { spanned ->
license_text.text = spanned
})
fun instantiate(position: Int): ComponentFragment {
val frag = ComponentFragment()
val args = Bundle(1)
args.putInt(KEY_POSITION, position)
frag.arguments = args
return frag
}
}
}
@SuppressLint("SetTextI18n")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val info = (activity as AboutActivity).components[arguments.getInt(KEY_POSITION)]
class LicenseModel(
application: Application
): AndroidViewModel(application) {
val v = inflater.inflate(R.layout.about_component, container, false)
val htmlText = MutableLiveData<Spanned>()
var title = info.title ?: getString(R.string.app_name)
info.version?.let { title += " ${info.version}" }
v.title.text = title
init {
thread {
getApplication<Application>().resources.assets.open("gplv3.html").use {
val spanned = HtmlCompat.fromHtml(IOUtils.toString(it, Charsets.UTF_8), HtmlCompat.FROM_HTML_MODE_LEGACY)
htmlText.postValue(spanned)
}
v.website.autoLinkMask = Linkify.WEB_URLS
v.website.text = info.website
v.copyright.text = "© ${info.copyright}"
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
}
}
}
}
@@ -0,0 +1,599 @@
/*
* 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.app.AlertDialog
import android.app.Dialog
import android.app.LoaderManager
import android.content.*
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
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.DialogFragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.Toolbar
import android.view.*
import android.widget.*
import at.bitfire.davdroid.App
import at.bitfire.davdroid.CustomCertificates
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.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(account: Account) {
val authorities = arrayOf(
App.addressBooksAuthority,
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
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)
// load CardDAV/CalDAV collections
loaderManager.initLoader(0, intent.extras, this)
}
override fun onPause() {
super.onPause()
CustomCertificates.certManager?.let { it.appInForeground = false }
}
override fun onResume() {
super.onResume()
CustomCertificates.certManager?.let { it.appInForeground = true }
}
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, _, position, _ ->
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()
}
}
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
}
/* 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, intent.extras, 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)
calendars.adapter = adapter
calendars.onItemClickListener = onItemClickListener
calendars.onItemLongClickListener = onItemLongClickListener
View.VISIBLE
} ?: View.GONE
}
override fun onLoaderReset(loader: Loader<AccountInfo>) {
address_books?.adapter = null
calendars?.adapter = null
}
class AccountLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, 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, App.addressBooksAuthority)
val accountManager = AccountManager.get(context)
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
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))
}
}
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
}
tv = v.findViewById(R.id.read_only)
tv.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 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
}
tv = v.findViewById(R.id.read_only)
tv.visibility = if (info.readOnly) View.VISIBLE else View.GONE
tv = v.findViewById(R.id.events)
tv.visibility = if (info.supportsVEVENT) View.VISIBLE else View.GONE
tv = v.findViewById(R.id.tasks)
tv.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(App.addressBookAccountType))
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(App.addressBookAccountType)) {
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(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(account)
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
}
}
@@ -11,9 +11,12 @@ package at.bitfire.davdroid.ui
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Application
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
@@ -21,36 +24,25 @@ import android.view.ViewGroup
import android.widget.AbsListView
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.ListFragment
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import at.bitfire.davdroid.AccountsChangedReceiver
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.account.AccountActivity
import kotlinx.android.synthetic.main.account_list_item.view.*
class AccountListFragment: ListFragment() {
class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>> {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
listAdapter = AccountListAdapter(requireActivity())
val model = ViewModelProviders.of(this).get(Model::class.java)
model.accounts.observe(this, Observer { accounts ->
val adapter = listAdapter as AccountListAdapter
adapter.clear()
adapter.addAll(*accounts)
})
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 account = listAdapter.getItem(position) as Account
val intent = Intent(activity, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
@@ -58,45 +50,54 @@ class AccountListFragment: ListFragment() {
}
// 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)!!
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
val account = getItem(position)
val v = _v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
v.account_name.text = account.name
return v
}
}
class Model(
application: Application
): AndroidViewModel(application), OnAccountsUpdateListener {
val accounts = MutableLiveData<Array<out Account>>()
private val accountManager = AccountManager.get(getApplication())!!
init {
accountManager.addOnAccountsUpdatedListener(this, null, true)
}
override fun onCleared() {
accountManager.removeOnAccountsUpdatedListener(this)
}
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
val context = getApplication<Application>()
accounts.postValue(
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
)
}
}
}
}
@@ -8,34 +8,25 @@
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncStatusObserver
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.security.KeyChain
import android.support.v4.app.DialogFragment
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.*
import android.support.v7.preference.Preference.OnPreferenceChangeListener
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.preference.*
import at.bitfire.davdroid.App
import at.bitfire.davdroid.*
import at.bitfire.davdroid.R
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.davdroid.log.Logger
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import org.apache.commons.lang3.StringUtils
@@ -43,7 +34,7 @@ import org.apache.commons.lang3.StringUtils
class AccountSettingsActivity: AppCompatActivity() {
companion object {
const val EXTRA_ACCOUNT = "account"
val EXTRA_ACCOUNT = "account"
}
private lateinit var account: Account
@@ -73,331 +64,200 @@ class AccountSettingsActivity: AppCompatActivity() {
false
class AccountSettingsFragment: PreferenceFragmentCompat(), SyncStatusObserver, Settings.OnChangeListener {
private lateinit var settings: Settings
class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
lateinit var account: Account
private var statusChangeListener: Any? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settings = Settings.getInstance(requireActivity())
account = arguments!!.getParcelable(EXTRA_ACCOUNT)!!
account = arguments.getParcelable(EXTRA_ACCOUNT)
loaderManager.initLoader(0, arguments, this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
addPreferencesFromResource(R.xml.settings_account)
}
override fun onResume() {
super.onResume()
override fun onCreateLoader(id: Int, args: Bundle) =
AccountSettingsLoader(context, args.getParcelable(EXTRA_ACCOUNT))
statusChangeListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
settings.addOnChangeListener(this)
reload()
}
override fun onPause() {
super.onPause()
statusChangeListener?.let {
ContentResolver.removeStatusChangeListener(it)
statusChangeListener = null
}
settings.removeOnChangeListener(this)
}
override fun onStatusChanged(which: Int) {
Handler(Looper.getMainLooper()).post {
reload()
}
}
override fun onSettingsChanged() = reload()
private fun reload() {
val accountSettings = AccountSettings(requireActivity(), account)
// preference group: authentication
val prefUserName = findPreference<EditTextPreference>("username")!!
val prefPassword = findPreference<EditTextPreference>("password")!!
val prefCertAlias = findPreference<Preference>("certificate_alias")!!
val credentials = accountSettings.credentials()
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
prefUserName.isVisible = true
prefUserName.summary = credentials.userName
prefUserName.text = credentials.userName
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.credentials(Credentials(newValue as String, credentials.password))
reload()
false
}
prefPassword.isVisible = true
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.credentials(Credentials(credentials.userName, newValue as String))
reload()
false
}
prefCertAlias.isVisible = false
}
Credentials.Type.ClientCertificate -> {
prefUserName.isVisible = false
prefPassword.isVisible = false
prefCertAlias.isVisible = true
prefCertAlias.summary = credentials.certificateAlias
prefCertAlias.setOnPreferenceClickListener {
KeyChain.choosePrivateKeyAlias(requireActivity(), { alias ->
accountSettings.credentials(Credentials(certificateAlias = alias))
Handler(Looper.getMainLooper()).post {
reload()
}
}, null, null, null, -1, credentials.certificateAlias)
true
}
}
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
if (settings == null) {
activity.finish()
return
}
// preference group: sync
// those are null if the respective sync type is not available for this account:
val syncIntervalContacts = accountSettings.getSyncInterval(getString(R.string.address_books_authority))
val syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
val syncIntervalTasks = accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
findPreference<ListPreference>("sync_interval_contacts")!!.let {
if (syncIntervalContacts != null) {
it.isEnabled = true
it.isVisible = true
it.value = syncIntervalContacts.toString()
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
Handler(Looper.myLooper()).post {
pref.isEnabled = false
accountSettings.setSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong())
reload()
}
false
}
} else
it.isVisible = false
}
findPreference<ListPreference>("sync_interval_calendars")!!.let {
if (syncIntervalCalendars != null) {
it.isEnabled = true
it.isVisible = true
it.value = syncIntervalCalendars.toString()
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
Handler(Looper.myLooper()).post {
pref.isEnabled = false
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
reload()
}
false
}
} else
it.isVisible = false
}
findPreference<ListPreference>("sync_interval_tasks")!!.let {
if (syncIntervalTasks != null) {
it.isEnabled = true
it.isVisible = true
it.value = syncIntervalTasks.toString()
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
Handler(Looper.myLooper()).post {
pref.isEnabled = false
accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
reload()
}
false
}
} else
it.isVisible = false
}
val prefWifiOnly = findPreference<SwitchPreferenceCompat>("sync_wifi_only")!!
prefWifiOnly.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY)
prefWifiOnly.isChecked = accountSettings.getSyncWifiOnly()
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
accountSettings.setSyncWiFiOnly(wifiOnly as Boolean)
reload()
// category: authentication
val prefUserName = findPreference("username") as EditTextPreference
prefUserName.summary = settings.username()
prefUserName.text = settings.username()
prefUserName.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
settings.username(newValue as String)
loaderManager.restartLoader(0, arguments, this)
false
}
val prefWifiOnlySSIDs = findPreference<EditTextPreference>("sync_wifi_only_ssids")!!
val onlySSIDs = accountSettings.getSyncWifiOnlySSIDs()?.joinToString(", ")
val prefPassword = findPreference("password") as EditTextPreference
prefPassword.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
settings.password(newValue as String)
loaderManager.restartLoader(0, arguments, this)
false
}
// category: synchronization
val prefSyncContacts = findPreference("sync_interval_contacts") as ListPreference
val syncIntervalContacts = settings.getSyncInterval(App.addressBooksAuthority)
if (syncIntervalContacts != null) {
prefSyncContacts.value = syncIntervalContacts.toString()
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSyncContacts.setSummary(R.string.settings_sync_summary_manually)
else
prefSyncContacts.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
prefSyncContacts.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
settings.setSyncInterval(App.addressBooksAuthority, (newValue as String).toLong())
loaderManager.restartLoader(0, arguments, this)
false
}
} else
prefSyncContacts.isVisible = false
val prefSyncCalendars = findPreference("sync_interval_calendars") as ListPreference
val syncIntervalCalendars = settings.getSyncInterval(CalendarContract.AUTHORITY)
if (syncIntervalCalendars != null) {
prefSyncCalendars.value = syncIntervalCalendars.toString()
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSyncCalendars.setSummary(R.string.settings_sync_summary_manually)
else
prefSyncCalendars.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
prefSyncCalendars.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
settings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
loaderManager.restartLoader(0, arguments, this)
false
}
} else
prefSyncCalendars.isVisible = false
val prefSyncTasks = findPreference("sync_interval_tasks") as ListPreference
val syncIntervalTasks = settings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
if (syncIntervalTasks != null) {
prefSyncTasks.value = syncIntervalTasks.toString()
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
prefSyncTasks.setSummary(R.string.settings_sync_summary_manually)
else
prefSyncTasks.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
prefSyncTasks.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
settings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
loaderManager.restartLoader(0, arguments, this)
false
}
} else
prefSyncTasks.isVisible = false
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
prefWifiOnly.isChecked = settings.getSyncWifiOnly()
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
settings.setSyncWiFiOnly(wifiOnly as Boolean)
loaderManager.restartLoader(0, arguments, this)
false
}
val prefWifiOnlySSIDs = findPreference("sync_wifi_only_ssids") as EditTextPreference
val onlySSIDs = settings.getSyncWifiOnlySSIDs()?.joinToString(", ")
prefWifiOnlySSIDs.text = onlySSIDs
if (onlySSIDs != null)
prefWifiOnlySSIDs.summary = getString(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1)
R.string.settings_sync_wifi_only_ssids_on_location_services else R.string.settings_sync_wifi_only_ssids_on, onlySSIDs)
prefWifiOnlySSIDs.summary = getString(R.string.settings_sync_wifi_only_ssids_on, onlySSIDs)
else
prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off)
prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct())
reload()
settings.setSyncWifiOnlySSIDs((newValue as String).split(',').map { StringUtils.trimToNull(it) }.filterNotNull().distinct())
loaderManager.restartLoader(0, arguments, this)
false
}
// getting the WiFi name requires location permission (and active location services) since Android 8.1
// see https://issuetracker.google.com/issues/70633700
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
accountSettings.getSyncWifiOnly() && onlySSIDs != null &&
ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
requestPermissions(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0)
// category: CardDAV
val prefGroupMethod = findPreference("contact_group_method") as ListPreference
if (syncIntervalContacts != null) {
prefGroupMethod.value = settings.getGroupMethod().name
prefGroupMethod.summary = prefGroupMethod.entry
prefGroupMethod.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
settings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
loaderManager.restartLoader(0, arguments, this)
false
}
} else
prefGroupMethod.isEnabled = false
// preference group: CardDAV
findPreference<ListPreference>("contact_group_method")!!.let {
if (syncIntervalContacts != null) {
it.isVisible = true
it.value = accountSettings.getGroupMethod().name
it.summary = it.entry
if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD))
it.isEnabled = false
else {
it.isEnabled = true
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.settings_contact_group_method_change)
.setMessage(R.string.settings_contact_group_method_change_reload_contacts)
.setPositiveButton(android.R.string.ok) { _, _ ->
// change group method
accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
reload()
// reload all contacts
val args = Bundle(1)
args.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
ContentResolver.requestSync(account, getString(R.string.address_books_authority), args)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
false
}
// category: CalDAV
val prefTimeRangePastDays = findPreference("time_range_past_days") as EditTextPreference
if (syncIntervalCalendars != null) {
val pastDays = settings.getTimeRangePastDays()
if (pastDays != null) {
prefTimeRangePastDays.text = pastDays.toString()
prefTimeRangePastDays.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
} else {
prefTimeRangePastDays.text = null
prefTimeRangePastDays.setSummary(R.string.settings_sync_time_range_past_none)
}
prefTimeRangePastDays.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
var days: Int
try {
days = (newValue as String).toInt()
} catch(ignored: NumberFormatException) {
days = -1
}
} else
it.isVisible = false
}
settings.setTimeRangePastDays(if (days < 0) null else days)
loaderManager.restartLoader(0, arguments, this)
false
}
} else
prefTimeRangePastDays.isEnabled = false
// preference group: CalDAV
findPreference<EditTextPreference>("time_range_past_days")!!.let {
if (syncIntervalCalendars != null) {
it.isVisible = true
val pastDays = accountSettings.getTimeRangePastDays()
if (pastDays != null) {
it.text = pastDays.toString()
it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
} else {
it.text = null
it.setSummary(R.string.settings_sync_time_range_past_none)
}
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val days = try {
(newValue as String).toInt()
} catch(e: NumberFormatException) {
-1
}
accountSettings.setTimeRangePastDays(if (days < 0) null else days)
val prefManageColors = findPreference("manage_calendar_colors") as SwitchPreferenceCompat
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
prefManageColors.isChecked = settings.getManageCalendarColors()
prefManageColors.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
settings.setManageCalendarColors(newValue as Boolean)
loaderManager.restartLoader(0, arguments, this)
false
}
} else
prefManageColors.isEnabled = false
// reset sync state of all calendars in this account to trigger a full sync
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
requireContext().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null).forEach { calendar ->
calendar.lastSyncState = null
}
} finally {
provider.closeCompat()
}
}
}
reload()
false
}
} else
it.isVisible = false
}
findPreference<SwitchPreferenceCompat>("manage_calendar_colors")!!.let {
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
it.isVisible = true
it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS)
it.isChecked = accountSettings.getManageCalendarColors()
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
accountSettings.setManageCalendarColors(newValue as Boolean)
reload()
false
}
} else
it.isVisible = false
}
findPreference<SwitchPreferenceCompat>("event_colors")!!.let {
if (syncIntervalCalendars != null) {
it.isVisible = true
it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS)
it.isChecked = accountSettings.getEventColors()
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
if (newValue as Boolean) {
accountSettings.setEventColors(true)
reload()
} else
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_error_dark)
.setTitle(R.string.settings_event_colors)
.setMessage(R.string.settings_event_colors_off_confirm)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
accountSettings.setEventColors(false)
reload()
}
.show()
false
}
} else
it.isVisible = false
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
override fun onLoaderReset(loader: Loader<AccountSettings>) {
}
if (permissions.first() == Manifest.permission.ACCESS_COARSE_LOCATION && grantResults.first() == PackageManager.PERMISSION_DENIED) {
// location permission denied, reset SSID restriction
AccountSettings(requireActivity(), account).setSyncWifiOnlySSIDs(null)
reload()
}
AlertDialog.Builder(requireActivity())
.setIcon(R.drawable.ic_network_wifi_dark)
.setTitle(R.string.settings_sync_wifi_only_ssids)
.setMessage(R.string.settings_sync_wifi_only_ssids_location_permission)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.setNeutralButton(R.string.settings_more_info_faq) { _, _ ->
val faqUrl = App.homepageUrl(requireActivity()).buildUpon()
.appendPath("faq").appendPath("wifi-ssid-restriction-location-permission")
.build()
val intent = Intent(Intent.ACTION_VIEW, faqUrl)
startActivity(Intent.createChooser(intent, null))
}
.show()
}
class AccountSettingsLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountSettings>(context), SyncStatusObserver {
lateinit var listenerHandle: Any
override fun onStartLoading() {
forceLoad()
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
}
override fun onStopLoading() =
ContentResolver.removeStatusChangeListener(listenerHandle)
override fun abandon() = onStopLoading()
override fun loadInBackground() =
try {
AccountSettings(context, account)
} catch(e: InvalidAccountException) {
null
}
override fun onStatusChanged(which: Int) {
Logger.log.fine("Reloading account settings")
forceLoad()
}
}
@@ -12,60 +12,52 @@ import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Intent
import android.content.SyncStatusObserver
import android.net.Uri
import android.os.Bundle
import android.support.design.widget.NavigationView
import android.support.design.widget.Snackbar
import android.support.v4.view.GravityCompat
import android.support.v7.app.ActionBarDrawerToggle
import android.support.v7.app.AppCompatActivity
import android.view.MenuItem
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import android.view.View
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.setup.LoginActivity
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.accounts_content.*
import kotlinx.android.synthetic.main.activity_accounts.*
import kotlinx.android.synthetic.main.activity_accounts.view.*
import java.util.*
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver {
companion object {
val accountsDrawerHandler = DefaultAccountsDrawerHandler()
const val fragTagStartup = "startup"
}
private lateinit var settings: Settings
private var syncStatusSnackbar: Snackbar? = null
private var syncStatusObserver: Any? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
settings = Settings.getInstance(this)
setContentView(R.layout.activity_accounts)
setSupportActionBar(toolbar)
if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) {
val ft = supportFragmentManager.beginTransaction()
StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, fragTagStartup) }
ft.commit()
}
fab.setOnClickListener({
startActivity(Intent(this@AccountsActivity, LoginActivity::class.java))
})
fab.setOnClickListener {
startActivity(Intent(this, LoginActivity::class.java))
}
fab.show()
accountsDrawerHandler.initMenu(this, drawer_layout.nav_view.menu)
val toggle = ActionBarDrawerToggle(
this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
drawer_layout.addDrawerListener(toggle)
drawer_layout.setDrawerListener(toggle)
toggle.syncState()
nav_view.setNavigationItemSelectedListener(this)
nav_view.itemIconTintList = null
if (savedInstanceState == null && packageName != getCallingPackage()) {
// first call
val ft = supportFragmentManager.beginTransaction()
StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, null) }
ft.commit()
}
}
override fun onResume() {
@@ -77,7 +69,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
override fun onPause() {
super.onPause()
syncStatusObserver?.let {
ContentResolver.removeStatusChangeListener(it)
syncStatusObserver = null
@@ -93,9 +84,9 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
if (!ContentResolver.getMasterSyncAutomatically()) {
val snackbar = Snackbar
.make(findViewById(R.id.coordinator), R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.accounts_global_sync_enable) {
ContentResolver.setMasterSyncAutomatically(true)
}
.setAction(R.string.accounts_global_sync_enable, {
ContentResolver.setMasterSyncAutomatically(true)
})
syncStatusSnackbar = snackbar
snackbar.show()
}
@@ -110,7 +101,28 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
val processed = accountsDrawerHandler.onNavigationItemSelected(this, item)
var processed = true
when (item.itemId) {
R.id.nav_about ->
startActivity(Intent(this, AboutActivity::class.java))
R.id.nav_app_settings ->
startActivity(Intent(this, AppSettingsActivity::class.java))
R.id.nav_twitter ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
R.id.nav_website ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))))
R.id.nav_faq ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.navigation_drawer_faq_url))))
R.id.nav_forums ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("forums/").build()))
R.id.nav_donate ->
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
.buildUpon().appendEncodedPath("donate/").build()))
else ->
processed = false
}
drawer_layout.closeDrawer(GravityCompat.START)
return processed
}
@@ -9,25 +9,26 @@
package at.bitfire.davdroid.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.davdroid.BuildConfig
import android.support.design.widget.Snackbar
import android.support.v7.app.AppCompatActivity
import android.support.v7.preference.EditTextPreference
import android.support.v7.preference.Preference
import android.support.v7.preference.PreferenceFragmentCompat
import android.support.v7.preference.SwitchPreferenceCompat
import at.bitfire.davdroid.App
import at.bitfire.davdroid.CustomCertificates
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.Settings
import com.google.android.material.snackbar.Snackbar
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.Settings
import java.net.URI
import java.net.URISyntaxException
class AppSettingsActivity: AppCompatActivity() {
companion object {
const val EXTRA_SCROLL_TO = "scrollTo"
val EXTRA_SCROLL_TO = "scrollTo"
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -43,120 +44,132 @@ class AppSettingsActivity: AppCompatActivity() {
}
class SettingsFragment: PreferenceFragmentCompat(), Settings.OnChangeListener {
class SettingsFragment: PreferenceFragmentCompat() {
lateinit var dbHelper: ServiceDB.OpenHelper
lateinit var settings: Settings
override fun onCreate(savedInstanceState: Bundle?) {
dbHelper = ServiceDB.OpenHelper(context)
settings = Settings(dbHelper.readableDatabase)
// will call onCreatePreferences, so settings should be already initialized
super.onCreate(savedInstanceState)
}
override fun onDestroy() {
super.onDestroy()
dbHelper.close()
}
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
addPreferencesFromResource(R.xml.settings_app)
loadSettings()
// UI settings
findPreference<Preference>("notification_settings")!!.apply {
if (Build.VERSION.SDK_INT >= 26)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
})
false
}
else
isVisible = false
}
findPreference<Preference>("reset_hints")!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
val prefResetHints = findPreference("reset_hints")
prefResetHints.onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetHints()
false
true
}
// security settings
findPreference<SwitchPreferenceCompat>(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!.apply {
isVisible = BuildConfig.customCerts
isEnabled = true
val prefOverrideProxy = findPreference("override_proxy") as SwitchPreferenceCompat
prefOverrideProxy.isChecked = settings.getBoolean(App.OVERRIDE_PROXY, false)
prefOverrideProxy.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
settings.putBoolean(App.OVERRIDE_PROXY, newValue as Boolean)
true
}
findPreference<Preference>("reset_certificates")!!.apply {
isVisible = BuildConfig.customCerts
isEnabled = true
onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetCertificates()
val prefProxyHost = findPreference("proxy_host") as EditTextPreference
val proxyHost = settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
prefProxyHost.text = proxyHost
prefProxyHost.summary = proxyHost
prefProxyHost.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val host = newValue as String
try {
URI(null, host, null, null)
settings.putString(App.OVERRIDE_PROXY_HOST, host)
prefProxyHost.summary = host
true
} catch(e: URISyntaxException) {
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
false
}
}
arguments?.getString(EXTRA_SCROLL_TO)?.let { key ->
scrollToPreference(key)
}
}
private fun loadSettings() {
val settings = Settings.getInstance(requireActivity())
// connection settings
findPreference<SwitchPreferenceCompat>(Settings.OVERRIDE_PROXY)!!.apply {
isChecked = settings.getBoolean(Settings.OVERRIDE_PROXY) ?: Settings.OVERRIDE_PROXY_DEFAULT
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY)
}
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_HOST)!!.apply {
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_HOST)
val proxyHost = settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT
text = proxyHost
summary = proxyHost
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val host = newValue as String
try {
URI(null, host, null, null)
settings.putString(Settings.OVERRIDE_PROXY_HOST, host)
summary = host
true
} catch(e: URISyntaxException) {
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
false
}
val prefProxyPort = findPreference("proxy_port") as EditTextPreference
val proxyPort = settings.getString(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT.toString())
prefProxyPort.text = proxyPort
prefProxyPort.summary = proxyPort
prefProxyPort.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
var port: Int
try {
port = Integer.parseInt(newValue as String)
} catch(e: NumberFormatException) {
port = App.OVERRIDE_PROXY_PORT_DEFAULT
}
settings.putInt(App.OVERRIDE_PROXY_PORT, port)
prefProxyPort.text = port.toString()
prefProxyPort.summary = port.toString()
true
}
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_PORT)!!.apply {
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_PORT)
val proxyPort = settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
text = proxyPort.toString()
summary = proxyPort.toString()
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
try {
val port = Integer.parseInt(newValue as String)
if (port in 1..65535) {
settings.putInt(Settings.OVERRIDE_PROXY_PORT, port)
text = port.toString()
summary = port.toString()
true
} else
false
} catch(e: NumberFormatException) {
false
}
}
val prefDistrustSystemCerts = findPreference("distrust_system_certs") as SwitchPreferenceCompat
if (CustomCertificates.certManager == null)
prefDistrustSystemCerts.isVisible = false
else
prefDistrustSystemCerts.isChecked = settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)
prefDistrustSystemCerts.onPreferenceClickListener = Preference.OnPreferenceClickListener {
setDistrustSystemCerts(prefDistrustSystemCerts.isChecked)
true
}
// security settings
findPreference<SwitchPreferenceCompat>(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!
.isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT
}
val prefResetCertificates = findPreference("reset_certificates")
if (CustomCertificates.certManager == null)
prefResetCertificates.isVisible = false
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
resetCertificates()
true
}
override fun onSettingsChanged() {
loadSettings()
}
val prefLogToExternalStorage = findPreference("log_to_external_storage") as SwitchPreferenceCompat
prefLogToExternalStorage.isChecked = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
prefLogToExternalStorage.onPreferenceClickListener = Preference.OnPreferenceClickListener {
setExternalLogging(prefLogToExternalStorage.isChecked)
true
}
arguments?.getString(EXTRA_SCROLL_TO)?.let { scrollToPreference(it) }
}
private fun resetHints() {
val settings = Settings.getInstance(requireActivity())
settings.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS)
settings.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
settings.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
settings.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
}
private fun resetCertificates() {
if (CustomCertManager.resetCertificates(activity!!))
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
private fun setDistrustSystemCerts(distrust: Boolean) {
settings.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, distrust)
// re-initialize certificate manager
CustomCertificates.reinitCertManager(context)
// reinitialize certificate manager of :sync process
context.sendBroadcast(Intent(Settings.ReinitSettingsReceiver.ACTION_REINIT_SETTINGS))
}
private fun resetCertificates() {
CustomCertificates.certManager?.resetCertificates()
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
}
private fun setExternalLogging(externalLogging: Boolean) {
settings.putBoolean(App.LOG_TO_EXTERNAL_STORAGE, externalLogging)
// reinitialize logger of default process
Logger.reinitLogger(context)
// reinitialize logger of :sync process
context.sendBroadcast(Intent(Settings.ReinitSettingsReceiver.ACTION_REINIT_SETTINGS))
}
}
}
@@ -9,48 +9,43 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Application
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.support.v4.app.NavUtils
import android.support.v7.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelProviders
import android.widget.ArrayAdapter
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityCreateAddressBookBinding
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import kotlinx.android.synthetic.main.activity_create_address_book.*
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
import java.util.*
import kotlin.concurrent.thread
class CreateAddressBookActivity: AppCompatActivity() {
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
companion object {
const val EXTRA_ACCOUNT = "account"
val EXTRA_ACCOUNT = "account"
}
private lateinit var model: Model
private lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setContentView(R.layout.activity_create_address_book)
model = ViewModelProviders.of(this).get(Model::class.java)
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
model.initialize(it)
}
val binding = DataBindingUtil.setContentView<ActivityCreateAddressBookBinding>(this, R.layout.activity_create_address_book)
binding.lifecycleOwner = this
binding.model = model
loaderManager.initLoader(0, intent.extras, this)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -61,81 +56,77 @@ class CreateAddressBookActivity: AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem) =
if (item.itemId == android.R.id.home) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
NavUtils.navigateUpTo(this, intent)
true
} else
false
fun onCreateCollection(item: MenuItem) {
val homeSet = home_sets.selectedItem as String
var ok = true
HttpUrl.parse(homeSet)?.let {
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
info.displayName = display_name.text.toString()
if (info.displayName.isNullOrBlank()) {
display_name.error = getString(R.string.create_collection_display_name_required)
ok = false
}
val args = Bundle()
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV)
info.description = StringUtils.trimToNull(description.text.toString())
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
val displayName = model.displayName.value
if (displayName.isNullOrBlank()) {
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
ok = false
} else {
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
model.displayNameError.value = null
}
StringUtils.trimToNull(model.description.value)?.let {
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
}
if (ok) {
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK)
val frag = CreateCollectionFragment()
frag.arguments = args
frag.show(supportFragmentManager, null)
if (ok) {
info.type = CollectionInfo.Type.ADDRESS_BOOK
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
}
}
}
class Model(
application: Application
) : AndroidViewModel(application) {
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
var account: Account? = null
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
info?.let {
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, it.homeSets)
}
}
val displayName = MutableLiveData<String>()
val displayNameError = MutableLiveData<String>()
override fun onLoaderReset(loader: Loader<AccountInfo>) {
}
val description = MutableLiveData<String>()
class AccountInfo {
val homeSets = LinkedList<String>()
}
val homeSets = MutableLiveData<HomeSetAdapter>()
val idxHomeSet = MutableLiveData<Int>()
class AccountInfoLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(context) {
@MainThread
fun initialize(account: Account) {
if (this.account != null)
return
this.account = account
override fun onStartLoading() = forceLoad()
thread {
// load account info
val adapter = HomeSetAdapter(getApplication())
override fun loadInBackground(): AccountInfo? {
val info = AccountInfo()
ServiceDB.OpenHelper(context).use { dbHelper ->
// find DAV service and home sets
val db = dbHelper.readableDatabase
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null).use { cursor ->
if (!cursor.moveToNext())
return null
val strServiceID = cursor.getString(0)
val db = AppDatabase.getInstance(getApplication())
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service ->
val homeSets = db.homeSetDao().getBindableByService(service.id)
adapter.addAll(homeSets)
}
if (!adapter.isEmpty) {
homeSets.postValue(adapter)
idxHomeSet.postValue(0)
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
while (c.moveToNext())
info.homeSets += c.getString(0)
}
}
}
return info
}
}
}
@@ -9,75 +9,54 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Application
import android.app.LoaderManager
import android.content.AsyncTaskLoader
import android.content.Context
import android.content.Intent
import android.content.Loader
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.support.v4.app.NavUtils
import android.support.v7.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import android.widget.Filter
import androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelProviders
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityCreateCalendarBinding
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.ical4android.DateUtils
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import kotlinx.android.synthetic.main.activity_create_calendar.*
import net.fortuna.ical4j.model.Calendar
import okhttp3.HttpUrl
import org.apache.commons.lang3.StringUtils
import yuku.ambilwarna.AmbilWarnaDialog
import java.util.*
import kotlin.concurrent.thread
class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
companion object {
const val EXTRA_ACCOUNT = "account"
val EXTRA_ACCOUNT = "account"
}
private lateinit var model: Model
private lateinit var account: Account
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
account = intent.extras.getParcelable(EXTRA_ACCOUNT)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
model = ViewModelProviders.of(this).get(Model::class.java)
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
model.initialize(it)
setContentView(R.layout.activity_create_calendar)
color.setOnClickListener { _ ->
AmbilWarnaDialog(this, (color.background as ColorDrawable).color, true, object: AmbilWarnaDialog.OnAmbilWarnaListener {
override fun onCancel(dialog: AmbilWarnaDialog) {}
override fun onOk(dialog: AmbilWarnaDialog, rgb: Int) =
color.setBackgroundColor(rgb)
}).show()
}
val binding = DataBindingUtil.setContentView<ActivityCreateCalendarBinding>(this, R.layout.activity_create_calendar)
binding.lifecycleOwner = this
binding.model = model
binding.color.setOnClickListener {
ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setColor((color.background as ColorDrawable).color)
.show(this)
}
binding.timezone.setAdapter(model.timezones)
}
override fun onColorSelected(dialogId: Int, rgb: Int) {
model.color.value = rgb
}
override fun onDialogDismissed(dialogId: Int) {
// color selection dismissed
loaderManager.initLoader(0, null, this)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -88,163 +67,105 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
override fun onOptionsItemSelected(item: MenuItem) =
if (item.itemId == android.R.id.home) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
NavUtils.navigateUpTo(this, intent)
true
} else
false
fun onCreateCollection(item: MenuItem) {
val homeSet = home_sets.selectedItem as String
var ok = true
HttpUrl.parse(homeSet)?.let {
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
info.displayName = display_name.text.toString()
if (info.displayName.isNullOrBlank()) {
display_name.error = getString(R.string.create_collection_display_name_required)
ok = false
}
val args = Bundle()
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV)
info.description = StringUtils.trimToNull(description.text.toString())
info.color = (color.background as ColorDrawable).color
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
val displayName = model.displayName.value
if (displayName.isNullOrBlank()) {
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
ok = false
} else {
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
model.displayNameError.value = null
}
StringUtils.trimToNull(model.description.value)?.let {
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
}
model.color.value?.let {
args.putInt(CreateCollectionFragment.ARG_COLOR, it)
}
val tzId = model.timezone.value
if (tzId.isNullOrBlank()) {
model.timezoneError.value = getString(R.string.create_calendar_time_zone_required)
ok = false
} else {
DateUtils.tzRegistry.getTimeZone(tzId)?.let { tz ->
DateUtils.tzRegistry.getTimeZone(time_zone.selectedItem as String)?.let { tz ->
val cal = Calendar()
cal.components += tz.vTimeZone
args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString())
info.timeZone = cal.toString()
}
model.timezoneError.value = null
}
val supportsVEVENT = model.supportVEVENT.value ?: false
val supportsVTODO = model.supportVTODO.value ?: false
val supportsVJOURNAL = model.supportVJOURNAL.value ?: false
if (!supportsVEVENT && !supportsVTODO && !supportsVJOURNAL) {
ok = false
model.typeError.value = ""
} else
model.typeError.value = null
when (type.checkedRadioButtonId) {
R.id.type_events ->
info.supportsVEVENT = true
R.id.type_tasks ->
info.supportsVTODO = true
R.id.type_events_and_tasks -> {
info.supportsVEVENT = true
info.supportsVTODO = true
}
}
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
// only if there's at least one component set not supported; don't include
// information about supported components otherwise (means: everything supported)
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT)
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VTODO, supportsVTODO)
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VJOURNAL, supportsVJOURNAL)
}
if (ok) {
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_CALENDAR)
val frag = CreateCollectionFragment()
frag.arguments = args
frag.show(supportFragmentManager, null)
if (ok) {
info.type = CollectionInfo.Type.CALENDAR
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
}
}
}
class TimeZoneAdapter(
context: Context
): ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, android.R.id.text1) {
val tz = TimeZone.getAvailableIDs()!!
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
override fun getFilter(): Filter {
return object: Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filtered = constraint?.let {
tz.filter { it.contains(constraint, true) }
} ?: listOf()
val results = FilterResults()
results.values = filtered
results.count = filtered.size
return results
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
clear()
@Suppress("UNCHECKED_CAST") addAll(results.values as List<String>)
if (results.count >= 0)
notifyDataSetChanged()
else
notifyDataSetInvalidated()
}
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
val timeZones = TimeZone.getAvailableIDs()
time_zone.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, timeZones)
// select system time zone
val defaultTimeZone = TimeZone.getDefault().id
for (i in 0 .. timeZones.size - 1)
if (timeZones[i] == defaultTimeZone) {
time_zone.setSelection(i)
break
}
}
info?.let {
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)
}
}
override fun onLoaderReset(loader: Loader<AccountInfo>) {}
class Model(
application: Application
): AndroidViewModel(application) {
var account: Account? = null
class AccountInfo {
val homeSets = LinkedList<String>()
}
val displayName = MutableLiveData<String>()
val displayNameError = MutableLiveData<String>()
class AccountInfoLoader(
context: Context,
val account: Account
): AsyncTaskLoader<AccountInfo>(context) {
val description = MutableLiveData<String>()
val color = MutableLiveData<Int>()
override fun onStartLoading() = forceLoad()
val homeSets = MutableLiveData<HomeSetAdapter>()
val idxHomeSet = MutableLiveData<Int>()
override fun loadInBackground(): AccountInfo? {
val info = AccountInfo()
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null).use { cursor ->
if (!cursor.moveToNext())
return null
val strServiceID = cursor.getString(0)
val timezones = TimeZoneAdapter(application)
val timezone = MutableLiveData<String>()
val timezoneError = MutableLiveData<String>()
val typeError = MutableLiveData<String>()
val supportVEVENT = MutableLiveData<Boolean>()
val supportVTODO = MutableLiveData<Boolean>()
val supportVJOURNAL = MutableLiveData<Boolean>()
@MainThread
fun initialize(account: Account) {
if (this.account != null)
return
this.account = account
color.value = Constants.DAVDROID_GREEN_RGBA
timezone.value = TimeZone.getDefault().id
supportVEVENT.value = true
supportVTODO.value = true
supportVJOURNAL.value = true
thread {
// load account info
val adapter = HomeSetAdapter(getApplication())
val db = AppDatabase.getInstance(getApplication())
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service ->
val homeSets = db.homeSetDao().getBindableByService(service.id)
adapter.addAll(homeSets)
}
if (!adapter.isEmpty) {
homeSets.postValue(adapter)
idxHomeSet.postValue(0)
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
while (c.moveToNext())
info.homeSets += c.getString(0)
}
}
}
return info
}
}
}
@@ -9,137 +9,97 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Application
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.XmlUtils
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import okhttp3.HttpUrl
import java.io.IOException
import java.io.StringWriter
import java.util.logging.Level
import kotlin.concurrent.thread
class CreateCollectionFragment: DialogFragment() {
@Suppress("DEPRECATION")
class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
companion object {
const val ARG_ACCOUNT = "account"
const val ARG_SERVICE_TYPE = "serviceType"
const val ARG_TYPE = "type"
const val ARG_URL = "url"
const val ARG_DISPLAY_NAME = "displayName"
const val ARG_DESCRIPTION = "description"
val ARG_ACCOUNT = "account"
val ARG_COLLECTION_INFO = "collectionInfo"
fun newInstance(account: Account, info: CollectionInfo): CreateCollectionFragment {
val frag = CreateCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putSerializable(ARG_COLLECTION_INFO, info)
frag.arguments = args
return frag
}
// CalDAV only
const val ARG_COLOR = "color"
const val ARG_TIMEZONE = "timezone"
const val ARG_SUPPORTS_VEVENT = "supportsVEVENT"
const val ARG_SUPPORTS_VTODO = "supportsVTODO"
const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL"
}
private lateinit var model: Model
private lateinit var account: Account
private lateinit var info: CollectionInfo
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = arguments ?: throw IllegalArgumentException()
model = ViewModelProviders.of(this).get(Model::class.java)
model.account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required")
model.serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required")
account = arguments.getParcelable(ARG_ACCOUNT)
info = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
model.collection = Collection(
type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"),
url = HttpUrl.parse(args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required"))!!,
displayName = args.getString(ARG_DISPLAY_NAME),
description = args.getString(ARG_DESCRIPTION),
color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) },
timezone = args.getString(ARG_TIMEZONE),
supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) },
supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) },
supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) },
sync = true /* by default, sync collections which just have been created */
)
model.createCollection().observe(this, Observer { exception ->
if (exception == null)
requireActivity().finish()
else {
dismiss()
requireFragmentManager().beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
.commit()
}
})
loaderManager.initLoader(0, null, this)
}
private fun<T> Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? =
if (containsKey(name))
getter(this)
else
null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val v = inflater.inflate(R.layout.create_collection, container, false)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(context)
progress.setTitle(R.string.create_collection_creating)
progress.setMessage(getString(R.string.please_wait))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return v
return progress
}
class Model(
application: Application
): AndroidViewModel(application) {
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(context, account, info)
lateinit var account: Account
lateinit var serviceType: String
lateinit var collection: Collection
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
dismissAllowingStateLoss()
val result = MutableLiveData<Exception>()
fun createCollection(): LiveData<Exception> {
thread {
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account))
.setForeground(true)
.build().use { httpClient ->
try {
val dav = DavResource(httpClient.okHttpClient, collection.url)
// create collection on remote server
dav.mkCol(generateXml()) {}
// no HTTP error -> create collection locally
val db = AppDatabase.getInstance(getApplication())
db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service ->
collection.serviceId = service.id
db.collectionDao().insert(collection)
}
// post success
result.postValue(null)
} catch (e: Exception) {
// post error
result.postValue(e)
}
}
}
return result
activity?.let { parent ->
if (exception != null)
fragmentManager.beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, account), null)
.commitAllowingStateLoss()
else
parent.finish()
}
private fun generateXml(): String {
}
override fun onLoaderReset(loader: Loader<Exception>) {}
class CreateCollectionLoader(
context: Context,
val account: Account,
val info: CollectionInfo
): AsyncTaskLoader<Exception>(context) {
override fun onStartLoading() = forceLoad()
override fun loadInBackground(): Exception? {
val writer = StringWriter()
try {
val serializer = XmlUtils.newSerializer()
@@ -149,80 +109,72 @@ class CreateCollectionFragment: DialogFragment() {
setPrefix("", XmlUtils.NS_WEBDAV)
setPrefix("CAL", XmlUtils.NS_CALDAV)
setPrefix("CARD", XmlUtils.NS_CARDDAV)
startTag(XmlUtils.NS_WEBDAV, "mkcol")
startTag(XmlUtils.NS_WEBDAV, "set")
startTag(XmlUtils.NS_WEBDAV, "prop")
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
startTag(XmlUtils.NS_WEBDAV, "collection")
endTag(XmlUtils.NS_WEBDAV, "collection")
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
startTag(XmlUtils.NS_CARDDAV, "addressbook")
endTag(XmlUtils.NS_CARDDAV, "addressbook")
} else if (collection.type == Collection.TYPE_CALENDAR) {
startTag(XmlUtils.NS_CALDAV, "calendar")
endTag(XmlUtils.NS_CALDAV, "calendar")
}
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
collection.displayName?.let {
startTag(XmlUtils.NS_WEBDAV, "displayname")
text(it)
endTag(XmlUtils.NS_WEBDAV, "displayname")
}
startTag(XmlUtils.NS_WEBDAV, "set")
startTag(XmlUtils.NS_WEBDAV, "prop")
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
startTag(XmlUtils.NS_WEBDAV, "collection")
endTag(XmlUtils.NS_WEBDAV, "collection")
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
startTag(XmlUtils.NS_CARDDAV, "addressbook")
endTag(XmlUtils.NS_CARDDAV, "addressbook")
} else if (info.type == CollectionInfo.Type.CALENDAR) {
startTag(XmlUtils.NS_CALDAV, "calendar")
endTag(XmlUtils.NS_CALDAV, "calendar")
}
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
info.displayName?.let {
startTag(XmlUtils.NS_WEBDAV, "displayname")
text(it)
endTag(XmlUtils.NS_WEBDAV, "displayname")
}
// addressbook-specific properties
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
info.description?.let {
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
text(it)
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
}
}
// calendar-specific properties
if (info.type == CollectionInfo.Type.CALENDAR) {
info.description?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-description")
text(it)
endTag(XmlUtils.NS_CALDAV, "calendar-description")
}
// addressbook-specific properties
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
collection.description?.let {
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
text(it)
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
}
}
info.color?.let {
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
text(DavUtils.ARGBtoCalDAVColor(it))
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
}
// calendar-specific properties
if (collection.type == Collection.TYPE_CALENDAR) {
collection.description?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-description")
text(it)
endTag(XmlUtils.NS_CALDAV, "calendar-description")
}
collection.color?.let {
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
text(DavUtils.ARGBtoCalDAVColor(it))
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
}
collection.timezone?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
cdsect(it)
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
}
if (collection.supportsVEVENT != null || collection.supportsVTODO != null || collection.supportsVJOURNAL != null) {
// only if there's at least one explicitly supported calendar component set, otherwise don't include the property
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
if (collection.supportsVEVENT != false) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VEVENT")
endTag(XmlUtils.NS_CALDAV, "comp")
}
if (collection.supportsVTODO != false) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VTODO")
endTag(XmlUtils.NS_CALDAV, "comp")
}
if (collection.supportsVJOURNAL != false) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VJOURNAL")
endTag(XmlUtils.NS_CALDAV, "comp")
}
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
}
}
endTag(XmlUtils.NS_WEBDAV, "prop")
endTag(XmlUtils.NS_WEBDAV, "set")
info.timeZone?.let {
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
cdsect(it)
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
}
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
if (info.supportsVEVENT) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VEVENT")
endTag(XmlUtils.NS_CALDAV, "comp")
}
if (info.supportsVTODO) {
startTag(XmlUtils.NS_CALDAV, "comp")
attribute(null, "name", "VTODO")
endTag(XmlUtils.NS_CALDAV, "comp")
}
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
}
endTag(XmlUtils.NS_WEBDAV, "prop")
endTag(XmlUtils.NS_WEBDAV, "set")
endTag(XmlUtils.NS_WEBDAV, "mkcol")
endDocument()
}
@@ -230,9 +182,41 @@ class CreateCollectionFragment: DialogFragment() {
Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e)
}
return writer.toString()
}
try {
val client = HttpClient.create(context, account)
val collection = DavResource(client, HttpUrl.parse(info.url)!!)
// create collection on remote server
collection.mkCol(writer.toString())
// now insert collection into database:
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
// 1. find service ID
val serviceType = when (info.type) {
CollectionInfo.Type.ADDRESS_BOOK -> ServiceDB.Services.SERVICE_CARDDAV
CollectionInfo.Type.CALENDAR -> ServiceDB.Services.SERVICE_CALDAV
else -> throw IllegalArgumentException("Collection must be an address book or calendar")
}
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, serviceType), null, null, null).use { c ->
assert(c.moveToNext())
val serviceID = c.getLong(0)
// 2. add collection to service
val values = info.toDB()
values.put(ServiceDB.Collections.SERVICE_ID, serviceID)
db.insert(ServiceDB.Collections._TABLE, null, values)
}
}
} catch(e: Exception) {
return e
}
return null
}
}
}
@@ -11,12 +11,8 @@ package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Application
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.app.LoaderManager
import android.content.*
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
@@ -24,58 +20,49 @@ import android.os.Bundle
import android.os.PowerManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.util.Log
import android.support.v4.content.ContextCompat
import android.support.v4.content.FileProvider
import android.support.v7.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.pm.PackageInfoCompat
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModelProviders
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityDebugInfoBinding
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.ContactsStorageException
import kotlinx.android.synthetic.main.activity_debug_info.*
import org.apache.commons.lang3.exception.ExceptionUtils
import org.dmfs.tasks.contract.TaskContract
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Level
import kotlin.concurrent.thread
class DebugInfoActivity: AppCompatActivity() {
class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<String> {
companion object {
const val KEY_THROWABLE = "throwable"
const val KEY_LOGS = "logs"
const val KEY_ACCOUNT = "account"
const val KEY_AUTHORITY = "authority"
const val KEY_LOCAL_RESOURCE = "localResource"
const val KEY_REMOTE_RESOURCE = "remoteResource"
@JvmField val KEY_THROWABLE = "throwable"
@JvmField val KEY_LOGS = "logs"
@JvmField val KEY_ACCOUNT = "account"
@JvmField val KEY_AUTHORITY = "authority"
@JvmField val KEY_PHASE = "phase"
@JvmField val KEY_LOCAL_RESOURCE = "localResource"
@JvmField val KEY_REMOTE_RESOURCE = "remoteResource"
}
private lateinit var model: ReportModel
var report: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_debug_info)
model = ViewModelProviders.of(this).get(ReportModel::class.java)
model.initialize(intent.extras)
val binding = DataBindingUtil.setContentView<ActivityDebugInfoBinding>(this, R.layout.activity_debug_info)
binding.model = model
binding.lifecycleOwner = this
loaderManager.initLoader(0, intent.extras, this)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -85,224 +72,201 @@ class DebugInfoActivity: AppCompatActivity() {
fun onShare(item: MenuItem) {
model.report.value?.let { report ->
val builder = ShareCompat.IntentBuilder.from(this)
.setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
.setText(getString(R.string.debug_info_logs_attached))
.setType("text/plain")
report?.let {
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
sendIntent.type = "text/plain"
sendIntent.putExtra(Intent.EXTRA_SUBJECT, "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
try {
val debugInfoDir = File(filesDir, "debug")
if (!(debugInfoDir.exists() && debugInfoDir.isDirectory) && !debugInfoDir.mkdir())
throw IOException("Couldn't create debug directory")
val debugInfoDir = File(cacheDir, "debug-info")
debugInfoDir.mkdir()
val reportFile = File(debugInfoDir, "davx5-info.txt")
val reportFile = File(debugInfoDir, "debug.txt")
Logger.log.fine("Writing debug info to ${reportFile.absolutePath}")
val writer = FileWriter(reportFile)
writer.write(report)
writer.write(it)
writer.close()
builder.setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), reportFile))
builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(this, getString(R.string.authority_log_provider), reportFile))
sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch(e: IOException) {
// creating an attachment failed, so send it inline
val text = "Couldn't create debug info file: " + Log.getStackTraceString(e) + "\n\n$report"
builder.setText(text)
sendIntent.putExtra(Intent.EXTRA_TEXT, it)
}
builder.startChooser()
startActivity(Intent.createChooser(sendIntent, null))
}
}
class ReportModel(application: Application): AndroidViewModel(application) {
override fun onCreateLoader(id: Int, args: Bundle?) =
ReportLoader(this, args)
private var initialized = false
val report = MutableLiveData<String>()
override fun onLoadFinished(loader: Loader<String>, data: String?) {
data?.let {
report = it
text_report.text = it
}
}
fun initialize(extras: Bundle?) {
if (initialized)
return
override fun onLoaderReset(loader: Loader<String>) {}
Logger.log.info("Generating debug info report")
initialized = true
thread {
val context = getApplication<Application>()
val text = StringBuilder("--- BEGIN DEBUG INFO ---\n")
class ReportLoader(
context: Context,
val extras: Bundle?
): AsyncTaskLoader<String>(context) {
// begin with most specific information
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
text.append("SYNCHRONIZATION INFO\nAccount name: ${it.name}\n")
}
extras?.getString(KEY_AUTHORITY)?.let {
text.append("Authority: $it\n")
}
override fun onStartLoading() = forceLoad()
// exception details
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
if (throwable is HttpException) {
throwable.request?.let { request ->
text.append("\nHTTP REQUEST:\n$request\n")
throwable.requestBody?.let { text.append(it) }
text.append("\n\n")
}
throwable.response?.let { response ->
text.append("HTTP RESPONSE:\n$response\n")
throwable.responseBody?.let { text.append(it) }
text.append("\n\n")
}
}
override fun loadInBackground(): String {
val report = StringBuilder("--- BEGIN DEBUG INFO ---\n")
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
text.append("\nLOCAL RESOURCE:\n$it\n")
}
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
text.append("\nREMOTE RESOURCE:\n$it\n")
}
throwable?.let {
// Log.getStackTraceString(e) returns "" in case of UnknownHostException
text.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
}
// logs (for instance, from failed resource detection)
extras?.getString(KEY_LOGS)?.let {
text.append("\nLOGS:\n$it\n")
}
// software information
try {
text.append("\nSOFTWARE INFORMATION\n")
val pm = context.packageManager
val appIDs = mutableSetOf( // we always want info about these packages
BuildConfig.APPLICATION_ID, // DAVx5
"${BuildConfig.APPLICATION_ID}.jbworkaround", // DAVdroid JB Workaround
"org.dmfs.tasks" // OpenTasks
)
// add info about contact, calendar, task provider
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, TaskProvider.ProviderName.OpenTasks.authority))
pm.resolveContentProvider(authority, 0)?.let { appIDs += it.packageName }
// add info about available contact, calendar, task apps
for (uri in arrayOf(ContactsContract.Contacts.CONTENT_URI, CalendarContract.Events.CONTENT_URI, TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority))) {
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, 1))
for (info in pm.queryIntentActivities(viewIntent, 0))
appIDs += info.activityInfo.packageName
}
for (appID in appIDs)
try {
val info = pm.getPackageInfo(appID, 0)
text .append("* ").append(appID)
.append(" ").append(info.versionName)
.append(" (").append(PackageInfoCompat.getLongVersionCode(info)).append(")")
pm.getInstallerPackageName(appID)?.let { installer ->
text.append(" from ").append(installer)
}
info.applicationInfo?.let { applicationInfo ->
if (!applicationInfo.enabled)
text.append(" disabled!")
if (applicationInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
text.append(" on external storage!")
}
text.append("\n")
} catch(e: PackageManager.NameNotFoundException) {
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
}
// connectivity
text.append("\nCONNECTIVITY (at the moment)\n")
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.activeNetworkInfo?.let { networkInfo ->
val type = when (networkInfo.type) {
ConnectivityManager.TYPE_WIFI -> "WiFi"
ConnectivityManager.TYPE_MOBILE -> "mobile"
else -> "type: ${networkInfo.type}"
}
text.append("Active connection: $type, ${networkInfo.detailedState}\n")
}
if (Build.VERSION.SDK_INT >= 23)
connectivityManager.defaultProxy?.let { proxy ->
text.append("System default proxy: ${proxy.host}:${proxy.port}")
}
text.append("\n")
text.append("CONFIGURATION\n")
// power saving
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= 23)
text.append("Power saving disabled: ")
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
.append("\n")
// permissions
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS,
Manifest.permission.ACCESS_COARSE_LOCATION)) {
text .append(permission).append(": ")
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
"granted"
else
"denied")
.append("\n")
}
// system-wide sync settings
text.append("System-wide synchronization: ")
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
.append("\n")
// main accounts
val accountManager = AccountManager.get(context)
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
try {
val accountSettings = AccountSettings(context, acct)
text.append("Account: ${acct.name}\n" +
" Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" +
" Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" +
" OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
" WiFi only: ").append(accountSettings.getSyncWifiOnly())
accountSettings.getSyncWifiOnlySSIDs()?.let {
text.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}")
}
text.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}")
.append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}")
.append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}")
.append("\n Use event colors: ${accountSettings.getEventColors()}")
.append("\n")
} catch (e: InvalidAccountException) {
text.append("$acct is invalid (unsupported settings version) or does not exist\n")
}
// address book accounts
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
try {
val addressBook = LocalAddressBook(context, acct, null)
text.append("Address book account: ${acct.name}\n" +
" Main account: ${addressBook.mainAccount}\n" +
" URL: ${addressBook.url}\n" +
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
} catch(e: Exception) {
text.append("$acct is invalid: ${e.message}\n")
}
text.append("\n")
text.append("SQLITE DUMP\n")
AppDatabase.getInstance(context).dump(text)
text.append("\n")
try {
text.append(
"SYSTEM INFORMATION\n" +
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
}
text.append("--- END DEBUG INFO ---\n")
report.postValue(text.toString())
// begin with most specific information
extras?.getInt(KEY_PHASE, -1).takeIf { it != -1 }?.let {
report.append("SYNCHRONIZATION INFO\nSynchronization phase: $it\n")
}
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
report.append("Account name: ${it.name}\n")
}
extras?.getString(KEY_AUTHORITY)?.let {
report.append("Authority: $it\n")
}
// exception details
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
if (throwable is HttpException) {
throwable.request?.let {
report.append("\nHTTP REQUEST:\n$it\n\n")
}
throwable.response?.let {
report.append("HTTP RESPONSE:\n$it\n")
}
}
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
report.append("\nLOCAL RESOURCE:\n$it\n")
}
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
report.append("\nREMOTE RESOURCE:\n$it\n")
}
throwable?.let {
report.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
}
// logs (for instance, from failed resource detection)
extras?.getString(KEY_LOGS)?.let {
report.append("\nLOGS:\n$it\n")
}
// software information
try {
val pm = context.packageManager
val installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID) ?: "APK (directly)"
var workaroundInstalled = false
try {
workaroundInstalled = pm.getPackageInfo("${BuildConfig.APPLICATION_ID}.jbworkaround", 0) != null
} catch(e: PackageManager.NameNotFoundException) {
}
val formatter = SimpleDateFormat.getDateInstance()
report.append("\nSOFTWARE INFORMATION\n" +
"Package: ${BuildConfig.APPLICATION_ID}\n" +
"Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) from ${formatter.format(Date(BuildConfig.buildTime))}\n")
.append("Installed from: $installedFrom\n")
.append("JB Workaround installed: ${if (workaroundInstalled) "yes" else "no"}\n\n")
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
}
// connectivity
report.append("CONNECTIVITY (at the moment)\n")
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.activeNetworkInfo?.let { networkInfo ->
val type = when (networkInfo.type) {
ConnectivityManager.TYPE_WIFI -> "WiFi"
ConnectivityManager.TYPE_MOBILE -> "mobile"
else -> "type: ${networkInfo.type}"
}
report.append("Active connection: $type, ${networkInfo.detailedState}\n")
}
if (Build.VERSION.SDK_INT >= 23)
connectivityManager.defaultProxy?.let { proxy ->
report.append("System default proxy: ${proxy.host}:${proxy.port}")
}
report.append("\n")
report.append("CONFIGURATION\n")
// power saving
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= 23)
report.append("Power saving disabled: ")
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
.append("\n")
// permissions
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
PermissionsActivity.PERMISSION_READ_TASKS, PermissionsActivity.PERMISSION_WRITE_TASKS))
report.append(permission).append(" permission: ")
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied")
.append("\n")
// system-wide sync settings
report.append("System-wide synchronization: ")
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
.append("\n")
// main accounts
val accountManager = AccountManager.get(context)
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
try {
val settings = AccountSettings(context, acct)
report.append("Account: ${acct.name}\n" +
" Address book sync. interval: ${syncStatus(settings, context.getString(R.string.address_books_authority))}\n" +
" Calendar sync. interval: ${syncStatus(settings, CalendarContract.AUTHORITY)}\n" +
" OpenTasks sync. interval: ${syncStatus(settings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
" WiFi only: ").append(settings.getSyncWifiOnly())
settings.getSyncWifiOnlySSIDs()?.let {
report.append(", SSIDs: ${settings.getSyncWifiOnlySSIDs()}")
}
report.append("\n [CardDAV] Contact group method: ${settings.getGroupMethod()}")
.append("\n [CalDAV] Time range (past days): ${settings.getTimeRangePastDays()}")
.append("\n Manage calendar colors: ${settings.getManageCalendarColors()}")
.append("\n")
} catch(e: InvalidAccountException) {
report.append("$acct is invalid (unsupported settings version) or does not exist\n")
}
// address book accounts
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
try {
val addressBook = LocalAddressBook(context, acct, null)
report.append("Address book account: ${acct.name}\n" +
" Main account: ${addressBook.getMainAccount()}\n" +
" URL: ${addressBook.getURL()}\n" +
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
} catch(e: ContactsStorageException) {
report.append("$acct is invalid: ${e.message}\n")
}
report.append("\n")
ServiceDB.OpenHelper(context).use { dbHelper ->
report.append("SQLITE DUMP\n")
dbHelper.dump(report)
report.append("\n")
}
try {
report.append(
"SYSTEM INFORMATION\n" +
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
}
report.append("--- END DEBUG INFO ---\n")
return report.toString()
}
private fun syncStatus(settings: AccountSettings, authority: String): String {
@@ -312,7 +276,6 @@ class DebugInfoActivity: AppCompatActivity() {
} else
""
}
}
}
@@ -1,65 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.Menu
import android.view.MenuItem
import at.bitfire.davdroid.App
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
companion object {
private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
}
override fun initMenu(context: Context, menu: Menu) {
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
menu.findItem(R.id.nav_beta_feedback).isVisible = true
}
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
when (item.itemId) {
R.id.nav_about ->
activity.startActivity(Intent(activity, AboutActivity::class.java))
R.id.nav_app_settings ->
activity.startActivity(Intent(activity, AppSettingsActivity::class.java))
R.id.nav_beta_feedback ->
UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO)
R.id.nav_twitter ->
UiUtils.launchUri(activity, Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle)))
R.id.nav_website ->
UiUtils.launchUri(activity, App.homepageUrl(activity))
R.id.nav_manual ->
UiUtils.launchUri(activity, App.homepageUrl(activity)
.buildUpon().appendPath("manual").build())
R.id.nav_faq ->
UiUtils.launchUri(activity, App.homepageUrl(activity)
.buildUpon().appendPath("faq").build())
R.id.nav_forums ->
UiUtils.launchUri(activity, App.homepageUrl(activity)
.buildUpon().appendPath("forums").build())
R.id.nav_donate ->
//if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
UiUtils.launchUri(activity, App.homepageUrl(activity)
.buildUpon().appendPath("donate").build())
else ->
return false
}
return true
}
}
@@ -9,129 +9,135 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Application
import android.app.Dialog
import android.app.ProgressDialog
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.MainThread
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import android.support.v4.app.DialogFragment
import android.support.v4.app.LoaderManager
import android.support.v4.content.AsyncTaskLoader
import android.support.v4.content.Loader
import android.support.v7.app.AlertDialog
import at.bitfire.dav4android.DavResource
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.databinding.DeleteCollectionBinding
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.settings.AccountSettings
import kotlin.concurrent.thread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import okhttp3.HttpUrl
class DeleteCollectionFragment: DialogFragment() {
@Suppress("DEPRECATION")
class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
companion object {
const val ARG_ACCOUNT = "account"
const val ARG_COLLECTION_ID = "collectionId"
fun newInstance(account: Account, collectionId: Long): DialogFragment {
val frag = DeleteCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putLong(ARG_COLLECTION_ID, collectionId)
frag.arguments = args
return frag
}
val ARG_ACCOUNT = "account"
val ARG_COLLECTION_INFO = "collectionInfo"
}
private lateinit var model: DeleteCollectionModel
private lateinit var account: Account
private lateinit var collectionInfo: CollectionInfo
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model = ViewModelProviders.of(this).get(DeleteCollectionModel::class.java)
model.initialize(
arguments!!.getParcelable(ARG_ACCOUNT)!!,
arguments!!.getLong(ARG_COLLECTION_ID)
)
account = arguments.getParcelable(ARG_ACCOUNT)
collectionInfo = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
loaderManager.initLoader(0, null, this)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false)
binding.lifecycleOwner = this
binding.model = model
binding.ok.setOnClickListener {
isCancelable = false
binding.progress.visibility = View.VISIBLE
binding.controls.visibility = View.GONE
model.deleteCollection().observe(this, Observer { exception ->
if (exception != null)
requireFragmentManager().beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
.commit()
dismiss()
})
}
binding.cancel.setOnClickListener {
dismiss()
}
return binding.root
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val progress = ProgressDialog(context)
progress.setTitle(R.string.delete_collection_deleting_collection)
progress.setMessage(getString(R.string.please_wait))
progress.isIndeterminate = true
progress.setCanceledOnTouchOutside(false)
isCancelable = false
return progress
}
class DeleteCollectionModel(
application: Application
): AndroidViewModel(application) {
override fun onCreateLoader(id: Int, args: Bundle?) =
DeleteCollectionLoader(context, account, collectionInfo)
var account: Account? = null
var collectionInfo: Collection? = null
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
dismissAllowingStateLoss()
val db = AppDatabase.getInstance(application)
if (exception != null)
fragmentManager.beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, account), null)
.commitAllowingStateLoss()
else
(activity as? AccountActivity)?.reload()
}
val confirmation = MutableLiveData<Boolean>()
val result = MutableLiveData<Exception>()
override fun onLoaderReset(loader: Loader<Exception>) {}
@MainThread
fun initialize(account: Account, collectionId: Long) {
if (this.account == null)
this.account = account
if (collectionInfo == null)
thread {
collectionInfo = db.collectionDao().get(collectionId)
class DeleteCollectionLoader(
context: Context,
val account: Account,
val collectionInfo: CollectionInfo
): AsyncTaskLoader<Exception>(context) {
override fun onStartLoading() = forceLoad()
override fun loadInBackground(): Exception? {
try {
val httpClient = HttpClient.create(context, account)
val collection = DavResource(httpClient, HttpUrl.parse(collectionInfo.url)!!)
// delete collection from server
collection.delete(null)
// delete collection locally
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
db.delete(ServiceDB.Collections._TABLE, "${ServiceDB.Collections.ID}=?", arrayOf(collectionInfo.id.toString()))
}
}
fun deleteCollection(): LiveData<Exception> {
thread {
val account = account ?: return@thread
val collectionInfo = collectionInfo ?: return@thread
val context = getApplication<Application>()
HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true)
.build().use { httpClient ->
try {
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
// delete collection from server
collection.delete(null) {}
// delete collection locally
db.collectionDao().delete(collectionInfo)
// return success
result.postValue(null)
} catch(e: Exception) {
// return error
result.postValue(e)
}
}
return null
} catch(e: Exception) {
return e
}
return result
}
}
class ConfirmDeleteCollectionFragment: DialogFragment() {
companion object {
fun newInstance(account: Account, collectionInfo: CollectionInfo): DialogFragment {
val frag = ConfirmDeleteCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putSerializable(ARG_COLLECTION_INFO, collectionInfo)
frag.arguments = args
return frag
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val collectionInfo = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
val name = if (collectionInfo.displayName.isNullOrBlank())
collectionInfo.url
else
collectionInfo.displayName
return AlertDialog.Builder(context)
.setTitle(R.string.delete_collection_confirm_title)
.setMessage(getString(R.string.delete_collection_confirm_warning, name))
.setPositiveButton(android.R.string.yes, { _, _ ->
val frag = DeleteCollectionFragment()
frag.arguments = arguments
frag.show(fragmentManager, null)
})
.setNegativeButton(android.R.string.no, { _, _ ->
dismiss()
})
.create()
}
}
}
@@ -12,17 +12,17 @@ import android.accounts.Account
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import at.bitfire.dav4jvm.exception.HttpException
import android.support.v4.app.DialogFragment
import android.support.v7.app.AlertDialog
import at.bitfire.dav4android.exception.HttpException
import at.bitfire.davdroid.R
import java.io.IOException
class ExceptionInfoFragment: DialogFragment() {
companion object {
const val ARG_ACCOUNT = "account"
const val ARG_EXCEPTION = "exception"
val ARG_ACCOUNT = "account"
val ARG_EXCEPTION = "exception"
fun newInstance(exception: Exception, account: Account?): ExceptionInfoFragment {
val frag = ExceptionInfoFragment()
@@ -35,9 +35,8 @@ class ExceptionInfoFragment: DialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val args = requireNotNull(arguments)
val exception = args.getSerializable(ARG_EXCEPTION) as Exception
val account: Account? = args.getParcelable(ARG_ACCOUNT)
val exception = arguments.getSerializable(ARG_EXCEPTION) as Exception
val account: Account? = arguments.getParcelable(ARG_ACCOUNT)
val title = when (exception) {
is HttpException -> R.string.exception_httpexception
@@ -45,17 +44,17 @@ class ExceptionInfoFragment: DialogFragment() {
else -> R.string.exception
}
val dialog = AlertDialog.Builder(requireActivity())
val dialog = AlertDialog.Builder(context)
.setIcon(R.drawable.ic_error_dark)
.setTitle(title)
.setMessage(exception::class.java.name + "\n" + exception.localizedMessage)
.setNegativeButton(R.string.exception_show_details) { _, _ ->
val intent = Intent(activity, DebugInfoActivity::class.java)
.setNegativeButton(R.string.exception_show_details, { _, _ ->
val intent = Intent(getContext(), DebugInfoActivity::class.java)
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, exception)
account?.let { intent.putExtra(DebugInfoActivity.KEY_ACCOUNT, it) }
startActivity(intent)
}
.setPositiveButton(android.R.string.ok) { _, _ -> }
})
.setPositiveButton(android.R.string.ok, { _, _ -> })
.create()
isCancelable = false
return dialog
@@ -1,42 +0,0 @@
package at.bitfire.davdroid.ui
import android.content.Context
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import at.bitfire.davdroid.model.HomeSet
class HomeSetAdapter(
context: Context
): ArrayAdapter<HomeSet>(context, android.R.layout.simple_list_item_2, android.R.id.text1) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val data = getItem(position)!!
val v: View
if (!data.displayName.isNullOrBlank()) {
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, null, false)
v.findViewById<TextView>(android.R.id.text1).text = data.displayName
v.findViewById<TextView>(android.R.id.text2).apply {
text = data.url.toString()
setSingleLine()
ellipsize = TextUtils.TruncateAt.START
}
} else {
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, null, false)
v.findViewById<TextView>(android.R.id.text1).apply {
text = data.url.toString()
setSingleLine()
ellipsize = TextUtils.TruncateAt.START
}
}
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) =
getView(position, convertView, parent)
}
@@ -1,22 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.app.Activity
import android.content.Context
import android.view.Menu
import android.view.MenuItem
interface IAccountsDrawerHandler {
fun initMenu(context: Context, menu: Menu)
fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean
}
@@ -1,79 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.ui
import android.annotation.TargetApi
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import at.bitfire.davdroid.App
import at.bitfire.davdroid.R
object NotificationUtils {
// notification IDs
const val NOTIFY_EXTERNAL_FILE_LOGGING = 1
const val NOTIFY_REFRESH_COLLECTIONS = 2
const val NOTIFY_SYNC_ERROR = 10
const val NOTIFY_INVALID_RESOURCE = 11
const val NOTIFY_OPENTASKS = 20
const val NOTIFY_PERMISSIONS = 21
const val NOTIFY_LICENSE = 100
// notification channels
const val CHANNEL_GENERAL = "general"
const val CHANNEL_DEBUG = "debug"
private const val CHANNEL_SYNC = "sync"
const val CHANNEL_SYNC_ERRORS = "syncProblems"
const val CHANNEL_SYNC_WARNINGS = "syncWarnings"
const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors"
fun createChannels(context: Context) {
@TargetApi(Build.VERSION_CODES.O)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync)))
nm.createNotificationChannels(listOf(
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH),
NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT),
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply {
description = context.getString(R.string.notification_channel_sync_errors_desc)
group = CHANNEL_SYNC
},
NotificationChannel(CHANNEL_SYNC_WARNINGS, context.getString(R.string.notification_channel_sync_warnings), NotificationManager.IMPORTANCE_LOW).apply {
description = context.getString(R.string.notification_channel_sync_warnings_desc)
group = CHANNEL_SYNC
},
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN).apply {
description = context.getString(R.string.notification_channel_sync_io_errors_desc)
group = CHANNEL_SYNC
}
))
}
}
fun newBuilder(context: Context, channel: String): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channel)
.setColor(context.resources.getColor(R.color.primaryColor))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
builder.setLargeIcon(App.getLauncherBitmap(context))
return builder
}
}
@@ -0,0 +1,95 @@
/*
* 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.content.pm.PackageManager
import android.os.Bundle
import android.support.v4.app.ActivityCompat
import android.support.v4.app.NotificationManagerCompat
import android.support.v7.app.AppCompatActivity
import android.view.View
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.resource.LocalTaskList
import kotlinx.android.synthetic.main.activity_permissions.*
class PermissionsActivity: AppCompatActivity() {
companion object {
@JvmField val PERMISSION_READ_TASKS = "org.dmfs.permission.READ_TASKS"
@JvmField val PERMISSION_WRITE_TASKS = "org.dmfs.permission.WRITE_TASKS"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_permissions)
}
override fun onResume() {
super.onResume()
refresh()
}
private fun refresh() {
val noCalendarPermissions =
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED;
calendar_permissions.visibility = if (noCalendarPermissions) View.VISIBLE else View.GONE
val noContactsPermissions =
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED;
contacts_permissions.visibility = if (noContactsPermissions) View.VISIBLE else View.GONE
val noTaskPermissions: Boolean
if (LocalTaskList.tasksProviderAvailable(this)) {
noTaskPermissions =
ActivityCompat.checkSelfPermission(this, PERMISSION_READ_TASKS) != PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(this, PERMISSION_WRITE_TASKS) != PackageManager.PERMISSION_GRANTED
findViewById(R.id.opentasks_permissions).visibility = if (noTaskPermissions) View.VISIBLE else View.GONE
} else {
findViewById(R.id.opentasks_permissions).visibility = View.GONE;
noTaskPermissions = false
}
if (!noCalendarPermissions && !noContactsPermissions && !noTaskPermissions) {
val nm = NotificationManagerCompat.from(this)
nm.cancel(Constants.NOTIFICATION_PERMISSIONS)
finish()
}
}
fun requestCalendarPermissions(v: View) {
ActivityCompat.requestPermissions(this, arrayOf(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR
), 0)
}
fun requestContactsPermissions(v: View) {
ActivityCompat.requestPermissions(this, arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
), 0)
}
fun requestOpenTasksPermissions(v: View) {
ActivityCompat.requestPermissions(this, arrayOf(
PERMISSION_READ_TASKS,
PERMISSION_WRITE_TASKS
), 0)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
refresh()
}
}
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff. Show more