Compare commits

..
Author SHA1 Message Date
Ricki Hirner c4d6453c26 Assign UID/file name for empty contact even when creating in read-only address book (so that it can be deleted later) 2017-09-25 14:41:41 +02:00
Ricki Hirner 9e5632822d cert4android: fetch translations from Transifex 2017-09-25 12:08:37 +02:00
Ricki Hirner 9538452895 Fetch translations from Transifex 2017-09-25 12:07:10 +02:00
Ricki Hirner 9fb73a0dd8 Version bump to 1.8.1 2017-09-25 12:03:46 +02:00
Ricki Hirner 6a4a262ba7 Optimize read-only contact notifications 2017-09-25 12:03:04 +02:00
Ricki Hirner 008f803b14 Fetch translations from Transifex 2017-09-23 21:23:15 +02:00
Ricki Hirner 20617072c4 Notify on read-only address books
* notify when a read-only address book is selected for synchronization
* notify when local changes have been discarded during sync
2017-09-23 21:12:09 +02:00
Ricki Hirner bba5942771 Initial implementation of read-only address books 2017-09-23 21:12:09 +02:00
Ricki Hirner 60a9e174f7 Fetch translations from Transifex 2017-09-20 22:15:09 +02:00
Ricki Hirner d68b626fdd Version bump to 1.8 2017-09-20 22:14:27 +02:00
Ricki Hirner 47e764067b Reminders: use seconds, too 2017-09-20 22:13:45 +02:00
Ricki Hirner 4d54b59a78 Fetch translations from Transifex 2017-09-15 13:29:02 +02:00
Ricki Hirner c312eaa4f8 Abbreviate too long log messages to reduce OOM errors 2017-09-15 13:26:03 +02:00
Ricki Hirner 0ed7e2aafd Move strings out of global App context 2017-09-15 13:26:01 +02:00
Ricki Hirner 119fd6b15c Change options for sync intervals
* remove 5 and 10 minutes because Android now enforces a minimum interval of 15 min
  (which makes sense in terms of battery optimization)
* add 30 min
2017-09-14 18:18:32 +02:00
Ricki Hirner bfe5ab002d ical4j update 2017-09-14 17:29:58 +02:00
Ricki Hirner 5aad4e477a Fetch translations from Transifex 2017-09-13 17:20:51 +02:00
Ricki Hirner fde0782bb1 Tests 2017-09-13 17:17:18 +02:00
Ricki Hirner 85c6f43332 Webcal support in UI 2017-09-13 17:11:12 +02:00
Ricki Hirner 6d7d825f0c database support for subscribed calendars (Webcal feeds)
* resource detection: detect/check subscribed calendars
* database: distinguish between CalDAV calendar and subscribed calendar
2017-09-13 17:09:01 +02:00
Ricki Hirner c8dca756ee Version bump to 1.7.3 2017-09-10 20:19:01 +02:00
Ricki Hirner 01c08dc0a2 Fetch translations from Transifex 2017-09-10 20:17:10 +02:00
Ricki Hirner 0d9e2235b2 Make SyncManager Closeable 2017-09-10 20:16:08 +02:00
Ricki Hirner bd0a160a1d Don't allow calendars which don't support VEVENT and/or VTODO to be selected for sync 2017-09-10 14:04:08 +02:00
Ricki Hirner b136328801 Upgrade to okhttp/3.9.0 2017-09-10 01:31:19 +02:00
Ricki Hirner f15e2e4827 cert4android: don't keep CustomCertManager in memory all the time
* remove static reference to CustomCertManager (and thus a Context)
* HttpClient now wraps OkHttpClient and is Closeable
2017-09-09 22:34:45 +02:00
Ricki Hirner 346921d851 Update cert4android 2017-09-09 22:25:42 +02:00
Ricki Hirner d4b39e1b16 Fetch cert4android translations from Transifex 2017-09-01 15:13:30 +02:00
Ricki Hirner e100d2dd27 Fetch translations from Transifex 2017-09-01 15:12:16 +02:00
Ricki Hirner 4328a19ef8 Version bump to 1.7.2 2017-09-01 15:11:46 +02:00
Ricki Hirner a8ea784fcb donation popup; ProGuard
* don't show donation popup too often
* ProGuard fixes
2017-08-31 01:05:38 +02:00
Ricki Hirner 192040cbbb Fetch cert4android translations from Transifex 2017-08-30 21:04:49 +02:00
Ricki Hirner 3037e003f1 Fetch translations from Transifex 2017-08-30 21:01:55 +02:00
Ricki Hirner 70698b4f0e Make event color support optional (opt-in) 2017-08-30 20:22:04 +02:00
Ricki Hirner 13067cc972 Update to Android support library 26.0.1 2017-08-30 12:32:16 +02:00
Ricki Hirner f6c5478956 Optimize logging 2017-08-29 18:07:45 +02:00
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
432 changed files with 11860 additions and 28162 deletions

No files matched your search

+4 -16
View File
@@ -1,32 +1,20 @@
image: registry.gitlab.com/bitfireat/docker-android-emulator: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:
- .gradle/
test:
tags:
- privileged
script:
- curl -d "email=gitlab%40bitfire.at&password=$DAVTEST_TOKEN&action=Request+access" -X POST "https://davtest.dev001.net/access/"
- start-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 -3
View File
@@ -1,9 +1,12 @@
[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
-47
View File
@@ -1,47 +0,0 @@
Contributing to DAVx⁵
=====================
**Thank you for your interest in contributing to DAVx⁵!**
Because you're reading this, you're probably interested in
contributing to the DAVx⁵ code. [Other ways to contribute:
see here.](https://www.davx5.com/donate#c306)
To contribute:
1. It's good idea to have a look at the [DAVx⁵ Roadmap](https://gitlab.com/bitfireAT/davx5-ose/wikis/Roadmap)
to see whether the change is already planned. Maybe there's even a link to a
corresponding forum thread there.
1. Determine which project the changes shall go to. There's
the DAVx⁵ main project (this repo), and the [related
libraries](README.md).
1. Please post to the [DAVx⁵ development forum](https://www.davx5.com/forums)
before doing actual work (unless you do it only for yourself, of course).
This will help to coordinate activities and you'll also get hints
about where to start and possible pitfalls.
1. Fork the repository.
1. Do the changes in your repository.
1. Submit a pull request to the original project.
1. Post in the forum again (to make sure the pull request is being notified).
Questions, discussion
=====================
We're happy to see questions, discussions etc. in the
[DAVx⁵ development forum](https://www.davx5.com/forums)!
Licensing
=========
All code has to be licensed under the GPL.
We (bitfire.at, initial developers) are also asking you to double-license the
code so that we can also use it for related non-open source projects like
[Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5).
Please find more about this in the Contributor's License Agreement (CLA)
we'll send to you if you want to contribute.
+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)
+56 -127
View File
@@ -7,169 +7,98 @@
*/
apply plugin: 'com.android.application'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jetbrains.dokka'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 30
buildToolsVersion '30.0.3'
compileSdkVersion 26
buildToolsVersion '26.0.1'
defaultConfig {
applicationId "at.bitfire.davdroid"
versionCode 303120002
minSdkVersion 16
targetSdkVersion 25
versionCode 172
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
minSdkVersion 21 // Android 5
targetSdkVersion 30 // Android 11
buildConfigField "String", "userAgent", "\"DAVx5\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
buildConfigField "boolean", "customCerts", "true"
}
compileOptions {
// enable because ical4android requires desugaring
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding = true
dataBinding = true
}
flavorDimensions "distribution"
productFlavors {
standard {
versionName "3.3.12-beta3-ose"
versionName "1.8.1-ose"
buildConfigField "boolean", "customCerts", "true"
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
buildTypes {
debug {
minifyEnabled false
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro'
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
}
}
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
disable 'RtlEnabled', 'RtlHardcoded' // RTL not supported yet
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
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'
disable 'NullSafeMutableLiveData' // crashes lint since Mar 2021
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
}
dokka.configuration {
sourceLink {
url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/"
lineSuffix = "#L"
}
jdkVersion = 8
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/")
packageListUrl = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/package-list")
}
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/")
packageListUrl = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/package-list")
}
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/")
packageListUrl = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/package-list")
}
externalDocumentationLink {
url = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/")
packageListUrl = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/package-list")
}
}
}
aboutLibraries {
additionalLicenses {
mpl_2_0
defaultConfig {
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 "org.jetbrains.kotlin:kotlin-stdlib-jdk7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.browser:browser:1.3.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.fragment:fragment-ktx:1.3.5'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'androidx.paging:paging-runtime-ktx:3.0.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.google.android:flexbox:2.0.1'
implementation 'com.google.android.material:material:1.4.0'
compile 'com.android.support:appcompat-v7:26.0.1'
compile 'com.android.support:cardview-v7:26.0.1'
compile 'com.android.support:design:26.0.1'
compile 'com.android.support:preference-v14:26.0.1'
def room_version = '2.3.0'
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
compile 'com.github.yukuku:ambilwarna:2.0.1'
implementation 'com.jaredrummler:colorpicker:1.1.0'
implementation "com.github.AppIntro:AppIntro:${versions.appIntro}"
implementation "com.gitlab.bitfireAT:dav4jvm:${versions.dav4jvm}"
implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}"
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}"
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
implementation 'commons-io:commons-io:2.6'
//noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7
implementation 'dnsjava:dnsjava:2.1.9'
//noinspection GradleDependency
implementation "org.apache.commons:commons-collections4:${versions.commonsCollections}"
//noinspection GradleDependency
implementation "org.apache.commons:commons-lang3:${versions.commonsLang}"
//noinspection GradleDependency
implementation "org.apache.commons:commons-text:${versions.commonsText}"
compile 'com.squareup.okhttp3:logging-interceptor:3.9.0'
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.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
androidTestImplementation 'junit:junit:4.13.2'
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.9.0'
testImplementation 'junit:junit:4.13.2'
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
testCompile 'junit:junit:4.12'
testCompile 'com.squareup.okhttp3:mockwebserver:3.9.0'
}
-25
View File
@@ -1,25 +0,0 @@
# R8 usage for DAVx⁵:
# shrinking yes (only in release builds)
# optimization yes (on by R8 defaults)
# obfuscation no (open-source)
-dontobfuscate
-printusage build/reports/r8-usage.txt
# ez-vcard: keep all vCard properties/parameters (used via reflection)
-keep class ezvcard.io.scribe.** { *; }
-keep class ezvcard.property.** { *; }
-keep class ezvcard.parameter.** { *; }
# ical4j: keep all iCalendar properties/parameters (used via reflection)
-keep class net.fortuna.ical4j.** { *; }
# DAVx + libs
-keep class at.bitfire.** { *; } # all DAVx code is required
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
+42
View File
@@ -0,0 +1,42 @@
# ProGuard usage for DAVdroid:
# shrinking yes (main reason for using ProGuard)
# optimization yes
# obfuscation no (DAVdroid is open-source)
# preverification no
-dontobfuscate
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
-optimizationpasses 5
-allowaccessmodification
-dontpreverify
# Kotlin
-dontwarn kotlin.**
# ez-vcard
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
# ical4j: ignore unused dynamic libraries
-dontwarn aQute.**
-dontwarn groovy.** # Groovy-based ContentBuilder not used
-dontwarn net.fortuna.ical4j.model.**
-dontwarn org.codehaus.groovy.**
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
# okhttp
-dontwarn okio.**
-dontwarn javax.annotation.Nullable
-dontwarn javax.annotation.ParametersAreNonnullByDefault
# dnsjava
-dontwarn sun.net.spi.nameservice.** # not available on Android
# DAVdroid + libs
-keep class at.bitfire.** { *; } # all DAVdroid code is required
@@ -1,298 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "b8699ef3cc4c62e8851df4360fb69e00",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b8699ef3cc4c62e8851df4360fb69e00')"
]
}
}
@@ -1,366 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "7e4bfdf7f9fa3529c333cf9485f8cf50",
"entities": [
{
"tableName": "service",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountName",
"columnName": "accountName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "principal",
"columnName": "principal",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_service_accountName_type",
"unique": true,
"columnNames": [
"accountName",
"type"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
}
],
"foreignKeys": []
},
{
"tableName": "homeset",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "personal",
"columnName": "personal",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privBind",
"columnName": "privBind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_homeset_serviceId_url",
"unique": true,
"columnNames": [
"serviceId",
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "collection",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "homeSetId",
"columnName": "homeSetId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "privWriteContent",
"columnName": "privWriteContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "privUnbind",
"columnName": "privUnbind",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forceReadOnly",
"columnName": "forceReadOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "owner",
"columnName": "owner",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "supportsVEVENT",
"columnName": "supportsVEVENT",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVTODO",
"columnName": "supportsVTODO",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "supportsVJOURNAL",
"columnName": "supportsVJOURNAL",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sync",
"columnName": "sync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_collection_serviceId_type",
"unique": false,
"columnNames": [
"serviceId",
"type"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
},
{
"name": "index_collection_homeSetId_type",
"unique": false,
"columnNames": [
"homeSetId",
"type"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
},
{
"name": "index_collection_url",
"unique": false,
"columnNames": [
"url"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
}
],
"foreignKeys": [
{
"table": "service",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serviceId"
],
"referencedColumns": [
"id"
]
},
{
"table": "homeset",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"homeSetId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "syncstats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "collectionId",
"columnName": "collectionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authority",
"columnName": "authority",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSync",
"columnName": "lastSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_syncstats_collectionId_authority",
"unique": true,
"columnNames": [
"collectionId",
"authority"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
}
],
"foreignKeys": [
{
"table": "collection",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"collectionId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e4bfdf7f9fa3529c333cf9485f8cf50')"
]
}
}
-1
View File
@@ -1 +0,0 @@
espressoTest
-28
View File
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="at.bitfire.davdroid"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="internalOnly">
<!-- account management permissions not required for own accounts since API level 22 -->
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
<application>
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/test_account_authenticator"/>
</service>
</application>
</manifest>
@@ -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,63 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import 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 javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import at.bitfire.cert4android.CustomCertManager;
import okhttp3.mockwebserver.MockWebServer;
import static android.support.test.InstrumentationRegistry.getInstrumentation;
import static android.support.test.InstrumentationRegistry.getTargetContext;
import static junit.framework.TestCase.assertFalse;
import static org.apache.commons.lang3.ArrayUtils.contains;
import static org.junit.Assert.assertTrue;
public class SSLSocketFactoryCompatTest {
CustomCertManager certMgr;
SSLSocketFactoryCompat factory;
MockWebServer server = new MockWebServer();
@Before
public void startServer() throws Exception {
certMgr = new CustomCertManager(getInstrumentation().getContext(), true, null);
factory = new SSLSocketFactoryCompat(certMgr);
server.start();
}
@After
public void stopServer() throws Exception {
server.shutdown();
certMgr.close();
}
@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"));
}
}
@@ -1,38 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
class AppDatabaseTest {
val TEST_DB = "test"
@Rule
@JvmField
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
fun testAllMigrations() {
// DB schema is available since version 8
helper.createDatabase(TEST_DB, 8).close()
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
TEST_DB
).addMigrations(*AppDatabase.migrations).build().apply {
openHelper.writableDatabase
close()
}
}
}
@@ -0,0 +1,133 @@
/*
* 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.After;
import org.junit.Before;
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 {
HttpClient httpClient;
MockWebServer server = new MockWebServer();
@Before
public void setUp() {
httpClient = new HttpClient.Builder().build();
}
@After
public void shutDown() {
httpClient.close();
}
@Test
public void testFromDavResource() throws IOException, HttpException, DavException {
// r/w address book
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.getOkHttpClient(), 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.getOkHttpClient(), 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.TYPE, CollectionInfo.Type.CALENDAR.name());
values.put(Collections.URL, "http://example.com");
values.put(Collections.READ_ONLY, 1);
values.put(Collections.DISPLAY_NAME, "display name");
values.put(Collections.DESCRIPTION, "description");
values.put(Collections.COLOR, 0xFFFF0000);
values.put(Collections.TIME_ZONE, "tzdata");
values.put(Collections.SUPPORTS_VEVENT, 1);
values.put(Collections.SUPPORTS_VTODO, 1);
values.put(Collections.SYNC, 1);
CollectionInfo info = new CollectionInfo(values);
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
assertEquals(1, (long)info.getId());
assertEquals(1, (long)info.getServiceID());
assertEquals("http://example.com", info.getUrl());
assertTrue(info.getReadOnly());
assertEquals("display name", info.getDisplayName());
assertEquals("description", info.getDescription());
assertEquals(0xFFFF0000, (int)info.getColor());
assertEquals("tzdata", info.getTimeZone());
assertTrue(info.getSupportsVEVENT());
assertTrue(info.getSupportsVTODO());
assertTrue(info.getSelected());
}
}
@@ -1,134 +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 androidx.test.filters.SmallTest
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class CollectionTest {
private lateinit var httpClient: HttpClient
private val server = MockWebServer()
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
}
@After
fun shutDown() {
httpClient.close()
}
@Test
@SmallTest
fun testFromDavResponseAddressBook() {
// r/w address book
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
assertTrue(info.privWriteContent)
assertTrue(info.privUnbind)
assertNull(info.supportsVEVENT)
assertNull(info.supportsVTODO)
assertNull(info.supportsVJOURNAL)
assertEquals("My Contacts", info.displayName)
assertEquals("My Contacts Description", info.description)
}
@Test
@SmallTest
fun testFromDavResponseCalendar() {
// read-only calendar, no display name
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_CALENDAR, info.type)
assertFalse(info.privWriteContent)
assertFalse(info.privUnbind)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timezone)
assertTrue(info.supportsVEVENT!!)
assertTrue(info.supportsVTODO!!)
assertTrue(info.supportsVJOURNAL!!)
}
@Test
@SmallTest
fun testFromDavResponseWebcal() {
// Webcal subscription
server.enqueue(MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
"<response>" +
" <href>/webcal1</href>" +
" <propstat><prop>" +
" <displayname>Sample Subscription</displayname>" +
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"))
lateinit var info: Collection
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME) { response, _ ->
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
}
assertEquals(Collection.TYPE_WEBCAL, info.type)
assertEquals("Sample Subscription", info.displayName)
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
}
}
@@ -1,64 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class DaoToolsTest {
private lateinit var db: AppDatabase
@Before
fun createDb() {
val context = InstrumentationRegistry.getInstrumentation().context
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
}
@After
fun closeDb() {
db.close()
}
@Test
fun testSyncAll() {
val serviceDao = db.serviceDao()
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
service.id = serviceDao.insertOrReplace(service)
val homeSetDao = db.homeSetDao()
val entry1 = HomeSet(id=1, serviceId=service.id, personal=true, url= "https://example.com/1".toHttpUrl())
val entry3 = HomeSet(id=3, serviceId=service.id, personal=true, url= "https://example.com/3".toHttpUrl())
val oldItems = listOf(
entry1,
HomeSet(id=2, serviceId=service.id, personal=true, url= "https://example.com/2".toHttpUrl()),
entry3
)
homeSetDao.insert(oldItems)
val newItems = mutableMapOf<HttpUrl, HomeSet>()
newItems[entry1.url] = entry1
// no id, because identity is given by the url
val updated = HomeSet(id=0, serviceId=service.id, personal=true,
url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry")
newItems[updated.url] = updated
val created = HomeSet(id=4, serviceId=service.id, personal=true, url= "https://example.com/4".toHttpUrl())
newItems[created.url] = created
DaoTools(homeSetDao).syncAll(oldItems, newItems, { it.url })
val afterSync = homeSetDao.getByService(service.id)
assertEquals(afterSync.size, 3)
assertFalse(afterSync.contains(entry3))
assertTrue(afterSync.contains(entry1))
assertTrue(afterSync.contains(updated))
assertTrue(afterSync.contains(created))
}
}
@@ -1,72 +0,0 @@
package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.os.Build
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.syncadapter.AccountUtils
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.util.concurrent.TimeUnit
class AccountSettingsTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account("Test Account", context.getString(R.string.account_type))
val fakeCredentials = Credentials("test", "test")
@Before
fun prepareAccount() {
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
}
@After
fun removeAccount() {
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
}
@Test
fun testSyncIntervals() {
val settings = AccountSettings(context, account)
val presetIntervals = context.resources.getStringArray(R.array.settings_sync_interval_seconds)
.map { it.toLong() }
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
for (interval in presetIntervals) {
assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval))
assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY))
}
}
@Test
fun testSyncIntervals_IsNotSyncable() {
val settings = AccountSettings(context, account)
val interval = 15*60L // 15 min
val result = settings.setSyncInterval(ContactsContract.AUTHORITY, interval)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason
assertFalse(result)
}
@Test
fun testSyncIntervals_TooShort() {
val settings = AccountSettings(context, account)
val interval = 60L // 1 min is not supported by Android
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason
assertFalse(result)
}
}
@@ -1,28 +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.Test
class SettingsManagerTest {
val settingsManager by lazy { SettingsManager.getInstance(InstrumentationRegistry.getInstrumentation().targetContext) }
@Test
fun testContainsKey() {
assertFalse(settingsManager.containsKey("notExisting"))
// provided by DefaultsProvider
assertTrue(settingsManager.containsKey(Settings.OVERRIDE_PROXY))
}
}
@@ -1,36 +0,0 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.TimeUnit
class AccountUtilsTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account("Test Account", context.getString(R.string.account_type))
@Test
fun testCreateAccount() {
val userData = Bundle(2)
userData.putString("int", "1")
userData.putString("string", "abc/\"-")
try {
assertTrue(AccountUtils.createAccount(context, account, userData))
// validate user data
val manager = AccountManager.get(context)
assertEquals("1", manager.getUserData(account, "int"))
assertEquals("abc/\"-", manager.getUserData(account, "string"))
} finally {
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
}
}
}
@@ -1,38 +0,0 @@
package at.bitfire.davdroid.syncadapter
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalCollection
class LocalTestCollection: LocalCollection<LocalTestResource> {
override val tag = "LocalTestCollection"
override val title = "Local Test Collection"
override var lastSyncState: SyncState? = null
val entries = mutableListOf<LocalTestResource>()
override fun findDeleted() = entries.filter { it.deleted }
override fun findDirty() = entries.filter { it.dirty }
override fun findByName(name: String) = entries.filter { it.fileName == name }.firstOrNull()
override fun markNotDirty(flags: Int): Int {
var updated = 0
for (dirty in findDirty()) {
dirty.flags = flags
updated++
}
return updated
}
override fun removeNotDirtyMarked(flags: Int): Int {
val numBefore = entries.size
entries.removeIf { !it.dirty && it.flags == flags }
return numBefore - entries.size
}
override fun forgetETags() {
}
}
@@ -1,43 +0,0 @@
package at.bitfire.davdroid.syncadapter
import android.net.Uri
import at.bitfire.davdroid.resource.LocalResource
class LocalTestResource: LocalResource<Any> {
override val id: Long? = null
override var fileName: String? = null
override var eTag: String? = null
override var scheduleTag: String? = null
override var flags: Int = 0
var deleted = false
var dirty = false
override fun prepareForUpload() = "generated-file.txt"
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
dirty = false
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
override fun updateFlags(flags: Int) {
this.flags = flags
}
override fun add(): Uri {
TODO("Not yet implemented")
}
override fun update(data: Any): Uri {
TODO("Not yet implemented")
}
override fun delete(): Int {
TODO("Not yet implemented")
}
}
@@ -1,31 +0,0 @@
package at.bitfire.davdroid.syncadapter
import android.os.Bundle
import androidx.test.filters.SmallTest
import at.bitfire.davdroid.syncadapter.SyncAdapterService.SyncAdapter
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SyncAdapterServiceTest {
@Test
@SmallTest
fun testPriorityCollections() {
val extras = Bundle()
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "")
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123")
assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,")
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3")
assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray())
}
}
@@ -1,437 +0,0 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.SyncResult
import android.os.Bundle
import androidx.core.app.NotificationManagerCompat
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.PropStat
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.settings.AccountSettings
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.*
import org.junit.Assert.*
import java.util.concurrent.TimeUnit
class SyncManagerTest {
companion object {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val account = Account("SyncManagerTest", context.getString(R.string.account_type))
@BeforeClass
@JvmStatic
fun createAccount() {
assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"))))
}
@AfterClass
@JvmStatic
fun removeAccount() {
assertTrue(AccountManager.get(context).removeAccount(account, null, null).getResult(10, TimeUnit.SECONDS))
// clear annoying syncError notifications
NotificationManagerCompat.from(context).cancelAll()
}
}
val server = MockWebServer()
private fun syncManager(collection: LocalTestCollection) =
TestSyncManager(
context,
account,
Bundle(),
"TestAuthority",
SyncResult(),
collection,
server
)
@Before
fun startServer() {
server.start()
}
@After
fun stopServer() {
server.close()
}
private fun queryCapabilitiesResponse(cTag: String? = null): MockResponse {
val body = StringBuilder()
body.append("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
"<multistatus xmlns=\"DAV:\" xmlns:CALDAV=\"http://calendarserver.org/ns/\">\n" +
" <response>\n" +
" <href>/</href>\n" +
" <propstat>\n" +
" <prop>\n")
if (cTag != null)
body.append("<CALDAV:getctag>$cTag</CALDAV:getctag>\n")
body.append(
" </prop>\n" +
" </propstat>\n" +
" </response>\n" +
"</multistatus>")
val response = MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/xml")
.setBody(body.toString())
return response
}
@Test
fun testPerformSync_FirstSync_Empty() {
val collection = LocalTestCollection() /* no last known ctag */
server.enqueue(queryCapabilitiesResponse())
val syncManager = syncManager(collection)
syncManager.performSync()
assertFalse(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertFalse(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertTrue(collection.entries.isEmpty())
}
@Test
fun testPerformSync_UploadNewMember_ETagOnPut() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
dirty = true
}
}
server.enqueue(queryCapabilitiesResponse("ctag1"))
// PUT -> 204 No Content
server.enqueue(MockResponse()
.setResponseCode(204)
.setHeader("ETag", "etag-from-put"))
// modifications sent, so DAVx5 will query CTag again
server.enqueue(queryCapabilitiesResponse("ctag2"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/generated-file.txt"),
null,
listOf(PropStat(
listOf(
GetETag("\"etag-from-put\"")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
}
syncManager.performSync()
assertTrue(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertFalse(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("etag-from-put", collection.entries.first().eTag)
}
@Test
fun testPerformSync_UploadModifiedMember_ETagOnPut() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
fileName = "existing-file.txt"
eTag = "old-etag-like-on-server"
dirty = true
}
}
server.enqueue(queryCapabilitiesResponse("ctag1"))
// PUT -> 204 No Content
server.enqueue(MockResponse()
.setResponseCode(204)
.addHeader("ETag", "etag-from-put"))
// modifications sent, so DAVx5 will query CTag again
server.enqueue(queryCapabilitiesResponse("ctag2"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/existing-file.txt"),
null,
listOf(PropStat(
listOf(
GetETag("etag-from-put")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-put"))
}
syncManager.performSync()
assertTrue(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertFalse(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("etag-from-put", collection.entries.first().eTag)
}
@Test
fun testPerformSync_UploadModifiedMember_NoETagOnPut() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
fileName = "existing-file.txt"
eTag = "old-etag-like-on-server"
dirty = true
}
}
server.enqueue(queryCapabilitiesResponse("ctag1"))
// PUT -> 204 No Content
server.enqueue(MockResponse().setResponseCode(204))
// modifications sent, so DAVx5 will query CTag again
server.enqueue(queryCapabilitiesResponse("ctag2"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/existing-file.txt"),
null,
listOf(PropStat(
listOf(
GetETag("etag-from-propfind")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-propfind"))
}
syncManager.performSync()
assertTrue(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertTrue(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("etag-from-propfind", collection.entries.first().eTag)
}
@Test
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
fileName = "existing-file.txt"
eTag = "etag-that-has-been-changed-on-server-in-the-meanwhile"
dirty = true
}
}
server.enqueue(queryCapabilitiesResponse("ctag1"))
// PUT -> 412 Precondition Failed
server.enqueue(MockResponse()
.setResponseCode(412))
// modifications sent, so DAVx5 will query CTag again
server.enqueue(queryCapabilitiesResponse("ctag1"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/existing-file.txt"),
null,
listOf(PropStat(
listOf(
GetETag("changed-etag-from-server")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "changed-etag-from-server"))
}
syncManager.performSync()
assertTrue(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertTrue(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("changed-etag-from-server", collection.entries.first().eTag)
}
@Test
fun testPerformSync_NoopOnMemberWithSameETag() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
entries += LocalTestResource().apply {
fileName = "downloaded-member.txt"
eTag = "MemberETag1"
}
}
server.enqueue(queryCapabilitiesResponse("ctag2"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/downloaded-member.txt"),
null,
listOf(PropStat(
listOf(
GetETag("\"MemberETag1\"")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
}
syncManager.performSync()
assertFalse(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertFalse(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("MemberETag1", collection.entries.first().eTag)
}
@Test
fun testPerformSync_DownloadNewMember() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
}
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/new-member.txt"),
null,
listOf(PropStat(
listOf(
GetETag("\"NewMemberETag1\"")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
assertDownloadRemote = mapOf(Pair(server.url("/new-member.txt"), "NewMemberETag1"))
}
syncManager.performSync()
assertFalse(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertTrue(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("NewMemberETag1", collection.entries.first().eTag)
}
@Test
fun testPerformSync_DownloadUpdatedMember() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
fileName = "downloaded-member.txt"
eTag = "MemberETag1"
}
}
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
val syncManager = syncManager(collection).apply {
listAllRemoteResult = listOf(
Pair(Response(
server.url("/"),
server.url("/downloaded-member.txt"),
null,
listOf(PropStat(
listOf(
GetETag("\"MemberETag2\"")
),
StatusLine(Protocol.HTTP_1_1, 200, "OK")
)
)), HrefRelation.MEMBER)
)
assertDownloadRemote = mapOf(Pair(server.url("/downloaded-member.txt"), "MemberETag2"))
}
syncManager.performSync()
assertFalse(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertTrue(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertEquals(1, collection.entries.size)
assertEquals("MemberETag2", collection.entries.first().eTag)
}
@Test
fun testPerformSync_RemoveVanishedMember() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
entries += LocalTestResource().apply {
fileName = "downloaded-member.txt"
}
}
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
val syncManager = syncManager(collection)
syncManager.performSync()
assertFalse(syncManager.didGenerateUpload)
assertTrue(syncManager.didListAllRemote)
assertFalse(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertTrue(collection.entries.isEmpty())
}
@Test
fun testPerformSync_CTagDidntChange() {
val collection = LocalTestCollection().apply {
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
}
server.enqueue(queryCapabilitiesResponse("ctag1"))
val syncManager = syncManager(collection)
syncManager.performSync()
assertFalse(syncManager.didGenerateUpload)
assertFalse(syncManager.didListAllRemote)
assertFalse(syncManager.didDownloadRemote)
assertFalse(syncManager.syncResult.hasError())
assertTrue(collection.entries.isEmpty())
}
}
@@ -1,100 +0,0 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
import okhttp3.HttpUrl
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
class TestSyncManager(
context: Context,
account: Account,
extras: Bundle,
authority: String,
syncResult: SyncResult,
localCollection: LocalTestCollection,
val mockWebServer: MockWebServer
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(context, account, AccountSettings(context, account), extras, authority, syncResult, localCollection) {
override fun prepare(): Boolean {
collectionURL = mockWebServer.url("/")
davCollection = DavCollection(httpClient.okHttpClient, collectionURL)
return true
}
var didQueryCapabilities = false
override fun queryCapabilities(): SyncState? {
if (didQueryCapabilities)
throw IllegalStateException("queryCapabilities() must not be called twice")
didQueryCapabilities = true
var cTag: SyncState? = null
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
if (rel == Response.HrefRelation.SELF)
response[GetCTag::class.java]?.cTag?.let {
cTag = SyncState(SyncState.Type.CTAG, it)
}
}
return cTag
}
var didGenerateUpload = false
override fun generateUpload(resource: LocalTestResource): RequestBody {
didGenerateUpload = true
return resource.toString().toRequestBody()
}
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
var listAllRemoteResult = emptyList<Pair<Response, Response.HrefRelation>>()
var didListAllRemote = false
override fun listAllRemote(callback: DavResponseCallback) {
if (didListAllRemote)
throw IllegalStateException("listAllRemote() must not be called twice")
didListAllRemote = true
for (result in listAllRemoteResult)
callback(result.first, result.second)
}
var assertDownloadRemote = emptyMap<HttpUrl, String>()
var didDownloadRemote = false
override fun downloadRemote(bunch: List<HttpUrl>) {
didDownloadRemote = true
assertEquals(assertDownloadRemote.keys.toList(), bunch)
for ((url, eTag) in assertDownloadRemote) {
val fileName = DavUtils.lastSegmentOfUrl(url)
var localEntry = localCollection.entries.filter { it.fileName == fileName }.firstOrNull()
if (localEntry == null) {
val newEntry = LocalTestResource().also {
it.fileName = fileName
}
localCollection.entries += newEntry
localEntry = newEntry
}
localEntry.eTag = eTag
localEntry.flags = LocalResource.FLAG_REMOTELY_PRESENT
}
}
override fun postProcess() {
}
override fun notifyInvalidResourceTitle(): String {
TODO("Not yet implemented")
}
}
@@ -1,205 +0,0 @@
package at.bitfire.davdroid.ui
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import androidx.test.espresso.*
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.contrib.DrawerMatchers.isClosed
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.util.HumanReadables
import androidx.test.espresso.util.TreeIterables
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.filters.LargeTest
import at.bitfire.davdroid.R
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeoutException
@LargeTest
class AccountsActivityEspressoTest {
@get:Rule
val activityScenarioRule = activityScenarioRule<AccountsActivity>()
private val username = "test"
private val password = "test"
private val baseUrl = "https://davtest.dev001.net/radicale/htpasswd/"
@Test
fun accountsActivityTest() {
skipIntroActivity()
onView(withId(R.id.fab)).perform(click())
// open first the option for Login with Base URL and then enter the test-data and confirm
onView(withText(R.string.login_type_url)).perform(click())
onView(withId(R.id.loginUrlBaseUrlEdittext)).perform(typeText(baseUrl), ViewActions.closeSoftKeyboard())
onView(withId(R.id.loginUrlUsernameEdittext)).perform(typeText(username), ViewActions.closeSoftKeyboard())
onView(withId(R.id.loginUrlPasswordEdittext)).perform(typeText(password), ViewActions.closeSoftKeyboard())
onView(withId(R.id.login)).perform(click())
// The detect configuration screen (detect_configuration.xml) is not asserted here, it's just skipped.
// login_account_details.xml is the next expected fragment, check if the expected headline appears and then click on the "Create Account" button
onView(isRoot()).perform(waitForView(R.id.accountName, 30000))
onView(withText(R.string.login_account_name_info)).check(matches(isDisplayed()))
onView(withId(R.id.create_account)).perform(click())
Thread.sleep(1000)
// check if the "test" calendar appeared
onView(isRoot()).perform(waitForView(R.id.title, 5000))
//onView(withId(R.id.title)).perform(waitForText("Test Addressbook", 30000))
onView(withText("Test Addressbook")).check(matches(withText("Test Addressbook")))
// Go back to the overview with all Accounts
Espresso.pressBack()
//Thread.sleep(2000)
// check if the account exists and click on it
onView(isRoot()).perform(waitForView((R.id.account_name), 5000))
onView(withText("test")).check(matches(withText("test")))
onView(withText("test")).perform(click())
// open the overflowMenu to delete the account
val overflowMenuButton = onView(
Matchers.allOf(withContentDescription("More options"),
childAtPosition(
childAtPosition(
withId(R.id.toolbar),
2),
1),
isDisplayed()))
overflowMenuButton.perform(click())
// click on the delete button
onView(withText(R.string.account_delete)).perform(click())
onView(withText(R.string.account_delete_confirmation_title)).check(matches(isDisplayed()))
// confirm deletion by clicking on YES
onView(withId(android.R.id.button1)).perform(click())
// doublecheck to make sure that the account doesn't exist anymore. The welcome text is displayed
onView(withText(R.string.account_list_empty)).check(matches(withText(R.string.account_list_empty)))
}
@Test
fun menuDrawerTest() {
skipIntroActivity()
// TESTING ABOUT DIALOG
// Open Drawer to click on navigation.
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.LEFT))) // Left Drawer should be closed.
.perform(DrawerActions.open()) // Open Drawer
// check if about can be opened
onView(withText(R.string.navigation_drawer_about)).perform(click())
onView(withText(R.string.about_copyright)).check(matches(isDisplayed()))
Espresso.pressBack()
// TESTING SETTINGS DIALOG
// Open Drawer to click on navigation.
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.LEFT))) // Left Drawer should be closed.
.perform(DrawerActions.open()) // Open Drawer
// check if about can be opened
onView(withText(R.string.navigation_drawer_settings)).perform(click())
onView(withText(R.string.app_settings_show_debug_info)).check(matches(isDisplayed()))
Espresso.pressBack()
// TESTING WEBSITE MENU ENTRY
// Open Drawer to click on navigation.
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.LEFT))) // Left Drawer should be closed.
.perform(DrawerActions.open()) // Open Drawer
// check if Website can be opened
//onView(withText(R.string.navigation_drawer_website)).perform(click())
}
private fun childAtPosition(
parentMatcher: Matcher<View>, position: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("Child at position $position in parent ")
parentMatcher.describeTo(description)
}
public override fun matchesSafely(view: View): Boolean {
val parent = view.parent
return parent is ViewGroup && parentMatcher.matches(parent)
&& view == parent.getChildAt(position)
}
}
}
private fun skipIntroActivity() {
try {
onView(withId(R.id.takeControl)).check(matches(withText(R.string.intro_slogan2))) // intro_welcome is the first fragment, check first if the String Resource "intro_slogan2" is shown.
// click through up to 5 intro fragments
for (i in 1..5)
try {
onView(withId(R.id.next)).perform(click())
} catch (ignored: Exception) { }
onView(withId(R.id.done)).perform(click())
} catch (ignored: NoMatchingViewException) {
// the IntroActivity or some fragments of it may not show up every time
}
onView(withText(R.string.account_list_empty)).check(matches(isDisplayed()))
}
/**
* This ViewAction tells espresso to wait till a certain view is found in the view hierarchy.
* Source: https://www.repeato.app/espresso-wait-for-view/
* @param viewId The id of the view to wait for.
* @param timeout The maximum time which espresso will wait for the view to show up (in milliseconds)
*/
private fun waitForView(viewId: Int, timeout: Long): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> {
return isRoot()
}
override fun getDescription(): String {
return "wait for a specific view with id $viewId; during $timeout millis."
}
override fun perform(uiController: UiController, rootView: View) {
uiController.loopMainThreadUntilIdle()
val startTime = System.currentTimeMillis()
val endTime = startTime + timeout
val viewMatcher = withId(viewId)
do {
// Iterate through all views on the screen and see if the view we are looking for is there already
for (child in TreeIterables.breadthFirstViewTraversal(rootView)) {
// found view with required ID
if (viewMatcher.matches(child)) {
return
}
}
// Loops the main thread for a specified period of time.
// Control may not return immediately, instead it'll return after the provided delay has passed and the queue is in an idle state again.
uiController.loopMainThreadForAtLeast(100)
} while (System.currentTimeMillis() < endTime) // in case of a timeout we throw an exception -&gt; test fails
throw PerformException.Builder()
.withCause(TimeoutException())
.withActionDescription(this.description)
.withViewDescription(HumanReadables.describe(rootView))
.build()
}
}
}
}
@@ -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.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;
HttpClient 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 = new HttpClient.Builder()
.addAuthentication(null, credentials.getUserName(), credentials.getPassword())
.build();
}
@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.getOkHttpClient(), 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.getOkHttpClient(), 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,208 +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 androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.AddressbookHomeSet
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.URI
class DavResourceFinderTest {
companion object {
private const val PATH_NO_DAV = "/nodav"
private const val PATH_CALDAV = "/caldav"
private const val PATH_CARDDAV = "/carddav"
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
private const val SUBPATH_PRINCIPAL = "/principal"
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
}
val server = MockWebServer()
lateinit var finder: DavResourceFinder
lateinit var client: HttpClient
lateinit var loginModel: LoginModel
@Before
fun initServerAndClient() {
server.dispatcher = TestDispatcher()
server.start()
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
client = HttpClient.Builder()
.addAuthentication(null, loginModel.credentials!!)
.build()
}
@After
fun stopServer() {
server.shutdown()
}
@Test
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
// recognize address book
info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
}
@Test
fun testProvidesService() {
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
}
@Test
fun testGetCurrentUserPrincipal() {
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
assertEquals(
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
)
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
}
@Test
fun testQueryEmailAddress() {
var info = ServiceInfo()
assertArrayEquals(
arrayOf("email1@example.com", "email2@example.com"),
finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray()
)
assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty())
}
// mock server
class TestDispatcher: Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (!checkAuth(request)) {
val authenticate = MockResponse().setResponseCode(401)
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
return authenticate
}
val path = request.path!!
if (request.method.equals("OPTIONS", true)) {
val dav = when {
path.startsWith(PATH_CALDAV) -> "calendar-access"
path.startsWith(PATH_CARDDAV) -> "addressbook"
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
else -> null
}
val response = MockResponse().setResponseCode(200)
if (dav != null)
response.addHeader("DAV", dav)
return response
} else if (request.method.equals("PROPFIND", true)) {
val props: String?
when (path) {
PATH_CALDAV,
PATH_CARDDAV ->
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
props = "<CARD:addressbook-home-set>" +
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
"</CARD:addressbook-home-set>"
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
props = "<resourcetype>" +
" <collection/>" +
" <CARD:addressbook/>" +
"</resourcetype>"
PATH_CALDAV + SUBPATH_PRINCIPAL ->
props = "<CAL:calendar-user-address-set>" +
" <href>urn:unknown-entry</href>" +
" <href>mailto:email1@example.com</href>" +
" <href>mailto:email2@example.com</href>" +
"</CAL:calendar-user-address-set>"
else -> props = null
}
Logger.log.info("Sending props: $props")
return MockResponse()
.setResponseCode(207)
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
"<response>" +
" <href>${request.path}</href>" +
" <propstat><prop>$props</prop></propstat>" +
"</response>" +
"</multistatus>")
}
return MockResponse().setResponseCode(404)
}
private fun checkAuth(rq: RecordedRequest) =
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Ricki Hirner (bitfire web engineering).
~ 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
@@ -9,6 +9,6 @@
<resources>
<string name="app_name">Davx5Test</string>
<string name="app_name">DavdroidTest</string>
</resources>
@@ -1,3 +0,0 @@
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="at.bitfire.davdroid.SyncManagerTest"
android:label="Test Account" />
+109 -140
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"
@@ -7,12 +14,10 @@
<!-- normal permissions -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- account management permissions not required for own accounts since API level 22 -->
@@ -20,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"/>
@@ -28,113 +40,43 @@
<uses-permission android:name="android.permission.READ_CALENDAR"/>
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
<!-- android.permission-group.LOCATION -->
<!-- getting the WiFi name (for "sync in Wifi only") requires
- coarse location (Android 8.1)
- fine location (Android 10) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- ical4android declares task access permissions -->
<application
android:name=".App"
android:allowBackup="false"
android:allowBackup="true"
android:fullBackupContent="false"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:resizeableActivity="true"
tools:ignore="UnusedAttribute"
android:supportsRtl="true">
tools:ignore="UnusedAttribute">
<service android:name=".DavService"/>
<service android:name=".ForegroundService"/>
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />
<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"/>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.PACKAGE_REPLACED" />
<data android:scheme="package" android:path="at.bitfire.davdroid" />
</intent-filter>
</activity>
<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"/>
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".ui.PermissionsActivity"
android:label="@string/app_settings_security_app_permissions"
android:parentActivityName=".ui.AppSettingsActivity" />
<activity
android:name=".ui.TasksActivity"
android:label="@string/intro_tasks_title"
android:parentActivityName=".ui.AppSettingsActivity" />
</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.account.CreateAddressBookActivity"
android:label="@string/create_addressbook"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateCalendarActivity"
android:label="@string/create_calendar"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.SettingsActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.WifiPermissionsActivity"
android:label="@string/wifi_permissions_label"
android:parentActivityName=".ui.account.SettingsActivity" />
<!-- account type "DAVx⁵" -->
<!-- account type "DAVdroid" -->
<service
android:name=".syncadapter.AccountAuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/account_authenticator"/>
@@ -142,41 +84,35 @@
<service
android:name=".syncadapter.CalendarsSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
</service>
<service
android:name=".syncadapter.OpenTasksSyncAdapterService"
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_opentasks"/>
</service>
<service
android:name=".syncadapter.TasksOrgSyncAdapterService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks_org"/>
android:resource="@xml/sync_tasks"/>
</service>
<!-- account type "DAVx⁵ Address book" -->
<!-- account type "DAVdroid Address book" -->
<service
android:name=".syncadapter.NullAuthenticatorService"
android:exported="true"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator"/>
</intent-filter>
@@ -194,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"/>
@@ -206,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"/>
@@ -219,54 +157,85 @@
android:resource="@xml/contacts"/>
</service>
<receiver android:name=".BootCompletedReceiver">
<service
android:name=".DavService"
android:enabled="true">
</service>
<receiver android:name=".AccountsChangedReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
</intent-filter>
</receiver>
<!-- provider to share debug info/logs -->
<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="androidx.core.content.FileProvider"
android:authorities="@string/authority_debug_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/debug_paths" />
android:resource="@xml/log_paths" />
</provider>
</application>
<!-- package visiblity which apps do we need to see? -->
<queries>
<!-- task providers -->
<package android:name="org.dmfs.tasks" />
<package android:name="org.tasks" />
<!-- ICSx5 for Webcal feeds -->
<package android:name="at.bitfire.icsdroid"/>
<!-- apps that interact with contact, calendar, task data (for debug info) -->
<intent>
<action android:name="*" />
<data android:scheme="content" android:host="com.android.contacts" />
</intent>
<intent>
<action android:name="*" />
<data android:scheme="content" android:host="com.android.calendar" />
</intent>
<!-- Web browsers (to check whether a Web browser is installed before viewing a URL) -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<!-- Custom Tabs support (e.g. Nextcloud Login Flow) -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</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>
-22
View File
@@ -1,22 +0,0 @@
carddav.a1.net
aol.com
dav.edis.at
dav.fruux.com
caldav.gmx.net
carddav.gmx.net
icloud.com
cloud.liberta.vip
office.luckycloud.de
calendar.mail.ru
mailbox.org
mailfence.com
posteo.de:8443
live.teambox.eu
spica.t-online.de
caldav.calendar.yahoo.com
yandex.ru
webmail.your-server.de/rpc.php/
calendar.zoho.com
calendar.zoho.eu
contacts.zoho.com
contacts.zoho.eu
+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>
-1
View File
@@ -1 +0,0 @@
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex","ma4ko"],"ca":["ettenmoors","hegocr_1709","jordibrus","Kintu","Solatec","zagur"],"cs":["Kryšpin","pavelb","tomas.odehnal"],"da":["knutztar","mjjzf","Tntdruid_","twikedk"],"de":["amandablue","anestiskaci","Atalanttore","corppneq","crit12","Gyges","hammaschlach","maxkl","nicolas_git","owncube","TheName","Waldmeisda","Wyrrrd","YvanM"],"el":["anestiskaci","diamond_gr","KristinaQejvanaj"],"es":["aluaces","Anoih","Ark74","Elhea","GranPC","jcvielma","oscfdezdz","plaguna","polkhas","sfouilleulb","xphnx"],"eu":["cockeredradiation","Osoitz","Thadah"],"fa":["ahangarha","amiraliakbari","javad.aboutorabi","joojoojoo","maryambehzi","mtashackori","Numb","taranehsaei"],"fi_FI":["raketti","tseipii"],"fr":["AlainR","alkino2","Amadeen","boutil","callmemagnus","chfo","chrcha","Floflr","grenatrad","jokx","Jorg722","JorisBodin","Llorc","LoiX07","mathieugfortin","Novick","paullbn","Poussinou","RomainTT","Synochy","Thecross","unareil","vincen","YvanM","ÉricB."],"fr_FR":["chrcha","Llorc","Poussinou","sfouilleulb"],"gl":["aluaces","pikamoku"],"hr":["tomiis"],"hr_HR":["tomiis"],"hu":["infeeeee","jtg","meskobalazs","Roshek","sfouilleulb"],"it":["Damtux","ed0","FranzMari","lzambarda","malaerba","noccio","nwandy","rickyroo","sfouilleulb","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["davtemp","dehart","erikhubers","frankyboy1963","glotzbach","toonvangerwen","XtremeNova"],"pl":["gsz","M4SK1N","mg6","oskarjakiela","TheName","TORminator","Valdnet"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["campbelldavid","Mikaelb"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"vi":["bruhwut"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","leoniscsem","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,487 @@
/*
* 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"
/* Whether DAVdroid populates and uses CalendarContract.Colors
value = null (not existing) false (default)
"1" true */
val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
value = null (not existing) groups as separate VCards (default)
"CATEGORIES" groups are per-contact CATEGORIES
*/
val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
@JvmField
val SYNC_INTERVAL_MANUALLY = -1L
fun initialUserData(userName: String): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
bundle.putString(KEY_USERNAME, userName)
return bundle
}
}
val accountManager: AccountManager = AccountManager.get(context)
init {
synchronized(AccountSettings::class.java) {
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
var version = 0
try {
version = Integer.parseInt(versionStr)
} catch (e: NumberFormatException) {
}
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
if (version < CURRENT_VERSION)
update(version)
}
}
// authentication settings
fun username(): String? = accountManager.getUserData(account, KEY_USERNAME)
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
fun password(): String? = accountManager.getPassword(account)
fun password(password: String) = accountManager.setPassword(account, password)
// sync. settings
fun getSyncInterval(authority: String): Long? {
if (ContentResolver.getIsSyncable(account, authority) <= 0)
return null
return if (ContentResolver.getSyncAutomatically(account, authority))
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
else
SYNC_INTERVAL_MANUALLY
}
fun setSyncInterval(authority: String, seconds: Long) {
if (seconds == SYNC_INTERVAL_MANUALLY) {
ContentResolver.setSyncAutomatically(account, authority, false)
} else {
ContentResolver.setSyncAutomatically(account, authority, true)
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
}
}
fun getSyncWifiOnly() = 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")
fun getEventColors() = 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 = 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, context.getString(R.string.address_books_authority), 0)
ContentResolver.cancelSync(account, null)
// get previous address book settings (including URL)
val raw = ContactsContract.SyncState.get(provider, account)
if (raw == null)
Logger.log.info("No contacts sync state, ignoring account")
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()
val url = params.getString("url")
if (url == null)
Logger.log.info("No address book URL, ignoring account")
else {
// create new address book
val info = CollectionInfo(url)
info.type = CollectionInfo.Type.ADDRESS_BOOK
info.displayName = account.name
Logger.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
Logger.log.info("Moving contacts from $account to $addressBookAccount")
val newAccount = ContentValues(2)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
newAccount,
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
arrayOf(account.name, account.type))
Logger.log.info("$affected contacts moved to new address book")
}
ContactsContract.SyncState.set(provider, account, null)
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
provider.release()
}
}
// update version number so that further syncs don't repeat the migration
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
}
/* Android 7.1.1 OpenTasks fix */
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context)
}
@Suppress("unused")
private fun update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES)
}
@Suppress("unused")
private fun update_2_3() {
// Don't show a warning for Android updates anymore
accountManager.setUserData(account, "last_android_version", null)
var serviceCardDAV: Long? = null
var serviceCalDAV: Long? = null
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.writableDatabase
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
// CardDAV: migrate address books
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
try {
val addrBook = LocalAddressBook(context, account, client)
val url = addrBook.getURL()
Logger.log.fine("Migrating address book $url")
// insert CardDAV service
val values = ContentValues(3)
values.put(Services.ACCOUNT_NAME, account.name)
values.put(Services.SERVICE, Services.SERVICE_CARDDAV)
serviceCardDAV = db.insert(Services._TABLE, null, values)
// insert address book
values.clear()
values.put(Collections.SERVICE_ID, serviceCardDAV)
values.put(Collections.URL, url)
values.put(Collections.SYNC, 1)
db.insert(Collections._TABLE, null, values)
// insert home set
HttpUrl.parse(url)?.let {
val homeSet = it.resolve("../")
values.clear()
values.put(HomeSets.SERVICE_ID, serviceCardDAV)
values.put(HomeSets.URL, homeSet.toString())
db.insert(HomeSets._TABLE, null, values)
}
} catch (e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e)
} finally {
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
// CalDAV: migrate calendars + task lists
val collections = HashSet<String>()
val homeSets = HashSet<HttpUrl>()
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { client ->
try {
val calendars = AndroidCalendar.find(account, client, LocalCalendar.Factory, null, null)
for (calendar in calendars)
calendar.name?.let { url ->
Logger.log.fine("Migrating calendar $url")
collections.add(url)
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
}
} catch (e: CalendarStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate calendars", e)
} finally {
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
AndroidTaskList.acquireTaskProvider(context.contentResolver)?.use { provider ->
try {
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
for (taskList in taskLists)
taskList.syncId?.let { url ->
Logger.log.fine("Migrating task list $url")
collections.add(url)
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
}
} catch (e: CalendarStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e)
}
}
if (!collections.isEmpty()) {
// insert CalDAV service
val values = ContentValues(3)
values.put(Services.ACCOUNT_NAME, account.name)
values.put(Services.SERVICE, Services.SERVICE_CALDAV)
serviceCalDAV = db.insert(Services._TABLE, null, values)
// insert collections
for (url in collections) {
values.clear()
values.put(Collections.SERVICE_ID, serviceCalDAV)
values.put(Collections.URL, url)
values.put(Collections.SYNC, 1)
db.insert(Collections._TABLE, null, values)
}
// insert home sets
for (homeSet in homeSets) {
values.clear()
values.put(HomeSets.SERVICE_ID, serviceCalDAV)
values.put(HomeSets.URL, homeSet.toString())
db.insert(HomeSets._TABLE, null, values)
}
}
}
// initiate service detection (refresh) to get display names, colors etc.
val refresh = Intent(context, DavService::class.java)
refresh.action = DavService.ACTION_REFRESH_COLLECTIONS
serviceCardDAV?.let {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
context.startService(refresh)
}
serviceCalDAV?.let {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
context.startService(refresh)
}
}
@Suppress("unused")
private fun update_1_2() {
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
*/
// move previous address book info to ContactsContract.SyncState
val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) ?:
throw ContactsStorageException("Couldn't access Contacts provider")
try {
val addr = LocalAddressBook(context, account, provider)
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
val values = ContentValues()
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
addr.updateSettings(values)
val url = accountManager.getUserData(account, "addressbook_url")
if (!url.isNullOrEmpty())
addr.setURL(url)
accountManager.setUserData(account, "addressbook_url", null)
val cTag = accountManager.getUserData (account, "addressbook_ctag")
if (!cTag.isNullOrEmpty())
addr.setCTag(cTag)
accountManager.setUserData(account, "addressbook_ctag", null)
} finally {
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
@Suppress("deprecation")
provider.release()
}
}
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)
}
}
}
@@ -1,29 +0,0 @@
package at.bitfire.davdroid
import android.content.Context
abstract class AndroidSingleton<T> {
var creatingSingleton = false
var singleton: T? = null
@Synchronized
fun getInstance(context: Context): T {
singleton?.let {
return it
}
if (creatingSingleton)
throw IllegalStateException("AndroidSingleton::getInstance() must not be called while createInstance()")
creatingSingleton = true
val newSingleton = createInstance(context.applicationContext)
singleton = newSingleton
creatingSingleton = false
return newSingleton
}
protected abstract fun createInstance(context: Context): T
}
+25 -72
View File
@@ -10,90 +10,43 @@ package at.bitfire.davdroid
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.StrictMode
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.graphics.drawable.toBitmap
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.UiUtils
import java.util.logging.Level
import kotlin.concurrent.thread
import kotlin.system.exitProcess
@Suppress("unused")
class App: Application(), Thread.UncaughtExceptionHandler {
class App: Application() {
companion object {
fun getLauncherBitmap(context: Context) =
AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)?.toBitmap()
@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"
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()!!
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
@JvmStatic
fun getLauncherBitmap(context: Context): Bitmap? {
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
context.getDrawable(R.mipmap.ic_launcher)
else
@Suppress("deprecation")
context.resources.getDrawable(R.mipmap.ic_launcher)
return if (drawableLogo is BitmapDrawable)
drawableLogo.bitmap
else
null
}
}
override fun onCreate() {
super.onCreate()
Logger.initialize(this)
if (BuildConfig.DEBUG)
// debug builds
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
.build())
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
// handle uncaught exceptions in non-debug standard flavor
Thread.setDefaultUncaughtExceptionHandler(this)
NotificationUtils.createChannels(this)
// set light/dark mode
UiUtils.setTheme(this) // when this is called in the asynchronous thread below, it recreates
// some current activity and causes an IllegalStateException in rare cases
// don't block UI for some background checks
thread {
// create/update app shortcuts
UiUtils.updateShortcuts(this)
// watch installed/removed apps
TasksWatcher(this)
// check whether a tasks app is currently installed
TasksWatcher.updateTaskSync(this)
// check/repair sync intervals
AccountSettings.repairSyncIntervals(this)
// foreground service (possible workaround for devices which prevent DAVx5 from being started)
ForegroundService.startIfEnabled(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.EXTRA_CAUSE, e)
startActivity(intent)
exitProcess(1)
Logger.reinitLogger(this)
}
}
@@ -1,22 +0,0 @@
package at.bitfire.davdroid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import at.bitfire.davdroid.log.Logger
/**
* There are circumstances when Android drops automatic sync of accounts and resets them
* to manual synchronization, for instance when the device is booted into safe mode.
*
* This receiver causes the app to be started when the device is rebooted. When the app
* is started, it checks (and repairs, if necessary) the sync intervals in [App.onCreate].
*/
class BootCompletedReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Logger.log.info("Device has been rebooted; checking sync intervals etc.")
// sync intervals are checked in App.onCreate()
}
}
@@ -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,24 +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
/**
* 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
}
@@ -9,99 +9,66 @@
package at.bitfire.davdroid
import android.accounts.Account
import android.app.IntentService
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.annotation.WorkerThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
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.Collection
import at.bitfire.davdroid.model.*
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.collections.*
import kotlin.concurrent.thread
@Suppress("DEPRECATION")
class DavService: IntentService("DavService") {
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,
Owner.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"
}
/**
* List of [Service] IDs for which the collections are currently refreshed
*/
private val runningRefresh = Collections.synchronizedSet(HashSet<Long>())
private val runningRefresh = HashSet<Long>()
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
/**
* Currently registered [RefreshingStatusListener]s, which will be notified
* when a collection refresh status changes
*/
private val refreshingStatusListeners = Collections.synchronizedList(LinkedList<WeakReference<RefreshingStatusListener>>())
@WorkerThread
override fun onHandleIntent(intent: Intent?) {
if (intent == null)
return
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
when (intent.action) {
ACTION_REFRESH_COLLECTIONS ->
if (runningRefresh.add(id)) {
refreshingStatusListeners.forEach { listener ->
listener.get()?.onDavRefreshStatusChanged(id, true)
when (intent.action) {
ACTION_ACCOUNTS_UPDATED ->
cleanupAccounts()
ACTION_REFRESH_COLLECTIONS ->
if (runningRefresh.add(id)) {
thread { refreshCollections(id) }
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
}
val db = AppDatabase.getInstance(this@DavService)
refreshCollections(db, id)
}
ACTION_FORCE_SYNC -> {
val uri = intent.data!!
val authority = uri.authority!!
val account = Account(
uri.pathSegments[1],
uri.pathSegments[0]
)
forceSync(authority, account)
}
}
return START_NOT_STICKY
}
@@ -118,20 +85,17 @@ class DavService: IntentService("DavService") {
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)
synchronized(runningRefresh) {
for (id in runningRefresh)
listener.onDavRefreshStatusChanged(id, true)
}
if (callImmediate)
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
}
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
val iter = refreshingStatusListeners.iterator()
while (iter.hasNext()) {
val item = iter.next().get()
if (item == listener || item == null)
if (listener == item)
iter.remove()
}
}
@@ -145,262 +109,274 @@ class DavService: IntentService("DavService") {
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(getString(R.string.account_type_address_book))
.map { LocalAddressBook(this, it, null) }
.forEach {
try {
if (!accountNames.contains(it.getMainAccount().name))
it.delete()
} catch(e: ContactsStorageException) {
Logger.log.log(Level.SEVERE, "Couldn't get address book main account", e)
}
}
// delete orphaned services in DB
if (sqlAccountNames.isEmpty())
db.delete(Services._TABLE, null, null)
else
db.delete(Services._TABLE, "${Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
}
}
private fun refreshCollections(db: AppDatabase, serviceId: Long) {
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.
*
* @param personal Whether this is the "outer" call of the recursion.
*
* *true* = found home sets belong to the current-user-principal; recurse if
* calendar proxies or group memberships are found
*
* *false* = found home sets don't directly belong to the current-user-principal; don't recurse
*
* @throws java.io.IOException
* @throws HttpException
* @throws at.bitfire.dav4jvm.exception.DavException
*/
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, personal: 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, personal, foundUrl)
fun saveHomeSets() {
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
for (homeSet in homeSets) {
val values = ContentValues(2)
values.put(HomeSets.SERVICE_ID, service)
values.put(HomeSets.URL, homeSet.toString())
db.insertOrThrow(HomeSets._TABLE, null, values)
}
}
fun saveCollections() {
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
for ((_,collection) in collections) {
val values = collection.toDB()
Logger.log.log(Level.FINE, "Saving collection", values)
values.put(Collections.SERVICE_ID, service)
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
}
try {
Logger.log.info("Refreshing $serviceType collections of service #$service")
// create authenticating OkHttpClient (credentials taken from account settings)
HttpClient.Builder(this@DavService, account)
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
// refresh home set list (from principal)
readPrincipal()?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
val principal = DavResource(httpClient, principalUrl)
queryHomeSets(principal)
// refresh home sets: calendar-proxy-read/write-for
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor.NAME) as List<Pair<DavResource, CalendarProxyReadFor>>)
for (href in proxyRead.hrefs) {
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
}
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor.NAME) as List<Pair<DavResource, CalendarProxyWriteFor>>)
for (href in proxyWrite.hrefs) {
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
}
// refresh home sets: direct group memberships
(principal.properties[GroupMembership.NAME] as GroupMembership?)?.let { groupMembership ->
for (href in groupMembership.hrefs) {
Logger.log.fine("Principal is member of group $href, checking for home sets")
principal.location.resolve(href)?.let { url ->
try {
queryHomeSets(DavResource(httpClient, url))
} catch(e: HttpException) {
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
} catch(e: DavException) {
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
}
}
}
if (personal)
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, personal, foundUrl)
}
}
if (personal)
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
}
}
}
// remember selected collections
val selectedCollections = HashSet<HttpUrl>()
collections.values
.filter { it.selected }
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
// query related homesets (those that do not belong to the current-user-principal)
for (resource in related)
queryHomeSets(client, resource, false)
}
// now refresh collections (taken from home sets)
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val homeSetUrl = itHomeSets.next()
Logger.log.fine("Listing home set $homeSetUrl")
fun saveHomesets() {
DaoTools(homeSetDao).syncAll(
homeSetDao.getByService(serviceId),
homeSets,
{ it.url })
}
fun saveCollections() {
DaoTools(collectionDao).syncAll(
collectionDao.getByService(serviceId),
collections, { it.url }) { new, old ->
new.forceReadOnly = old.forceReadOnly
new.sync = old.sync
}
}
try {
Logger.log.info("Refreshing ${service.type} collections of service #$service")
// cancel previous notification
NotificationManagerCompat.from(this)
.cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
// create authenticating OkHttpClient (credentials taken from account settings)
HttpClient.Builder(this, AccountSettings(this, account))
.setForeground(true)
.build().use { client ->
val httpClient = client.okHttpClient
// refresh home set list (from principal)
service.principal?.let { principalUrl ->
Logger.log.fine("Querying principal $principalUrl for home sets")
queryHomeSets(httpClient, principalUrl)
}
// now refresh homesets and their member collections
val itHomeSets = homeSets.iterator()
while (itHomeSets.hasNext()) {
val (homeSetUrl, homeSet) = itHomeSets.next()
Logger.log.fine("Listing home set $homeSetUrl")
try {
DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
if (!response.isSuccess())
return@propfind
if (relation == Response.HrefRelation.SELF) {
// this response is about the homeset itself
homeSet.displayName = response[DisplayName::class.java]?.displayName
homeSet.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
}
// in any case, check whether the response is about a useable collection
val info = Collection.fromDavResponse(response) ?: return@propfind
info.serviceId = serviceId
info.refHomeSet = homeSet
info.confirmed = true
info.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) }
Logger.log.log(Level.FINE, "Found collection", info)
// remember usable collections
if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type)))
collections[response.href] = info
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
}
// check/refresh unconfirmed collections
val itCollections = collections.entries.iterator()
while (itCollections.hasNext()) {
val (url, info) = itCollections.next()
if (!info.confirmed)
val homeSet = DavResource(httpClient, homeSetUrl)
try {
// this collection doesn't belong to a homeset anymore, otherwise it would have been confirmed
info.homeSetId = null
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)
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
if (!response.isSuccess())
return@propfind
val collection = Collection.fromDavResponse(response) ?: return@propfind
collection.confirmed = true
// remove unusable collections
if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) ||
(service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
(collection.type == Collection.TYPE_WEBCAL && collection.source == null))
itCollections.remove()
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
collections[member.location] = info
}
} catch(e: HttpException) {
if (e.code in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
throw e
if (e.status in arrayOf(403, 404, 410))
// delete home set only if it was not accessible (40x)
itHomeSets.remove()
}
}
}
db.runInTransaction {
saveHomesets()
// use refHomeSet (if available) to determine homeset ID
for (collection in collections.values)
collection.refHomeSet?.let { homeSet ->
collection.homeSetId = homeSet.id
}
saveCollections()
}
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
// check/refresh unconfirmed collections
val itCollections = collections.entries.iterator()
while (itCollections.hasNext()) {
val (url, info) = itCollections.next()
if (!info.confirmed)
try {
val collection = DavResource(httpClient, url)
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
val info = CollectionInfo(collection)
info.confirmed = true
val debugIntent = Intent(this, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.EXTRA_CAUSE, e)
debugIntent.putExtra(DebugInfoActivity.EXTRA_ACCOUNT, account)
// remove unusable collections
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
itCollections.remove()
} catch(e: HttpException) {
if (e.status in arrayOf(403, 404, 410))
// delete collection only if it was not accessible (40x)
itCollections.remove()
else
throw e
}
}
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)
// restore selections
for (url in selectedCollections)
collections[url]?.let { it.selected = true }
}
db.beginTransactionNonExclusive()
try {
saveHomeSets()
saveCollections()
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Invalid account", e)
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
val nm = NotificationManagerCompat.from(this@DavService)
val notify = NotificationCompat.Builder(this@DavService)
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(this@DavService))
.setContentTitle(getString(R.string.dav_service_refresh_failed))
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
.setContentIntent(PendingIntent.getActivity(this@DavService, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.build()
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify)
} finally {
runningRefresh.remove(service)
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
}
}
}
}
}
+12 -178
View File
@@ -6,199 +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 android.provider.ContactsContract
import androidx.core.content.getSystemService
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.TaskUtils
import okhttp3.HttpUrl
import org.xbill.DNS.*
import java.util.*
/**
* Some WebDAV and related network utility methods
*/
object DavUtils {
enum class SyncStatus {
ACTIVE, PENDING, IDLE
}
@Suppress("FunctionName")
@JvmStatic
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
val alpha = (colorWithAlpha shr 24) and 0xFF
val color = colorWithAlpha and 0xFFFFFF
return String.format(Locale.ROOT, "#%06X%02X", color, alpha)
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(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
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<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<out Record>?): SRVRecord? {
if (records == null)
return null
val srvRecords = records.filterIsInstance(SRVRecord::class.java)
if (srvRecords.size <= 1)
return srvRecords.firstOrNull()
/* RFC 2782
Priority
The priority of this target host. A client MUST attempt to
contact the target host with the lowest-numbered priority it can
reach; target hosts with the same priority SHOULD be tried in an
order defined by the weight field. [...]
Weight
A server selection mechanism. The weight field specifies a
relative weight for entries with the same priority. [...]
To select a target to be contacted next, arrange all SRV RRs
(that have not been ordered yet) in any order, except that all
those with weight 0 are placed at the beginning of the list.
Compute the sum of the weights of those RRs, and with each RR
associate the running sum in the selected order. Then choose a
uniform random number between 0 and the sum computed
(inclusive), and select the RR whose running sum value is the
first in the selected order which is greater than or equal to
the random number selected. The target host specified in the
selected SRV RR is the next one to be contacted by the client.
*/
val minPriority = srvRecords.map { it.priority }.minOrNull()
val useableRecords = srvRecords.filter { it.priority == minPriority }.sortedBy { it.weight != 0 }
val map = TreeMap<Int, SRVRecord>()
var runningWeight = 0
for (record in useableRecords) {
val weight = record.weight
runningWeight += weight
map[runningWeight] = record
}
val selector = (0..runningWeight).random()
return map.ceilingEntry(selector)!!.value
}
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
}
/**
* Returns the sync status of a given account. Checks the account itself and possible
* sub-accounts (address book accounts).
*
* @param authorities sync authorities to check (usually taken from [syncAuthorities])
*
* @return sync status of the given account
*/
fun accountSyncStatus(context: Context, authorities: Iterable<String>, account: Account): SyncStatus {
// check active syncs
if (authorities.any { ContentResolver.isSyncActive(account, it) })
return SyncStatus.ACTIVE
val addrBookAccounts = LocalAddressBook.findAll(context, null, account).map { it.account }
if (addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) })
return SyncStatus.ACTIVE
// check get pending syncs
if (authorities.any { ContentResolver.isSyncPending(account, it) } ||
addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) })
return SyncStatus.PENDING
return SyncStatus.IDLE
}
/**
* Requests an immediate, manual sync of all available authorities for the given account.
*
* @param account account to sync
*/
fun requestSync(context: Context, account: Account) {
for (authority in syncAuthorities(context)) {
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)
}
}
/**
* Returns a list of all available sync authorities for main accounts (!= address book accounts):
*
* 1. address books authority (not [ContactsContract.AUTHORITY], but the one which manages address book accounts)
* 1. calendar authority
* 1. tasks authority (if available)
*
* Checking the availability of authorities may be relatively expensive, so the
* result should be cached for the current operation.
*
* @return list of available sync authorities for main accounts
*/
fun syncAuthorities(context: Context): List<String> {
val result = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
)
TaskUtils.currentProvider(context)?.let { taskProvider ->
result += taskProvider.authority
}
return result
return "/"
}
}
@@ -1,62 +0,0 @@
package at.bitfire.davdroid
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.AppSettingsActivity
import at.bitfire.davdroid.ui.NotificationUtils
class ForegroundService : Service() {
companion object {
/**
* Starts/stops a foreground service, according to the app setting [Settings.FOREGROUND_SERVICE].
*/
const val ACTION_FOREGROUND = "foreground"
fun isEnabled(context: Context): Boolean {
val settings = SettingsManager.getInstance(context)
return settings.getBooleanOrNull(Settings.FOREGROUND_SERVICE) == true
}
fun startIfEnabled(context: Context) {
if (isEnabled(context)) {
val serviceIntent = Intent(ACTION_FOREGROUND, null, context, ForegroundService::class.java)
if (Build.VERSION.SDK_INT >= 26)
context.startForegroundService(serviceIntent)
else
context.startService(serviceIntent)
}
}
}
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (isEnabled(this)) {
val settingsIntent = Intent(this, AppSettingsActivity::class.java).apply {
putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, Settings.FOREGROUND_SERVICE)
}
val builder = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_STATUS)
.setSmallIcon(R.drawable.ic_foreground_notify)
.setContentTitle(getString(R.string.foreground_service_notify_title))
.setContentText(getString(R.string.foreground_service_notify_text))
.setStyle(NotificationCompat.BigTextStyle())
.setContentIntent(PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT))
startForeground(NotificationUtils.NOTIFY_FOREGROUND, builder.build())
return START_STICKY
} else {
stopForeground(true)
return START_NOT_STICKY
}
}
}
@@ -6,236 +6,140 @@
* 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.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 at.bitfire.davdroid.settings.SettingsManager
import okhttp3.*
import okhttp3.brotli.BrotliInterceptor
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.Settings
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.internal.tls.OkHostnameVerifier
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.io.Closeable
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.*
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
private val certManager: CustomCertManager?
): AutoCloseable {
companion object {
/** max. size of disk cache (10 MB) */
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
/** [OkHttpClient] singleton to build all clients from */
val sharedClient: OkHttpClient = OkHttpClient.Builder()
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
// traffic within a minute, a sync will be cancelled.
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
.connectionSpecs(listOf(
ConnectionSpec.CLEARTEXT,
ConnectionSpec.COMPATIBLE_TLS
))
// don't allow redirects by default, because it would break PROPFIND handling
.followRedirects(false)
// offer Brotli and gzip compression
.addInterceptor(BrotliInterceptor)
// add User-Agent to every request
.addNetworkInterceptor(UserAgentInterceptor)
.build()
}
): Closeable {
override fun close() {
okHttpClient.cache?.close()
certManager?.close()
}
class Builder(
class Builder @JvmOverloads constructor(
val context: Context? = null,
accountSettings: AccountSettings? = null,
val logger: java.util.logging.Logger = Logger.log
logger: java.util.logging.Logger = Logger.log
) {
private var certManager: CustomCertManager? = null
private var certificateAlias: String? = null
private var cache: Cache? = null
private val orig = sharedClient.newBuilder()
var certManager: CustomCertManager? = null
private val orig = OkHttpClient.Builder()
init {
// set timeouts
orig.connectTimeout(30, TimeUnit.SECONDS)
orig.writeTimeout(30, TimeUnit.SECONDS)
orig.readTimeout(120, TimeUnit.SECONDS)
// don't allow redirects by default, because it would break PROPFIND handling
orig.followRedirects(false)
// add User-Agent to every request
orig.addNetworkInterceptor(UserAgentInterceptor)
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
orig.cookieJar(MemoryCookieStore())
// add network logging, if requested
if (logger.isLoggable(Level.FINEST)) {
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
message -> logger.finest(message)
})
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
orig.addInterceptor(loggingInterceptor)
}
if (context != null) {
val settings = SettingsManager.getInstance(context)
context?.let {
ServiceDB.OpenHelper(context).use { dbHelper ->
val settings = Settings(dbHelper.readableDatabase)
// custom proxy support
try {
if (settings.getBoolean(Settings.OVERRIDE_PROXY)) {
val address = InetSocketAddress(
settings.getString(Settings.OVERRIDE_PROXY_HOST),
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
)
// custom proxy support
try {
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
val address = InetSocketAddress(
settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT),
settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
)
val proxy = Proxy(Proxy.Type.HTTP, address)
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy", proxy)
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)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
// TODO don't instantiate CustomCertManager in .Builder (causes service leaks)
customCertManager(CustomCertManager(context, true /*BuildConfig.customCertsUI*/,
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES))))
// use cert4android to manage self-signed certificates
if (BuildConfig.customCerts)
customCertManager(CustomCertManager(context, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
}
}
// use account settings for authentication
accountSettings?.let {
addAuthentication(null, it.credentials())
val userName = it.username()
val password = it.password()
if (userName != null && password != null)
addAuthentication(null, userName, password)
}
}
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
addAuthentication(host, credentials)
constructor(context: Context, account: Account): this(context, AccountSettings(context, account))
constructor(context: Context, host: String?, username: String, password: String): this(context) {
addAuthentication(host, username, password)
}
fun withDiskCache(context: Context): Builder {
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 followRedirects(follow: Boolean) { orig.followRedirects(follow) }
fun customCertManager(manager: CustomCertManager) {
certManager = manager
orig.sslSocketFactory(SSLSocketFactoryCompat(manager), manager)
orig.hostnameVerifier(manager.hostnameVerifier(OkHostnameVerifier.INSTANCE))
}
fun setForeground(foreground: Boolean): Builder {
certManager?.appInForeground = foreground
return this
}
fun addAuthentication(host: String?, credentials: Credentials): Builder {
if (credentials.userName != null && credentials.password != null) {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName, credentials.password)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
fun addAuthentication(host: String?, username: String, password: String): Builder {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
return this
}
fun build(): HttpClient {
val trustManager = certManager ?: run {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier)
?: OkHostnameVerifier
var keyManager: KeyManager? = null
certificateAlias?.let { alias ->
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 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))
}
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)
}
fun build() = HttpClient(orig.build(), certManager)
}
private object UserAgentInterceptor: Interceptor {
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
init {
Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests")
}
private val userAgentDate = SimpleDateFormat("yyyy/MM/dd", Locale.US).format(Date(BuildConfig.buildTime))
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
override fun intercept(chain: Interceptor.Chain): Response {
val locale = Locale.getDefault()
@@ -27,12 +27,12 @@ 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) {
for (cookie in cookies)
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie)
}
}
@@ -46,7 +46,7 @@ class MemoryCookieStore: CookieJar {
val cookie = iter.value
// remove expired cookies
if (cookie.expiresAt <= System.currentTimeMillis()) {
if (cookie.expiresAt() <= System.currentTimeMillis()) {
iter.remove()
continue
}
@@ -1,25 +1,65 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.TaskProvider
abstract class PackageChangedReceiver(
val context: Context
): BroadcastReceiver(), AutoCloseable {
class PackageChangedReceiver: BroadcastReceiver() {
init {
val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
addAction(Intent.ACTION_PACKAGE_CHANGED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED)
updateTaskSync(context)
}
companion object {
@JvmStatic
fun updateTaskSync(context: Context) {
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
if (tasksInstalled) {
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL.toLong())
}
} else
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
}
}
}
}
context.registerReceiver(this, filter)
}
override fun close() {
context.unregisterReceiver(this)
}
}
}
@@ -1,126 +0,0 @@
package at.bitfire.davdroid
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.location.LocationManagerCompat
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.PermissionsActivity
object PermissionUtils {
val CONTACT_PERMISSIONS = arrayOf(
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
)
val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR
)
val WIFI_SSID_PERMISSIONS =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1)
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION)
else
arrayOf()
/**
* Checks whether all conditions to access the current WiFi's SSID are met:
*
* 1. location permissions ([WIFI_SSID_PERMISSIONS]) granted (Android 8.1+)
* 2. location enabled (Android 9+)
*
* @return *true* if SSID can be obtained; *false* if the SSID will be <unknown> or something like that
*/
fun canAccessWifiSsid(context: Context): Boolean {
// before Android 8.1, SSIDs are always readable
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1)
return true
val locationAvailable =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
true // Android <9 doesn't require active location services
else
ContextCompat.getSystemService(context, LocationManager::class.java)?.let { locationManager ->
LocationManagerCompat.isLocationEnabled(locationManager)
} ?: /* location feature not available on this device */ false
return havePermissions(context, WIFI_SSID_PERMISSIONS) &&
locationAvailable
}
/**
* Whether this app declares the given permission (regardless of whether it has been granted or not).
*
* @param permission permission to check
*
* @return *true* if this app declares [permission] in the manifest; *false* otherwise
*/
fun declaresPermission(packageManager: PackageManager, permission: String): Boolean {
val info = packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
return info.requestedPermissions.contains(permission)
}
/**
* Checks whether at least one of the given permissions is granted.
*
* @param context context to check
* @param permissions array of permissions to check
*
* @return whether at least one of [permissions] is granted
*/
fun haveAnyPermission(context: Context, permissions: Array<String>) =
permissions.any { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
/**
* Checks whether all given permissions are granted.
*
* @param context context to check
* @param permissions array of permissions to check
*
* @return whether all [permissions] are granted
*/
fun havePermissions(context: Context, permissions: Array<String>) =
permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
/**
* Shows a notification about missing permissions.
*
* @param context notification context
* @param intent will be set as content Intent; if null, an Intent to launch PermissionsActivity will be used
*/
fun notifyPermissions(context: Context, intent: Intent?) {
val contentIntent = intent ?: Intent(context, PermissionsActivity::class.java)
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
.setSmallIcon(R.drawable.ic_sync_problem_notify)
.setContentTitle(context.getString(R.string.sync_error_permissions))
.setContentText(context.getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context)
.notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
}
fun showAppSettings(context: Context) {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", BuildConfig.APPLICATION_ID, null))
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
else
Logger.log.warning("App settings Intent not resolvable")
}
}
@@ -0,0 +1,160 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.os.Build
import at.bitfire.davdroid.log.Logger
import java.io.IOException
import java.net.InetAddress
import java.net.Socket
import java.security.GeneralSecurityException
import java.util.*
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
class SSLSocketFactoryCompat(trustManager: X509TrustManager): SSLSocketFactory() {
private var delegate: SSLSocketFactory
companion object {
// Android 5.0+ (API level 21) provides reasonable default settings
// but it still allows SSLv3
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
var protocols: Array<String>? = null
var cipherSuites: Array<String>? = null
init {
if (Build.VERSION.SDK_INT >= 23) {
// Since Android 6.0 (API level 23),
// - TLSv1.1 and TLSv1.2 is enabled by default
// - SSLv3 is disabled by default
// - all modern ciphers are activated by default
protocols = null
cipherSuites = null
Logger.log.fine("Using device default TLS protocols/ciphers")
} else {
val socket = SSLSocketFactory.getDefault().createSocket() as SSLSocket?
try {
socket?.let {
/* set reasonable protocol versions */
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
// - remove all SSL versions (especially SSLv3) because they're insecure now
val _protocols = LinkedList<String>()
for (protocol in socket.supportedProtocols.filterNot { it.contains("SSL", true) })
_protocols += protocol
Logger.log.info("Enabling (only) these TLS protocols: ${_protocols.joinToString(", ")}")
protocols = _protocols.toTypedArray()
/* set up reasonable cipher suites */
val knownCiphers = arrayOf<String>(
// TLS 1.2
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
// maximum interoperability
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"SSL_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
// additionally
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"
)
val availableCiphers = socket.supportedCipherSuites
Logger.log.info("Available cipher suites: ${availableCiphers.joinToString(", ")}")
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus
* disabling ciphers which are enabled by default, but have become unsecure), but for
* the security level of DAVdroid and maximum compatibility, disabling of insecure
* ciphers should be a server-side task */
// for the final set of enabled ciphers, take the ciphers enabled by default, ...
val _cipherSuites = LinkedList<String>()
_cipherSuites.addAll(socket.enabledCipherSuites)
Logger.log.fine("Cipher suites enabled by default: ${_cipherSuites.joinToString(", ")}")
// ... add explicitly allowed ciphers ...
_cipherSuites.addAll(knownCiphers)
// ... and keep only those which are actually available
_cipherSuites.retainAll(availableCiphers)
Logger.log.info("Enabling (only) these TLS ciphers: " + _cipherSuites.joinToString(", "))
cipherSuites = _cipherSuites.toTypedArray()
}
} catch(e: IOException) {
Logger.log.severe("Couldn't determine default TLS settings")
} finally {
socket?.close() // doesn't implement Closeable on all supported Android versions
}
}
}
}
init {
try {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(trustManager), null)
delegate = sslContext.socketFactory
} catch (e: GeneralSecurityException) {
throw IllegalStateException() // system has no TLS
}
}
override fun getDefaultCipherSuites(): Array<String>? = cipherSuites ?: delegate.defaultCipherSuites
override fun getSupportedCipherSuites(): Array<String>? = cipherSuites ?: delegate.supportedCipherSuites
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket {
val ssl = delegate.createSocket(s, host, port, autoClose)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: String, port: Int): Socket {
val ssl = delegate.createSocket(host, port)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
val ssl = delegate.createSocket(host, port, localHost, localPort)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(host: InetAddress, port: Int): Socket {
val ssl = delegate.createSocket(host, port)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
val ssl = delegate.createSocket(address, port, localAddress, localPort)
if (ssl is SSLSocket)
upgradeTLS(ssl)
return ssl
}
private fun upgradeTLS(ssl: SSLSocket) {
protocols?.let { ssl.enabledProtocols = it }
cipherSuites?.let { ssl.enabledCipherSuites = it }
}
}
@@ -1,97 +0,0 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import androidx.annotation.WorkerThread
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TasksWatcher(
context: Context
): PackageChangedReceiver(context) {
companion object {
@WorkerThread
fun updateTaskSync(context: Context) {
val tasksProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or other package (un)installed; current tasks provider = $tasksProvider")
var permissionsRequired = false // whether additional permissions are required
val currentProvider by lazy { // only this provider shall be enabled (null to disable all providers)
TaskUtils.currentProvider(context)
}
// check all accounts and (de)activate task provider(s) if a CalDAV service is defined
val db = AppDatabase.getInstance(context)
val accountManager = AccountManager.get(context)
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
for (providerName in TaskProvider.ProviderName.values()) {
val isSyncable = ContentResolver.getIsSyncable(account, providerName.authority) // may be -1 (unknown state)
val shallBeSyncable = hasCalDAV && providerName == currentProvider
if ((shallBeSyncable && isSyncable != 1) || (!shallBeSyncable && isSyncable != 0)) {
// enable/disable sync
setSyncable(context, account, providerName.authority, shallBeSyncable)
// if sync has just been enabled: check whether additional permissions are required
if (shallBeSyncable && !PermissionUtils.havePermissions(context, providerName.permissions))
permissionsRequired = true
}
}
}
if (permissionsRequired) {
Logger.log.warning("Tasks synchronization is now enabled for at least one account, but permissions are not granted")
PermissionUtils.notifyPermissions(context, null)
}
}
private fun setSyncable(context: Context, account: Account, authority: String, syncable: Boolean) {
if (syncable) {
Logger.log.info("Enabling $authority sync for $account")
ContentResolver.setIsSyncable(account, authority, 1)
try {
val settings = AccountSettings(context, account)
val interval = settings.getSavedTasksSyncInterval() ?:
SettingsManager.getInstance(context).getLong(Settings.DEFAULT_SYNC_INTERVAL)
settings.setSyncInterval(authority, interval)
} catch (e: InvalidAccountException) {
// account has already been removed
}
} else {
Logger.log.info("Disabling $authority sync for $account")
ContentResolver.setIsSyncable(account, authority, 0)
}
}
}
override fun onReceive(context: Context, intent: Intent) {
CoroutineScope(Dispatchers.Default).launch {
updateTaskSync(context)
}
}
}
@@ -1,79 +0,0 @@
package at.bitfire.davdroid
import org.apache.commons.lang3.StringUtils
import java.util.*
class TextTable(
vararg val headers: String
) {
companion object {
fun indent(str: String, pos: Int): String =
" ".repeat(pos) +
str.split('\n').joinToString("\n" + " ".repeat(pos))
}
private val lines = mutableListOf<Array<String>>()
fun addLine(vararg values: Any?) {
if (values.size != headers.size)
throw IllegalArgumentException("Table line must have ${headers.size} column(s)")
lines += values.map {
it?.toString() ?: ""
}.toTypedArray()
}
override fun toString(): String {
val sb = StringBuilder()
val headerWidths = headers.map { it.length }
val colWidths = Array<Int>(headers.size) { colIdx ->
Collections.max(listOf(headerWidths[colIdx]) + lines.map { it[colIdx] }.map { it.length })
}
// first line
sb.append("\n")
for (colIdx in 0 until headers.size)
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┐' else '┬')
sb.append('\n')
// header
sb.append('│')
for (colIdx in 0 until headers.size)
sb .append(' ')
.append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1))
.append('│')
sb.append('\n')
// separator between header and body
sb.append('├')
for (colIdx in 0 until headers.size) {
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┤' else '┼')
}
sb.append('\n')
// body
for (line in lines) {
for (colIdx in 0 until headers.size)
sb .append("")
.append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1))
sb.append("\n")
}
// last line
sb.append("")
for (colIdx in 0 until headers.size) {
sb .append("".repeat(colWidths[colIdx] + 2))
.append(if (colIdx == headers.size - 1) '┘' else '┴')
}
sb.append("\n\n")
return sb.toString()
}
}
@@ -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,138 +8,94 @@
package at.bitfire.davdroid.log
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Process
import android.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.preference.PreferenceManager
import at.bitfire.davdroid.BuildConfig
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.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import org.apache.commons.lang3.time.DateFormatUtils
import java.io.File
import java.io.IOException
import java.util.*
import java.util.logging.FileHandler
import java.util.logging.Level
import java.util.logging.Logger
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
object Logger {
private const val LOG_TO_FILE = "log_to_file"
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger("davx5")
private lateinit var context: Application
private lateinit var preferences: SharedPreferences
@JvmField
val log = Logger.getLogger("davdroid")!!
fun initialize(app: Application) {
context = app
preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.registerOnSharedPreferenceChangeListener(this)
fun reinitLogger(context: Context) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val settings = Settings(dbHelper.readableDatabase)
reinitialize()
}
val logToFile = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (key == LOG_TO_FILE) {
log.info("Logging settings changed; re-initializing logger")
reinitialize()
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
rootLogger.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)
}
}
@Synchronized
private fun reinitialize() {
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
val logVerbose = logToFile || BuildConfig.DEBUG || 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
// reset all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { handler ->
rootLogger.removeHandler(handler)
if (handler is FileHandler) // gracefully close previous verbose-logging FileHandlers
handler.close()
}
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(context)
// log to external file according to preferences
if (logToFile) {
val logDir = debugDir() ?: return
val logFile = File(logDir, "davx5-log.txt")
if (logFile.createNewFile())
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
try {
val fileHandler = FileHandler(logFile.toString(), true).apply {
formatter = PlainTextFormatter.DEFAULT
}
rootLogger.addHandler(fileHandler)
log.info("Now logging to file: $logFile")
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
.setContentTitle(context.getString(R.string.app_settings_logging))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentText(context.getString(R.string.logging_notification_text, context.getString(R.string.app_name)))
.setOngoing(true)
val shareIntent = Intent(Intent.ACTION_SEND, null, context, DebugInfoActivity::class.java)
shareIntent.putExtra(DebugInfoActivity.EXTRA_LOG_FILE, logFile.absolutePath)
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(NotificationCompat.Action.Builder(
R.drawable.ic_share,
context.getString(R.string.logging_notification_view_share),
pendingShare
).build())
val prefIntent = Intent(context, AppSettingsActivity::class.java)
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingPref = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(NotificationCompat.Action.Builder(
R.drawable.ic_settings,
context.getString(R.string.logging_notification_disable),
pendingPref
).build())
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.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()
}
} else {
// verbose logging is disabled -> cancel notification and remove old logs
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
debugDir()?.deleteRecursively()
}
}
private fun debugDir(): 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
}
}
@@ -11,7 +11,6 @@ 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.*
import java.util.logging.Formatter
import java.util.logging.LogRecord
@@ -20,17 +19,17 @@ class PlainTextFormatter private constructor(
): Formatter() {
companion object {
val LOGCAT = PlainTextFormatter(true)
val DEFAULT = PlainTextFormatter(false)
@JvmField val LOGCAT = PlainTextFormatter(true)
@JvmField val DEFAULT = PlainTextFormatter(false)
const val MAX_MESSAGE_LENGTH = 20000
val MAX_MESSAGE_LENGTH = 20000
}
override fun format(r: LogRecord): String {
val builder = StringBuilder()
if (!logcat)
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss", Locale.ROOT))
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss"))
.append(" ").append(r.threadID).append(" ")
val className = shortClassName(r.sourceClassName)
@@ -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,216 +0,0 @@
package at.bitfire.davdroid.model
import android.content.Context
import android.database.sqlite.SQLiteQueryBuilder
import androidx.core.database.getStringOrNull
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.AndroidSingleton
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.log.Logger
import java.io.Writer
@Suppress("ClassName")
@Database(entities = [
Service::class,
HomeSet::class,
Collection::class,
SyncStats::class
], exportSchema = true, version = 9)
@TypeConverters(Converters::class)
abstract class AppDatabase: RoomDatabase() {
abstract fun serviceDao(): ServiceDao
abstract fun homeSetDao(): HomeSetDao
abstract fun collectionDao(): CollectionDao
abstract fun syncStatsDao(): SyncStatsDao
companion object: AndroidSingleton<AppDatabase>() {
override fun createInstance(context: Context): AppDatabase =
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
.addMigrations(*migrations)
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
.build()
// migrations
val migrations: Array<Migration> = arrayOf(
object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE syncstats (" +
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
"authority TEXT NOT NULL," +
"lastSync INTEGER NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
}
},
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
}
},
object : 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 : 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 : 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 : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
}
},
object : 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 : 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"))
}
}
)
}
fun dump(writer: Writer) {
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 tableName = cursorTables.getString(0)
writer.append("$tableName\n")
db.query("SELECT * FROM $tableName").use { cursor ->
val table = TextTable(*cursor.columnNames)
val cols = cursor.columnCount
// print rows
while (cursor.moveToNext()) {
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
table.addLine(*values)
}
writer.append(table.toString())
}
}
db.endTransaction()
}
}
}
@@ -1,170 +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
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
@Entity(tableName = "collection",
foreignKeys = [
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL)
],
indices = [
Index("serviceId","type"),
Index("homeSetId","type"),
Index("url")
]
)
data class Collection(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
var serviceId: Long = 0,
var homeSetId: Long? = null,
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,
var owner: HttpUrl? = 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() {
@Ignore
var refHomeSet: HomeSet? = null
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
}
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
val owner = dav[Owner::class.java]?.href?.let { ownerHref ->
dav.href.resolve(ownerHref)
}
var description: String? = null
var color: Int? = null
var timezone: String? = null
var supportsVEVENT: Boolean? = null
var supportsVTODO: Boolean? = null
var supportsVJOURNAL: Boolean? = null
var source: HttpUrl? = null
when (type) {
TYPE_ADDRESSBOOK -> {
dav[AddressbookDescription::class.java]?.let { description = it.description }
}
TYPE_CALENDAR, TYPE_WEBCAL -> {
dav[CalendarDescription::class.java]?.let { description = it.description }
dav[CalendarColor::class.java]?.let { color = it.color }
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
if (type == TYPE_CALENDAR) {
supportsVEVENT = true
supportsVTODO = true
supportsVJOURNAL = true
dav[SupportedCalendarComponentSet::class.java]?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
supportsVJOURNAL = it.supportsJournal
}
} else { // Type.WEBCAL
dav[Source::class.java]?.let {
source = it.hrefs.firstOrNull()?.let { rawHref ->
val href = rawHref
.replace("^webcal://".toRegex(), "http://")
.replace("^webcals://".toRegex(), "https://")
href.toHttpUrlOrNull()
}
}
supportsVEVENT = true
}
}
}
return Collection(
type = type,
url = url,
privWriteContent = privWriteContent,
privUnbind = privUnbind,
displayName = displayName,
owner = owner,
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,55 +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 ORDER BY displayName, url")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
/**
* Returns collections which
* - support VEVENT and/or VTODO (= supported calendar collections), or
* - have supportsVEVENT = supportsVTODO = null (= address books)
*/
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " +
"AND (supportsVTODO OR supportsVEVENT OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL)) ORDER BY displayName, URL")
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
fun getByServiceAndSync(serviceId: Long): List<Collection>
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName, collection.url")
fun pagePersonalByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
@Query("SELECT * FROM collection WHERE url=:url")
fun getByUrl(url: String): 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 type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName, url")
fun getSyncCalendars(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' 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,159 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.model
import android.content.ContentValues
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.model.ServiceDB.Collections
import java.io.Serializable
data class CollectionInfo @JvmOverloads constructor(
val url: String,
var id: Long? = null,
var serviceID: Long? = null,
var type: Type? = null,
var readOnly: Boolean = false,
var displayName: String? = null,
var description: String? = null,
var color: Int? = null,
var timeZone: String? = null,
var supportsVEVENT: Boolean = false,
var supportsVTODO: Boolean = false,
var selected: Boolean = false,
// subscriptions
var source: String? = null,
// non-persistent properties
var confirmed: Boolean = false
): Serializable {
enum class Type {
ADDRESS_BOOK,
CALENDAR,
WEBCAL // iCalendar subscription
}
companion object {
@JvmField
val DAV_PROPERTIES = arrayOf(
ResourceType.NAME,
CurrentUserPrivilegeSet.NAME,
DisplayName.NAME,
AddressbookDescription.NAME, SupportedAddressData.NAME,
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
Source.NAME
)
}
constructor(dav: DavResource): this(dav.location.toString()) {
(dav.properties[ResourceType.NAME] as ResourceType?)?.let { type ->
when {
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL
}
}
(dav.properties[CurrentUserPrivilegeSet.NAME] as CurrentUserPrivilegeSet?)?.let { privilegeSet ->
readOnly = !privilegeSet.mayWriteContent
}
(dav.properties[DisplayName.NAME] as DisplayName?)?.let {
if (!it.displayName.isNullOrEmpty())
displayName = it.displayName
}
when (type) {
Type.ADDRESS_BOOK -> {
(dav.properties[AddressbookDescription.NAME] as AddressbookDescription?)?.let { description = it.description }
}
Type.CALENDAR -> {
(dav.properties[CalendarDescription.NAME] as CalendarDescription?)?.let { description = it.description }
(dav.properties[CalendarColor.NAME] as CalendarColor?)?.let { color = it.color }
(dav.properties[CalendarTimezone.NAME] as CalendarTimezone?)?.let { timeZone = it.vTimeZone }
supportsVEVENT = true
supportsVTODO = true
(dav.properties[SupportedCalendarComponentSet.NAME] as SupportedCalendarComponentSet?)?.let {
supportsVEVENT = it.supportsEvents
supportsVTODO = it.supportsTasks
}
}
Type.WEBCAL -> {
(dav.properties[Source.NAME] as Source?)?.let { source = it.hrefs.firstOrNull() }
supportsVEVENT = true
}
}
}
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
id = values.getAsLong(Collections.ID)
serviceID = values.getAsLong(Collections.SERVICE_ID)
type = try {
Type.valueOf(values.getAsString(Collections.TYPE))
} catch (e: Exception) {
null
}
readOnly = values.getAsInteger(Collections.READ_ONLY) != 0
displayName = values.getAsString(Collections.DISPLAY_NAME)
description = values.getAsString(Collections.DESCRIPTION)
color = values.getAsInteger(Collections.COLOR)
timeZone = values.getAsString(Collections.TIME_ZONE)
supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false
supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false
source = values.getAsString(Collections.SOURCE)
selected = values.getAsInteger(Collections.SYNC) != 0
}
fun toDB(): ContentValues {
val values = ContentValues()
// Collections.SERVICE_ID is never changed
type?.let { values.put(Collections.TYPE, it.name) }
values.put(Collections.URL, url)
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
values.put(Collections.DISPLAY_NAME, displayName)
values.put(Collections.DESCRIPTION, description)
values.put(Collections.COLOR, color)
values.put(Collections.TIME_ZONE, timeZone)
values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0)
values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0)
values.put(Collections.SOURCE, source)
values.put(Collections.SYNC, if (selected) 1 else 0)
return values
}
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
val i = values.getAsInteger(field)
return if (i == null)
null
else
(i != 0)
}
}
@@ -1,17 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.TypeConverter
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
class Converters {
@TypeConverter
fun httpUrlToString(url: HttpUrl?) =
url?.toString()
@TypeConverter
fun stringToHttpUrl(url: String?): HttpUrl? =
url?.toHttpUrlOrNull()
}
@@ -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.model
class Credentials(
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null
) {
override fun toString(): String {
val maskedPassword = "*****".takeIf { password != null }
return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)"
}
}
@@ -1,46 +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 {
/**
* Synchronizes a list of "old" elements with a list of "new" elements so that the list
* only contain equal elements.
*
* @param allOld list of old elements
* @param allNew map of new elements (stored in key map)
* @param selectKey generates a unique key from the element (will be called on old elements)
* @param prepareNew prepares new elements (can be used to take over properties of old elements)
*/
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) {
Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew))
val remainingNew = allNew.toMutableMap()
allOld.forEach { old ->
val key = selectKey(old)
val matchingNew = remainingNew[key]
if (matchingNew != null) {
// keep this old item, but maybe update it
matchingNew.id = old.id // identity is proven by key
prepareNew(matchingNew, old)
if (matchingNew != old)
update(matchingNew)
// remove from remainingNew
remainingNew -= key
} else {
// this old item is not present anymore, delete it
delete(old)
}
}
val toInsert = remainingNew.values.toList()
val insertIds = insert(toInsert)
insertIds.withIndex().forEach { (idx, id) ->
toInsert[idx].id = id
}
}
}
@@ -1,34 +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,
/**
* Whether this homeset belongs to the [Service.principal] given by [serviceId].
*/
var personal: Boolean,
var url: HttpUrl,
var privBind: Boolean = true,
var displayName: String? = null
): IdEntity()
@@ -1,24 +0,0 @@
package at.bitfire.davdroid.model
import androidx.lifecycle.LiveData
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>
@Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind")
fun hasBindableByService(serviceId: Long): LiveData<Boolean>
@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,189 @@
/*
* 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 TYPE = "type"
@JvmField val SERVICE_ID = "serviceID"
@JvmField val URL = "url"
@JvmField val READ_ONLY = "readOnly"
@JvmField val DISPLAY_NAME = "displayName"
@JvmField val DESCRIPTION = "description"
@JvmField val COLOR = "color"
@JvmField val TIME_ZONE = "timezone"
@JvmField val SUPPORTS_VEVENT = "supportsVEVENT"
@JvmField val SUPPORTS_VTODO = "supportsVTODO"
@JvmField val SOURCE = "source"
@JvmField val SYNC = "sync"
}
companion object {
@JvmStatic
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
val values = ContentValues(1)
values.put(Services.ACCOUNT_NAME, newName)
db.updateWithOnConflict(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName), SQLiteDatabase.CONFLICT_REPLACE)
}
}
class OpenHelper(
context: Context
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
companion object {
val DATABASE_NAME = "services.db"
val DATABASE_VERSION = 2
}
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.TYPE} TEXT NOT NULL," +
"${Collections.URL} TEXT NOT NULL," +
"${Collections.READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
"${Collections.DISPLAY_NAME} TEXT NULL," +
"${Collections.DESCRIPTION} TEXT NULL," +
"${Collections.COLOR} INTEGER NULL," +
"${Collections.TIME_ZONE} TEXT NULL," +
"${Collections.SUPPORTS_VEVENT} INTEGER NULL," +
"${Collections.SUPPORTS_VTODO} INTEGER NULL," +
"${Collections.SOURCE} TEXT NULL," +
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion == 1 && newVersion == 2) {
// the only possible migration at the moment
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL")
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" +
"SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " +
"FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" +
")",
arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK))
}
}
fun dump(sb: StringBuilder) {
val db = readableDatabase
db.beginTransactionNonExclusive()
// iterate through all tables
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
while (cursorTables.moveToNext()) {
val table = cursorTables.getString(0)
sb.append(table).append("\n")
db.query(table, null, null, null, null, null, null).use { cursor ->
// print columns
val cols = cursor.columnCount
sb.append("\t| ")
for (i in 0 .. cols-1)
sb .append(" ")
.append(cursor.getColumnName(i))
.append(" |")
sb.append("\n")
// print rows
while (cursor.moveToNext()) {
sb.append("\t| ")
for (i in 0 .. cols-1) {
sb.append(" ")
try {
val value = cursor.getString(i)
if (value != null)
sb.append(value
.replace("\r", "<CR>")
.replace("\n", "<LF>"))
else
sb.append("<null>")
} catch (e: SQLiteException) {
sb.append("<unprintable>")
}
sb.append(" |")
}
sb.append("\n")
}
sb.append("----------\n")
}
}
db.endTransaction()
}
}
}
}
@@ -1,33 +0,0 @@
package at.bitfire.davdroid.model
import androidx.lifecycle.LiveData
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): LiveData<Long>
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): 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,108 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.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.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 getLong(name: String, defaultValue: Long): Long {
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.getLong(0)
}
return defaultValue
}
fun putLong(name: String, value: Long?) {
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)")
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,24 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "syncstats",
foreignKeys = [
ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE)
],
indices = [
Index("collectionId", "authority", unique = true),
]
)
data class SyncStats(
@PrimaryKey(autoGenerate = true)
val id: Long,
val collectionId: Long,
val authority: String,
var lastSync: Long
)
@@ -1,13 +0,0 @@
package at.bitfire.davdroid.model
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@Dao
interface SyncStatsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(syncStats: SyncStats)
}
@@ -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>): LongArray
@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()
}
@@ -21,81 +21,71 @@ import android.util.Base64
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.syncadapter.AccountUtils
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_READ_ONLY = "read_only"
val USER_DATA_CTAG = "ctag"
@JvmStatic
@Throws(ContactsStorageException::class)
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
val accountManager = AccountManager.get(context)
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
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 (!AccountUtils.createAccount(context, account, userData))
throw IllegalStateException("Couldn't create address book account")
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
throw ContactsStorageException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, account, provider)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
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 {
try {
it.mainAccount == mainAccount
} catch(e: IllegalStateException) {
false
}
}
.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(context.getString(R.string.account_type_address_book))
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
.forEach { result += it }
return result
}
@JvmStatic
fun accountName(mainAccount: Account, info: CollectionInfo): String {
val baos = ByteArrayOutputStream()
baos.write(info.url.hashCode())
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
val sb = StringBuilder(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)
@@ -104,239 +94,180 @@ 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 tag: String
get() = "contacts-${account.name}"
override val title = account.name!!
/**
* Whether contact groups ([LocalGroup]) are included in query results
* and are affected by updates/deletes on generic members.
*
* For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
* 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
/**
* The associated main account which this address book accounts belongs to.
* @throws IllegalStateException when no main account is assigned
*/
var mainAccount: Account
get() {
_mainAccount?.let { return it }
AccountManager.get(context).let { accountManager ->
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
if (name != null && type != null)
return Account(name, type)
else
throw IllegalStateException("No main account assigned to address book account")
}
}
set(newMainAccount) {
AccountManager.get(context).let { accountManager ->
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
}
_mainAccount = newMainAccount
}
var url: String
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
?: throw IllegalStateException("Address book has no URL")
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
override var readOnly: Boolean
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override var lastSyncState: SyncState?
get() = syncState?.let { SyncState.fromString(String(it)) }
set(state) {
syncState = state?.toString()?.toByteArray()
}
/* operations on the collection (address book) itself */
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalContact.COLUMN_FLAGS, flags)
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
if (includeGroups) {
values.clear()
values.put(LocalGroup.COLUMN_FLAGS, flags)
number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
}
return number
}
override fun removeNotDirtyMarked(flags: Int): Int {
var number = provider!!.delete(rawContactsSyncUri(),
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
if (includeGroups)
number += provider.delete(groupsSyncUri(),
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
return number
}
fun update(info: Collection) {
val newAccountName = accountName(mainAccount, info)
@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)
}
Constants.log.info("Address book read-only? = ${info.readOnly}")
setReadOnly(info.readOnly)
// make sure it will still be synchronized when contacts are updated
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) <= 0)
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
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, null)
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
/**
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
* @throws RemoteException on content provider errors
*/
override fun findDirty() =
if (includeGroups)
findDirtyContacts() + findDirtyGroups()
else
findDirtyContacts()
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
override fun forgetETags() {
if (includeGroups) {
val values = ContentValues(1)
values.putNull(AndroidGroup.COLUMN_ETAG)
provider!!.update(groupsSyncUri(), values, null, null)
}
val values = ContentValues(1)
values.putNull(AndroidContact.COLUMN_ETAG)
provider!!.update(rawContactsSyncUri(), values, null, null)
@Throws(ContactsStorageException::class)
override fun getDeleted(): List<LocalResource> {
val deleted = LinkedList<LocalResource>()
deleted.addAll(getDeletedContacts())
if (includeGroups)
deleted.addAll(getDeletedGroups())
return deleted
}
/**
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
* whose contact data checksum has not changed.
* @return number of "really dirty" contacts
* @throws 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)
}
}
@@ -345,23 +276,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 */
@@ -371,4 +308,52 @@ 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() =
AccountManager.get(context).getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
fun setURL(url: String) =
AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
fun getReadOnly() =
AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
fun setReadOnly(readOnly: Boolean) =
AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
override fun getCTag(): String? =
AccountManager.get(context).getUserData(account, USER_DATA_CTAG)
override fun setCTag(cTag: String?) =
AccountManager.get(context).setUserData(account, USER_DATA_CTAG, cTag)
}
@@ -10,20 +10,18 @@ package at.bitfire.davdroid.resource
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentProviderOperation
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import at.bitfire.davdroid.Constants
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.CalendarContract.*
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.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,41 +33,51 @@ 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.
values.put(Calendars.ACCOUNT_NAME, account.name)
values.put(Calendars.ACCOUNT_TYPE, account.type)
// Email address for scheduling. Used by the calendar provider to determine whether the
// user is ORGANIZER/ATTENDEE for a certain event.
values.put(Calendars.OWNER_ACCOUNT, account.name)
// flag as visible & synchronizable at creation, might be changed by user at any time
values.put(Calendars.VISIBLE, 1)
values.put(Calendars.SYNC_EVENTS, 1)
return create(account, provider, values)
val uri = create(account, provider, values)
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 ->
@@ -79,174 +87,143 @@ class LocalCalendar private constructor(
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
}
}
// add base values for Calendars
values.putAll(calendarBaseValues)
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, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
return values
}
}
override val tag: String
get() = "events-${account.name}-$id"
override val title: String
get() = displayName ?: id.toString()
override var lastSyncState: SyncState?
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
return SyncState.fromString(cursor.getString(0))
else
null
}
set(state) {
val values = ContentValues(1)
values.put(COLUMN_SYNC_STATE, state.toString())
provider.update(calendarSyncURI(), values, null, null)
}
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() =
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
@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>()
/*
* RFC 5545 3.8.7.4. Sequence Number
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
* CUA each time the "Organizer" makes a significant revision to the calendar component.
*/
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
try {
val event = requireNotNull(localEvent.event)
val nonGroupScheduled = event.attendees.isEmpty()
val weAreOrganizer = localEvent.weAreOrganizer
val sequence = event.sequence
if (sequence == null)
// sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
event.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
// get dirty events which are required to have an increased SEQUENCE value
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
val event = localEvent.event!!
val sequence = event.sequence
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
event.sequence = 0
else if (localEvent.weAreOrganizer)
event.sequence = sequence!! + 1
dirty += localEvent
}
return dirty
}
override fun findByName(name: String) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
override fun markNotDirty(flags: Int): Int {
val values = ContentValues(1)
values.put(LocalEvent.COLUMN_FLAGS, flags)
return provider.update(eventsSyncURI(), values,
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
arrayOf(id.toString()))
}
override fun removeNotDirtyMarked(flags: Int): Int {
var deleted = 0
// list all non-dirty events with the given flags and delete every row + its exceptions
provider.query(eventsSyncURI(), arrayOf(Events._ID),
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
val batch = BatchOperation(provider)
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(eventsSyncURI())
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
@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)
}
deleted = batch.commit()
null
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
}
@Throws(CalendarStorageException::class)
override fun setCTag(cTag: String?) {
try {
val values = ContentValues(1)
values.put(COLUMN_CTAG, cTag)
provider.update(calendarSyncURI(), values, null, null)
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
}
return deleted
}
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?",
arrayOf(id.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} 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
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1))
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
.withValue(Events.DIRTY, 1)
))
}
// remove exception
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
))
batch.commit()
}
// completely remove deleted exception
batch.enqueue(BatchOperation.CpoBuilder.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))))
batch.commit()
}
}
// process dirty exceptions
Logger.log.info("Processing dirty exceptions")
provider.query(
syncAdapterURI(Events.CONTENT_URI),
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
arrayOf(id.toString()), null)?.use { cursor ->
while (cursor.moveToNext()) {
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
val id = cursor.getLong(0) // can't be null (by definition)
val originalID = cursor.getLong(1) // can't be null (by query)
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
// 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.CpoBuilder
.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
.withValue(Events.DIRTY, 1))
// increase SEQUENCE and set DIRTY to 0
batch.enqueue(BatchOperation.CpoBuilder
.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)
}
}
@@ -258,4 +235,4 @@ class LocalCalendar private constructor(
}
}
}
@@ -8,70 +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> {
/** a tag that uniquely identifies the collection (DAVx5-wide) */
val tag: String
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getDeleted(): List<T>
/** collection title (used for user notifications etc.) **/
val title: String
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getWithoutFileName(): List<T>
var lastSyncState: SyncState?
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
fun getDirty(): List<T>
/**
* Finds local resources of this collection which have been marked as *deleted* by the user
* or an app acting on their behalf.
*
* @return list of resources marked as *deleted*
*/
fun findDeleted(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getAll(): List<T>
/**
* Finds local resources of this collection which have been marked as *dirty*, i.e. resources
* which have been modified by the user or an app acting on their behalf.
*
* @return list of resources marked as *dirty*
*/
fun findDirty(): List<T>
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun getCTag(): String?
/**
* Finds a local resource of this collection with a given file name. (File names are assigned
* by the sync adapter.)
*
* @param name file name to look for
* @return resource with the given name, or null if none
*/
fun findByName(name: String): T?
/**
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
* and have an [Events.ORIGINAL_ID] of null.
*
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
*
* @return number of marked entries
*/
fun markNotDirty(flags: Int): Int
/**
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
* a given flag combination.
*
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
* all entries with exactly this flag will be removed)
*
* @return number of removed entries
*/
fun removeNotDirtyMarked(flags: Int): Int
/**
* Forgets the ETags of all members so that they will be reloaded from the server during sync.
*/
fun forgetETags()
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun setCTag(cTag: String?)
}
@@ -8,6 +8,7 @@
package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.os.Build
import android.os.RemoteException
@@ -19,127 +20,132 @@ import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.UnknownProperties
import at.bitfire.vcard4android.*
import ezvcard.Ezvcard
import org.apache.commons.lang3.StringUtils
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 scheduleTag: String?
get() = null
set(_) = throw NotImplementedError()
override var flags: Int = 0
constructor(addressBook: AndroidAddressBook<LocalContact,*>, id: Long, fileName: String?, eTag: String?):
super(addressBook, id, fileName, eTag)
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
: super(addressBook, values) {
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
}
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
super(addressBook, contact, fileName, eTag)
override fun prepareForUpload(): String {
var uid: String? = null
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = StringUtils.trimToNull(cursor.getString(0))
}
if (uid == null) {
// generate new UID
uid = UUID.randomUUID().toString()
val values = ContentValues(1)
values.put(COLUMN_UID, uid)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
contact!!.uid = uid
}
return "$uid.vcf"
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
val values = ContentValues(4)
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
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)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
}
override fun resetDeleted() {
@Throws(ContactsStorageException::class)
fun resetDeleted() {
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
values.put(ContactsContract.RawContacts.DELETED, 0)
try {
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear deleted flag", e)
}
}
@Throws(ContactsStorageException::class)
fun resetDirty() {
val values = ContentValues(1)
values.put(ContactsContract.RawContacts.DIRTY, 0)
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 builder = insertDataBuilder(UnknownProperties.RAW_CONTACT_ID)
.withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
batch.enqueue(builder)
try {
contact!!.unknownProperties?.let { unknownProperties ->
val op: BatchOperation.Operation
val builder = ContentProviderOperation.newInsert(dataSyncURI())
if (id == null)
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
else {
op = BatchOperation.Operation(builder)
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
}
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
batch.enqueue(op)
}
} catch(e: FileNotFoundException) {
throw ContactsStorageException("Couldn't insert data rows", e)
}
}
@@ -148,6 +154,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")
@@ -162,71 +169,88 @@ 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 hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
if (batch == null) {
val values = ContentValues(1)
val values = ContentValues(1)
try {
val hashCode = dataHashCode()
Logger.log.fine("Storing contact hash = $hashCode")
values.put(COLUMN_HASHCODE, hashCode)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
} else
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(rawContactSyncURI())
.withValue(COLUMN_HASHCODE, hashCode))
if (batch == null)
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
else {
val builder = ContentProviderOperation
.newUpdate(rawContactSyncURI())
.withValues(values)
batch.enqueue(BatchOperation.Operation(builder))
}
} catch(e: Exception) {
throw ContactsStorageException("Couldn't store contact checksum", e)
}
}
@Throws(ContactsStorageException::class)
fun getLastHashCode(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
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
}
fun addToGroup(batch: BatchOperation, groupID: Long) {
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID))
groupMemberships += groupID
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
.withValue(GroupMembership.RAW_CONTACT_ID, id)
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
))
groupMemberships.add(groupID)
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
)
cachedGroupMemberships += groupID
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(dataSyncURI())
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
.withValue(CachedGroupMembership.GROUP_ID, groupID)
.withYieldAllowed(true)
))
cachedGroupMemberships.add(groupID)
}
fun removeGroupMemberships(batch: BatchOperation) {
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(dataSyncURI())
.withSelection(
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
))
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(dataSyncURI())
.withSelection(
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
)
.withYieldAllowed(true)
))
groupMemberships.clear()
cachedGroupMemberships.clear()
}
/**
* Returns the IDs of all groups the contact was member of (cached memberships).
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
* 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
@@ -235,26 +259,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: BatchOperation.CpoBuilder, 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)
}
}
@@ -8,138 +8,141 @@
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 org.apache.commons.lang3.StringUtils
import java.io.FileNotFoundException
import java.util.*
class LocalEvent: AndroidEvent, LocalResource<Event> {
class LocalEvent: AndroidEvent, LocalResource {
companion object {
init {
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.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
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
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 scheduleTag: String? = null
override var flags: Int = 0
private set
var weAreOrganizer = false
private set
var weAreOrganizer = true
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: 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.scheduleTag = scheduleTag
this.flags = flags
}
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
fileName = values.getAsString(Events._SYNC_ID)
eTag = values.getAsString(COLUMN_ETAG)
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
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)
}
}
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
/* 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)
fileName = row.getAsString(Events._SYNC_ID)
eTag = row.getAsString(COLUMN_ETAG)
event.uid = row.getAsString(COLUMN_UID)
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
weAreOrganizer = isOrganizer != null && isOrganizer != 0
super.populateEvent(row, groupScheduled)
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
}
}
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
super.buildEvent(recurrence, builder)
val event = requireNotNull(event)
val buildException = recurrence != null
val eventToBuild = recurrence ?: event
builder .withValue(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)
else
builder .withValue(Events._SYNC_ID, fileName)
.withValue(COLUMN_ETAG, eTag)
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
super.buildEvent(recurrence, builder)
}
override fun prepareForUpload(): String {
var uid: String? = null
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = StringUtils.trimToNull(cursor.getString(0))
}
/* custom queries */
if (uid == null) {
// generate new UID
uid = UUID.randomUUID().toString()
@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 values = ContentValues(1)
values.put(Events.UID_2445, uid)
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)
}
return "$uid.ics"
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
val values = ContentValues(5)
if (fileName != null)
values.put(Events._SYNC_ID, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
values.put(COLUMN_SEQUENCE, event!!.sequence)
values.put(Events.DIRTY, 0)
calendar.provider.update(eventSyncURI(), values, null, null)
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(2)
values.put(CalendarContract.Events.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_SEQUENCE, event!!.sequence);
calendar.provider.update(eventSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
this.scheduleTag = scheduleTag
}
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)
}
}
@@ -8,9 +8,9 @@
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
@@ -19,107 +19,168 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
import android.provider.ContactsContract.Groups
import android.provider.ContactsContract.RawContacts
import android.provider.ContactsContract.RawContacts.Data
import at.bitfire.dav4android.Constants
import at.bitfire.vcard4android.*
import org.apache.commons.lang3.StringUtils
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
/** marshalled list of member UIDs, as sent by server */
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
/** marshaled list of member UIDs, as sent by server */
val COLUMN_PENDING_MEMBERS = Groups.SYNC3
/**
* Processes all groups with non-null [COLUMN_PENDING_MEMBERS]: the pending memberships
* are applied (if possible) to keep cached memberships in sync.
*
* 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.CpoBuilder
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
.withValue(COLUMN_PENDING_MEMBERS, null))
batch.commit()
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't get pending memberships", e)
}
}
}
override var scheduleTag: String?
get() = null
set(value) = throw NotImplementedError()
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?):
super(addressBook, id, fileName, eTag)
override var flags: Int = 0
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
@Throws(ContactsStorageException::class)
fun resetDeleted() {
val uri = ContentUris.withAppendedId(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), requireNotNull(id))
val values = ContentValues(1)
values.put(ContactsContract.Groups.DELETED, 0)
try {
addressBook.provider!!.update(uri, values, null, null)
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't clear deleted flag", e)
}
}
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
: super(addressBook, contact, fileName, eTag) {
this.flags = flags
@Throws(ContactsStorageException::class)
override fun clearDirty(eTag: String?) {
val id = requireNotNull(id)
val values = ContentValues(2)
values.put(Groups.DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
this.eTag = eTag
update(values)
// update cached group memberships
val batch = BatchOperation(addressBook.provider!!)
// delete cached group memberships
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
)
))
// insert updated cached group memberships
for (member in getMembers())
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id)
.withYieldAllowed(true)
))
batch.commit()
}
@Throws(ContactsStorageException::class)
override fun prepareForUpload() {
val uid = UUID.randomUUID().toString()
val newFileName = "$uid.vcf"
val values = ContentValues(2)
values.put(COLUMN_FILENAME, newFileName)
values.put(COLUMN_UID, uid)
update(values)
fileName = newFileName
}
@Throws(FileNotFoundException::class, ContactsStorageException::class)
override fun contentValues(): ContentValues {
val values = super.contentValues()
values.put(COLUMN_FLAGS, flags)
val members = Parcel.obtain()
try {
@@ -132,118 +193,48 @@ class LocalGroup: AndroidGroup, LocalAddress {
}
override fun prepareForUpload(): String {
var uid: String? = null
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = StringUtils.trimToNull(cursor.getString(0))
}
if (uid == null) {
// generate new UID
uid = UUID.randomUUID().toString()
val values = ContentValues(1)
values.put(AndroidContact.COLUMN_UID, uid)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
contact!!.uid = uid
}
return "$uid.vcf"
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
val id = requireNotNull(id)
val values = ContentValues(3)
if (fileName != null)
values.put(COLUMN_FILENAME, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(Groups.DIRTY, 0)
update(values)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
// update cached group memberships
val batch = BatchOperation(addressBook.provider!!)
// delete cached group memberships
batch.enqueue(BatchOperation.CpoBuilder
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withSelection(
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
))
// insert updated cached group memberships
for (member in getMembers())
batch.enqueue(BatchOperation.CpoBuilder
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
.withValue(CachedGroupMembership.GROUP_ID, id))
batch.commit()
}
/**
* Marks all members of the current group as dirty.
*/
@Throws(ContactsStorageException::class)
fun markMembersDirty() {
val batch = BatchOperation(addressBook.provider!!)
for (member in getMembers())
batch.enqueue(BatchOperation.CpoBuilder
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1))
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
.withValue(RawContacts.DIRTY, 1)
.withYieldAllowed(true)
))
batch.commit()
}
override fun resetDeleted() {
val values = ContentValues(1)
values.put(Groups.DELETED, 0)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
}
override fun updateFlags(flags: Int) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
addressBook.provider!!.update(groupSyncUri(), values, null, null)
this.flags = flags
}
// helpers
private fun groupSyncUri(): Uri {
val id = requireNotNull(id)
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
}
/**
* Lists all members of this group.
* @return list of all members' raw contact IDs
* @throws RemoteException on contact provider errors
* @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
}
@@ -252,8 +243,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,90 +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?
/**
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
* (in this case, [fileName] is the remote file name).
*/
val fileName: String?
/** remote ETag for the resource */
var fileName: String?
var eTag: String?
/** remote Schedule-Tag for the resource */
var scheduleTag: String?
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
val flags: Int
/**
* Prepares the resource for uploading:
*
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
* saved to the content provider. The sync manager is responsible for saving the file name that
* was actually used.
*
* @return new file name of the resource (like "<uid>.vcf")
*/
fun prepareForUpload(): String
/**
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
* locally modified resource.
*
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
*/
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
/**
* Sets (local) flags of the resource. At the moment, the only allowed values are
* 0 and [FLAG_REMOTELY_PRESENT].
*/
fun updateFlags(flags: Int)
/**
* Adds the data object to the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the created row (e.g. event URI)
*/
fun add(): Uri
/**
* Updates the data object in the content provider and ensures that the dirty flag is clear.
*
* @return content URI of the updated row (e.g. event URI)
*/
fun update(data: TData): Uri
/**
* Deletes the data object from the content provider.
*
* @return number of affected rows
*/
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun delete(): Int
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun prepareForUpload()
@Throws(CalendarStorageException::class, ContactsStorageException::class)
fun clearDirty(eTag: String?)
}
@@ -8,106 +8,110 @@
package at.bitfire.davdroid.resource
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.provider.CalendarContract.Events
import at.bitfire.ical4android.*
import org.dmfs.tasks.contract.TaskContract.Tasks
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 scheduleTag: 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 */
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
@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 prepareForUpload(): String {
var uid: String? = null
taskList.provider.client.query(taskSyncURI(), arrayOf(Tasks._UID), null, null, null)?.use { cursor ->
if (cursor.moveToNext())
uid = cursor.getString(0)
}
@Throws(CalendarStorageException::class)
override fun prepareForUpload() {
try {
val uid = UUID.randomUUID().toString()
val newFileName = uid + ".ics"
if (uid == null) {
// generate new UID
uid = UUID.randomUUID().toString()
val values = ContentValues(1)
values.put(Tasks._UID, uid)
val values = ContentValues(2)
values.put(Tasks._SYNC_ID, newFileName)
values.put(COLUMN_UID, uid)
taskList.provider.client.update(taskSyncURI(), values, null, null)
fileName = newFileName
task!!.uid = uid
} catch (e: Exception) {
throw CalendarStorageException("Couldn't update UID", e)
}
return "$uid.ics"
}
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
if (scheduleTag != null)
Ical4Android.log.fine("Schedule-Tag for tasks not supported yet, won't save")
@Throws(CalendarStorageException::class)
override fun clearDirty(eTag: String?) {
try {
val values = ContentValues(2)
values.put(Tasks._DIRTY, 0)
values.put(COLUMN_ETAG, eTag)
val values = ContentValues(4)
if (fileName != null)
values.put(Tasks._SYNC_ID, fileName)
values.put(COLUMN_ETAG, eTag)
values.put(Tasks.SYNC_VERSION, task!!.sequence)
values.put(Tasks._DIRTY, 0)
taskList.provider.client.update(taskSyncURI(), values, null, null)
if (fileName != null)
this.fileName = fileName
this.eTag = eTag
}
override fun updateFlags(flags: Int) {
if (id != null) {
val values = ContentValues(1)
values.put(COLUMN_FLAGS, flags)
values.put(COLUMN_SEQUENCE, task!!.sequence)
taskList.provider.client.update(taskSyncURI(), values, null, null)
}
this.flags = flags
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,21 +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 at.bitfire.davdroid.Constants
import android.os.Build
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.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,
@@ -33,7 +33,30 @@ class LocalTaskList private constructor(
companion object {
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
val defaultColor = 0xFFC3EA6E.toInt() // "DAVdroid green"
val COLUMN_CTAG = TaskLists.SYNC_VERSION
val BASE_INFO_COLUMNS = arrayOf(
Tasks._ID,
Tasks._SYNC_ID,
LocalTask.COLUMN_ETAG
)
@JvmStatic
fun tasksProviderAvailable(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
else {
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
provider?.use { return true }
return false
}
}
@JvmStatic
@Throws(CalendarStorageException::class)
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
val values = valuesFromCollectionInfo(info, true)
values.put(TaskLists.OWNER, account.name)
values.put(TaskLists.SYNC_ENABLED, 1)
@@ -41,106 +64,93 @@ class LocalTaskList private constructor(
return create(account, provider, values)
}
@SuppressLint("Recycle")
@JvmStatic
@Throws(Exception::class)
fun onRenameAccount(context: Context, oldName: String, newName: String) {
TaskProvider.acquire(context)?.use { provider ->
val values = ContentValues(1)
values.put(Tasks.ACCOUNT_NAME, newName)
provider.client.update(
Tasks.getContentUri(provider.name.authority),
values,
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
)
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
var client: ContentProviderClient? = null
try {
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
client?.use {
val values = ContentValues(1)
values.put(Tasks.ACCOUNT_NAME, newName)
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
client?.close()
else
client?.release()
}
}
private fun valuesFromCollectionInfo(info: 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 tag: String
get() = "tasks-${account.name}-$id"
override val title: String
get() = name ?: id.toString()
override var lastSyncState: SyncState?
get() {
try {
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
null, null, null)?.use { cursor ->
if (cursor.moveToNext())
cursor.getString(0)?.let {
return SyncState.fromString(it)
}
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
}
return null
}
set(state) {
val values = ContentValues(1)
values.put(TaskLists.SYNC_VERSION, state?.toString())
provider.client.update(taskListSyncUri(), values, null, null)
}
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, null)
@Throws(CalendarStorageException::class)
override fun getAll() = queryTasks(null, null)
override fun findDirty(): List<LocalTask> {
val tasks = queryTasks(Tasks._DIRTY, null)
@Throws(CalendarStorageException::class)
override fun getDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
@Throws(CalendarStorageException::class)
override fun getWithoutFileName() = queryTasks("${Tasks._SYNC_ID} IS NULL", null)
@Throws(FileNotFoundException::class, CalendarStorageException::class)
override fun getDirty(): List<LocalTask> {
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
for (localTask in tasks) {
try {
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else // task was modified, increase sequence
task.sequence = sequence + 1
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
}
val task = requireNotNull(localTask.task)
val sequence = task.sequence
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
task.sequence = 0
else
task.sequence = sequence + 1
}
return tasks
}
override fun 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()))
}
override fun removeNotDirtyMarked(flags: Int) =
provider.client.delete(tasksSyncUri(),
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
arrayOf(id.toString(), flags.toString()))
override fun forgetETags() {
val values = ContentValues(1)
values.putNull(LocalEvent.COLUMN_ETAG)
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
arrayOf(id.toString()))
@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)
}
}
@@ -151,4 +161,4 @@ class LocalTaskList private constructor(
}
}
}
@@ -1,34 +0,0 @@
package at.bitfire.davdroid.resource
import android.content.Context
import at.bitfire.davdroid.TasksWatcher
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.ical4android.TaskProvider.ProviderName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
object TaskUtils {
fun currentProvider(context: Context): ProviderName? {
val preferredAuthority = SettingsManager.getInstance(context).getString(Settings.PREFERRED_TASKS_PROVIDER)
ProviderName.values()
.sortedByDescending { it.authority == preferredAuthority }
.forEach { providerName ->
if (context.packageManager.resolveContentProvider(providerName.authority, 0) != null)
return providerName
}
return null
}
fun isAvailable(context: Context) = currentProvider(context) != null
fun setPreferredProvider(context: Context, providerName: ProviderName) {
SettingsManager.getInstance(context).putString(Settings.PREFERRED_TASKS_PROVIDER, providerName.authority)
CoroutineScope(Dispatchers.Default).launch {
TasksWatcher.updateTaskSync(context)
}
}
}
@@ -1,678 +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.content.pm.PackageManager
import android.os.Bundle
import android.os.Parcel
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.CalendarContract.ExtendedProperties
import android.provider.ContactsContract
import android.util.Base64
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.TasksWatcher
import at.bitfire.davdroid.closeCompat
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.model.Collection
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.model.Service
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidEvent
import at.bitfire.ical4android.TaskProvider
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
import at.bitfire.ical4android.UnknownProperty
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.property.Url
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
import java.io.ByteArrayInputStream
import java.io.ObjectInputStream
import java.util.logging.Level
/**
* Manages settings of an account.
*
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
*/
@Suppress("FunctionName")
class AccountSettings(
val context: Context,
val account: Account
) {
companion object {
const val CURRENT_VERSION = 12
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
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 KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
/** Time range limitation to the past [in days]. Values:
*
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
* - <0 (typically -1): no limit
* - n>0: entries more than n days in the past won't be synchronized
*/
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
/**
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
* Value can be null (no default alarm) or an integer (default alarm shall be created this
* number of minutes before the event/task).
*/
const val KEY_DEFAULT_ALARM = "default_alarm"
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
value = *null* (not existing): true (default);
"0" false */
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
/** Whether DAVx5 populates and uses CalendarContract.Colors
value = *null* (not existing) false (default);
"1" true */
const val KEY_EVENT_COLORS = "event_colors"
/** Contact group method:
*null (not existing)* groups as separate vCards (default);
"CATEGORIES" groups are per-contact CATEGORIES
*/
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
/** UI preference: Show only personal collections
value = *null* (not existing) show all collections (default);
"1" show only personal collections */
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
const val SYNC_INTERVAL_MANUALLY = -1L
fun initialUserData(credentials: Credentials?): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.userName != null)
bundle.putString(KEY_USERNAME, credentials.userName)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}
return bundle
}
fun repairSyncIntervals(context: Context) {
val addressBooksAuthority = context.getString(R.string.address_books_authority)
val taskAuthority = TaskUtils.currentProvider(context)?.authority
val am = AccountManager.get(context)
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
try {
val settings = AccountSettings(context, account)
// repair address book sync
settings.getSavedAddressbooksSyncInterval()?.let { shouldBe ->
val current = settings.getSyncInterval(addressBooksAuthority)
if (current != shouldBe) {
Logger.log.warning("${account.name}: $addressBooksAuthority sync interval should be $shouldBe but is $current -> setting to $current")
settings.setSyncInterval(addressBooksAuthority, shouldBe)
}
}
// repair calendar sync
settings.getSavedCalendarsSyncInterval()?.let { shouldBe ->
val current = settings.getSyncInterval(CalendarContract.AUTHORITY)
if (current != shouldBe) {
Logger.log.warning("${account.name}: ${CalendarContract.AUTHORITY} sync interval should be $shouldBe but is $current -> setting to $current")
settings.setSyncInterval(CalendarContract.AUTHORITY, shouldBe)
}
}
if (taskAuthority != null)
// repair calendar sync
settings.getSavedTasksSyncInterval()?.let { shouldBe ->
val current = settings.getSyncInterval(taskAuthority)
if (current != shouldBe) {
Logger.log.warning("${account.name}: $taskAuthority sync interval should be $shouldBe but is $current -> setting to $current")
settings.setSyncInterval(taskAuthority, shouldBe)
}
}
} catch (ignored: InvalidAccountException) {
// account doesn't exist (anymore)
}
}
}
val accountManager: AccountManager = AccountManager.get(context)
val settings = SettingsManager.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
}
/**
* Sets the sync interval and enables/disables automatic sync for the given account and authority.
* Does *not* call [ContentResolver.setIsSyncable].
*
* This method blocks until the settings have arrived in the sync framework, so it should not
* be called from the UI thread.
*
* @param authority sync authority (like [CalendarContract.AUTHORITY])
* @param seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
* otherwise: automatic sync will be enabled and set to the given number of seconds
*
* @return whether the sync interval was successfully set
*/
@WorkerThread
fun setSyncInterval(authority: String, seconds: Long): Boolean {
/* Ugly hack: because there is no callback for when the sync status/interval has been
updated, we need to make this call blocking. */
val setInterval: () -> Boolean =
if (seconds == SYNC_INTERVAL_MANUALLY) {
{
Logger.log.fine("Disabling automatic sync of $account/$authority")
ContentResolver.setSyncAutomatically(account, authority, false)
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
}
} else {
{
Logger.log.fine("Setting automatic sync of $account/$authority to $seconds seconds")
ContentResolver.setSyncAutomatically(account, authority, true)
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
/* return */ ContentResolver.getSyncAutomatically(account, authority) &&
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period == seconds
}
}
// try up to 10 times with 100 ms pause
var success = false
for (idxTry in 0 until 10) {
success = setInterval()
if (success)
break
Thread.sleep(100)
}
if (!success)
return false
// store sync interval in account settings (used when the provider is switched)
if (authority == context.getString(R.string.address_books_authority))
accountManager.setUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS, seconds.toString())
else if (authority == CalendarContract.AUTHORITY)
accountManager.setUserData(account, KEY_SYNC_INTERVAL_CALENDARS, seconds.toString())
else if (TaskProvider.ProviderName.values().any { it.authority == authority })
accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, seconds.toString())
return true
}
fun getSavedAddressbooksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS)?.toLong()
fun getSavedCalendarsSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_CALENDARS)?.toLong()
fun getSavedTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
fun getSyncWifiOnly() =
if (settings.containsKey(KEY_WIFI_ONLY))
settings.getBoolean(KEY_WIFI_ONLY)
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 (getSyncWifiOnly()) {
val strSsids = if (settings.containsKey(KEY_WIFI_ONLY_SSIDS))
settings.getString(KEY_WIFI_ONLY_SSIDS)
else
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS)
strSsids?.split(',')
} else
null
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
// CalDAV settings
fun getTimeRangePastDays(): Int? {
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
return if (strDays != null) {
val days = strDays.toInt()
if (days < 0)
null
else
days
} else
DEFAULT_TIME_RANGE_PAST_DAYS
}
fun setTimeRangePastDays(days: Int?) =
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
/**
* Takes the default alarm setting (in this order) from
*
* 1. the local account settings
* 2. the settings provider (unless the value is -1 there).
*
* @return A default reminder shall be created this number of minutes before the start of every
* non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun getDefaultAlarm() =
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
/**
* Sets the default alarm value in the local account settings, if the new value differs
* from the value of the settings provider. If the new value is the same as the value of
* the settings provider, the local setting will be deleted, so that the settings provider
* value applies.
*
* @param minBefore The number of minutes a default reminder shall be created before the
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
*/
fun setDefaultAlarm(minBefore: Int?) =
accountManager.setUserData(account, KEY_DEFAULT_ALARM,
if (minBefore == settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
null
else
minBefore?.toString())
fun getManageCalendarColors() = if (settings.containsKey(KEY_MANAGE_CALENDAR_COLORS))
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS)
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.containsKey(KEY_EVENT_COLORS))
settings.getBoolean(KEY_EVENT_COLORS)
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)
}
// UI settings
/**
* Whether only personal collections should be shown.
*
* @return [Pair] of values:
*
* 1. (first) whether only personal collections should be shown
* 2. (second) whether the user shall be able to change the setting (= setting not locked)
*/
fun getShowOnlyPersonal(): Pair<Boolean, Boolean> =
when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
0 -> Pair(false, false)
1 -> Pair(true, false)
else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true)
}
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) {
accountManager.setUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null)
}
// 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")
/**
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
* convert legacy unknown properties to the current format.
*/
private fun update_11_12() {
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
// to the given account! So all extended properties will be processed number-of-accounts times.
val extUri = AndroidCalendar.syncAdapterURI(ExtendedProperties.CONTENT_URI, account)
provider.query(extUri, arrayOf(
ExtendedProperties._ID, // idx 0
ExtendedProperties.NAME, // idx 1
ExtendedProperties.VALUE // idx 2
), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val rawValue = cursor.getString(2)
val uri by lazy {
AndroidCalendar.syncAdapterURI(ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI, id), account)
}
when (cursor.getString(1)) {
UnknownProperty.CONTENT_ITEM_TYPE -> {
// unknown property; check whether it's a URL
try {
val property = UnknownProperty.fromJsonString(rawValue)
if (property is Url) { // rewrite to MIMETYPE_URL
val newValues = ContentValues(2)
newValues.put(ExtendedProperties.NAME, AndroidEvent.MIMETYPE_URL)
newValues.put(ExtendedProperties.VALUE, property.value)
provider.update(uri, newValues, null, null)
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rewrite URL from unknown property to ${AndroidEvent.MIMETYPE_URL}", e)
}
}
"unknown-property" -> {
// unknown property (deprecated format); convert to current format
try {
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
ObjectInputStream(stream).use {
(it.readObject() as? Property)?.let { property ->
// rewrite to current format
val newValues = ContentValues(2)
newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
newValues.put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
provider.update(uri, newValues, null, null)
}
}
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
}
}
"unknown-property.v2" -> {
// unknown property (deprecated MIME type); rewrite to current MIME type
val newValues = ContentValues(1)
newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
provider.update(uri, newValues, null, null)
}
}
}
}
}
}
@Suppress("unused","FunctionName")
/**
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
* again when the tasks provider is switched.
*/
private fun update_10_11() {
TaskUtils.currentProvider(context)?.let { provider ->
val interval = getSyncInterval(provider.authority)
if (interval != null)
accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, interval.toString())
}
}
@Suppress("unused","FunctionName")
/**
* Task synchronization now handles alarms, categories, relations and unknown properties.
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
*
* Also update the allowed reminder types for calendars.
**/
private fun update_9_10() {
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), account)
val emptyETag = ContentValues(1)
emptyETag.putNull(LocalTask.COLUMN_ETAG)
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
}
@SuppressLint("Recycle")
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account),
AndroidCalendar.calendarBaseValues, null, null)
provider.closeCompat()
}
}
@Suppress("unused","FunctionName")
/**
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
* Disable it on those accounts for the future.
*/
private fun update_8_9() {
val db = AppDatabase.getInstance(context)
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) {
Logger.log.info("Disabling OpenTasks sync for $account")
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
}
}
@Suppress("unused","FunctionName")
@SuppressLint("Recycle")
/**
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
* SEQUENCE and should not be used for the eTag.
*/
private fun update_7_8() {
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
// ETag is now in sync_version instead of sync1
// UID is now in _uid instead of sync2
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account),
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
arrayOf(account.type, account.name), null)!!.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(0)
val eTag = cursor.getString(1)
val uid = cursor.getString(2)
val values = ContentValues(4)
values.put(TaskContract.Tasks._UID, uid)
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
values.putNull(TaskContract.Tasks.SYNC1)
values.putNull(TaskContract.Tasks.SYNC2)
Logger.log.log(Level.FINER, "Updating task $id", values)
provider.client.update(
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
values, null, null)
}
}
}
}
@Suppress("unused")
@SuppressLint("Recycle")
private fun update_6_7() {
// add calendar colors
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
try {
AndroidCalendar.insertColors(provider, account)
} finally {
provider.closeCompat()
}
}
// update allowed WiFi settings key
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID)
accountManager.setUserData(account, "wifi_only_ssid", null)
}
@Suppress("unused")
@SuppressLint("Recycle", "ParcelClassLoader")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
val parcel = Parcel.obtain()
try {
// don't run syncs during the migration
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
ContentResolver.cancelSync(account, null)
// get previous address book settings (including URL)
val raw = ContactsContract.SyncState.get(provider, account)
if (raw == null)
Logger.log.info("No contacts sync state, ignoring account")
else {
parcel.unmarshall(raw, 0, raw.size)
parcel.setDataPosition(0)
val params = parcel.readBundle()!!
val url = params.getString("url")?.toHttpUrlOrNull()
if (url == null)
Logger.log.info("No address book URL, ignoring account")
else {
// create new address book
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
Logger.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
throw ContactsStorageException("Couldn't create address book account")
// move contacts to new address book
Logger.log.info("Moving contacts from $account to $addressBookAccount")
val newAccount = ContentValues(2)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
newAccount,
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
arrayOf(account.name, account.type))
Logger.log.info("$affected contacts moved to new address book")
}
ContactsContract.SyncState.set(provider, account, null)
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
} finally {
parcel.recycle()
provider.closeCompat()
}
}
// update version number so that further syncs don't repeat the migration
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
setSyncInterval(context.getString(R.string.address_books_authority), 4*3600)
}
/* 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
TasksWatcher.updateTaskSync(context)
}
@Suppress("unused")
private fun update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES)
}
// updates from AccountSettings version 2 and below are not supported anymore
}
@@ -1,69 +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 at.bitfire.davdroid.TextTable
import java.io.Writer
abstract class BaseDefaultsProvider(
val context: Context,
val settingsManager: SettingsManager
): SettingsProvider {
abstract val booleanDefaults: Map<String, Boolean>
abstract val intDefaults: Map<String, Int>
abstract val longDefaults: Map<String, Long>
abstract val stringDefaults: Map<String, String?>
override fun canWrite() = false
override fun close() {
// override this, if needed
}
override fun forceReload() {
// override this, if needed
}
override fun contains(key: String) =
booleanDefaults.containsKey(key) ||
intDefaults.containsKey(key) ||
longDefaults.containsKey(key) ||
stringDefaults.containsKey(key)
override fun getBoolean(key: String) = booleanDefaults[key]
override fun getInt(key: String) = intDefaults[key]
override fun getLong(key: String) = longDefaults[key]
override fun getString(key: String) = stringDefaults[key]
override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError()
override fun putInt(key: String, value: Int?) = throw NotImplementedError()
override fun putLong(key: String, value: Long?) = throw NotImplementedError()
override fun putString(key: String, value: String?) = throw NotImplementedError()
override fun remove(key: String) = throw NotImplementedError()
override fun dump(writer: Writer) {
val strValues = mutableMapOf<String, String?>()
strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() })
strValues.putAll(stringDefaults)
val table = TextTable("Setting", "Value")
for ((key, value) in strValues.toSortedMap())
table.addLine(key, value)
writer.write(table.toString())
}
}
@@ -1,78 +0,0 @@
package at.bitfire.davdroid.settings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import androidx.core.content.getSystemService
class DefaultsProvider(
context: Context,
settingsManager: SettingsManager
): BaseDefaultsProvider(context, settingsManager) {
override val booleanDefaults = mutableMapOf(
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
Pair(Settings.OVERRIDE_PROXY, false)
)
override val intDefaults = mapOf(
Pair(Settings.OVERRIDE_PROXY_PORT, 8118)
)
override val longDefaults = mapOf<String, Long>(
Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */
)
override val stringDefaults = mapOf(
Pair(Settings.OVERRIDE_PROXY_HOST, "localhost")
)
val dataSaverChangedListener by lazy {
object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
evaluateDataSaver(true)
}
}
}
init {
if (Build.VERSION.SDK_INT >= 24) {
val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)
context.registerReceiver(dataSaverChangedListener, dataSaverChangedFilter)
evaluateDataSaver()
}
}
override fun forceReload() {
evaluateDataSaver()
}
override fun close() {
if (Build.VERSION.SDK_INT >= 24)
context.unregisterReceiver(dataSaverChangedListener)
}
fun evaluateDataSaver(notify: Boolean = false) {
if (Build.VERSION.SDK_INT >= 24) {
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
if (connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED)
booleanDefaults[AccountSettings.KEY_WIFI_ONLY] = true
else
booleanDefaults -= AccountSettings.KEY_WIFI_ONLY
}
if (notify)
settingsManager.onSettingsChanged()
}
}
class Factory : SettingsProviderFactory {
override fun getProviders(context: Context, settingsManager: SettingsManager) =
listOf(DefaultsProvider(context, settingsManager))
}
}
@@ -1,30 +0,0 @@
package at.bitfire.davdroid.settings
import androidx.appcompat.app.AppCompatDelegate
object Settings {
const val FOREGROUND_SERVICE = "foreground_service"
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
const val OVERRIDE_PROXY = "override_proxy"
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
/**
* Default sync interval (long), in seconds.
* Used to initialize an account.
*/
const val DEFAULT_SYNC_INTERVAL = "default_sync_interval"
/**
* Preferred theme (light/dark). Value must be one of [AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM]
* (default if setting is missing), [AppCompatDelegate.MODE_NIGHT_NO] or [AppCompatDelegate.MODE_NIGHT_YES].
*/
const val PREFERRED_THEME = "preferred_theme"
const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
const val PREFERRED_TASKS_PROVIDER = "preferred_tasks_provider"
}
@@ -1,175 +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.util.NoSuchPropertyException
import androidx.annotation.AnyThread
import at.bitfire.davdroid.AndroidSingleton
import at.bitfire.davdroid.log.Logger
import java.io.Writer
import java.lang.ref.WeakReference
import java.util.*
import java.util.logging.Level
/**
* Settings manager which coordinates [SettingsProvider]s to read/write
* application settings.
*/
class SettingsManager private constructor(
context: Context
) {
companion object: AndroidSingleton<SettingsManager>() {
override fun createInstance(context: Context) = SettingsManager(context)
}
private val providers = LinkedList<SettingsProvider>()
private var writeProvider: SettingsProvider? = null
private val observers = LinkedList<WeakReference<OnChangeListener>>()
init {
val factories = ServiceLoader.load(SettingsProviderFactory::class.java, context.classLoader)
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
for (factory in factories)
providers.addAll(factory.getProviders(context, this))
writeProvider = providers.first { it.canWrite() }
Logger.log.fine("Changed settings are handled by $writeProvider")
}
/**
* Requests all providers to reload their settings.
*/
@AnyThread
fun forceReload() {
for (provider in providers)
provider.forceReload()
onSettingsChanged()
}
/*** OBSERVERS ***/
fun addOnChangeListener(observer: OnChangeListener) {
synchronized(observers) {
observers += WeakReference(observer)
}
}
fun removeOnChangeListener(observer: OnChangeListener) {
synchronized(observers) {
observers.removeAll { it.get() == null || it.get() == observer }
}
}
/**
* Notifies registered listeners about changes in the configuration.
* Should be called by config providers when settings have changed.
*/
@AnyThread
fun onSettingsChanged() {
synchronized(observers) {
for (observer in observers.mapNotNull { it.get() })
observer.onSettingsChanged()
}
}
/*** SETTINGS ACCESS ***/
fun containsKey(key: String) = providers.any { it.contains(key) }
private fun<T> getValue(key: String, reader: (SettingsProvider) -> T?): T? {
Logger.log.fine("Looking up setting $key")
val result: T? = null
for (provider in providers)
try {
val value = reader(provider)
Logger.log.finer("${provider::class.java.simpleName}: $key = $value")
if (value != null) {
Logger.log.fine("Looked up setting $key -> $value")
return value
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
}
Logger.log.fine("Looked up setting $key -> no result")
return result
}
fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) }
fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(key)
fun getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) }
fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(key)
fun getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) }
fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key)
fun getString(key: String) = getValue(key) { provider -> provider.getString(key) }
fun isWritable(key: String): Boolean {
for (provider in providers) {
if (provider.canWrite())
return true
else if (provider.contains(key))
// non-writeable provider contains this key -> setting will always be provided by this read-only provider
return false
}
return false
}
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Unit) {
Logger.log.fine("Trying to write setting $key = $value")
val provider = writeProvider ?: return
try {
writer(provider)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't write setting to $writeProvider", e)
}
}
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) = putString(key, null)
/*** HELPERS ***/
fun dump(writer: Writer) {
for ((idx, provider) in providers.withIndex()) {
writer.write("${idx + 1}. ${provider::class.java.simpleName} canWrite=${provider.canWrite()}\n")
provider.dump(writer)
}
}
interface OnChangeListener {
/**
* Will be called when something has changed in a [SettingsProvider].
* May run in worker thread!
*/
@AnyThread
fun onSettingsChanged()
}
}
@@ -1,54 +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.annotation.AnyThread
import java.io.Writer
/**
* Defines a settings provider, which provides settings from a certain source
* to the [SettingsManager].
*
* Implementations must be thread-safe and synchronize get/put operations on their own.
*/
interface SettingsProvider {
/**
* Whether this provider can write settings.
*
* If this method returns false, the put...() methods will never be called for this provider.
*
* @return true = this provider provides read/write settings;
* false = this provider provides read-only settings
*/
fun canWrite(): Boolean
fun close()
@AnyThread
fun forceReload()
fun contains(key: String): Boolean
fun getBoolean(key: String): Boolean?
fun getInt(key: String): Int?
fun getLong(key: String): Long?
fun getString(key: String): String?
fun putBoolean(key: String, value: Boolean?)
fun putInt(key: String, value: Int?)
fun putLong(key: String, value: Long?)
fun putString(key: String, value: String?)
fun remove(key: String)
fun dump(writer: Writer)
}
@@ -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 SettingsProviderFactory {
fun getProviders(context: Context, settingsManager: SettingsManager): Iterable<SettingsProvider>
}
@@ -1,139 +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 androidx.preference.PreferenceManager
import at.bitfire.davdroid.TextTable
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import java.io.Writer
class SharedPreferencesProvider(
val context: Context,
val settingsManager: SettingsManager
): 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 canWrite() = true
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
settingsManager.onSettingsChanged()
}
override fun contains(key: String) = preferences.contains(key)
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): T? =
try {
if (preferences.contains(key))
reader(preferences)
else
null
} catch(e: ClassCastException) {
null
}
override fun getBoolean(key: String) =
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
override fun getInt(key: String) =
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
override fun getLong(key: String) =
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
override fun getString(key: String): String? =
preferences.getString(key, /* will never be used: */ null)
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit) {
if (value == null)
remove(key)
else {
Logger.log.fine("Writing setting $key = $value")
val edit = preferences.edit()
writer(edit, value)
edit.apply()
}
}
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) {
Logger.log.fine("Removing setting $key")
preferences.edit().remove(key).apply()
}
override fun dump(writer: Writer) {
val table = TextTable("Setting", "Value")
for ((key, value) in preferences.all.toSortedMap())
table.addLine(key, value)
writer.write(table.toString())
}
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 : SettingsProviderFactory {
override fun getProviders(context: Context, settingsManager: SettingsManager) = listOf(SharedPreferencesProvider(context, settingsManager))
}
}
@@ -7,49 +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 at.bitfire.davdroid.ui.setup.LoginActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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 {
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>?) {
CoroutineScope(Dispatchers.Default).launch {
AccountUtils.cleanupAccounts(this@AccountAuthenticatorService)
}
}
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
@@ -1,97 +0,0 @@
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Bundle
import androidx.annotation.AnyThread
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.AppDatabase
import at.bitfire.davdroid.resource.LocalAddressBook
import java.util.logging.Level
object AccountUtils {
@AnyThread
@Synchronized
fun cleanupAccounts(context: Context) {
val accountManager = AccountManager.get(context)
val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
val accountNames = accounts.map { it.name }
Logger.log.log(Level.INFO, "Cleaning up orphaned accounts. Current accounts:", accountNames)
// 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())
}
/**
* Creates an account and makes sure the user data are set correctly.
*
* @param context operating context
* @param account account to create
* @param userData user data to set
*
* @return whether the account has been created
*
* @throws IllegalArgumentException when user data contains non-String values
* @throws IllegalStateException if user data can't be set
*/
fun createAccount(context: Context, account: Account, userData: Bundle, password: String? = null): Boolean {
// validate user data
for (key in userData.keySet()) {
userData.get(key)?.let { entry ->
if (entry !is String)
throw IllegalArgumentException("userData[$key] is ${entry::class.java} (expected: String)")
}
}
// create account
val manager = AccountManager.get(context)
if (!manager.addAccountExplicitly(account, password, userData))
return false
// Android seems to lose the initial user data sometimes, so set it a second time if that happens
// https://forums.bitfire.at/post/11644
if (!verifyUserData(context, account, userData))
for (key in userData.keySet())
manager.setUserData(account, key, userData.getString(key))
if (!verifyUserData(context, account, userData))
throw IllegalStateException("Android doesn't store user data in account")
return true
}
private fun verifyUserData(context: Context, account: Account, userData: Bundle): Boolean {
val accountManager = AccountManager.get(context)
userData.keySet().forEach { key ->
val stored = accountManager.getUserData(account, key)
val expected = userData.getString(key)
if (stored != expected) {
Logger.log.warning("Stored user data \"$stored\" differs from expected data \"$expected\" for $key")
return false
}
}
return true
}
}
Loaded 100 of 432 files, more files were not shown because too many files have changed in this diff. Show more