From 8b5f87c2d4f44463c55451590b1ccff9b4ceb4e6 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Mon, 2 Jan 2017 19:39:10 +0000 Subject: [PATCH] Adjust DAVdroid to use the EteSync protocol (mostly working) This commit includes the major changes between DAVdroid and EteSync. It adjusts the app to use the EteSync protocol and server. It includes some ugliness still, and it's a squash of many ugly snapshot commits while hacking on the initial DAVdroid code. History should be "clean" from this point onwards. --- .gitignore | 3 + .gitmodules | 9 +- app/build.gradle | 32 +- app/proguard-rules.txt | 21 +- .../davdroid/SSLSocketFactoryCompatTest.java | 2 - .../davdroid/model/CollectionInfoTest.java | 65 - .../ui/setup/DavResourceFinderTest.java | 79 - app/src/main/AndroidManifest.xml | 16 +- .../at/bitfire/davdroid/AccountSettings.java | 258 +- .../main/java/at/bitfire/davdroid/App.java | 1 - .../java/at/bitfire/davdroid/ArrayUtils.java | 33 - .../java/at/bitfire/davdroid/Constants.java | 12 +- .../java/at/bitfire/davdroid/DavService.java | 252 +- .../java/at/bitfire/davdroid/DavUtils.java | 41 - .../java/at/bitfire/davdroid/GsonHelper.java | 31 + .../java/at/bitfire/davdroid/HttpClient.java | 62 +- .../bitfire/davdroid/MemoryCookieStore.java | 69 - .../davdroid/journalmanager/BaseManager.java | 104 + .../davdroid/journalmanager/Exceptions.java | 31 + .../davdroid/journalmanager/Helpers.java | 128 + .../journalmanager/JournalAuthenticator.java | 58 + .../journalmanager/JournalEntryManager.java | 112 + .../journalmanager/JournalManager.java | 124 + .../davdroid/model/CollectionInfo.java | 99 +- .../at/bitfire/davdroid/model/ServiceDB.java | 41 +- .../davdroid/resource/LocalAddressBook.java | 4 +- .../davdroid/resource/LocalCalendar.java | 5 +- .../davdroid/resource/LocalCollection.java | 1 + .../davdroid/resource/LocalContact.java | 51 +- .../bitfire/davdroid/resource/LocalEvent.java | 49 +- .../bitfire/davdroid/resource/LocalGroup.java | 21 +- .../davdroid/resource/LocalResource.java | 11 +- .../bitfire/davdroid/resource/LocalTask.java | 19 +- .../davdroid/resource/LocalTaskList.java | 6 +- .../syncadapter/CalendarSyncManager.java | 203 +- .../CalendarsSyncAdapterService.java | 39 +- .../ContactsSyncAdapterService.java | 39 +- .../syncadapter/ContactsSyncManager.java | 362 +- .../syncadapter/SyncAdapterService.java | 7 +- .../davdroid/syncadapter/SyncManager.java | 511 +- .../syncadapter/TasksSyncAdapterService.java | 155 - .../syncadapter/TasksSyncManager.java | 217 - .../bitfire/davdroid/ui/AccountActivity.java | 126 +- .../davdroid/ui/AccountSettingsActivity.java | 74 +- .../bitfire/davdroid/ui/AccountsActivity.java | 24 +- .../ui/CreateAddressBookActivity.java | 87 +- .../davdroid/ui/CreateCalendarActivity.java | 124 +- .../davdroid/ui/CreateCollectionFragment.java | 112 +- .../davdroid/ui/DebugInfoActivity.java | 10 +- .../davdroid/ui/DeleteCollectionFragment.java | 33 +- .../davdroid/ui/ExceptionInfoFragment.java | 2 +- .../davdroid/ui/StartupDialogFragment.java | 6 +- .../davdroid/ui/setup/DavResourceFinder.java | 360 +- .../ui/setup/DetectConfigurationFragment.java | 4 +- .../ui/setup/EncryptionDetailsFragment.java | 83 + .../davdroid/ui/setup/LoginActivity.java | 10 +- .../ui/setup/LoginCredentialsFragment.java | 161 +- ...ment.java => SetupEncryptionFragment.java} | 168 +- app/src/main/res/drawable/ic_bug_report.xml | 10 + app/src/main/res/drawable/ic_email_black.xml | 10 + .../main/res/layout/account_caldav_item.xml | 12 - .../main/res/layout/account_carddav_item.xml | 9 - app/src/main/res/layout/activity_account.xml | 4 +- .../res/layout/activity_create_calendar.xml | 48 - .../res/layout/login_credentials_fragment.xml | 91 +- ...tails.xml => login_encryption_details.xml} | 37 +- app/src/main/res/menu/activity_account.xml | 4 - .../res/menu/activity_accounts_drawer.xml | 22 +- app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values/strings.xml | 26 +- app/src/main/res/values/styles.xml | 13 +- app/src/main/res/xml/contacts.xml | 2 +- app/src/main/res/xml/settings_account.xml | 49 +- .../at/bitfire/davdroid/ArrayUtilsTest.java | 42 - .../at/bitfire/davdroid/TestDavUtils.java | 27 - dav4android | 1 - doc/.gitignore | 1 - doc/DAVdroid-Linuxwochen-2016.odp | Bin 14791 -> 0 bytes doc/NIST.SP.800-52r1.pdf | Bin 585486 -> 0 bytes doc/caldav-proxy.txt | 560 -- doc/how_davdroid_works.svgz | Bin 1971248 -> 0 bytes ...rfc3744-webdav-access-control-protocol.txt | 4035 ---------- doc/rfc4791-caldav.txt | 5995 -------------- doc/rfc4918-webdav.txt | 7115 ----------------- ...397-webdav-current-principal-extension.txt | 281 - doc/rfc5785-well-known-uris.txt | 451 -- doc/rfc6352-carddav.txt | 2691 ------- ...fc6638-scheduling-extensions-to-caldav.txt | 4371 ---------- ...c6764-caldav-carddav-service-discovery.txt | 787 -- gradle.properties | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- ical4android | 2 +- settings.gradle | 1 - vcard4android | 2 +- 94 files changed, 1747 insertions(+), 29725 deletions(-) delete mode 100644 app/src/main/java/at/bitfire/davdroid/ArrayUtils.java delete mode 100644 app/src/main/java/at/bitfire/davdroid/DavUtils.java create mode 100644 app/src/main/java/at/bitfire/davdroid/GsonHelper.java delete mode 100644 app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.java create mode 100644 app/src/main/java/at/bitfire/davdroid/journalmanager/BaseManager.java create mode 100644 app/src/main/java/at/bitfire/davdroid/journalmanager/Exceptions.java create mode 100644 app/src/main/java/at/bitfire/davdroid/journalmanager/Helpers.java create mode 100644 app/src/main/java/at/bitfire/davdroid/journalmanager/JournalAuthenticator.java create mode 100644 app/src/main/java/at/bitfire/davdroid/journalmanager/JournalEntryManager.java create mode 100644 app/src/main/java/at/bitfire/davdroid/journalmanager/JournalManager.java delete mode 100644 app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java delete mode 100644 app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.java create mode 100644 app/src/main/java/at/bitfire/davdroid/ui/setup/EncryptionDetailsFragment.java rename app/src/main/java/at/bitfire/davdroid/ui/setup/{AccountDetailsFragment.java => SetupEncryptionFragment.java} (54%) create mode 100644 app/src/main/res/drawable/ic_bug_report.xml create mode 100644 app/src/main/res/drawable/ic_email_black.xml rename app/src/main/res/layout/{login_account_details.xml => login_encryption_details.xml} (62%) delete mode 100644 app/src/test/java/at/bitfire/davdroid/ArrayUtilsTest.java delete mode 100644 app/src/test/java/at/bitfire/davdroid/TestDavUtils.java delete mode 160000 dav4android delete mode 100644 doc/.gitignore delete mode 100644 doc/DAVdroid-Linuxwochen-2016.odp delete mode 100644 doc/NIST.SP.800-52r1.pdf delete mode 100644 doc/caldav-proxy.txt delete mode 100644 doc/how_davdroid_works.svgz delete mode 100644 doc/rfc3744-webdav-access-control-protocol.txt delete mode 100644 doc/rfc4791-caldav.txt delete mode 100644 doc/rfc4918-webdav.txt delete mode 100644 doc/rfc5397-webdav-current-principal-extension.txt delete mode 100644 doc/rfc5785-well-known-uris.txt delete mode 100644 doc/rfc6352-carddav.txt delete mode 100644 doc/rfc6638-scheduling-extensions-to-caldav.txt delete mode 100644 doc/rfc6764-caldav-carddav-service-discovery.txt diff --git a/.gitignore b/.gitignore index e2e1ee9c..aede5fae 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ gradle-app.setting # Javadoc javadoc/ + +### VIM ### +*.swp diff --git a/.gitmodules b/.gitmodules index 938ab729..8e635782 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,9 @@ -[submodule "dav4android"] - path = dav4android - url = ../dav4android.git [submodule "ical4android"] path = ical4android - url = ../ical4android.git + url = https://gitlab.com/bitfireAT/ical4android.git [submodule "vcard4android"] path = vcard4android - url = ../vcard4android.git + url = https://gitlab.com/bitfireAT/vcard4android.git [submodule "cert4android"] path = cert4android - url = ../cert4android.git + url = https://gitlab.com/bitfireAT/cert4android.git diff --git a/app/build.gradle b/app/build.gradle index a206c52e..6943a9f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -32,11 +32,25 @@ android { buildTypes { debug { - minifyEnabled false + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + + /* + * To override the server's url (for example when developing), + * create file gradle.properties in ~/.gradle/ with this content: + * + * appDebugRemoteUrl="http://localserver:8080/" + */ + if (project.hasProperty('appDebugRemoteUrl')) { + buildConfigField 'String', 'DEBUG_REMOTE_URL', appDebugRemoteUrl + } else { + buildConfigField 'String', 'DEBUG_REMOTE_URL', 'null' + } } release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + buildConfigField 'String', 'DEBUG_REMOTE_URL', 'null' } } @@ -55,6 +69,14 @@ android { disable 'Typos' disable "RestrictedApi" // https://code.google.com/p/android/issues/detail?id=230387 } + + dexOptions { + preDexLibraries = true + // dexInProcess requires much RAM, which is not available on all dev systems + dexInProcess = false + javaMaxHeapSize "2g" + } + packagingOptions { exclude 'LICENSE' exclude 'META-INF/LICENSE.txt' @@ -68,7 +90,6 @@ android { dependencies { compile project(':cert4android') - compile project(':dav4android') compile project(':ical4android') compile project(':vcard4android') @@ -79,11 +100,10 @@ dependencies { compile 'com.github.yukuku:ambilwarna:2.0.1' + compile group: 'com.madgag.spongycastle', name: 'core', version: '1.54.0.0' + compile group: 'com.madgag.spongycastle', name: 'prov', version: '1.54.0.0' + compile group: 'com.google.code.gson', name: 'gson', version: '1.7.2' compile 'com.squareup.okhttp3:logging-interceptor:3.5.0' - compile 'commons-io:commons-io:2.5' - compile 'dnsjava:dnsjava:2.1.7' - compile 'org.apache.commons:commons-lang3:3.4' - compile 'org.apache.commons:commons-collections4:4.1' provided 'org.projectlombok:lombok:1.16.12' // for tests diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt index e3581e46..a38721ee 100644 --- a/app/proguard-rules.txt +++ b/app/proguard-rules.txt @@ -30,8 +30,23 @@ -dontwarn java.nio.file.** # not available on Android -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement -# dnsjava --dontwarn sun.net.spi.nameservice.** # not available on Android - # DAVdroid + libs -keep class at.bitfire.** { *; } # all DAVdroid code is required + +# Spongcastle +-dontwarn org.spongycastle.jce.provider.X509LDAPCertStoreSpi +-dontwarn org.spongycastle.x509.util.LDAPStoreHelper +-keep class org.spongycastle.crypto.BufferedBlockCipher +-keep class org.spongycastle.crypto.CipherParameters +-keep class org.spongycastle.crypto.InvalidCipherTextException +-keep class org.spongycastle.crypto.digests.SHA256Digest +-keep class org.spongycastle.crypto.engines.AESEngine +-keep class org.spongycastle.crypto.generators.SCrypt +-keep class org.spongycastle.crypto.macs.HMac +-keep class org.spongycastle.crypto.modes.CBCBlockCipher +-keep class org.spongycastle.crypto.paddings.BlockCipherPadding +-keep class org.spongycastle.crypto.paddings.PKCS7Padding +-keep class org.spongycastle.crypto.paddings.PaddedBufferedBlockCipher +-keep class org.spongycastle.crypto.params.KeyParameter +-keep class org.spongycastle.crypto.params.ParametersWithIV +-keep class org.spongycastle.util.encoders.Hex diff --git a/app/src/androidTest/java/at/bitfire/davdroid/SSLSocketFactoryCompatTest.java b/app/src/androidTest/java/at/bitfire/davdroid/SSLSocketFactoryCompatTest.java index 803a3dd7..d6c412cb 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/SSLSocketFactoryCompatTest.java +++ b/app/src/androidTest/java/at/bitfire/davdroid/SSLSocketFactoryCompatTest.java @@ -9,12 +9,10 @@ package at.bitfire.davdroid; import android.os.Build; -import android.support.test.runner.AndroidJUnit4; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; import java.io.IOException; import java.net.Socket; diff --git a/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.java b/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.java index 7d3b7d0b..00d41faa 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.java +++ b/app/src/androidTest/java/at/bitfire/davdroid/model/CollectionInfoTest.java @@ -9,84 +9,19 @@ package at.bitfire.davdroid.model; import android.content.ContentValues; -import android.support.test.runner.AndroidJUnit4; import org.junit.Test; -import org.junit.runner.RunWith; -import java.io.IOException; - -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.property.ResourceType; -import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.model.ServiceDB.Collections; -import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class CollectionInfoTest { MockWebServer server = new MockWebServer(); - @Test - public void testFromDavResource() throws IOException, HttpException, DavException { - // r/w address book - server.enqueue(new MockResponse() - .setResponseCode(207) - .setBody("" + - "" + - " /" + - " " + - " " + - " My Contacts" + - " My Contacts Description" + - " " + - "" + - "")); - - DavResource dav = new DavResource(HttpClient.create(null), server.url("/")); - dav.propfind(0, ResourceType.NAME); - CollectionInfo info = CollectionInfo.fromDavResource(dav); - assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type); - assertFalse(info.readOnly); - assertEquals("My Contacts", info.displayName); - assertEquals("My Contacts Description", info.description); - - // read-only calendar, no display name - server.enqueue(new MockResponse() - .setResponseCode(207) - .setBody("" + - "" + - " /" + - " " + - " " + - " " + - " My Calendar" + - " tzdata" + - " #ff0000" + - " " + - "" + - "")); - - dav = new DavResource(HttpClient.create(null), server.url("/")); - dav.propfind(0, ResourceType.NAME); - info = CollectionInfo.fromDavResource(dav); - assertEquals(CollectionInfo.Type.CALENDAR, info.type); - assertTrue(info.readOnly); - assertNull(info.displayName); - assertEquals("My Calendar", info.description); - assertEquals(0xFFFF0000, (int)info.color); - assertEquals("tzdata", info.timeZone); - assertTrue(info.supportsVEVENT); - assertTrue(info.supportsVTODO); - } - @Test public void testFromDB() { ContentValues values = new ContentValues(); diff --git a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.java b/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.java index f8190fbe..0dd94a4a 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.java +++ b/app/src/androidTest/java/at/bitfire/davdroid/ui/setup/DavResourceFinderTest.java @@ -8,27 +8,13 @@ package at.bitfire.davdroid.ui.setup; -import android.support.test.runner.AndroidJUnit4; -import android.test.InstrumentationTestCase; - import org.junit.After; import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import java.io.IOException; import java.net.URI; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.property.AddressbookHomeSet; -import at.bitfire.dav4android.property.ResourceType; import at.bitfire.davdroid.App; import at.bitfire.davdroid.HttpClient; -import at.bitfire.davdroid.ui.setup.DavResourceFinder; -import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo; -import at.bitfire.davdroid.ui.setup.LoginCredentials; import okhttp3.OkHttpClient; import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; @@ -36,10 +22,6 @@ 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 { @@ -69,7 +51,6 @@ public class DavResourceFinderTest { finder = new DavResourceFinder(getTargetContext(), credentials); client = HttpClient.create(null); - client = HttpClient.addAuthentication(client, credentials.userName, credentials.password); } @After @@ -77,66 +58,6 @@ public class DavResourceFinderTest { server.shutdown(); } - @Test - public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException { - ServiceInfo info; - - // before dav.propfind(), no info is available - DavResource dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)); - finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo()); - assertEquals(0, info.collections.size()); - assertEquals(0, info.homeSets.size()); - - // recognize home set - dav.propfind(0, AddressbookHomeSet.NAME); - finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo()); - assertEquals(0, info.collections.size()); - assertEquals(1, info.homeSets.size()); - assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.homeSets.iterator().next()); - - // recognize address book - dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK)); - dav.propfind(0, ResourceType.NAME); - finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo()); - assertEquals(1, info.collections.size()); - assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.collections.keySet().iterator().next()); - assertEquals(0, info.homeSets.size()); - } - - @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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 072c30ad..7f445adb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -102,26 +102,14 @@ android:process=":sync" tools:ignore="ExportedService"> - + + android:resource="@xml/sync_calendars" /> - - - - - - = 0 entries more than n days in the past won't be synchronized + /** + * Time range limitation to the past [in days] + * value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS) + * < 0 (-1) no limit + * >= 0 entries more than n days in the past won't be synchronized */ private final static String KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"; private final static int DEFAULT_TIME_RANGE_PAST_DAYS = 90; @@ -70,10 +53,11 @@ public class AccountSettings { "0" false */ private final static String KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"; - /** Contact group method: - value = null (not existing) groups as separate VCards (default) - "CATEGORIES" groups are per-contact CATEGORIES - */ + /** + * Contact group method: + * value = null (not existing) groups as separate VCards (default) + * "CATEGORIES" groups are per-contact CATEGORIES + */ private final static String KEY_CONTACT_GROUP_METHOD = "contact_group_method"; public final static long SYNC_INTERVAL_MANUALLY = -1; @@ -90,7 +74,7 @@ public class AccountSettings { accountManager = AccountManager.get(context); - synchronized(AccountSettings.class) { + synchronized (AccountSettings.class) { String versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION); if (versionStr == null) throw new InvalidAccountException(account); @@ -107,21 +91,52 @@ public class AccountSettings { } } - public static Bundle initialUserData(String userName) { + public static Bundle initialUserData(URI uri, String userName) { Bundle bundle = new Bundle(); bundle.putString(KEY_SETTINGS_VERSION, String.valueOf(CURRENT_VERSION)); bundle.putString(KEY_USERNAME, userName); + bundle.putString(KEY_URI, uri.toString()); return bundle; } // authentication settings - public String username() { return accountManager.getUserData(account, KEY_USERNAME); } - public void username(@NonNull String userName) { accountManager.setUserData(account, KEY_USERNAME, userName); } + public URI getUri() { + try { + return new URI(accountManager.getUserData(account, KEY_URI)); + } catch (URISyntaxException e) { + return null; + } + } + + public void setUri(@NonNull URI uri) { + accountManager.setUserData(account, KEY_URI, uri.toString()); + } + + public String getAuthToken() { + return accountManager.getUserData(account, KEY_TOKEN); + } + + public void setAuthToken(@NonNull String token) { + accountManager.setUserData(account, KEY_TOKEN, token); + } + + public String username() { + return accountManager.getUserData(account, KEY_USERNAME); + } + + public void username(@NonNull String userName) { + accountManager.setUserData(account, KEY_USERNAME, userName); + } - public String password() { return accountManager.getPassword(account); } - public void password(@NonNull String password) { accountManager.setPassword(account, password); } + public String password() { + return accountManager.getPassword(account); + } + + public void password(@NonNull String password) { + accountManager.setPassword(account, password); + } // sync. settings @@ -224,175 +239,6 @@ public class AccountSettings { } } - @SuppressWarnings({ "Recycle", "unused" }) - private void update_1_2() throws ContactsStorageException { - /* - KEY_ADDRESSBOOK_URL ("addressbook_url"), - - KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"), - - KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState) - - KEY_LAST_ANDROID_VERSION ("last_android_version") has been added - */ - - // move previous address book info to ContactsContract.SyncState - @Cleanup("release") ContentProviderClient provider = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY); - if (provider == null) - throw new ContactsStorageException("Couldn't access Contacts provider"); - - LocalAddressBook addr = new LocalAddressBook(account, provider); - - // until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly - ContentValues values = new ContentValues(); - values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1); - addr.updateSettings(values); - - String url = accountManager.getUserData(account, "addressbook_url"); - if (!TextUtils.isEmpty(url)) - addr.setURL(url); - accountManager.setUserData(account, "addressbook_url", null); - - String cTag = accountManager.getUserData(account, "addressbook_ctag"); - if (!TextUtils.isEmpty(cTag)) - addr.setCTag(cTag); - accountManager.setUserData(account, "addressbook_ctag", null); - } - - @SuppressWarnings({ "Recycle", "unused" }) - private void update_2_3() { - // Don't show a warning for Android updates anymore - accountManager.setUserData(account, "last_android_version", null); - - Long serviceCardDAV = null, serviceCalDAV = null; - - ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(context); - try { - SQLiteDatabase db = dbHelper.getWritableDatabase(); - // we have to create the WebDAV Service database only from the old address book, calendar and task list URLs - - // CardDAV: migrate address books - ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY); - if (client != null) - try { - LocalAddressBook addrBook = new LocalAddressBook(account, client); - String url = addrBook.getURL(); - if (url != null) { - App.log.fine("Migrating address book " + url); - - // insert CardDAV service - ContentValues values = new ContentValues(); - values.put(Services.ACCOUNT_NAME, account.name); - values.put(Services.SERVICE, Services.SERVICE_CARDDAV); - serviceCardDAV = db.insert(Services._TABLE, null, values); - - // insert address book - values.clear(); - values.put(Collections.SERVICE_ID, serviceCardDAV); - values.put(Collections.URL, url); - values.put(Collections.SYNC, 1); - db.insert(Collections._TABLE, null, values); - - // insert home set - HttpUrl homeSet = HttpUrl.parse(url).resolve("../"); - values.clear(); - values.put(HomeSets.SERVICE_ID, serviceCardDAV); - values.put(HomeSets.URL, homeSet.toString()); - db.insert(HomeSets._TABLE, null, values); - } - - } catch (ContactsStorageException e) { - App.log.log(Level.SEVERE, "Couldn't migrate address book", e); - } finally { - client.release(); - } - - // CalDAV: migrate calendars + task lists - Set collections = new HashSet<>(); - Set homeSets = new HashSet<>(); - - client = context.getContentResolver().acquireContentProviderClient(CalendarContract.AUTHORITY); - if (client != null) - try { - LocalCalendar calendars[] = (LocalCalendar[])LocalCalendar.find(account, client, LocalCalendar.Factory.INSTANCE, null, null); - for (LocalCalendar calendar : calendars) { - String url = calendar.getName(); - App.log.fine("Migrating calendar " + url); - collections.add(url); - homeSets.add(HttpUrl.parse(url).resolve("../")); - } - } catch (CalendarStorageException e) { - App.log.log(Level.SEVERE, "Couldn't migrate calendars", e); - } finally { - client.release(); - } - - TaskProvider provider = LocalTaskList.acquireTaskProvider(context.getContentResolver()); - if (provider != null) - try { - LocalTaskList[] taskLists = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null); - for (LocalTaskList taskList : taskLists) { - String url = taskList.getSyncId(); - App.log.fine("Migrating task list " + url); - collections.add(url); - homeSets.add(HttpUrl.parse(url).resolve("../")); - } - } catch (CalendarStorageException e) { - App.log.log(Level.SEVERE, "Couldn't migrate task lists", e); - } finally { - provider.close(); - } - - if (!collections.isEmpty()) { - // insert CalDAV service - ContentValues values = new ContentValues(); - values.put(Services.ACCOUNT_NAME, account.name); - values.put(Services.SERVICE, Services.SERVICE_CALDAV); - serviceCalDAV = db.insert(Services._TABLE, null, values); - - // insert collections - for (String url : collections) { - values.clear(); - values.put(Collections.SERVICE_ID, serviceCalDAV); - values.put(Collections.URL, url); - values.put(Collections.SYNC, 1); - db.insert(Collections._TABLE, null, values); - } - - // insert home sets - for (HttpUrl homeSet : homeSets) { - values.clear(); - values.put(HomeSets.SERVICE_ID, serviceCalDAV); - values.put(HomeSets.URL, homeSet.toString()); - db.insert(HomeSets._TABLE, null, values); - } - } - } finally { - dbHelper.close(); - } - - // initiate service detection (refresh) to get display names, colors etc. - Intent refresh = new Intent(context, DavService.class); - refresh.setAction(DavService.ACTION_REFRESH_COLLECTIONS); - if (serviceCardDAV != null) { - refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCardDAV); - context.startService(refresh); - } - if (serviceCalDAV != null) { - refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCalDAV); - context.startService(refresh); - } - } - - @SuppressWarnings({ "Recycle", "unused" }) - private void update_3_4() { - setGroupMethod(GroupMethod.CATEGORIES); - } - - /* Android 7.1.1 OpenTasks fix */ - @SuppressWarnings({ "Recycle", "unused" }) - private void update_4_5() { - // call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available - PackageChangedReceiver.updateTaskSync(context); - } - - public static class AppUpdatedReceiver extends BroadcastReceiver { @Override diff --git a/app/src/main/java/at/bitfire/davdroid/App.java b/app/src/main/java/at/bitfire/davdroid/App.java index f7fa1871..97d98321 100644 --- a/app/src/main/java/at/bitfire/davdroid/App.java +++ b/app/src/main/java/at/bitfire/davdroid/App.java @@ -68,7 +68,6 @@ public class App extends Application { public final static Logger log = Logger.getLogger("davdroid"); static { - at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android"); at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android"); } diff --git a/app/src/main/java/at/bitfire/davdroid/ArrayUtils.java b/app/src/main/java/at/bitfire/davdroid/ArrayUtils.java deleted file mode 100644 index c9ac7247..00000000 --- a/app/src/main/java/at/bitfire/davdroid/ArrayUtils.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ -package at.bitfire.davdroid; - -import java.lang.reflect.Array; - -public class ArrayUtils { - - @SuppressWarnings("unchecked") - public static T[][] partition(T[] bigArray, int max) { - int nItems = bigArray.length; - int nPartArrays = (nItems + max-1)/max; - - T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0); - - // nItems is now the number of remaining items - for (int i = 0; nItems > 0; i++) { - int n = (nItems < max) ? nItems : max; - partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n); - System.arraycopy(bigArray, i*max, partArrays[i], 0, n); - - nItems -= n; - } - - return partArrays; - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/Constants.java b/app/src/main/java/at/bitfire/davdroid/Constants.java index 22543d17..0600b9e7 100644 --- a/app/src/main/java/at/bitfire/davdroid/Constants.java +++ b/app/src/main/java/at/bitfire/davdroid/Constants.java @@ -9,6 +9,8 @@ package at.bitfire.davdroid; import android.net.Uri; +import static at.bitfire.davdroid.BuildConfig.DEBUG_REMOTE_URL; + public class Constants { public static final String @@ -24,7 +26,15 @@ public class Constants { NOTIFICATION_TASK_SYNC = 12, NOTIFICATION_PERMISSIONS = 20; - public static final Uri webUri = Uri.parse("https://davdroid.bitfire.at/?pk_campaign=davdroid-app"); + public static final Uri webUri = Uri.parse("https://www.etesync.com/"); + public static final Uri contactUri = Uri.parse("mailto:contact.app@etesync.com"); + public static final Uri registrationUrl = webUri.buildUpon().appendEncodedPath("accounts/signup/").build(); + public static final Uri reportIssueUri = Uri.parse("https://github.com/etesync/android/issues"); + public static final Uri feedbackUri = reportIssueUri; + public static final Uri faqUri = webUri.buildUpon().appendEncodedPath("faq/").build(); + public static final Uri helpUri = faqUri; + + public static final Uri serviceUrl = Uri.parse((DEBUG_REMOTE_URL == null) ? "https://api.etesync.com/" : DEBUG_REMOTE_URL); public static final int DEFAULT_SYNC_INTERVAL = 4 * 3600; // 4 hours diff --git a/app/src/main/java/at/bitfire/davdroid/DavService.java b/app/src/main/java/at/bitfire/davdroid/DavService.java index 514d4bb6..cd20b93c 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavService.java +++ b/app/src/main/java/at/bitfire/davdroid/DavService.java @@ -11,8 +11,6 @@ package at.bitfire.davdroid; import android.accounts.Account; import android.accounts.AccountManager; import android.annotation.SuppressLint; -import android.app.Notification; -import android.app.PendingIntent; import android.app.Service; import android.content.ContentValues; import android.content.Intent; @@ -22,41 +20,25 @@ import android.database.sqlite.SQLiteDatabase; import android.os.Binder; import android.os.IBinder; import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.v4.app.NotificationManagerCompat; -import android.support.v7.app.NotificationCompat; import android.text.TextUtils; -import org.apache.commons.collections4.iterators.IteratorChain; -import org.apache.commons.collections4.iterators.SingletonIterator; - -import java.io.IOException; import java.lang.ref.WeakReference; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Level; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.UrlUtils; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.property.AddressbookHomeSet; -import at.bitfire.dav4android.property.CalendarHomeSet; -import at.bitfire.dav4android.property.CalendarProxyReadFor; -import at.bitfire.dav4android.property.CalendarProxyWriteFor; -import at.bitfire.dav4android.property.GroupMembership; +import at.bitfire.davdroid.journalmanager.Exceptions; +import at.bitfire.davdroid.journalmanager.JournalManager; import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.davdroid.model.ServiceDB.Collections; import at.bitfire.davdroid.model.ServiceDB.HomeSets; import at.bitfire.davdroid.model.ServiceDB.OpenHelper; import at.bitfire.davdroid.model.ServiceDB.Services; -import at.bitfire.davdroid.ui.DebugInfoActivity; import lombok.Cleanup; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -165,7 +147,6 @@ public class DavService extends Service { private class RefreshCollections implements Runnable { final long service; final OpenHelper dbHelper; - SQLiteDatabase db; RefreshCollections(long davServiceId) { this.service = davServiceId; @@ -177,148 +158,56 @@ public class DavService extends Service { Account account = null; try { - db = dbHelper.getWritableDatabase(); + @Cleanup SQLiteDatabase db = dbHelper.getWritableDatabase(); - String serviceType = serviceType(); + String serviceType = dbHelper.getServiceType(db, service); App.log.info("Refreshing " + serviceType + " collections of service #" + service); // get account - account = account(); + account = dbHelper.getServiceAccount(db, service); - // create authenticating OkHttpClient (credentials taken from account settings) OkHttpClient httpClient = HttpClient.create(DavService.this, account); - // refresh home sets: principal - Set homeSets = readHomeSets(); - HttpUrl principal = readPrincipal(); - if (principal != null) { - App.log.fine("Querying principal for home sets"); - DavResource dav = new DavResource(httpClient, principal); - queryHomeSets(serviceType, dav, homeSets); - - // refresh home sets: calendar-proxy-read/write-for - CalendarProxyReadFor proxyRead = (CalendarProxyReadFor)dav.properties.get(CalendarProxyReadFor.NAME); - if (proxyRead != null) - for (String href : proxyRead.principals) { - App.log.fine("Principal is a read-only proxy for " + href + ", checking for home sets"); - queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets); - } - CalendarProxyWriteFor proxyWrite = (CalendarProxyWriteFor)dav.properties.get(CalendarProxyWriteFor.NAME); - if (proxyWrite != null) - for (String href : proxyWrite.principals) { - App.log.fine("Principal is a read-write proxy for " + href + ", checking for home sets"); - queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets); - } + AccountSettings settings = new AccountSettings(DavService.this, account); + JournalManager journalsManager = new JournalManager(httpClient, HttpUrl.get(settings.getUri())); - // refresh home sets: direct group memberships - GroupMembership groupMembership = (GroupMembership)dav.properties.get(GroupMembership.NAME); - if (groupMembership != null) - for (String href : groupMembership.hrefs) { - App.log.fine("Principal is member of group " + href + ", checking for home sets"); - DavResource group = new DavResource(httpClient, dav.location.resolve(href)); - try { - queryHomeSets(serviceType, group, homeSets); - } catch(HttpException|DavException e) { - App.log.log(Level.WARNING, "Couldn't query member group ", e); - } - } - } + List collections = new LinkedList<>(); - // now refresh collections (taken from home sets) - Map collections = readCollections(); - - // (remember selections before) - Set selectedCollections = new HashSet<>(); - for (CollectionInfo info : collections.values()) - if (info.selected) - selectedCollections.add(HttpUrl.parse(info.url)); - - for (Iterator itHomeSets = homeSets.iterator(); itHomeSets.hasNext(); ) { - HttpUrl homeSet = itHomeSets.next(); - App.log.fine("Listing home set " + homeSet); - - DavResource dav = new DavResource(httpClient, homeSet); - try { - dav.propfind(1, CollectionInfo.DAV_PROPERTIES); - IteratorChain itCollections = new IteratorChain<>(dav.members.iterator(), new SingletonIterator(dav)); - while (itCollections.hasNext()) { - DavResource member = itCollections.next(); - CollectionInfo info = CollectionInfo.fromDavResource(member); - info.confirmed = true; - App.log.log(Level.FINE, "Found collection", info); - - if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type == CollectionInfo.Type.ADDRESS_BOOK) || - (serviceType.equals(Services.SERVICE_CALDAV) && info.type == CollectionInfo.Type.CALENDAR)) - collections.put(member.location, info); - } - } catch(HttpException e) { - if (e.status == 403 || e.status == 404 || e.status == 410) - // delete home set only if it was not accessible (40x) - itHomeSets.remove(); + for (JournalManager.Journal journal : journalsManager.getJournals(settings.password())) { + CollectionInfo info = CollectionInfo.fromJson(journal.getContent(settings.password())); + info.url = journal.getUuid(); + if (info.isOfTypeService(serviceType)) { + collections.add(info); } } - // check/refresh unconfirmed collections - for (Iterator> iterator = collections.entrySet().iterator(); iterator.hasNext(); ) { - Map.Entry entry = iterator.next(); - HttpUrl url = entry.getKey(); - CollectionInfo info = entry.getValue(); - - if (!info.confirmed) - try { - DavResource dav = new DavResource(httpClient, url); - dav.propfind(0, CollectionInfo.DAV_PROPERTIES); - info = CollectionInfo.fromDavResource(dav); - info.confirmed = true; - - // remove unusable collections - if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type != CollectionInfo.Type.ADDRESS_BOOK) || - (serviceType.equals(Services.SERVICE_CALDAV) && info.type != CollectionInfo.Type.CALENDAR)) - iterator.remove(); - } catch(HttpException e) { - if (e.status == 403 || e.status == 404 || e.status == 410) - // delete collection only if it was not accessible (40x) - iterator.remove(); - else - throw e; - } - } + // FIXME: handle deletion from server - // restore selections - for (HttpUrl url : selectedCollections) { - CollectionInfo info = collections.get(url); - if (info != null) - info.selected = true; + if (collections.isEmpty()) { + CollectionInfo info = CollectionInfo.defaultForService(serviceType); + JournalManager.Journal journal = new JournalManager.Journal(settings.password(), info.toJson()); + journalsManager.putJournal(journal); + info.url = journal.getUuid(); + collections.add(info); } db.beginTransactionNonExclusive(); try { - saveHomeSets(homeSets); - saveCollections(collections.values()); + saveCollections(db, collections); db.setTransactionSuccessful(); } finally { db.endTransaction(); } - } catch(InvalidAccountException e) { - App.log.log(Level.SEVERE, "Invalid account", e); - } catch(IOException|HttpException|DavException e) { - App.log.log(Level.SEVERE, "Couldn't refresh collection list", e); - - Intent debugIntent = new Intent(DavService.this, DebugInfoActivity.class); - debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e); - if (account != null) - debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account); - - NotificationManagerCompat nm = NotificationManagerCompat.from(DavService.this); - Notification notify = new NotificationCompat.Builder(DavService.this) - .setSmallIcon(R.drawable.ic_error_light) - .setLargeIcon(App.getLauncherBitmap(DavService.this)) - .setContentTitle(getString(R.string.dav_service_refresh_failed)) - .setContentText(getString(R.string.dav_service_refresh_couldnt_refresh)) - .setContentIntent(PendingIntent.getActivity(DavService.this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT)) - .build(); - nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify); + } catch (InvalidAccountException e) { + // FIXME: Do something + e.printStackTrace(); + } catch (Exceptions.HttpException e) { + // FIXME: do something + e.printStackTrace(); + } catch (Exceptions.IntegrityException e) { + // FIXME: do something + e.printStackTrace(); } finally { dbHelper.close(); @@ -331,91 +220,20 @@ public class DavService extends Service { } } - /** - * Checks if the given URL defines home sets and adds them to the home set list. - * @param serviceType CalDAV/CardDAV (calendar home set / addressbook home set) - * @param dav DavResource to check - * @param homeSets set where found home set URLs will be put into - */ - private void queryHomeSets(String serviceType, DavResource dav, Set homeSets) throws IOException, HttpException, DavException { - if (Services.SERVICE_CARDDAV.equals(serviceType)) { - dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME); - AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)dav.properties.get(AddressbookHomeSet.NAME); - if (addressbookHomeSet != null) - for (String href : addressbookHomeSet.hrefs) - homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href))); - } else if (Services.SERVICE_CALDAV.equals(serviceType)) { - dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME); - CalendarHomeSet calendarHomeSet = (CalendarHomeSet)dav.properties.get(CalendarHomeSet.NAME); - if (calendarHomeSet != null) - for (String href : calendarHomeSet.hrefs) - homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href))); - } - } - - - @NonNull - private Account account() { - @Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null); - if (cursor.moveToNext()) { - return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE); - } else - throw new IllegalArgumentException("Service not found"); - } - - @NonNull - private String serviceType() { - @Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.SERVICE }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null); - if (cursor.moveToNext()) - return cursor.getString(0); - else - throw new IllegalArgumentException("Service not found"); - } - - @Nullable - private HttpUrl readPrincipal() { - @Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.PRINCIPAL }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null); - if (cursor.moveToNext()) { - String principal = cursor.getString(0); - if (principal != null) - return HttpUrl.parse(cursor.getString(0)); - } - return null; - } - - @NonNull - private Set readHomeSets() { - Set homeSets = new LinkedHashSet<>(); - @Cleanup Cursor cursor = db.query(HomeSets._TABLE, new String[] { HomeSets.URL }, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }, null, null, null); - while (cursor.moveToNext()) - homeSets.add(HttpUrl.parse(cursor.getString(0))); - return homeSets; - } - - private void saveHomeSets(Set homeSets) { - db.delete(HomeSets._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }); - for (HttpUrl homeSet : homeSets) { - ContentValues values = new ContentValues(1); - values.put(HomeSets.SERVICE_ID, service); - values.put(HomeSets.URL, homeSet.toString()); - db.insertOrThrow(HomeSets._TABLE, null, values); - } - } - @NonNull - private Map readCollections() { - Map collections = new LinkedHashMap<>(); + private Map readCollections(SQLiteDatabase db) { + Map collections = new LinkedHashMap<>(); @Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", new String[]{String.valueOf(service)}, null, null, null); while (cursor.moveToNext()) { ContentValues values = new ContentValues(); DatabaseUtils.cursorRowToContentValues(cursor, values); - collections.put(HttpUrl.parse(values.getAsString(Collections.URL)), CollectionInfo.fromDB(values)); + collections.put(values.getAsString(Collections.URL), CollectionInfo.fromDB(values)); } return collections; } - private void saveCollections(Iterable collections) { - db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }); + private void saveCollections(SQLiteDatabase db, Iterable collections) { + db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[]{String.valueOf(service)}); for (CollectionInfo collection : collections) { ContentValues values = collection.toDB(); App.log.log(Level.FINE, "Saving collection", values); diff --git a/app/src/main/java/at/bitfire/davdroid/DavUtils.java b/app/src/main/java/at/bitfire/davdroid/DavUtils.java deleted file mode 100644 index 8664f467..00000000 --- a/app/src/main/java/at/bitfire/davdroid/DavUtils.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid; - -import android.support.annotation.NonNull; - -import org.apache.commons.lang3.StringUtils; - -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -import okhttp3.HttpUrl; - -public class DavUtils { - - public static String ARGBtoCalDAVColor(int colorWithAlpha) { - byte alpha = (byte)(colorWithAlpha >> 24); - int color = colorWithAlpha & 0xFFFFFF; - return String.format("#%06X%02X", color, alpha); - } - - public static String lastSegmentOfUrl(@NonNull String url) { - // the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy - List segments = new LinkedList<>(HttpUrl.parse(url).pathSegments()); - Collections.reverse(segments); - - for (String segment : segments) - if (!StringUtils.isEmpty(segment)) - return segment; - - return "/"; - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/GsonHelper.java b/app/src/main/java/at/bitfire/davdroid/GsonHelper.java new file mode 100644 index 00000000..094652c3 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/GsonHelper.java @@ -0,0 +1,31 @@ +package at.bitfire.davdroid; + +import android.util.Base64; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; + +public class GsonHelper { + public static final Gson gson = new GsonBuilder().registerTypeHierarchyAdapter(byte[].class, + new ByteArrayToBase64TypeAdapter()).create(); + + // Using Android's base64 libraries. This can be replaced with any base64 library. + private static class ByteArrayToBase64TypeAdapter implements JsonSerializer, JsonDeserializer { + public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return Base64.decode(json.getAsString(), Base64.NO_WRAP); + } + + public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(Base64.encodeToString(src, Base64.NO_WRAP)); + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/HttpClient.java b/app/src/main/java/at/bitfire/davdroid/HttpClient.java index 2da5aa30..6a0b6253 100644 --- a/app/src/main/java/at/bitfire/davdroid/HttpClient.java +++ b/app/src/main/java/at/bitfire/davdroid/HttpClient.java @@ -25,7 +25,6 @@ import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; -import at.bitfire.dav4android.BasicDigestAuthHandler; import at.bitfire.davdroid.model.ServiceDB; import at.bitfire.davdroid.model.Settings; import okhttp3.Interceptor; @@ -39,9 +38,10 @@ public class HttpClient { private static final UserAgentInterceptor userAgentInterceptor = new UserAgentInterceptor(); private static final String userAgent; + static { String date = new SimpleDateFormat("yyyy/MM/dd", Locale.US).format(new Date(BuildConfig.buildTime)); - userAgent = "DAVdroid/" + BuildConfig.VERSION_NAME + " (" + date + "; dav4android; okhttp3) Android/" + Build.VERSION.RELEASE; + userAgent = "DAVdroid/" + BuildConfig.VERSION_NAME + " (" + date + "; okhttp3) Android/" + Build.VERSION.RELEASE; } private HttpClient() { @@ -52,7 +52,7 @@ public class HttpClient { // use account settings for authentication AccountSettings settings = new AccountSettings(context, account); - builder = addAuthentication(builder, null, settings.username(), settings.password()); + builder = addAuthentication(builder, null, settings.getAuthToken()); return builder.build(); } @@ -75,7 +75,7 @@ public class HttpClient { // use MemorizingTrustManager to manage self-signed certificates if (context != null) { - App app = (App)context.getApplicationContext(); + App app = (App) context.getApplicationContext(); if (App.getSslSocketFactoryCompat() != null && app.getCertManager() != null) builder.sslSocketFactory(App.getSslSocketFactoryCompat(), app.getCertManager()); if (App.getHostnameVerifier() != null) @@ -87,9 +87,6 @@ public class HttpClient { builder.writeTimeout(30, TimeUnit.SECONDS); builder.readTimeout(120, TimeUnit.SECONDS); - // don't allow redirects, because it would break PROPFIND handling - builder.followRedirects(false); - // custom proxy support if (context != null) { SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(context); @@ -105,7 +102,7 @@ public class HttpClient { builder.proxy(proxy); App.log.log(Level.INFO, "Using proxy", proxy); } - } catch(IllegalArgumentException|NullPointerException e) { + } catch (IllegalArgumentException | NullPointerException e) { App.log.log(Level.SEVERE, "Can't set proxy, ignoring", e); } finally { dbHelper.close(); @@ -115,9 +112,6 @@ public class HttpClient { // add User-Agent to every request builder.addNetworkInterceptor(userAgentInterceptor); - // add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking) - builder.cookieJar(new MemoryCookieStore()); - // add network logging, if requested if (logger.isLoggable(Level.FINEST)) { HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() { @@ -126,32 +120,46 @@ public class HttpClient { logger.finest(message); } }); - loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY); + loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC); builder.addInterceptor(loggingInterceptor); } return builder; } - private static OkHttpClient.Builder addAuthentication(@NonNull OkHttpClient.Builder builder, @Nullable String host, @NonNull String username, @NonNull String password) { - BasicDigestAuthHandler authHandler = new BasicDigestAuthHandler(host, username, password); - return builder - .addNetworkInterceptor(authHandler) - .authenticator(authHandler); - } + private static OkHttpClient.Builder addAuthentication(@NonNull OkHttpClient.Builder builder, @Nullable String host, @NonNull String token) { + TokenAuthenticator authHandler = new TokenAuthenticator(host, token); - public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String username, @NonNull String password) { - OkHttpClient.Builder builder = client.newBuilder(); - addAuthentication(builder, null, username, password); - return builder.build(); + return builder.addNetworkInterceptor(authHandler); } - public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String host, @NonNull String username, @NonNull String password) { - OkHttpClient.Builder builder = client.newBuilder(); - addAuthentication(builder, host, username, password); - return builder.build(); - } + private static class TokenAuthenticator implements Interceptor { + protected static final String + HEADER_AUTHORIZATION = "Authorization"; + + // FIXME: Host is not used + final String host, token; + + private TokenAuthenticator(String host, String token) { + this.host = host; + this.token = token; + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + + if ((token != null) + && (request.header(HEADER_AUTHORIZATION) == null)) { + request = request.newBuilder() + .header(HEADER_AUTHORIZATION, "Token " + token) + .build(); + } + + return chain.proceed(request); + } + } static class UserAgentInterceptor implements Interceptor { @Override diff --git a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.java b/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.java deleted file mode 100644 index 1b1cf330..00000000 --- a/app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid; - -import org.apache.commons.collections4.MapIterator; -import org.apache.commons.collections4.keyvalue.MultiKey; -import org.apache.commons.collections4.map.HashedMap; -import org.apache.commons.collections4.map.MultiKeyMap; - -import java.util.LinkedList; -import java.util.List; - -import okhttp3.Cookie; -import okhttp3.CookieJar; -import okhttp3.HttpUrl; - -/** - * Primitive cookie store that stores cookies in a (volatile) hash map. - * Will be sufficient for session cookies. - */ -public class MemoryCookieStore implements CookieJar { - - /** - * Stored cookies. The multi-key consists of three parts: name, domain, and path. - * This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model] - * Not thread-safe! - */ - protected final MultiKeyMap storage = MultiKeyMap.multiKeyMap(new HashedMap, Cookie>()); - - @Override - public void saveFromResponse(HttpUrl url, List cookies) { - synchronized(storage) { - for (Cookie cookie : cookies) - storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie); - } - } - - @Override - public List loadForRequest(HttpUrl url) { - List cookies = new LinkedList<>(); - - synchronized(storage) { - MapIterator, Cookie> iter = storage.mapIterator(); - while (iter.hasNext()) { - iter.next(); - Cookie cookie = iter.getValue(); - - // remove expired cookies - if (cookie.expiresAt() <= System.currentTimeMillis()) { - iter.remove(); - continue; - } - - // add applicable cookies - if (cookie.matches(url)) - cookies.add(cookie); - } - } - - return cookies; - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/journalmanager/BaseManager.java b/app/src/main/java/at/bitfire/davdroid/journalmanager/BaseManager.java new file mode 100644 index 00000000..eef20483 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/journalmanager/BaseManager.java @@ -0,0 +1,104 @@ +package at.bitfire.davdroid.journalmanager; + +import org.apache.commons.codec.Charsets; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.logging.Level; + +import at.bitfire.davdroid.App; +import at.bitfire.davdroid.GsonHelper; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +import static at.bitfire.davdroid.journalmanager.Helpers.hmac; + +abstract class BaseManager { + final static protected MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + protected HttpUrl remote; + protected OkHttpClient client; + + Response newCall(Request request) throws Exceptions.HttpException { + Response response; + try { + response = client.newCall(request).execute(); + } catch (IOException e) { + App.log.log(Level.SEVERE, "Couldn't download external resource", e); + throw new Exceptions.ServiceUnavailableException("Failed downloading"); + } + + if (!response.isSuccessful()) { + switch (response.code()) { + case 401: + throw new Exceptions.UnauthorizedException("Failed to connect"); + default: + throw new Exceptions.HttpException("Error getting"); + } + } + + return response; + } + + static class Base { + @Setter(AccessLevel.PACKAGE) + @Getter(AccessLevel.PACKAGE) + private byte[] content; + @Setter(AccessLevel.PACKAGE) + private String uid; + + public String getUuid() { + return uid; + } + + public String getContent(String keyBase64) { + // FIXME: probably cache encryption object + return new String(new Helpers.Cipher().decrypt(keyBase64, content), Charsets.UTF_8); + } + + void setContent(String keyBase64, String content) { + // FIXME: probably cache encryption object + this.content = new Helpers.Cipher().encrypt(keyBase64, content.getBytes(Charsets.UTF_8)); + } + + byte[] calculateHmac(String keyBase64, String uuid) { + ByteArrayOutputStream hashContent = new ByteArrayOutputStream(); + + try { + if (uuid != null) { + hashContent.write(uuid.getBytes(Charsets.UTF_8)); + } + + hashContent.write(content); + } catch (IOException e) { + // FIXME: Do something + e.printStackTrace(); + } + + return hmac(keyBase64, hashContent.toByteArray()); + } + + protected Base() { + } + + Base(String keyBase64, String content, String uid) { + setContent(keyBase64, content); + setUid(uid); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "<" + uid + ">"; + } + + String toJson() { + return GsonHelper.gson.toJson(this, getClass()); + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/journalmanager/Exceptions.java b/app/src/main/java/at/bitfire/davdroid/journalmanager/Exceptions.java new file mode 100644 index 00000000..4d9deff4 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/journalmanager/Exceptions.java @@ -0,0 +1,31 @@ +package at.bitfire.davdroid.journalmanager; + +public class Exceptions { + public static class UnauthorizedException extends HttpException { + public UnauthorizedException(String message) { + super(401, message); + } + } + + public static class ServiceUnavailableException extends HttpException { + public ServiceUnavailableException(String message) { + super(message); + } + } + + public static class HttpException extends Exception { + public HttpException(String message) { + super(message); + } + + public HttpException(int status, String message) { + super(status + " " + message); + } + } + + public static class IntegrityException extends Exception { + public IntegrityException(String message) { + super(message); + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/journalmanager/Helpers.java b/app/src/main/java/at/bitfire/davdroid/journalmanager/Helpers.java new file mode 100644 index 00000000..c25ef7c2 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/journalmanager/Helpers.java @@ -0,0 +1,128 @@ +package at.bitfire.davdroid.journalmanager; + +import android.util.Base64; + +import org.apache.commons.codec.Charsets; +import org.spongycastle.crypto.BufferedBlockCipher; +import org.spongycastle.crypto.CipherParameters; +import org.spongycastle.crypto.InvalidCipherTextException; +import org.spongycastle.crypto.digests.SHA256Digest; +import org.spongycastle.crypto.engines.AESEngine; +import org.spongycastle.crypto.generators.SCrypt; +import org.spongycastle.crypto.macs.HMac; +import org.spongycastle.crypto.modes.CBCBlockCipher; +import org.spongycastle.crypto.paddings.BlockCipherPadding; +import org.spongycastle.crypto.paddings.PKCS7Padding; +import org.spongycastle.crypto.paddings.PaddedBufferedBlockCipher; +import org.spongycastle.crypto.params.KeyParameter; +import org.spongycastle.crypto.params.ParametersWithIV; +import org.spongycastle.util.encoders.Hex; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; + +public class Helpers { + // FIXME: This should be somewhere else + public static String deriveKey(String salt, String password) { + final int keySize = 190; + + return Base64.encodeToString(SCrypt.generate(password.getBytes(Charsets.UTF_8), salt.getBytes(Charsets.UTF_8), 16384, 8, 1, keySize), Base64.NO_WRAP); + } + + private static byte[] hmac256(byte[] keyByte, byte[] data) { + HMac hmac = new HMac(new SHA256Digest()); + KeyParameter key = new KeyParameter(keyByte); + byte[] ret = new byte[hmac.getMacSize()]; + hmac.init(key); + hmac.update(data, 0, data.length); + hmac.doFinal(ret, 0); + return ret; + } + + static byte[] hmac(String keyBase64, byte[] data) { + byte[] derivedKey = hmac256("hmac".getBytes(Charsets.UTF_8), Base64.decode(keyBase64, Base64.NO_WRAP)); + return hmac256(derivedKey, data); + } + + static class Cipher { + SecureRandom random; + + Cipher() { + random = new SecureRandom(); + } + + private static final int blockSize = 16; // AES's block size in bytes + + private BufferedBlockCipher getCipher(String keyBase64, byte[] iv, boolean encrypt) { + byte[] derivedKey = hmac256("aes".getBytes(Charsets.UTF_8), Base64.decode(keyBase64, Base64.NO_WRAP)); + KeyParameter key = new KeyParameter(derivedKey); + CipherParameters params = new ParametersWithIV(key, iv); + + BlockCipherPadding padding = new PKCS7Padding(); + BufferedBlockCipher cipher = new PaddedBufferedBlockCipher( + new CBCBlockCipher(new AESEngine()), padding); + cipher.reset(); + cipher.init(encrypt, params); + + return cipher; + } + + byte[] decrypt(String keyBase64, byte[] _data) { + byte[] iv = Arrays.copyOfRange(_data, 0, blockSize); + byte[] data = Arrays.copyOfRange(_data, blockSize, _data.length); + + BufferedBlockCipher cipher = getCipher(keyBase64, iv, false); + + byte[] buf = new byte[cipher.getOutputSize(data.length)]; + int len = cipher.processBytes(data, 0, data.length, buf, 0); + try { + len += cipher.doFinal(buf, len); + } catch (InvalidCipherTextException e) { + // FIXME: Fail! + e.printStackTrace(); + } + + // remove padding + byte[] out = new byte[len]; + System.arraycopy(buf, 0, out, 0, len); + + return out; + } + + byte[] encrypt(String keyBase64, byte[] data) { + byte[] iv = new byte[blockSize]; + random.nextBytes(iv); + + BufferedBlockCipher cipher = getCipher(keyBase64, iv, true); + + byte[] buf = new byte[cipher.getOutputSize(data.length) + blockSize]; + System.arraycopy(iv, 0, buf, 0, iv.length); + int len = iv.length + cipher.processBytes(data, 0, data.length, buf, iv.length); + try { + cipher.doFinal(buf, len); + } catch (InvalidCipherTextException e) { + // FIXME: Fail! + e.printStackTrace(); + } + + return buf; + } + } + + /* FIXME: Replace with bouncy-castle once available. */ + static String sha256(String base) { + return sha256(base.getBytes(Charsets.UTF_8)); + } + + /* FIXME: Replace with bouncy-castle once available. */ + static String sha256(byte[] base) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(base); + return Hex.toHexString(hash); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalAuthenticator.java b/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalAuthenticator.java new file mode 100644 index 00000000..6e1ae7a7 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalAuthenticator.java @@ -0,0 +1,58 @@ +package at.bitfire.davdroid.journalmanager; + +import java.io.IOException; +import java.util.logging.Level; + +import at.bitfire.davdroid.App; +import at.bitfire.davdroid.GsonHelper; +import okhttp3.FormBody; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class JournalAuthenticator { + private HttpUrl remote; + private OkHttpClient client; + + public JournalAuthenticator(OkHttpClient client, HttpUrl remote) { + this.client = client; + this.remote = remote.newBuilder() + .addPathSegments("api-token-auth") + .addPathSegment("") + .build(); + } + + private class AuthResponse { + private String token; + + private AuthResponse() { + } + } + + public String getAuthToken(String username, String password) throws Exceptions.HttpException { + FormBody.Builder formBuilder = new FormBody.Builder() + .add("username", username) + .add("password", password); + + Request request = new Request.Builder() + .post(formBuilder.build()) + .url(remote) + .build(); + + try { + Response response = client.newCall(request).execute(); + if (response.isSuccessful()) { + return GsonHelper.gson.fromJson(response.body().charStream(), AuthResponse.class).token; + } else if (response.code() == 400) { + throw new Exceptions.UnauthorizedException("Username or password incorrect"); + } else { + throw new Exceptions.HttpException("Error authenticating"); + } + } catch (IOException e) { + App.log.log(Level.SEVERE, "Couldn't download external resource", e); + } + + return null; + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalEntryManager.java b/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalEntryManager.java new file mode 100644 index 00000000..b5fe6592 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalEntryManager.java @@ -0,0 +1,112 @@ +package at.bitfire.davdroid.journalmanager; + +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.List; + +import aQute.lib.hex.Hex; +import at.bitfire.davdroid.App; +import at.bitfire.davdroid.GsonHelper; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class JournalEntryManager extends BaseManager { + final static private Type entryType = new TypeToken>() { + }.getType(); + + + public JournalEntryManager(OkHttpClient httpClient, HttpUrl remote, String journal) { + this.remote = remote.newBuilder() + .addPathSegments("api/v1/journal") + .addPathSegments(journal) + .addPathSegment("") + .build(); + App.log.info("Created for: " + this.remote.toString()); + + this.client = httpClient; + } + + public List getEntries(String keyBase64, String last) throws Exceptions.HttpException, Exceptions.IntegrityException { + Entry previousEntry = null; + HttpUrl.Builder urlBuilder = this.remote.newBuilder(); + if (last != null) { + urlBuilder.addQueryParameter("last", last); + previousEntry = Entry.getFakeWithUid(last); + } + + HttpUrl remote = urlBuilder.build(); + + Request request = new Request.Builder() + .get() + .url(remote) + .build(); + + Response response = newCall(request); + ResponseBody body = response.body(); + List ret = GsonHelper.gson.fromJson(body.charStream(), entryType); + + for (Entry entry : ret) { + entry.verify(keyBase64, previousEntry); + previousEntry = entry; + } + + return ret; + } + + public void putEntries(List entries, String last) throws Exceptions.HttpException { + HttpUrl.Builder urlBuilder = this.remote.newBuilder(); + if (last != null) { + urlBuilder.addQueryParameter("last", last); + } + + HttpUrl remote = urlBuilder.build(); + + RequestBody body = RequestBody.create(JSON, GsonHelper.gson.toJson(entries, entryType)); + + Request request = new Request.Builder() + .post(body) + .url(remote) + .build(); + + newCall(request); + } + + public static class Entry extends Base { + public Entry() { + super(); + } + + public void update(String keyBase64, String content, Entry previous) { + setContent(keyBase64, content); + setUid(calculateHmac(keyBase64, previous)); + } + + void verify(String keyBase64, Entry previous) throws Exceptions.IntegrityException { + String correctHash = calculateHmac(keyBase64, previous); + if (!getUuid().equals(correctHash)) { + throw new Exceptions.IntegrityException("Bad HMAC. " + getUuid() + " != " + correctHash); + } + } + + public static Entry getFakeWithUid(String uid) { + Entry ret = new Entry(); + ret.setUid(uid); + return ret; + } + + private String calculateHmac(String keyBase64, Entry previous) { + String uuid = null; + if (previous != null) { + uuid = previous.getUuid(); + } + + return Hex.toHexString(calculateHmac(keyBase64, uuid)); + } + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalManager.java b/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalManager.java new file mode 100644 index 00000000..bbed9b7e --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/journalmanager/JournalManager.java @@ -0,0 +1,124 @@ +package at.bitfire.davdroid.journalmanager; + +import com.google.gson.reflect.TypeToken; + +import org.spongycastle.util.Arrays; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.UUID; + +import aQute.lib.hex.Hex; +import at.bitfire.davdroid.App; +import at.bitfire.davdroid.GsonHelper; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import static at.bitfire.davdroid.journalmanager.Helpers.sha256; + +public class JournalManager extends BaseManager { + final static private Type journalType = new TypeToken>() { + }.getType(); + + + public JournalManager(OkHttpClient httpClient, HttpUrl remote) { + this.remote = remote.newBuilder() + .addPathSegments("api/v1/journals") + .addPathSegment("") + .build(); + App.log.info("Created for: " + this.remote.toString()); + + this.client = httpClient; + } + + public List getJournals(String keyBase64) throws Exceptions.HttpException, Exceptions.IntegrityException { + Request request = new Request.Builder() + .get() + .url(remote) + .build(); + + Response response = newCall(request); + ResponseBody body = response.body(); + List ret = GsonHelper.gson.fromJson(body.charStream(), journalType); + + for (Journal journal : ret) { + journal.processFromJson(); + journal.verify(keyBase64); + } + + return ret; + } + + public void deleteJournal(Journal journal) throws Exceptions.HttpException { + HttpUrl remote = this.remote.resolve(journal.getUuid() + "/"); + Request request = new Request.Builder() + .delete() + .url(remote) + .build(); + + newCall(request); + } + + public void putJournal(Journal journal) throws Exceptions.HttpException { + RequestBody body = RequestBody.create(JSON, journal.toJson()); + + Request request = new Request.Builder() + .post(body) + .url(remote) + .build(); + + newCall(request); + } + + public static class Journal extends Base { + final private transient int hmacSize = 256 / 8; // hmac256 in bytes + private transient byte[] hmac = null; + + @SuppressWarnings("unused") + private Journal() { + super(); + } + + public Journal(String keyBase64, String content) { + this(keyBase64, content, sha256(UUID.randomUUID().toString())); + } + + public Journal(String keyBase64, String content, String uid) { + super(keyBase64, content, uid); + hmac = calculateHmac(keyBase64); + } + + private void processFromJson() { + hmac = Arrays.copyOfRange(getContent(), 0, hmacSize); + setContent(Arrays.copyOfRange(getContent(), hmacSize, getContent().length)); + } + + void verify(String keyBase64) throws Exceptions.IntegrityException { + if (hmac == null) { + throw new Exceptions.IntegrityException("HMAC is null!"); + } + + byte[] correctHash = calculateHmac(keyBase64); + if (!Arrays.areEqual(hmac, correctHash)) { + throw new Exceptions.IntegrityException("Bad HMAC. " + Hex.toHexString(hmac) + " != " + Hex.toHexString(correctHash)); + } + } + + byte[] calculateHmac(String keyBase64) { + return super.calculateHmac(keyBase64, getUuid()); + } + + @Override + String toJson() { + byte[] rawContent = getContent(); + setContent(Arrays.concatenate(hmac, rawContent)); + String ret = super.toJson(); + setContent(rawContent); + return ret; + } + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.java b/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.java index 3e0f710a..be1a54ef 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.java +++ b/app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.java @@ -10,36 +10,25 @@ package at.bitfire.davdroid.model; import android.content.ContentValues; -import org.apache.commons.lang3.StringUtils; - import java.io.Serializable; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.Property; -import at.bitfire.dav4android.property.AddressbookDescription; -import at.bitfire.dav4android.property.CalendarColor; -import at.bitfire.dav4android.property.CalendarDescription; -import at.bitfire.dav4android.property.CalendarTimezone; -import at.bitfire.dav4android.property.CurrentUserPrivilegeSet; -import at.bitfire.dav4android.property.DisplayName; -import at.bitfire.dav4android.property.ResourceType; -import at.bitfire.dav4android.property.SupportedAddressData; -import at.bitfire.dav4android.property.SupportedCalendarComponentSet; +import at.bitfire.davdroid.GsonHelper; import at.bitfire.davdroid.model.ServiceDB.Collections; import lombok.ToString; @ToString public class CollectionInfo implements Serializable { - public long id; - public Long serviceID; + public transient long id; + public transient Long serviceID; public enum Type { ADDRESS_BOOK, CALENDAR } + public Type type; - public String url; + public transient String url; // Essentially the uuid public boolean readOnly; public String displayName, description; @@ -51,68 +40,29 @@ public class CollectionInfo implements Serializable { public boolean selected; - // non-persistent properties - public boolean confirmed; - - - public static final Property.Name[] DAV_PROPERTIES = { - ResourceType.NAME, - CurrentUserPrivilegeSet.NAME, - DisplayName.NAME, - AddressbookDescription.NAME, SupportedAddressData.NAME, - CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME - }; + public CollectionInfo() { + } - public static CollectionInfo fromDavResource(DavResource dav) { + public static CollectionInfo defaultForService(String sService) { + Type service = Type.valueOf(sService); CollectionInfo info = new CollectionInfo(); - info.url = dav.location.toString(); - - ResourceType type = (ResourceType)dav.properties.get(ResourceType.NAME); - if (type != null) { - if (type.types.contains(ResourceType.ADDRESSBOOK)) - info.type = Type.ADDRESS_BOOK; - else if (type.types.contains(ResourceType.CALENDAR)) - info.type = Type.CALENDAR; - } - + info.displayName = "Default"; + info.selected = true; info.readOnly = false; - CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME); - if (privilegeSet != null) - info.readOnly = !privilegeSet.mayWriteContent; - - DisplayName displayName = (DisplayName)dav.properties.get(DisplayName.NAME); - if (displayName != null && !StringUtils.isEmpty(displayName.displayName)) - info.displayName = displayName.displayName; - - if (info.type == Type.ADDRESS_BOOK) { - AddressbookDescription addressbookDescription = (AddressbookDescription)dav.properties.get(AddressbookDescription.NAME); - if (addressbookDescription != null) - info.description = addressbookDescription.description; - - } else if (info.type == Type.CALENDAR) { - CalendarDescription calendarDescription = (CalendarDescription)dav.properties.get(CalendarDescription.NAME); - if (calendarDescription != null) - info.description = calendarDescription.description; - - CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME); - if (calendarColor != null) - info.color = calendarColor.color; - - CalendarTimezone timeZone = (CalendarTimezone)dav.properties.get(CalendarTimezone.NAME); - if (timeZone != null) - info.timeZone = timeZone.vTimeZone; - - info.supportsVEVENT = info.supportsVTODO = true; - SupportedCalendarComponentSet supportedCalendarComponentSet = (SupportedCalendarComponentSet)dav.properties.get(SupportedCalendarComponentSet.NAME); - if (supportedCalendarComponentSet != null) { - info.supportsVEVENT = supportedCalendarComponentSet.supportsEvents; - info.supportsVTODO = supportedCalendarComponentSet.supportsTasks; - } - } + info.type = service; + if (service.equals(Type.CALENDAR)) { + info.supportsVEVENT = true; + // info.supportsVTODO = true; + } else { + // Carddav + } return info; } + public boolean isOfTypeService(String service) { + return service.equals(type.toString()); + } public static CollectionInfo fromDB(ContentValues values) { CollectionInfo info = new CollectionInfo(); @@ -154,6 +104,13 @@ public class CollectionInfo implements Serializable { return values; } + public static CollectionInfo fromJson(String json) { + return GsonHelper.gson.fromJson(json, CollectionInfo.class); + } + + public String toJson() { + return GsonHelper.gson.toJson(this, CollectionInfo.class); + } private static Boolean getAsBooleanOrNull(ContentValues values, String field) { Integer i = values.getAsInteger(field); diff --git a/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.java b/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.java index c24af265..06ad1fd4 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.java +++ b/app/src/main/java/at/bitfire/davdroid/model/ServiceDB.java @@ -8,6 +8,7 @@ package at.bitfire.davdroid.model; +import android.accounts.Account; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; @@ -16,9 +17,11 @@ import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteOpenHelper; import android.os.Build; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; import at.bitfire.davdroid.App; +import at.bitfire.davdroid.Constants; import lombok.Cleanup; public class ServiceDB { @@ -35,13 +38,12 @@ public class ServiceDB { _TABLE = "services", ID = "_id", ACCOUNT_NAME = "accountName", - SERVICE = "service", - PRINCIPAL = "principal"; + SERVICE = "service"; // allowed values for SERVICE column public static final String - SERVICE_CALDAV = "caldav", - SERVICE_CARDDAV = "carddav"; + SERVICE_CALDAV = CollectionInfo.Type.CALENDAR.toString(), + SERVICE_CARDDAV = CollectionInfo.Type.ADDRESS_BOOK.toString(); } public static class HomeSets { @@ -103,8 +105,7 @@ public class ServiceDB { 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" + + Services.SERVICE + " TEXT NOT NULL" + ")"); db.execSQL("CREATE UNIQUE INDEX services_account ON " + Services._TABLE + " (" + Services.ACCOUNT_NAME + "," + Services.SERVICE + ")"); @@ -183,6 +184,34 @@ public class ServiceDB { } db.endTransaction(); } + + @NonNull + public Account getServiceAccount(SQLiteDatabase db, long service) { + @Cleanup Cursor cursor = db.query(Services._TABLE, new String[]{Services.ACCOUNT_NAME}, Services.ID + "=?", new String[]{String.valueOf(service)}, null, null, null); + if (cursor.moveToNext()) { + return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE); + } else + throw new IllegalArgumentException("Service not found"); + } + + @NonNull + public String getServiceType(SQLiteDatabase db, long service) { + @Cleanup Cursor cursor = db.query(Services._TABLE, new String[]{Services.SERVICE}, Services.ID + "=?", new String[]{String.valueOf(service)}, null, null, null); + if (cursor.moveToNext()) + return cursor.getString(0); + else + throw new IllegalArgumentException("Service not found"); + } + + @Nullable + public Long getService(@NonNull SQLiteDatabase db, @NonNull Account account, String service) { + @Cleanup Cursor c = db.query(Services._TABLE, new String[]{Services.ID}, + Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[]{account.name, service}, null, null, null); + if (c.moveToNext()) + return c.getLong(0); + else + return null; + } } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java index d8dde423..f503bfe3 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalAddressBook.java @@ -123,7 +123,7 @@ public class LocalAddressBook extends AndroidAddressBook implements LocalCollect } public LocalContact[] getDirtyContacts() throws ContactsStorageException { - return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0", null); + return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0 AND " + RawContacts.DELETED + "== 0", null); } public LocalGroup[] getDeletedGroups() throws ContactsStorageException { @@ -131,7 +131,7 @@ public class LocalAddressBook extends AndroidAddressBook implements LocalCollect } public LocalGroup[] getDirtyGroups() throws ContactsStorageException { - return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0", null); + return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0 AND " + Groups.DELETED + "== 0", null); } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java index d5626a31..31d89d67 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.java @@ -32,7 +32,6 @@ import java.util.LinkedList; import java.util.List; import at.bitfire.davdroid.App; -import at.bitfire.davdroid.DavUtils; import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.ical4android.AndroidCalendar; import at.bitfire.ical4android.AndroidCalendarFactory; @@ -86,7 +85,7 @@ public class LocalCalendar extends AndroidCalendar implements LocalCollection { private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) { ContentValues values = new ContentValues(); values.put(Calendars.NAME, info.url); - values.put(Calendars.CALENDAR_DISPLAY_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url)); + values.put(Calendars.CALENDAR_DISPLAY_NAME, info.displayName); if (withColor) values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor); @@ -131,7 +130,7 @@ public class LocalCalendar extends AndroidCalendar implements LocalCollection { List dirty = new LinkedList<>(); // get dirty events which are required to have an increased SEQUENCE value - for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "!=0 AND " + Events.ORIGINAL_ID + " IS NULL", null)) { + for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "!=0 AND " + Events.DELETED + "==0 AND " + Events.ORIGINAL_ID + " IS NULL", null)) { if (event.getEvent().sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created) event.getEvent().sequence = 0; else if (event.weAreOrganizer) diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java index 18994d70..bb97dbae 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalCollection.java @@ -17,6 +17,7 @@ public interface LocalCollection { LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException; LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException; + /** Dirty *non-deleted* entries */ LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException, FileNotFoundException; LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException; diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java index f12fa78e..74bab556 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalContact.java @@ -15,12 +15,16 @@ import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.GroupMembership; import android.provider.ContactsContract.RawContacts.Data; import android.support.annotation.NonNull; +import android.text.TextUtils; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; +import java.io.IOException; import java.util.HashSet; import java.util.Set; +import java.util.logging.Level; -import at.bitfire.davdroid.BuildConfig; +import at.bitfire.davdroid.App; import at.bitfire.davdroid.model.UnknownProperties; import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidContact; @@ -29,24 +33,32 @@ import at.bitfire.vcard4android.BatchOperation; import at.bitfire.vcard4android.CachedGroupMembership; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; -import ezvcard.Ezvcard; +import ezvcard.VCardVersion; -public class LocalContact extends AndroidContact implements LocalResource { - static { - Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION; - } +import static at.bitfire.vcard4android.GroupMethod.GROUP_VCARDS; +public class LocalContact extends AndroidContact implements LocalResource { protected final Set cachedGroupMemberships = new HashSet<>(), groupMemberships = new HashSet<>(); - protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) { - super(addressBook, id, fileName, eTag); + protected LocalContact(AndroidAddressBook addressBook, long id, String uuid, String eTag) { + super(addressBook, id, uuid, eTag); + } + + public LocalContact(AndroidAddressBook addressBook, Contact contact, String uuid, String eTag) { + super(addressBook, contact, uuid, eTag); + } + + public String getUuid() { + // The same now + return getFileName(); } - public LocalContact(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) { - super(addressBook, contact, fileName, eTag); + @Override + public boolean isLocalOnly() { + return TextUtils.isEmpty(getETag()); } public void clearDirty(String eTag) throws ContactsStorageException { @@ -64,7 +76,7 @@ public class LocalContact extends AndroidContact implements LocalResource { public void updateFileNameAndUID(String uid) throws ContactsStorageException { try { - String newFileName = uid + ".vcf"; + String newFileName = uid; ContentValues values = new ContentValues(2); values.put(COLUMN_FILENAME, newFileName); @@ -77,6 +89,18 @@ public class LocalContact extends AndroidContact implements LocalResource { } } + @Override + public String getContent() throws IOException, ContactsStorageException { + final Contact contact; + contact = getContact(); + + App.log.log(Level.FINE, "Preparing upload of VCard " + getUuid(), contact); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + contact.write(VCardVersion.V4_0, GROUP_VCARDS, os); + + return os.toString(); + } @Override protected void populateData(String mimeType, ContentValues row) { @@ -181,11 +205,6 @@ public class LocalContact extends AndroidContact implements LocalResource { return new LocalContact(addressBook, id, fileName, eTag); } - @Override - public LocalContact newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) { - return new LocalContact(addressBook, contact, fileName, eTag); - } - @Override public LocalContact[] newArray(int size) { return new LocalContact[size]; diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java index 858d8d20..b2f52c98 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.java @@ -16,30 +16,33 @@ import android.os.RemoteException; import android.provider.CalendarContract; import android.provider.CalendarContract.Events; import android.support.annotation.NonNull; +import android.text.TextUtils; -import net.fortuna.ical4j.model.property.ProdId; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.logging.Level; -import at.bitfire.davdroid.BuildConfig; +import at.bitfire.davdroid.App; import at.bitfire.ical4android.AndroidCalendar; import at.bitfire.ical4android.AndroidEvent; import at.bitfire.ical4android.AndroidEventFactory; import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.Event; +import at.bitfire.vcard4android.ContactsStorageException; import lombok.Getter; import lombok.Setter; @TargetApi(17) public class LocalEvent extends AndroidEvent implements LocalResource { - static { - Event.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x"); - } - static final String COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1, - COLUMN_UID = Build.VERSION.SDK_INT >= 17 ? Events.UID_2445 : Events.SYNC_DATA2, - COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3; + COLUMN_UID = Build.VERSION.SDK_INT >= 17 ? Events.UID_2445 : Events.SYNC_DATA2, + COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3; - @Getter protected String fileName; - @Getter @Setter protected String eTag; + @Getter + protected String fileName; + @Getter + @Setter + protected String eTag; public boolean weAreOrganizer = true; @@ -57,6 +60,26 @@ public class LocalEvent extends AndroidEvent implements LocalResource { } } + @Override + public String getContent() throws IOException, ContactsStorageException, CalendarStorageException { + App.log.log(Level.FINE, "Preparing upload of event " + getFileName(), getEvent()); + + ByteArrayOutputStream os = new ByteArrayOutputStream(); + getEvent().write(os); + + return os.toString(); + } + + @Override + public boolean isLocalOnly() { + return TextUtils.isEmpty(getETag()); + } + + @Override + public String getUuid() { + // Now the same + return getFileName(); + } /* process LocalEvent-specific fields */ @@ -83,7 +106,7 @@ public class LocalEvent extends AndroidEvent implements LocalResource { boolean buildException = recurrence != null; Event eventToBuild = buildException ? recurrence : event; - builder .withValue(COLUMN_UID, event.uid) + builder.withValue(COLUMN_UID, event.uid) .withValue(COLUMN_SEQUENCE, eventToBuild.sequence) .withValue(CalendarContract.Events.DIRTY, 0) .withValue(CalendarContract.Events.DELETED, 0); @@ -91,7 +114,7 @@ public class LocalEvent extends AndroidEvent implements LocalResource { if (buildException) builder.withValue(Events.ORIGINAL_SYNC_ID, fileName); else - builder .withValue(Events._SYNC_ID, fileName) + builder.withValue(Events._SYNC_ID, fileName) .withValue(COLUMN_ETAG, eTag); } @@ -100,7 +123,7 @@ public class LocalEvent extends AndroidEvent implements LocalResource { public void updateFileNameAndUID(String uid) throws CalendarStorageException { try { - String newFileName = uid + ".ics"; + String newFileName = uid; ContentValues values = new ContentValues(2); values.put(Events._SYNC_ID, newFileName); diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java index 1f96f2a7..7e0d8c8e 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.java @@ -23,11 +23,12 @@ import android.provider.ContactsContract.RawContacts.Data; import org.apache.commons.lang3.ArrayUtils; import java.io.FileNotFoundException; +import java.io.IOException; import java.util.LinkedList; import java.util.List; import java.util.logging.Level; -import at.bitfire.dav4android.Constants; +import at.bitfire.davdroid.App; import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidGroup; import at.bitfire.vcard4android.AndroidGroupFactory; @@ -36,10 +37,13 @@ import at.bitfire.vcard4android.CachedGroupMembership; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; import lombok.Cleanup; +import lombok.Getter; import lombok.ToString; @ToString(callSuper=true) public class LocalGroup extends AndroidGroup implements LocalResource { + @Getter + protected String uuid; /** marshalled list of member UIDs, as sent by server */ public static final String COLUMN_PENDING_MEMBERS = Groups.SYNC3; @@ -51,6 +55,15 @@ public class LocalGroup extends AndroidGroup implements LocalResource { super(addressBook, contact, fileName, eTag); } + @Override + public String getContent() throws IOException, ContactsStorageException { + return null; + } + + @Override + public boolean isLocalOnly() { + return false; + } @Override public void clearDirty(String eTag) throws ContactsStorageException { @@ -145,7 +158,7 @@ public class LocalGroup extends AndroidGroup implements LocalResource { BatchOperation batch = new BatchOperation(addressBook.provider); while (cursor != null && cursor.moveToNext()) { long id = cursor.getLong(0); - Constants.log.fine("Assigning members to group " + id); + App.log.fine("Assigning members to group " + id); // delete all memberships and cached memberships for this group batch.enqueue(new BatchOperation.Operation( @@ -167,12 +180,12 @@ public class LocalGroup extends AndroidGroup implements LocalResource { // insert memberships for (String uid : members) { - Constants.log.fine("Assigning member: " + uid); + App.log.fine("Assigning member: " + uid); try { LocalContact member = addressBook.findContactByUID(uid); member.addToGroup(batch, id); } catch(FileNotFoundException e) { - Constants.log.log(Level.WARNING, "Group member not found: " + uid, e); + App.log.log(Level.WARNING, "Group member not found: " + uid, e); } } diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java index bb18503c..3989299c 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalResource.java @@ -8,15 +8,20 @@ package at.bitfire.davdroid.resource; +import java.io.IOException; + import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.vcard4android.ContactsStorageException; public interface LocalResource { - + String getUuid(); Long getId(); - String getFileName(); - String getETag(); + /** True if doesn't exist on server yet, false otherwise. */ + boolean isLocalOnly(); + + /** Returns a string of how this should be represented for example: vCard. */ + String getContent() throws IOException, ContactsStorageException, CalendarStorageException; int delete() throws CalendarStorageException, ContactsStorageException; diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.java index fe9ad292..6c4d0ec3 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalTask.java @@ -14,26 +14,24 @@ import android.os.RemoteException; import android.provider.CalendarContract.Events; import android.support.annotation.NonNull; -import net.fortuna.ical4j.model.property.ProdId; - import org.dmfs.provider.tasks.TaskContract.Tasks; import java.io.FileNotFoundException; +import java.io.IOException; import java.text.ParseException; -import at.bitfire.davdroid.BuildConfig; import at.bitfire.ical4android.AndroidTask; import at.bitfire.ical4android.AndroidTaskFactory; import at.bitfire.ical4android.AndroidTaskList; import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.Task; +import at.bitfire.vcard4android.ContactsStorageException; import lombok.Getter; import lombok.Setter; public class LocalTask extends AndroidTask implements LocalResource { - static { - Task.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x"); - } + @Getter + protected String uuid; static final String COLUMN_ETAG = Tasks.SYNC1, COLUMN_UID = Tasks.SYNC2, @@ -56,6 +54,15 @@ public class LocalTask extends AndroidTask implements LocalResource { } } + @Override + public String getContent() throws IOException, ContactsStorageException { + return null; + } + + @Override + public boolean isLocalOnly() { + return false; + } /* process LocalTask-specific fields */ diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.java b/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.java index b293a7fa..e08a264d 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.java +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.java @@ -18,14 +18,12 @@ import android.net.Uri; import android.os.Build; import android.os.RemoteException; import android.support.annotation.NonNull; -import android.text.TextUtils; import org.dmfs.provider.tasks.TaskContract.TaskLists; import org.dmfs.provider.tasks.TaskContract.Tasks; import java.io.FileNotFoundException; -import at.bitfire.davdroid.DavUtils; import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.ical4android.AndroidTaskList; import at.bitfire.ical4android.AndroidTaskListFactory; @@ -71,7 +69,7 @@ public class LocalTaskList extends AndroidTaskList implements LocalCollection { private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) { ContentValues values = new ContentValues(); values.put(TaskLists._SYNC_ID, info.url); - values.put(TaskLists.LIST_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url)); + values.put(TaskLists.LIST_NAME, info.displayName); if (withColor) values.put(TaskLists.LIST_COLOR, info.color != null ? info.color : defaultColor); @@ -97,7 +95,7 @@ public class LocalTaskList extends AndroidTaskList implements LocalCollection { @Override public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException { - LocalTask[] tasks = (LocalTask[])queryTasks(Tasks._DIRTY + "!=0", null); + LocalTask[] tasks = (LocalTask[])queryTasks(Tasks._DIRTY + "!=0 AND " + Tasks._DELETED + "== 0", null); if (tasks != null) for (LocalTask task : tasks) { if (task.getTask().sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created) diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java index 6c3d6698..9d57f24d 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.java @@ -14,34 +14,18 @@ import android.content.SyncResult; import android.os.Bundle; import org.apache.commons.codec.Charsets; -import org.apache.commons.lang3.StringUtils; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.nio.charset.Charset; -import java.util.Calendar; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.logging.Level; - -import at.bitfire.dav4android.DavCalendar; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.property.CalendarData; -import at.bitfire.dav4android.property.GetCTag; -import at.bitfire.dav4android.property.GetContentType; -import at.bitfire.dav4android.property.GetETag; + import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.App; -import at.bitfire.davdroid.ArrayUtils; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Exceptions; +import at.bitfire.davdroid.journalmanager.JournalEntryManager; import at.bitfire.davdroid.resource.LocalCalendar; import at.bitfire.davdroid.resource.LocalEvent; import at.bitfire.davdroid.resource.LocalResource; @@ -49,23 +33,20 @@ import at.bitfire.ical4android.CalendarStorageException; import at.bitfire.ical4android.Event; import at.bitfire.ical4android.InvalidCalendarException; import at.bitfire.vcard4android.ContactsStorageException; -import lombok.Cleanup; import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; /** - * Synchronization manager for CalDAV collections; handles events ({@code VEVENT}). + *

Synchronization manager for CardDAV collections; handles contacts and groups.

*/ public class CalendarSyncManager extends SyncManager { + protected static final int MAX_MULTIGET = 10; - protected static final int MAX_MULTIGET = 20; - + final private HttpUrl remote; - public CalendarSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult result, LocalCalendar calendar) throws InvalidAccountException { + public CalendarSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult result, LocalCalendar calendar, HttpUrl remote) throws InvalidAccountException { super(context, account, settings, extras, authority, result, "calendar/" + calendar.getId()); localCollection = calendar; + this.remote = remote; } @Override @@ -78,16 +59,14 @@ public class CalendarSyncManager extends SyncManager { return context.getString(R.string.sync_error_calendar, account.name); } - @Override - protected void prepare() { - collectionURL = HttpUrl.parse(localCalendar().getName()); - davCollection = new DavCalendar(httpClient, collectionURL); + protected void prepare() throws ContactsStorageException { + journal = new JournalEntryManager(httpClient, remote, localCalendar().getName()); } @Override - protected void queryCapabilities() throws DavException, IOException, HttpException { - davCollection.propfind(0, GetCTag.NAME); + protected void applyLocalEntries() throws IOException, Exceptions.HttpException, ContactsStorageException, CalendarStorageException { + } @Override @@ -97,139 +76,55 @@ public class CalendarSyncManager extends SyncManager { localCalendar().processDirtyExceptions(); } - @Override - protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException { - LocalEvent local = (LocalEvent)resource; - App.log.log(Level.FINE, "Preparing upload of event " + local.getFileName(), local.getEvent()); - ByteArrayOutputStream os = new ByteArrayOutputStream(); - local.getEvent().write(os); + // helpers - return RequestBody.create( - DavCalendar.MIME_ICALENDAR, - os.toByteArray() - ); + private LocalCalendar localCalendar() { + return (LocalCalendar) localCollection; } - @Override - protected void listRemote() throws IOException, HttpException, DavException { - // calculate time range limits - Date limitStart = null; - Integer pastDays = settings.getTimeRangePastDays(); - if (pastDays != null) { - Calendar calendar = Calendar.getInstance(); - calendar.add(Calendar.DAY_OF_MONTH, -pastDays); - limitStart = calendar.getTime(); - } + protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException { + InputStream is = new ByteArrayInputStream(cEntry.getContent().getBytes(Charsets.UTF_8)); - // fetch list of remote VEVENTs and build hash table to index file name - davCalendar().calendarQuery("VEVENT", limitStart, null); + Event[] events = Event.fromStream(is, Charsets.UTF_8); + if (events.length == 0) { + App.log.warning("Received VCard without data, ignoring"); + return; + } else if (events.length > 1) + App.log.warning("Received multiple VCALs, using first one"); - remoteResources = new HashMap<>(davCollection.members.size()); - for (DavResource iCal : davCollection.members) { - String fileName = iCal.fileName(); - App.log.fine("Found remote VEVENT: " + fileName); - remoteResources.put(fileName, iCal); - } - } + Event event = events[0]; - @Override - protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException { - App.log.info("Downloading " + toDownload.size() + " events (" + MAX_MULTIGET + " at once)"); - - // download new/updated iCalendars from server - for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) { - if (Thread.interrupted()) - return; - App.log.info("Downloading " + StringUtils.join(bunch, ", ")); - - if (bunch.length == 1) { - // only one contact, use GET - DavResource remote = bunch[0]; - - ResponseBody body = remote.get("text/calendar"); - - // CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4] - GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME); - if (eTag == null || StringUtils.isEmpty(eTag.eTag)) - throw new DavException("Received CalDAV GET response without ETag for " + remote.location); - - Charset charset = Charsets.UTF_8; - MediaType contentType = body.contentType(); - if (contentType != null) - charset = contentType.charset(Charsets.UTF_8); - - @Cleanup InputStream stream = body.byteStream(); - processVEvent(remote.fileName(), eTag.eTag, stream, charset); - - } else { - // multiple contacts, use multi-get - List urls = new LinkedList<>(); - for (DavResource remote : bunch) - urls.add(remote.location); - davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()])); - - // process multiget results - for (DavResource remote : davCollection.members) { - String eTag; - GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); - if (getETag != null) - eTag = getETag.eTag; - else - throw new DavException("Received multi-get response without ETag"); - - Charset charset = Charsets.UTF_8; - GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME); - if (getContentType != null && getContentType.type != null) { - MediaType type = MediaType.parse(getContentType.type); - if (type != null) - charset = type.charset(Charsets.UTF_8); - } - - CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME); - if (calendarData == null || calendarData.iCalendar == null) - throw new DavException("Received multi-get response without address data"); - - @Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes()); - processVEvent(remote.fileName(), eTag, stream, charset); - } + if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { + LocalResource local = processEvent(event); + + if (local != null) { + localResources.put(local.getUuid(), local); } + + } else { + LocalResource local = localResources.get(event.uid); + App.log.info("Removing local record #" + local.getId() + " which has been deleted on the server"); + localResources.remove(local.getUuid()); + local.delete(); } } - - // helpers - - private LocalCalendar localCalendar() { return ((LocalCalendar)localCollection); } - private DavCalendar davCalendar() { return (DavCalendar)davCollection; } - - private void processVEvent(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException { - Event[] events; - try { - events = Event.fromStream(stream, charset); - } catch (InvalidCalendarException e) { - App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e); - return; + private LocalResource processEvent(final Event newData) throws IOException, ContactsStorageException, CalendarStorageException { + // delete local event, if it exists + LocalEvent localEvent = (LocalEvent) localResources.get(newData.uid); + if (localEvent != null) { + App.log.info("Updating " + newData.uid + " in local calendar"); + localEvent.setETag(newData.uid); + localEvent.update(newData); + syncResult.stats.numUpdates++; + } else { + App.log.info("Adding " + newData.uid + " to local calendar"); + localEvent = new LocalEvent(localCalendar(), newData, newData.uid, null); + localEvent.add(); + syncResult.stats.numInserts++; } - if (events.length == 1) { - Event newData = events[0]; - - // delete local event, if it exists - LocalEvent localEvent = (LocalEvent)localResources.get(fileName); - if (localEvent != null) { - App.log.info("Updating " + fileName + " in local calendar"); - localEvent.setETag(eTag); - localEvent.update(newData); - syncResult.stats.numUpdates++; - } else { - App.log.info("Adding " + fileName + " to local calendar"); - localEvent = new LocalEvent(localCalendar(), newData, fileName, eTag); - localEvent.add(); - syncResult.stats.numInserts++; - } - } else - App.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName); + return localEvent; } - } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java index 67f617c8..e6937a3e 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarsSyncAdapterService.java @@ -18,11 +18,9 @@ import android.database.Cursor; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; -import android.database.sqlite.SQLiteOpenHelper; import android.os.Bundle; import android.provider.CalendarContract; import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import java.util.LinkedHashMap; import java.util.Map; @@ -38,6 +36,7 @@ import at.bitfire.davdroid.model.ServiceDB.Services; import at.bitfire.davdroid.resource.LocalCalendar; import at.bitfire.ical4android.CalendarStorageException; import lombok.Cleanup; +import okhttp3.HttpUrl; public class CalendarsSyncAdapterService extends SyncAdapterService { @@ -47,7 +46,7 @@ public class CalendarsSyncAdapterService extends SyncAdapterService { } - private static class SyncAdapter extends SyncAdapterService.SyncAdapter { + private static class SyncAdapter extends SyncAdapterService.SyncAdapter { public SyncAdapter(Context context) { super(context); @@ -62,29 +61,33 @@ public class CalendarsSyncAdapterService extends SyncAdapterService { if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) return; - updateLocalCalendars(provider, account, settings); + HttpUrl principal = updateLocalCalendars(provider, account, settings); - for (LocalCalendar calendar : (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null)) { - App.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName()); - CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, settings, extras, authority, syncResult, calendar); + for (LocalCalendar calendar : (LocalCalendar[]) LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null)) { + App.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName()); + CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, settings, extras, authority, syncResult, calendar, principal); syncManager.performSync(); } - } catch(CalendarStorageException|SQLiteException e) { + } catch (CalendarStorageException | SQLiteException e) { App.log.log(Level.SEVERE, "Couldn't prepare local calendars", e); syncResult.databaseError = true; - } catch(InvalidAccountException e) { + } catch (InvalidAccountException e) { App.log.log(Level.SEVERE, "Couldn't get account settings", e); } App.log.info("Calendar sync complete"); } - private void updateLocalCalendars(ContentProviderClient provider, Account account, AccountSettings settings) throws CalendarStorageException { - SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); + private HttpUrl updateLocalCalendars(ContentProviderClient provider, Account account, AccountSettings settings) throws CalendarStorageException { + HttpUrl ret = null; + ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); try { // enumerate remote and local calendars SQLiteDatabase db = dbHelper.getReadableDatabase(); - Long service = getService(db, account); + Long service = dbHelper.getService(db, account, Services.SERVICE_CALDAV); + + ret = HttpUrl.get(settings.getUri()); + Map remote = remoteCalendars(db, service); LocalCalendar[] local = (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null); @@ -116,16 +119,8 @@ public class CalendarsSyncAdapterService extends SyncAdapterService { } finally { dbHelper.close(); } - } - @Nullable - Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) { - @Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID }, - Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null); - if (c.moveToNext()) - return c.getLong(0); - else - return null; + return ret; } @NonNull @@ -134,7 +129,7 @@ public class CalendarsSyncAdapterService extends SyncAdapterService { if (service != null) { @Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VEVENT + "!=0 AND " + Collections.SYNC, - new String[] { String.valueOf(service) }, null, null, null); + new String[]{String.valueOf(service)}, null, null, null); while (cursor.moveToNext()) { ContentValues values = new ContentValues(); DatabaseUtils.cursorRowToContentValues(cursor, values); diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java index c179d908..5dd84b3c 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java @@ -17,7 +17,6 @@ import android.content.SyncResult; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -31,6 +30,7 @@ import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.davdroid.model.ServiceDB; import at.bitfire.davdroid.model.ServiceDB.Collections; import lombok.Cleanup; +import okhttp3.HttpUrl; public class ContactsSyncAdapterService extends SyncAdapterService { @@ -40,7 +40,7 @@ public class ContactsSyncAdapterService extends SyncAdapterService { } - private static class ContactsSyncAdapter extends SyncAdapter { + private static class ContactsSyncAdapter extends SyncAdapter { public ContactsSyncAdapter(Context context) { super(context); @@ -50,25 +50,23 @@ public class ContactsSyncAdapterService extends SyncAdapterService { public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { super.onPerformSync(account, extras, authority, provider, syncResult); - SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); + ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); try { AccountSettings settings = new AccountSettings(getContext(), account); if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) return; SQLiteDatabase db = dbHelper.getReadableDatabase(); - Long service = getService(db, account); + Long service = dbHelper.getService(db, account, ServiceDB.Services.SERVICE_CARDDAV); if (service != null) { - CollectionInfo remote = remoteAddressBook(db, service); - if (remote != null) - try { - ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, remote); - syncManager.performSync(); - } catch(InvalidAccountException e) { - App.log.log(Level.SEVERE, "Couldn't get account settings", e); - } - else - App.log.info("No address book collection selected for synchronization"); + HttpUrl principal = HttpUrl.get(settings.getUri()); + CollectionInfo info = remoteAddressBook(db, service); + try { + ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, principal, info); + syncManager.performSync(); + } catch (InvalidAccountException e) { + App.log.log(Level.SEVERE, "Couldn't get account settings", e); + } } else App.log.info("No CardDAV service found in DB"); } catch (InvalidAccountException e) { @@ -80,20 +78,10 @@ public class ContactsSyncAdapterService extends SyncAdapterService { App.log.info("Address book sync complete"); } - @Nullable - private Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) { - @Cleanup Cursor c = db.query(ServiceDB.Services._TABLE, new String[] { ServiceDB.Services.ID }, - ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", new String[] { account.name, ServiceDB.Services.SERVICE_CARDDAV }, null, null, null); - if (c.moveToNext()) - return c.getLong(0); - else - return null; - } - @Nullable private CollectionInfo remoteAddressBook(@NonNull SQLiteDatabase db, long service) { @Cleanup Cursor c = db.query(Collections._TABLE, null, - Collections.SERVICE_ID + "=? AND " + Collections.SYNC, new String[] { String.valueOf(service) }, null, null, null); + Collections.SERVICE_ID + "=? AND " + Collections.SYNC, new String[]{String.valueOf(service)}, null, null, null); if (c.moveToNext()) { ContentValues values = new ContentValues(); DatabaseUtils.cursorRowToContentValues(c, values); @@ -101,7 +89,6 @@ public class ContactsSyncAdapterService extends SyncAdapterService { } else return null; } - } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java index 6ac1bcec..d066eeeb 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.java @@ -10,123 +10,58 @@ package at.bitfire.davdroid.syncadapter; import android.accounts.Account; import android.content.ContentProviderClient; -import android.content.ContentProviderOperation; -import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.SyncResult; -import android.database.Cursor; import android.os.Bundle; -import android.os.RemoteException; import android.provider.ContactsContract; -import android.provider.ContactsContract.Groups; -import android.support.annotation.NonNull; -import android.text.TextUtils; import org.apache.commons.codec.Charsets; -import org.apache.commons.collections4.SetUtils; import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.nio.charset.Charset; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Set; import java.util.logging.Level; -import at.bitfire.dav4android.DavAddressBook; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.property.AddressData; -import at.bitfire.dav4android.property.GetCTag; -import at.bitfire.dav4android.property.GetContentType; -import at.bitfire.dav4android.property.GetETag; -import at.bitfire.dav4android.property.ResourceType; -import at.bitfire.dav4android.property.SupportedAddressData; import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.App; -import at.bitfire.davdroid.ArrayUtils; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.JournalEntryManager; import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.davdroid.resource.LocalAddressBook; import at.bitfire.davdroid.resource.LocalContact; import at.bitfire.davdroid.resource.LocalGroup; import at.bitfire.davdroid.resource.LocalResource; import at.bitfire.ical4android.CalendarStorageException; -import at.bitfire.vcard4android.BatchOperation; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; -import at.bitfire.vcard4android.GroupMethod; -import ezvcard.VCardVersion; import lombok.Cleanup; import lombok.RequiredArgsConstructor; import okhttp3.HttpUrl; -import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; -import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; /** *

Synchronization manager for CardDAV collections; handles contacts and groups.

- * - *

Group handling differs according to the {@link #groupMethod}. There are two basic methods to - * handle/manage groups:

- *
    - *
  • {@code CATEGORIES}: groups memberships are attached to each contact and represented as - * "category". When a group is dirty or has been deleted, all its members have to be set to - * dirty, too (because they have to be uploaded without the respective category). This - * is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing, - * which is done in {@link #postProcess()} because groups may become empty after downloading - * updated remoted contacts.
  • - *
  • Groups as separate VCards: individual and group contacts (with a list of member UIDs) are - * distinguished. When a local group is dirty, its members don't need to be set to dirty. - *
      - *
    1. However, when a contact is dirty, it has - * to be checked whether its group memberships have changed. In this case, the respective - * groups have to be set to dirty. For instance, if contact A is in group G and H, and then - * group membership of G is removed, the contact will be set to dirty because of the changed - * {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will - * then have to check whether the group memberships have actually changed, and if so, - * all affected groups have to be set to dirty. To detect changes in group memberships, - * DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership} - * data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows. - * If the cached group memberships are not the same as the current group member ships, the - * difference set (in our example G, because its in the cached memberships, but not in the - * actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.
    2. - *
    3. When downloading remote contacts, groups (+ member information) may be received - * by the actual members. Thus, the member lists have to be cached until all VCards - * are received. This is done by caching the member UIDs of each group in - * {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()}, - * these "pending memberships" are assigned to the actual contacs and then cleaned up.
    4. - *
    - *
*/ public class ContactsSyncManager extends SyncManager { protected static final int MAX_MULTIGET = 10; final private ContentProviderClient provider; - final private CollectionInfo remote; + final private HttpUrl remote; + final private CollectionInfo info; - private boolean hasVCard4; - private GroupMethod groupMethod; - - - public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, CollectionInfo remote) throws InvalidAccountException { + public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, HttpUrl principal, CollectionInfo info) throws InvalidAccountException { super(context, account, settings, extras, authority, result, "addressBook"); this.provider = provider; - this.remote = remote; + this.remote = principal; + this.info = info; } @Override @@ -139,20 +74,12 @@ public class ContactsSyncManager extends SyncManager { return context.getString(R.string.sync_error_contacts, account.name); } - @Override - protected void prepare() throws ContactsStorageException { + protected void prepare() throws ContactsStorageException, CalendarStorageException { // prepare local address book localCollection = new LocalAddressBook(account, provider); LocalAddressBook localAddressBook = localAddressBook(); - - String url = remote.url; - String lastUrl = localAddressBook.getURL(); - if (!url.equals(lastUrl)) { - App.log.info("Selected address book has changed from " + lastUrl + " to " + url + ", deleting all local contacts"); - localAddressBook.deleteAll(); - localAddressBook.setURL(remote.url); - } + localAddressBook.setURL(info.url); // set up Contacts Provider Settings ContentValues values = new ContentValues(2); @@ -160,22 +87,12 @@ public class ContactsSyncManager extends SyncManager { values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1); localAddressBook.updateSettings(values); - collectionURL = HttpUrl.parse(url); - davCollection = new DavAddressBook(httpClient, collectionURL); + journal = new JournalEntryManager(httpClient, remote, info.url); } @Override - protected void queryCapabilities() throws DavException, IOException, HttpException { - // prepare remote address book - davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME); - SupportedAddressData supportedAddressData = (SupportedAddressData)davCollection.properties.get(SupportedAddressData.NAME); - hasVCard4 = supportedAddressData != null && supportedAddressData.hasVCard4(); - App.log.info("Server advertises VCard/4 support: " + hasVCard4); - - groupMethod = settings.getGroupMethod(); - App.log.info("Contact group method: " + groupMethod); + protected void applyLocalEntries() throws IOException, ContactsStorageException, CalendarStorageException { - localAddressBook().includeGroups = groupMethod == GroupMethod.GROUP_VCARDS; } @Override @@ -184,232 +101,75 @@ public class ContactsSyncManager extends SyncManager { LocalAddressBook addressBook = localAddressBook(); - if (groupMethod == GroupMethod.CATEGORIES) { - /* groups memberships are represented as contact CATEGORIES */ + /* groups as separate VCards: thtere are group contacts and individual contacts */ - // groups with DELETED=1: set all members to dirty, then remove group - for (LocalGroup group : addressBook.getDeletedGroups()) { - App.log.fine("Finally removing group " + group); - // useless because Android deletes group memberships as soon as a group is set to DELETED: - // group.markMembersDirty(); - group.delete(); - } + // mark groups with changed members as dirty - // groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group - for (LocalGroup group : addressBook.getDirtyGroups()) { - App.log.fine("Marking members of modified group " + group + " as dirty"); - group.markMembersDirty(); - group.clearDirty(null); - } - } else { - /* groups as separate VCards: there are group contacts and individual contacts */ + // FIXME: add back - // mark groups with changed members as dirty - BatchOperation batch = new BatchOperation(addressBook.provider); - for (LocalContact contact : addressBook.getDirtyContacts()) - try { - App.log.fine("Looking for changed group memberships of contact " + contact.getFileName()); - Set cachedGroups = contact.getCachedGroupMemberships(), - currentGroups = contact.getGroupMemberships(); - for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) { - App.log.fine("Marking group as dirty: " + groupID); - batch.enqueue(new BatchOperation.Operation( - ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID))) - .withValue(Groups.DIRTY, 1) - .withYieldAllowed(true) - )); - } - } catch(FileNotFoundException ignored) { - } - batch.commit(); - } - } - - @Override - protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException { - final Contact contact; - if (resource instanceof LocalContact) { - LocalContact local = ((LocalContact)resource); - contact = local.getContact(); - - if (groupMethod == GroupMethod.CATEGORIES) { - // add groups as CATEGORIES - for (long groupID : local.getGroupMemberships()) { - try { - @Cleanup Cursor c = provider.query( - localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)), - new String[] { Groups.TITLE }, - null, null, - null - ); - if (c != null && c.moveToNext()) { - String title = c.getString(0); - if (!TextUtils.isEmpty(title)) - contact.categories.add(title); - } - } catch(RemoteException e) { - throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e); - } - } - } - } else if (resource instanceof LocalGroup) - contact = ((LocalGroup)resource).getContact(); - else - throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup"); - - App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact); - - ByteArrayOutputStream os = new ByteArrayOutputStream(); - contact.write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, groupMethod, os); - - return RequestBody.create( - hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8, - os.toByteArray() - ); - } - - @Override - protected void listRemote() throws IOException, HttpException, DavException { - // fetch list of remote VCards and build hash table to index file name - davAddressBook().propfind(1, ResourceType.NAME, GetETag.NAME); - - remoteResources = new HashMap<>(davCollection.members.size()); - for (DavResource vCard : davCollection.members) { - // ignore member collections - ResourceType type = (ResourceType)vCard.properties.get(ResourceType.NAME); - if (type != null && type.types.contains(ResourceType.COLLECTION)) - continue; - - String fileName = vCard.fileName(); - App.log.fine("Found remote VCard: " + fileName); - remoteResources.put(fileName, vCard); - } - } - - @Override - protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException { - App.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)"); - - // prepare downloader which may be used to download external resource like contact photos - Contact.Downloader downloader = new ResourceDownloader(collectionURL); - - // download new/updated VCards from server - for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) { - if (Thread.interrupted()) - return; - - App.log.info("Downloading " + StringUtils.join(bunch, ", ")); - - if (bunch.length == 1) { - // only one contact, use GET - DavResource remote = bunch[0]; - - ResponseBody body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5"); - - // CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3] - GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME); - if (eTag == null || StringUtils.isEmpty(eTag.eTag)) - throw new DavException("Received CardDAV GET response without ETag for " + remote.location); - - Charset charset = Charsets.UTF_8; - MediaType contentType = body.contentType(); - if (contentType != null) - charset = contentType.charset(Charsets.UTF_8); - - @Cleanup InputStream stream = body.byteStream(); - processVCard(remote.fileName(), eTag.eTag, stream, charset, downloader); - - } else { - // multiple contacts, use multi-get - List urls = new LinkedList<>(); - for (DavResource remote : bunch) - urls.add(remote.location); - davAddressBook().multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4); - - // process multiget results - for (DavResource remote : davCollection.members) { - String eTag; - GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); - if (getETag != null) - eTag = getETag.eTag; - else - throw new DavException("Received multi-get response without ETag"); - - Charset charset = Charsets.UTF_8; - GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME); - if (getContentType != null && getContentType.type != null) { - MediaType type = MediaType.parse(getContentType.type); - if (type != null) - charset = type.charset(Charsets.UTF_8); - } - - AddressData addressData = (AddressData)remote.properties.get(AddressData.NAME); - if (addressData == null || addressData.vCard == null) - throw new DavException("Received multi-get response without address data"); - - @Cleanup InputStream stream = new ByteArrayInputStream(addressData.vCard.getBytes()); - processVCard(remote.fileName(), eTag, stream, charset, downloader); - } - } - } } @Override protected void postProcess() throws CalendarStorageException, ContactsStorageException { - if (groupMethod == GroupMethod.CATEGORIES) { - /* VCard3 group handling: groups memberships are represented as contact CATEGORIES */ - - // remove empty groups - App.log.info("Removing empty groups"); - localAddressBook().removeEmptyGroups(); - - } else { - /* VCard4 group handling: there are group contacts and individual contacts */ - App.log.info("Assigning memberships of downloaded contact groups"); - LocalGroup.applyPendingMemberships(localAddressBook()); - } + /* VCard4 group handling: there are group contacts and individual contacts */ + App.log.info("Assigning memberships of downloaded contact groups"); + LocalGroup.applyPendingMemberships(localAddressBook()); } // helpers - private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; } - private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; } + private LocalAddressBook localAddressBook() { + return (LocalAddressBook) localCollection; + } + + protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException { + InputStream is = new ByteArrayInputStream(cEntry.getContent().getBytes(Charsets.UTF_8)); + // FIXME: Probably cache this and enable it. prepare downloader which may be used to download external resource like contact photos + // Contact.Downloader downloader = new ResourceDownloader(collectionURL); - private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException { - App.log.info("Processing CardDAV resource " + fileName); - Contact[] contacts = Contact.fromStream(stream, charset, downloader); + Contact[] contacts = Contact.fromStream(is, Charsets.UTF_8, null); if (contacts.length == 0) { App.log.warning("Received VCard without data, ignoring"); return; } else if (contacts.length > 1) App.log.warning("Received multiple VCards, using first one"); - final Contact newData = contacts[0]; + Contact contact = contacts[0]; + + if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { + LocalResource local = processContact(contact); - if (groupMethod == GroupMethod.CATEGORIES && newData.group) { - groupMethod = GroupMethod.GROUP_VCARDS; - App.log.warning("Received group VCard although group method is CATEGORIES. Deleting all groups; new group method: " + groupMethod); - localAddressBook().removeGroups(); - settings.setGroupMethod(groupMethod); + if (local != null) { + localResources.put(local.getUuid(), local); + } + + } else { + LocalResource local = localResources.get(contact.uid); + App.log.info("Removing local record #" + local.getId() + " which has been deleted on the server"); + localResources.remove(local.getUuid()); + local.delete(); } + } + private LocalResource processContact(final Contact newData) throws IOException, ContactsStorageException { + String uuid = newData.uid; // update local contact, if it exists - LocalResource local = localResources.get(fileName); + LocalResource local = localResources.get(uuid); if (local != null) { - App.log.log(Level.INFO, "Updating " + fileName + " in local address book", newData); + App.log.log(Level.INFO, "Updating " + uuid + " in local address book", newData); if (local instanceof LocalGroup && newData.group) { // update group - LocalGroup group = (LocalGroup)local; - group.eTag = eTag; + LocalGroup group = (LocalGroup) local; + group.eTag = uuid; group.updateFromServer(newData); syncResult.stats.numUpdates++; } else if (local instanceof LocalContact && !newData.group) { // update contact - LocalContact contact = (LocalContact)local; - contact.eTag = eTag; + LocalContact contact = (LocalContact) local; + contact.eTag = uuid; contact.update(newData); syncResult.stats.numUpdates++; @@ -418,7 +178,7 @@ public class ContactsSyncManager extends SyncManager { try { local.delete(); local = null; - } catch(CalendarStorageException e) { + } catch (CalendarStorageException e) { // CalendarStorageException is not used by LocalGroup and LocalContact } } @@ -427,13 +187,13 @@ public class ContactsSyncManager extends SyncManager { if (local == null) { if (newData.group) { App.log.log(Level.INFO, "Creating local group", newData); - LocalGroup group = new LocalGroup(localAddressBook(), newData, fileName, eTag); + LocalGroup group = new LocalGroup(localAddressBook(), newData, uuid, null); group.create(); local = group; } else { App.log.log(Level.INFO, "Creating local contact", newData); - LocalContact contact = new LocalContact(localAddressBook(), newData, fileName, eTag); + LocalContact contact = new LocalContact(localAddressBook(), newData, uuid, null); contact.create(); local = contact; @@ -441,25 +201,9 @@ public class ContactsSyncManager extends SyncManager { syncResult.stats.numInserts++; } - if (groupMethod == GroupMethod.CATEGORIES && local instanceof LocalContact) { - // VCard3: update group memberships from CATEGORIES - LocalContact contact = (LocalContact)local; - - BatchOperation batch = new BatchOperation(provider); - App.log.log(Level.FINE, "Removing contact group memberships"); - contact.removeGroupMemberships(batch); - - for (String category : contact.getContact().categories) { - long groupID = localAddressBook().findOrCreateGroup(category); - App.log.log(Level.FINE, "Adding membership in group " + category + " (" + groupID + ")"); - contact.addToGroup(batch, groupID); - } - - batch.commit(); - } + return local; } - // downloader helper class @RequiredArgsConstructor @@ -484,7 +228,7 @@ public class ContactsSyncManager extends SyncManager { OkHttpClient resourceClient = HttpClient.create(context); // authenticate only against a certain host, and only upon request - resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), settings.username(), settings.password()); + // resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), settings.username(), settings.password()); // allow redirects resourceClient = resourceClient.newBuilder() @@ -505,7 +249,7 @@ public class ContactsSyncManager extends SyncManager { } else App.log.severe("Couldn't download external resource"); } - } catch(IOException e) { + } catch (IOException e) { App.log.log(Level.SEVERE, "Couldn't download external resource", e); } return null; diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.java index d2a62c9c..3d9cbd02 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncAdapterService.java @@ -65,7 +65,7 @@ public abstract class SyncAdapterService extends Service { @Override public void onSecurityException(Account account, Bundle extras, String authority, SyncResult syncResult) { - App.log.log(Level.WARNING, "Security exception when opening content provider for " + authority); + App.log.log(Level.WARNING, "Security exception when opening content provider for " + authority); syncResult.databaseError = true; Intent intent = new Intent(getContext(), PermissionsActivity.class); @@ -85,7 +85,7 @@ public abstract class SyncAdapterService extends Service { protected boolean checkSyncConditions(@NonNull AccountSettings settings) { if (settings.getSyncWifiOnly()) { - ConnectivityManager cm = (ConnectivityManager)getContext().getSystemService(CONNECTIVITY_SERVICE); + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(CONNECTIVITY_SERVICE); NetworkInfo network = cm.getActiveNetworkInfo(); if (network == null) { App.log.info("No network available, stopping"); @@ -99,7 +99,7 @@ public abstract class SyncAdapterService extends Service { String onlySSID = settings.getSyncWifiOnlySSID(); if (onlySSID != null) { onlySSID = "\"" + onlySSID + "\""; - WifiManager wifi = (WifiManager)getContext().getApplicationContext().getSystemService(WIFI_SERVICE); + WifiManager wifi = (WifiManager) getContext().getApplicationContext().getSystemService(WIFI_SERVICE); WifiInfo info = wifi.getConnectionInfo(); if (info == null || !onlySSID.equals(info.getSSID())) { App.log.info("Connected to wrong WiFi network (" + info.getSSID() + ", required: " + onlySSID + "), ignoring"); @@ -109,7 +109,6 @@ public abstract class SyncAdapterService extends Service { } return true; } - } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java index bd8e4c5d..c88746f7 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/SyncManager.java @@ -1,16 +1,15 @@ - /* - * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ +/* +* Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the GNU Public License v3.0 +* which accompanies this distribution, and is available at +* http://www.gnu.org/licenses/gpl.html +*/ package at.bitfire.davdroid.syncadapter; import android.accounts.Account; import android.annotation.TargetApi; import android.app.PendingIntent; -import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SyncResult; @@ -18,55 +17,49 @@ import android.net.Uri; import android.os.Bundle; import android.support.v4.app.NotificationManagerCompat; import android.support.v7.app.NotificationCompat; -import android.text.TextUtils; +import java.io.FileNotFoundException; import java.io.IOException; +import java.util.ArrayList; import java.util.Date; import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; import java.util.logging.Level; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.ConflictException; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.exception.PreconditionFailedException; -import at.bitfire.dav4android.exception.ServiceUnavailableException; -import at.bitfire.dav4android.exception.UnauthorizedException; -import at.bitfire.dav4android.property.GetCTag; -import at.bitfire.dav4android.property.GetETag; import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.App; +import at.bitfire.davdroid.GsonHelper; import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Exceptions; +import at.bitfire.davdroid.journalmanager.JournalEntryManager; import at.bitfire.davdroid.resource.LocalCollection; import at.bitfire.davdroid.resource.LocalResource; import at.bitfire.davdroid.ui.AccountSettingsActivity; import at.bitfire.davdroid.ui.DebugInfoActivity; import at.bitfire.ical4android.CalendarStorageException; +import at.bitfire.ical4android.InvalidCalendarException; import at.bitfire.vcard4android.ContactsStorageException; -import okhttp3.HttpUrl; +import lombok.Getter; import okhttp3.OkHttpClient; -import okhttp3.RequestBody; abstract public class SyncManager { - protected final int SYNC_PHASE_PREPARE = 0, - SYNC_PHASE_QUERY_CAPABILITIES = 1, - SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2, - SYNC_PHASE_PREPARE_DIRTY = 3, - SYNC_PHASE_UPLOAD_DIRTY = 4, - SYNC_PHASE_CHECK_SYNC_STATE = 5, - SYNC_PHASE_LIST_LOCAL = 6, - SYNC_PHASE_LIST_REMOTE = 7, - SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8, - SYNC_PHASE_DOWNLOAD_REMOTE = 9, - SYNC_PHASE_POST_PROCESSING = 10, - SYNC_PHASE_SAVE_SYNC_STATE = 11; + protected final String SYNC_PHASE_PREPARE = "sync_phase_prepare", + SYNC_PHASE_QUERY_CAPABILITIES = "sync_phase_query_capabilities", + SYNC_PHASE_PREPARE_LOCAL = "sync_phase_prepare_local", + SYNC_PHASE_CREATE_LOCAL_ENTRIES = "sync_phase_create_local_entries", + SYNC_PHASE_FETCH_ENTRIES = "sync_phase_fetch_entries", + SYNC_PHASE_APPLY_REMOTE_ENTRIES = "sync_phase_apply_remote_entries", + SYNC_PHASE_APPLY_LOCAL_ENTRIES = "sync_phase_apply_local_entries", + SYNC_PHASE_PUSH_ENTRIES = "sync_phase_push_entries", + SYNC_PHASE_POST_PROCESSING = "sync_phase_post_processing", + SYNC_PHASE_SAVE_SYNC_TAG = "sync_phase_save_sync_tag"; + protected final NotificationManagerCompat notificationManager; protected final String uniqueCollectionId; @@ -81,23 +74,28 @@ abstract public class SyncManager { protected LocalCollection localCollection; protected OkHttpClient httpClient; - protected HttpUrl collectionURL; - protected DavResource davCollection; + protected JournalEntryManager journal; - /** remote CTag at the time of {@link #listRemote()} */ + /** + * remote CTag (uuid of the last entry on the server). We update it when we fetch/push and save when everything works. + */ protected String remoteCTag = null; - /** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */ - protected Map localResources; - - /** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */ - protected Map remoteResources; - - /** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */ - protected Set toDownload; + /** + * Syncable local journal entries. + */ + protected List localEntries; + /** + * Syncable remote journal entries (fetch from server). + */ + protected List remoteEntries; + /** + * sync-able resources in the local collection, as enumerated by {@link #prepareLocal()} + */ + protected Map localResources; public SyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult syncResult, String uniqueCollectionId) throws InvalidAccountException { this.context = context; @@ -117,95 +115,100 @@ abstract public class SyncManager { } protected abstract int notificationId(); + protected abstract String getSyncErrorTitle(); @TargetApi(21) public void performSync() { - int syncPhase = SYNC_PHASE_PREPARE; + String syncPhase = SYNC_PHASE_PREPARE; try { - App.log.info("Preparing synchronization"); + App.log.info("Sync phase: " + syncPhase); prepare(); if (Thread.interrupted()) return; syncPhase = SYNC_PHASE_QUERY_CAPABILITIES; - App.log.info("Querying capabilities"); + App.log.info("Sync phase: " + syncPhase); queryCapabilities(); - syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED; - App.log.info("Processing locally deleted entries"); - processLocallyDeleted(); + if (Thread.interrupted()) + return; + syncPhase = SYNC_PHASE_PREPARE_LOCAL; + App.log.info("Sync phase: " + syncPhase); + prepareLocal(); + /* Create journal entries out of local changes. */ if (Thread.interrupted()) return; - syncPhase = SYNC_PHASE_PREPARE_DIRTY; - App.log.info("Locally preparing dirty entries"); - prepareDirty(); - - syncPhase = SYNC_PHASE_UPLOAD_DIRTY; - App.log.info("Uploading dirty entries"); - uploadDirty(); - - syncPhase = SYNC_PHASE_CHECK_SYNC_STATE; - App.log.info("Checking sync state"); - if (checkSyncState()) { - syncPhase = SYNC_PHASE_LIST_LOCAL; - App.log.info("Listing local entries"); - listLocal(); - - if (Thread.interrupted()) - return; - syncPhase = SYNC_PHASE_LIST_REMOTE; - App.log.info("Listing remote entries"); - listRemote(); - - if (Thread.interrupted()) - return; - syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE; - App.log.info("Comparing local/remote entries"); - compareLocalRemote(); - - syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE; - App.log.info("Downloading remote entries"); - downloadRemote(); - - syncPhase = SYNC_PHASE_POST_PROCESSING; - App.log.info("Post-processing"); - postProcess(); - - syncPhase = SYNC_PHASE_SAVE_SYNC_STATE; - App.log.info("Saving sync state"); - saveSyncState(); - } else - App.log.info("Remote collection didn't change, skipping remote sync"); - - } catch (IOException|ServiceUnavailableException e) { + syncPhase = SYNC_PHASE_CREATE_LOCAL_ENTRIES; + App.log.info("Sync phase: " + syncPhase); + createLocalEntries(); + + if (Thread.interrupted()) + return; + syncPhase = SYNC_PHASE_FETCH_ENTRIES; + App.log.info("Sync phase: " + syncPhase); + fetchEntries(); + + if (Thread.interrupted()) + return; + syncPhase = SYNC_PHASE_APPLY_REMOTE_ENTRIES; + App.log.info("Sync phase: " + syncPhase); + applyRemoteEntries(); + + if (Thread.interrupted()) + return; + syncPhase = SYNC_PHASE_APPLY_LOCAL_ENTRIES; + App.log.info("Sync phase: " + syncPhase); + applyLocalEntries(); + + if (Thread.interrupted()) + return; + syncPhase = SYNC_PHASE_PUSH_ENTRIES; + App.log.info("Sync phase: " + syncPhase); + pushEntries(); + + /* Cleanup and finalize changes */ + if (Thread.interrupted()) + return; + syncPhase = SYNC_PHASE_POST_PROCESSING; + App.log.info("Sync phase: " + syncPhase); + postProcess(); + + syncPhase = SYNC_PHASE_SAVE_SYNC_TAG; + App.log.info("Sync phase: " + syncPhase); + saveSyncTag(); + + } catch (IOException e) { App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e); syncResult.stats.numIoExceptions++; - if (e instanceof ServiceUnavailableException) { - Date retryAfter = ((ServiceUnavailableException) e).retryAfter; + } catch (Exceptions.ServiceUnavailableException e) { + Date retryAfter = null; // ((Exceptions.ServiceUnavailableException) e).retryAfter; if (retryAfter != null) { // how many seconds to wait? getTime() returns ms, so divide by 1000 - syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000; + // syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000; } - } - - } catch(Exception|OutOfMemoryError e) { + } catch (Exception | OutOfMemoryError e) { final int messageString; - if (e instanceof UnauthorizedException) { + if (e instanceof Exceptions.UnauthorizedException) { App.log.log(Level.SEVERE, "Not authorized anymore", e); messageString = R.string.sync_error_unauthorized; syncResult.stats.numAuthExceptions++; - } else if (e instanceof HttpException || e instanceof DavException) { - App.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e); + } else if (e instanceof Exceptions.HttpException) { + App.log.log(Level.SEVERE, "HTTP Exception during sync", e); messageString = R.string.sync_error_http_dav; syncResult.stats.numParseExceptions++; } else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) { App.log.log(Level.SEVERE, "Couldn't access local storage", e); messageString = R.string.sync_error_local_storage; syncResult.databaseError = true; + } else if (e instanceof Exceptions.IntegrityException) { + App.log.log(Level.SEVERE, "Integrity error", e); + // FIXME: Make a proper error message + messageString = R.string.sync_error; + syncResult.stats.numParseExceptions++; } else { App.log.log(Level.SEVERE, "Unknown sync error", e); messageString = R.string.sync_error; @@ -213,7 +216,7 @@ abstract public class SyncManager { } final Intent detailsIntent; - if (e instanceof UnauthorizedException) { + if (e instanceof Exceptions.UnauthorizedException) { detailsIntent = new Intent(context, AccountSettingsActivity.class); detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account); } else { @@ -228,206 +231,162 @@ abstract public class SyncManager { detailsIntent.setData(Uri.parse("uri://" + getClass().getName() + "/" + uniqueCollectionId)); NotificationCompat.Builder builder = new NotificationCompat.Builder(context); - builder .setSmallIcon(R.drawable.ic_error_light) + builder.setSmallIcon(R.drawable.ic_error_light) .setLargeIcon(App.getLauncherBitmap(context)) .setContentTitle(getSyncErrorTitle()) .setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT)) .setCategory(NotificationCompat.CATEGORY_ERROR); - try { - String[] phases = context.getResources().getStringArray(R.array.sync_error_phases); - String message = context.getString(messageString, phases[syncPhase]); - builder.setContentText(message); - } catch (IndexOutOfBoundsException ex) { - // should never happen - } + String message = context.getString(messageString, syncPhase); + builder.setContentText(message); + notificationManager.notify(uniqueCollectionId, notificationId(), builder.build()); } } - abstract protected void prepare() throws ContactsStorageException; + abstract protected void prepare() throws ContactsStorageException, CalendarStorageException; - abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException; + abstract protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException; - /** - * Process locally deleted entries (DELETE them on the server as well). - * Checks Thread.interrupted() before each request to allow quick sync cancellation. - */ - protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException { - // Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before), - // but only if they don't have changed on the server. Then finally remove them from the local address book. - LocalResource[] localList = localCollection.getDeleted(); - for (LocalResource local : localList) { - if (Thread.interrupted()) - return; + abstract protected void applyLocalEntries() throws IOException, ContactsStorageException, CalendarStorageException, Exceptions.HttpException; - final String fileName = local.getFileName(); - if (!TextUtils.isEmpty(fileName)) { - App.log.info(fileName + " has been deleted locally -> deleting from server"); - try { - new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build()) - .delete(local.getETag()); - } catch (IOException|HttpException e) { - App.log.warning("Couldn't delete " + fileName + " from server; ignoring (may be downloaded again)"); - } - } else - App.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded"); - local.delete(); - syncResult.stats.numDeletes++; - } + protected void queryCapabilities() throws IOException, CalendarStorageException, ContactsStorageException { } - protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { - // assign file names and UIDs to new contacts so that we can use the file name as an index - App.log.info("Looking for contacts/groups without file name"); - for (LocalResource local : localCollection.getWithoutFileName()) { - String uuid = UUID.randomUUID().toString(); - App.log.fine("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid); - local.updateFileNameAndUID(uuid); + protected void fetchEntries() throws Exceptions.HttpException, ContactsStorageException, CalendarStorageException, Exceptions.IntegrityException { + remoteEntries = journal.getEntries(settings.password(), remoteCTag); + + if (!remoteEntries.isEmpty()) { + remoteCTag = remoteEntries.get(remoteEntries.size() - 1).getUuid(); } } - abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException; - - /** - * Uploads dirty records to the server, using a PUT request for each record. - * Checks Thread.interrupted() before each request to allow quick sync cancellation. - */ - protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException { - // upload dirty contacts - for (LocalResource local : localCollection.getDirty()) { + protected void applyRemoteEntries() throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException { + // Process new vcards from server + for (JournalEntryManager.Entry entry : remoteEntries) { if (Thread.interrupted()) return; - final String fileName = local.getFileName(); + App.log.info("Processing " + entry.toString()); - DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build()); + SyncEntry cEntry = SyncEntry.fromJournalEntry(settings.password(), entry); + App.log.info("Processing resource for journal entry " + entry.getUuid()); + processSyncEntry(cEntry); + } + } - // generate entity to upload (VCard, iCal, whatever) - RequestBody body = prepareUpload(local); + protected void pushEntries() throws Exceptions.HttpException, IOException, ContactsStorageException, CalendarStorageException { + // upload dirty contacts + // FIXME: Deal with failure + if (!localEntries.isEmpty()) { + journal.putEntries(localEntries, remoteCTag); - try { - if (local.getETag() == null) { - App.log.info("Uploading new record " + fileName); - remote.put(body, null, true); - } else { - App.log.info("Uploading locally modified record " + fileName); - remote.put(body, local.getETag(), false); - } - } catch (ConflictException|PreconditionFailedException e) { - // we can't interact with the user to resolve the conflict, so we treat 409 like 412 - App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e); + for (LocalResource local : localCollection.getDirty()) { + App.log.info("Added/changed resource with UUID: " + local.getUuid()); + local.clearDirty(local.getUuid()); } - String eTag = null; - GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME); - if (newETag != null) { - eTag = newETag.eTag; - App.log.fine("Received new ETag=" + eTag + " after uploading"); - } else - App.log.fine("Didn't receive new ETag after uploading, setting to null"); + for (LocalResource local : localCollection.getDeleted()) { + local.delete(); + } - local.clearDirty(eTag); + remoteCTag = localEntries.get(localEntries.size() - 1).getUuid(); } } - /** - * Checks the current sync state (e.g. CTag) and whether synchronization from remote is required. - * @return
    - *
  • true if the remote collection has changed, i.e. synchronization from remote is required
  • - *
  • false if the remote collection hasn't changed
  • - *
- */ - protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException { - // check CTag (ignore on manual sync) - GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME); - if (getCTag != null) - remoteCTag = getCTag.cTag; - - String localCTag = null; - if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) - App.log.info("Manual sync, ignoring CTag"); - else - localCTag = localCollection.getCTag(); - - if (remoteCTag != null && remoteCTag.equals(localCTag)) { - App.log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children"); - return false; - } else - return true; + protected void createLocalEntries() throws CalendarStorageException, ContactsStorageException, IOException { + localEntries = new LinkedList<>(); + + // Not saving, just creating a fake one until we load it from a local db + JournalEntryManager.Entry previousEntry = (remoteCTag != null) ? JournalEntryManager.Entry.getFakeWithUid(remoteCTag) : null; + + for (LocalResource local : processLocallyDeleted()) { + SyncEntry entry = new SyncEntry(local.getContent(), SyncEntry.Actions.DELETE); + JournalEntryManager.Entry tmp = new JournalEntryManager.Entry(); + tmp.update(settings.password(), entry.toJson(), previousEntry); + previousEntry = tmp; + localEntries.add(previousEntry); + } + + try { + for (LocalResource local : localCollection.getDirty()) { + SyncEntry.Actions action; + if (local.isLocalOnly()) { + action = SyncEntry.Actions.ADD; + } else { + action = SyncEntry.Actions.CHANGE; + } + + SyncEntry entry = new SyncEntry(local.getContent(), action); + JournalEntryManager.Entry tmp = new JournalEntryManager.Entry(); + tmp.update(settings.password(), entry.toJson(), previousEntry); + previousEntry = tmp; + localEntries.add(previousEntry); + } + } catch (FileNotFoundException e) { + // FIXME: Do something + e.printStackTrace(); + } } /** * Lists all local resources which should be taken into account for synchronization into {@link #localResources}. */ - protected void listLocal() throws CalendarStorageException, ContactsStorageException { + protected void prepareLocal() throws CalendarStorageException, ContactsStorageException { + prepareDirty(); + // fetch list of local contacts and build hash table to index file name LocalResource[] localList = localCollection.getAll(); localResources = new HashMap<>(localList.length); for (LocalResource resource : localList) { - App.log.fine("Found local resource: " + resource.getFileName()); - localResources.put(resource.getFileName(), resource); + App.log.fine("Found local resource: " + resource.getUuid()); + localResources.put(resource.getUuid(), resource); } + + remoteCTag = localCollection.getCTag(); } - /** - * Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}. - */ - abstract protected void listRemote() throws IOException, HttpException, DavException; /** - * Compares {@link #localResources} and {@link #remoteResources} by file name and ETag: - *
    - *
  • Local resources which are not available in the remote collection (anymore) will be removed.
  • - *
  • Resources whose remote ETag has changed will be added into {@link #toDownload}
  • - *
+ * Delete unpublished locally deleted, and return the rest. + * Checks Thread.interrupted() before each request to allow quick sync cancellation. */ - protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException { - /* check which contacts - 1. are not present anymore remotely -> delete immediately on local side - 2. updated remotely -> add to downloadNames - 3. added remotely -> add to downloadNames - */ - toDownload = new HashSet<>(); - for (String localName : localResources.keySet()) { - DavResource remote = remoteResources.get(localName); - if (remote == null) { - App.log.info(localName + " is not on server anymore, deleting"); - localResources.get(localName).delete(); - syncResult.stats.numDeletes++; - } else { - // contact is still on server, check whether it has been updated remotely - GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); - if (getETag == null || getETag.eTag == null) - throw new DavException("Server didn't provide ETag"); - String localETag = localResources.get(localName).getETag(), - remoteETag = getETag.eTag; - if (remoteETag.equals(localETag)) - syncResult.stats.numSkippedEntries++; - else { - App.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")"); - toDownload.add(remote); - } + protected List processLocallyDeleted() throws CalendarStorageException, ContactsStorageException { + // FIXME: This needs refactoring and fixing, it's just not true. + // Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before), + // but only if they don't have changed on the server. Then finally remove them from the local address book. + LocalResource[] localList = localCollection.getDeleted(); + List ret = new ArrayList<>(localList.length); + + for (LocalResource local : localList) { + if (Thread.interrupted()) + return ret; - // remote entry has been seen, remove from list - remoteResources.remove(localName); + if (!local.isLocalOnly()) { + App.log.info(local.getUuid() + " has been deleted locally -> deleting from server"); + ret.add(local); + } else { + App.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded"); + local.delete(); } - } - // add all unseen (= remotely added) remote contacts - if (!remoteResources.isEmpty()) { - App.log.info("New resources have been found on the server: " + TextUtils.join(", ", remoteResources.keySet())); - toDownload.addAll(remoteResources.values()); + syncResult.stats.numDeletes++; } + + return ret; } - /** - * Downloads the remote resources in {@link #toDownload} and stores them locally. - * Must check Thread.interrupted() periodically to allow quick sync cancellation. - */ - abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException; + protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { + // assign file names and UIDs to new contacts so that we can use the file name as an index + App.log.info("Looking for contacts/groups without file name"); + for (LocalResource local : localCollection.getWithoutFileName()) { + String uuid = UUID.randomUUID().toString(); + App.log.fine("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid); + local.updateFileNameAndUID(uuid); + } + } /** * For post-processing of entries, for instance assigning groups. @@ -435,12 +394,54 @@ abstract public class SyncManager { protected void postProcess() throws CalendarStorageException, ContactsStorageException { } - protected void saveSyncState() throws CalendarStorageException, ContactsStorageException { - /* Save sync state (CTag). It doesn't matter if it has changed during the sync process - (for instance, because another client has uploaded changes), because this will simply - cause all remote entries to be listed at the next sync. */ + protected void saveSyncTag() throws CalendarStorageException, ContactsStorageException { App.log.info("Saving CTag=" + remoteCTag); localCollection.setCTag(remoteCTag); } + + static class SyncEntry { + @Getter + private String content; + @Getter + private Actions action; + + enum Actions { + ADD("ADD"), + CHANGE("CHANGE"), + DELETE("DELETE"); + + private final String text; + + Actions(final String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + + @SuppressWarnings("unused") + private SyncEntry() { + } + + protected SyncEntry(String content, Actions action) { + this.content = content; + this.action = action; + } + + boolean isAction(Actions action) { + return this.action.equals(action); + } + + static SyncEntry fromJournalEntry(String keyBase64, JournalEntryManager.Entry entry) { + return GsonHelper.gson.fromJson(entry.getContent(keyBase64), SyncEntry.class); + } + + String toJson() { + return GsonHelper.gson.toJson(this, this.getClass()); + } + } } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java deleted file mode 100644 index 648e39ac..00000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncAdapterService.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ -package at.bitfire.davdroid.syncadapter; - -import android.accounts.Account; -import android.content.AbstractThreadedSyncAdapter; -import android.content.ContentProviderClient; -import android.content.ContentResolver; -import android.content.ContentValues; -import android.content.Context; -import android.content.SyncResult; -import android.database.Cursor; -import android.database.DatabaseUtils; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; - -import org.dmfs.provider.tasks.TaskContract; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.logging.Level; - -import at.bitfire.davdroid.AccountSettings; -import at.bitfire.davdroid.App; -import at.bitfire.davdroid.InvalidAccountException; -import at.bitfire.davdroid.model.CollectionInfo; -import at.bitfire.davdroid.model.ServiceDB; -import at.bitfire.davdroid.model.ServiceDB.Collections; -import at.bitfire.davdroid.model.ServiceDB.Services; -import at.bitfire.davdroid.resource.LocalTaskList; -import at.bitfire.ical4android.CalendarStorageException; -import at.bitfire.ical4android.TaskProvider; -import lombok.Cleanup; - -/** - * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). - */ -public class TasksSyncAdapterService extends SyncAdapterService { - - @Override - protected AbstractThreadedSyncAdapter syncAdapter() { - return new SyncAdapter(this); - } - - - private static class SyncAdapter extends SyncAdapterService.SyncAdapter { - - public SyncAdapter(Context context) { - super(context); - } - - @Override - public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient providerClient, SyncResult syncResult) { - super.onPerformSync(account, extras, authority, providerClient, syncResult); - - try { - @Cleanup TaskProvider provider = TaskProvider.acquire(getContext().getContentResolver(), TaskProvider.ProviderName.OpenTasks); - if (provider == null) - throw new CalendarStorageException("Couldn't access OpenTasks provider"); - - AccountSettings settings = new AccountSettings(getContext(), account); - if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) - return; - - updateLocalTaskLists(provider, account, settings); - - for (LocalTaskList taskList : (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, TaskContract.TaskLists.SYNC_ENABLED + "!=0", null)) { - App.log.info("Synchronizing task list #" + taskList.getId() + " [" + taskList.getSyncId() + "]"); - TasksSyncManager syncManager = new TasksSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, taskList); - syncManager.performSync(); - } - } catch (CalendarStorageException e) { - App.log.log(Level.SEVERE, "Couldn't enumerate local task lists", e); - } catch (InvalidAccountException e) { - App.log.log(Level.SEVERE, "Couldn't get account settings", e); - } - - App.log.info("Task sync complete"); - } - - private void updateLocalTaskLists(TaskProvider provider, Account account, AccountSettings settings) throws CalendarStorageException { - SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); - try { - // enumerate remote and local task lists - SQLiteDatabase db = dbHelper.getReadableDatabase(); - Long service = getService(db, account); - Map remote = remoteTaskLists(db, service); - LocalTaskList[] local = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null); - - boolean updateColors = settings.getManageCalendarColors(); - - // delete obsolete local task lists - for (LocalTaskList list : local) { - String url = list.getSyncId(); - if (!remote.containsKey(url)) { - App.log.fine("Deleting obsolete local task list" + url); - list.delete(); - } else { - // remote CollectionInfo found for this local collection, update data - CollectionInfo info = remote.get(url); - App.log.fine("Updating local task list " + url + " with " + info); - list.update(info, updateColors); - // we already have a local task list for this remote collection, don't take into consideration anymore - remote.remove(url); - } - } - - // create new local task lists - for (String url : remote.keySet()) { - CollectionInfo info = remote.get(url); - App.log.info("Adding local task list " + info); - LocalTaskList.create(account, provider, info); - } - } finally { - dbHelper.close(); - } - } - - @Nullable - Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) { - @Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID }, - Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null); - if (c.moveToNext()) - return c.getLong(0); - else - return null; - } - - @NonNull - private Map remoteTaskLists(@NonNull SQLiteDatabase db, Long service) { - Map collections = new LinkedHashMap<>(); - if (service != null) { - @Cleanup Cursor cursor = db.query(Collections._TABLE, null, - Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VTODO + "!=0 AND " + Collections.SYNC, - new String[] { String.valueOf(service) }, null, null, null); - while (cursor.moveToNext()) { - ContentValues values = new ContentValues(); - DatabaseUtils.cursorRowToContentValues(cursor, values); - CollectionInfo info = CollectionInfo.fromDB(values); - collections.put(info.url, info); - } - } - return collections; - } - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.java deleted file mode 100644 index 1a1994fa..00000000 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Public License v3.0 - * which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/gpl.html - */ - -package at.bitfire.davdroid.syncadapter; - -import android.accounts.Account; -import android.content.Context; -import android.content.SyncResult; -import android.os.Bundle; - -import org.apache.commons.codec.Charsets; -import org.apache.commons.lang3.StringUtils; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.Charset; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.logging.Level; - -import at.bitfire.dav4android.DavCalendar; -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.DavException; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.dav4android.property.CalendarData; -import at.bitfire.dav4android.property.GetCTag; -import at.bitfire.dav4android.property.GetContentType; -import at.bitfire.dav4android.property.GetETag; -import at.bitfire.davdroid.AccountSettings; -import at.bitfire.davdroid.App; -import at.bitfire.davdroid.ArrayUtils; -import at.bitfire.davdroid.Constants; -import at.bitfire.davdroid.InvalidAccountException; -import at.bitfire.davdroid.R; -import at.bitfire.davdroid.resource.LocalResource; -import at.bitfire.davdroid.resource.LocalTask; -import at.bitfire.davdroid.resource.LocalTaskList; -import at.bitfire.ical4android.CalendarStorageException; -import at.bitfire.ical4android.InvalidCalendarException; -import at.bitfire.ical4android.Task; -import at.bitfire.ical4android.TaskProvider; -import lombok.Cleanup; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; - -public class TasksSyncManager extends SyncManager { - - protected static final int MAX_MULTIGET = 30; - - final protected TaskProvider provider; - - - public TasksSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, TaskProvider provider, SyncResult result, LocalTaskList taskList) throws InvalidAccountException { - super(context, account, settings, extras, authority, result, "taskList/" + taskList.getId()); - this.provider = provider; - localCollection = taskList; - } - - @Override - protected int notificationId() { - return Constants.NOTIFICATION_TASK_SYNC; - } - - @Override - protected String getSyncErrorTitle() { - return context.getString(R.string.sync_error_tasks, account.name); - } - - - @Override - protected void prepare() { - collectionURL = HttpUrl.parse(localTaskList().getSyncId()); - davCollection = new DavCalendar(httpClient, collectionURL); - } - - @Override - protected void queryCapabilities() throws DavException, IOException, HttpException { - davCollection.propfind(0, GetCTag.NAME); - } - - @Override - protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException { - LocalTask local = (LocalTask)resource; - App.log.log(Level.FINE, "Preparing upload of task " + local.getFileName(), local.getTask() ); - - ByteArrayOutputStream os = new ByteArrayOutputStream(); - local.getTask().write(os); - - return RequestBody.create( - DavCalendar.MIME_ICALENDAR, - os.toByteArray() - ); - } - - @Override - protected void listRemote() throws IOException, HttpException, DavException { - // fetch list of remote VTODOs and build hash table to index file name - davCalendar().calendarQuery("VTODO", null, null); - remoteResources = new HashMap<>(davCollection.members.size()); - for (DavResource vCard : davCollection.members) { - String fileName = vCard.fileName(); - App.log.fine("Found remote VTODO: " + fileName); - remoteResources.put(fileName, vCard); - } - } - - @Override - protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException { - App.log.info("Downloading " + toDownload.size() + " tasks (" + MAX_MULTIGET + " at once)"); - - // download new/updated iCalendars from server - for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) { - if (Thread.interrupted()) - return; - - App.log.info("Downloading " + StringUtils.join(bunch, ", ")); - - if (bunch.length == 1) { - // only one contact, use GET - DavResource remote = bunch[0]; - - ResponseBody body = remote.get("text/calendar"); - - // CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4] - GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME); - if (eTag == null || StringUtils.isEmpty(eTag.eTag)) - throw new DavException("Received CalDAV GET response without ETag for " + remote.location); - - Charset charset = Charsets.UTF_8; - MediaType contentType = body.contentType(); - if (contentType != null) - charset = contentType.charset(Charsets.UTF_8); - - @Cleanup InputStream stream = body.byteStream(); - processVTodo(remote.fileName(), eTag.eTag, stream, charset); - - } else { - // multiple contacts, use multi-get - List urls = new LinkedList<>(); - for (DavResource remote : bunch) - urls.add(remote.location); - davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()])); - - // process multiget results - for (DavResource remote : davCollection.members) { - String eTag; - GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); - if (getETag != null) - eTag = getETag.eTag; - else - throw new DavException("Received multi-get response without ETag"); - - Charset charset = Charsets.UTF_8; - GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME); - if (getContentType != null && getContentType.type != null) { - MediaType type = MediaType.parse(getContentType.type); - if (type != null) - charset = type.charset(Charsets.UTF_8); - } - - CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME); - if (calendarData == null || calendarData.iCalendar == null) - throw new DavException("Received multi-get response without address data"); - - @Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes()); - processVTodo(remote.fileName(), eTag, stream, charset); - } - } - } - } - - - // helpers - - private LocalTaskList localTaskList() { return ((LocalTaskList)localCollection); } - private DavCalendar davCalendar() { return (DavCalendar)davCollection; } - - private void processVTodo(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException { - Task[] tasks; - try { - tasks = Task.fromStream(stream, charset); - } catch (InvalidCalendarException e) { - App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e); - return; - } - - if (tasks.length == 1) { - Task newData = tasks[0]; - - // update local task, if it exists - LocalTask localTask = (LocalTask)localResources.get(fileName); - if (localTask != null) { - App.log.info("Updating " + fileName + " in local tasklist"); - localTask.setETag(eTag); - localTask.update(newData); - syncResult.stats.numUpdates++; - } else { - App.log.info("Adding " + fileName + " to local task list"); - localTask = new LocalTask(localTaskList(), newData, fileName, eTag); - localTask.add(); - syncResult.stats.numInserts++; - } - } else - App.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring " + fileName); - } - -} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.java index 219b4541..35d12739 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountActivity.java @@ -57,11 +57,8 @@ import android.widget.EditText; import android.widget.ListView; import android.widget.PopupMenu; import android.widget.ProgressBar; -import android.widget.RadioButton; import android.widget.TextView; -import org.apache.commons.lang3.BooleanUtils; - import java.io.IOException; import java.util.LinkedList; import java.util.List; @@ -106,15 +103,14 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu // CardDAV toolbar tbCardDAV = (Toolbar)findViewById(R.id.carddav_menu); - tbCardDAV.setOverflowIcon(icMenu); - tbCardDAV.inflateMenu(R.menu.carddav_actions); - tbCardDAV.setOnMenuItemClickListener(this); + tbCardDAV.setTitle(R.string.settings_carddav); // CalDAV toolbar tbCalDAV = (Toolbar)findViewById(R.id.caldav_menu); tbCalDAV.setOverflowIcon(icMenu); tbCalDAV.inflateMenu(R.menu.caldav_actions); tbCalDAV.setOnMenuItemClickListener(this); + tbCalDAV.setTitle(R.string.settings_caldav); // load CardDAV/CalDAV collections getLoaderManager().initLoader(0, getIntent().getExtras(), this); @@ -142,14 +138,6 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu return true; } - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - MenuItem itemRename = menu.findItem(R.id.rename_account); - // renameAccount is available for API level 21+ - itemRename.setVisible(Build.VERSION.SDK_INT >= 21); - return super.onPrepareOptionsMenu(menu); - } - @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { @@ -161,9 +149,6 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account); startActivity(intent); break; - case R.id.rename_account: - RenameAccountFragment.newInstance(account).show(getSupportFragmentManager(), null); - break; case R.id.delete_account: new AlertDialog.Builder(AccountActivity.this) .setIcon(R.drawable.ic_error_dark) @@ -297,7 +282,6 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu long id; boolean refreshing; - boolean hasHomeSets; List collections; } } @@ -324,13 +308,9 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu listCardDAV.setEnabled(!info.carddav.refreshing); listCardDAV.setAlpha(info.carddav.refreshing ? 0.5f : 1); - tbCardDAV.getMenu().findItem(R.id.create_address_book).setEnabled(info.carddav.hasHomeSets); - AddressBookAdapter adapter = new AddressBookAdapter(this); adapter.addAll(info.carddav.collections); listCardDAV.setAdapter(adapter); - listCardDAV.setOnItemClickListener(onItemClickListener); - listCardDAV.setOnItemLongClickListener(onItemLongClickListener); } else card.setVisibility(View.GONE); @@ -343,8 +323,6 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu listCalDAV.setEnabled(!info.caldav.refreshing); listCalDAV.setAlpha(info.caldav.refreshing ? 0.5f : 1); - tbCalDAV.getMenu().findItem(R.id.create_calendar).setEnabled(info.caldav.hasHomeSets); - final CalendarAdapter adapter = new CalendarAdapter(this); adapter.addAll(info.caldav.collections); listCalDAV.setAdapter(adapter); @@ -433,7 +411,6 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu info.carddav = new AccountInfo.ServiceInfo(); info.carddav.id = id; info.carddav.refreshing = (davService != null && davService.isRefreshing(id)) || ContentResolver.isSyncActive(account, ContactsContract.AUTHORITY); - info.carddav.hasHomeSets = hasHomeSets(db, id); info.carddav.collections = readCollections(db, id); } else if (Services.SERVICE_CALDAV.equals(service)) { @@ -442,19 +419,12 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu info.caldav.refreshing = (davService != null && davService.isRefreshing(id)) || ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) || ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority); - info.caldav.hasHomeSets = hasHomeSets(db, id); info.caldav.collections = readCollections(db, id); } } return info; } - private boolean hasHomeSets(@NonNull SQLiteDatabase db, long service) { - @Cleanup Cursor cursor = db.query(ServiceDB.HomeSets._TABLE, null, ServiceDB.HomeSets.SERVICE_ID + "=?", - new String[] { String.valueOf(service) }, null, null, null); - return cursor.getCount() > 0; - } - private List readCollections(@NonNull SQLiteDatabase db, long service) { List collections = new LinkedList<>(); @Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", @@ -484,9 +454,6 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu final CollectionInfo info = getItem(position); - RadioButton checked = (RadioButton)v.findViewById(R.id.checked); - checked.setChecked(info.selected); - TextView tv = (TextView)v.findViewById(R.id.title); tv.setText(TextUtils.isEmpty(info.displayName) ? info.url : info.displayName); @@ -541,99 +508,10 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu tv = (TextView)v.findViewById(R.id.read_only); tv.setVisibility(info.readOnly ? View.VISIBLE : View.GONE); - tv = (TextView)v.findViewById(R.id.events); - tv.setVisibility(BooleanUtils.isTrue(info.supportsVEVENT) ? View.VISIBLE : View.GONE); - - tv = (TextView)v.findViewById(R.id.tasks); - tv.setVisibility(BooleanUtils.isTrue(info.supportsVTODO) ? View.VISIBLE : View.GONE); - return v; } } - - /* DIALOG FRAGMENTS */ - - public static class RenameAccountFragment extends DialogFragment { - - private final static String ARG_ACCOUNT = "account"; - - static RenameAccountFragment newInstance(@NonNull Account account) { - RenameAccountFragment fragment = new RenameAccountFragment(); - Bundle args = new Bundle(1); - args.putParcelable(ARG_ACCOUNT, account); - fragment.setArguments(args); - return fragment; - } - - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Account oldAccount = getArguments().getParcelable(ARG_ACCOUNT); - - final EditText editText = new EditText(getContext()); - editText.setText(oldAccount.name); - - return new AlertDialog.Builder(getContext()) - .setTitle(R.string.account_rename) - .setMessage(R.string.account_rename_new_name) - .setView(editText) - .setPositiveButton(R.string.account_rename_rename, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - final String newName = editText.getText().toString(); - - if (newName.equals(oldAccount.name)) - return; - - final AccountManager accountManager = AccountManager.get(getContext()); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - accountManager.renameAccount(oldAccount, newName, - new AccountManagerCallback() { - @Override - public void run(AccountManagerFuture future) { - App.log.info("Updating account name references"); - - // cancel running synchronization - ContentResolver.cancelSync(oldAccount, null); - - // update account name references in database - @Cleanup OpenHelper dbHelper = new OpenHelper(getContext()); - ServiceDB.onRenameAccount(dbHelper.getWritableDatabase(), oldAccount.name, newName); - - // update account_name of local contacts - try { - LocalAddressBook.onRenameAccount(getContext().getContentResolver(), oldAccount.name, newName); - } catch(RemoteException e) { - App.log.log(Level.SEVERE, "Couldn't propagate new account name to contacts provider"); - } - - // calendar provider doesn't allow changing account_name of Events - - // update account_name of local tasks - try { - LocalTaskList.onRenameAccount(getContext().getContentResolver(), oldAccount.name, newName); - } catch(RemoteException e) { - App.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider"); - } - - // synchronize again - requestSync(new Account(newName, oldAccount.type)); - } - }, null - ); - getActivity().finish(); - } - }) - .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - } - }) - .create(); - } - } - - /* USER ACTIONS */ private void deleteAccount() { diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java index d4a8d1db..bdf94ee1 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountSettingsActivity.java @@ -92,23 +92,11 @@ public class AccountSettingsActivity extends AppCompatActivity { } // category: authentication - final EditTextPreference prefUserName = (EditTextPreference)findPreference("username"); - prefUserName.setSummary(settings.username()); - prefUserName.setText(settings.username()); - prefUserName.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - settings.username((String)newValue); - refresh(); - return false; - } - }); - final EditTextPreference prefPassword = (EditTextPreference)findPreference("password"); prefPassword.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { - settings.password((String)newValue); + settings.setAuthToken((String)newValue); refresh(); return false; } @@ -205,66 +193,6 @@ public class AccountSettingsActivity extends AppCompatActivity { return false; } }); - - // category: CardDAV - final ListPreference prefGroupMethod = (ListPreference)findPreference("contact_group_method"); - if (syncIntervalContacts != null) { - prefGroupMethod.setValue(settings.getGroupMethod().name()); - prefGroupMethod.setSummary(prefGroupMethod.getEntry()); - prefGroupMethod.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object o) { - String name = (String)o; - settings.setGroupMethod(GroupMethod.valueOf(name)); - refresh(); - return false; - } - }); - } else - prefGroupMethod.setEnabled(false); - - // category: CalDAV - final EditTextPreference prefTimeRangePastDays = (EditTextPreference)findPreference("time_range_past_days"); - if (syncIntervalCalendars != null) { - Integer pastDays = settings.getTimeRangePastDays(); - if (pastDays != null) { - prefTimeRangePastDays.setText(pastDays.toString()); - prefTimeRangePastDays.setSummary(getResources().getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)); - } else { - prefTimeRangePastDays.setText(null); - prefTimeRangePastDays.setSummary(R.string.settings_sync_time_range_past_none); - } - prefTimeRangePastDays.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - int days; - try { - days = Integer.parseInt((String)newValue); - } catch(NumberFormatException ignored) { - days = -1; - } - settings.setTimeRangePastDays(days < 0 ? null : days); - refresh(); - return false; - } - }); - } else - prefTimeRangePastDays.setEnabled(false); - - final SwitchPreferenceCompat prefManageColors = (SwitchPreferenceCompat)findPreference("manage_calendar_colors"); - if (syncIntervalCalendars != null || syncIntervalTasks != null) { - prefManageColors.setChecked(settings.getManageCalendarColors()); - prefManageColors.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - settings.setManageCalendarColors((Boolean)newValue); - refresh(); - return false; - } - }); - } else - prefManageColors.setEnabled(false); - } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.java index 3abb0e83..0826c8b2 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/AccountsActivity.java @@ -10,11 +10,9 @@ package at.bitfire.davdroid.ui; import android.content.Intent; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.NavigationView; -import android.support.v4.app.FragmentTransaction; import android.support.v4.view.GravityCompat; import android.support.v4.widget.DrawerLayout; import android.support.v7.app.ActionBarDrawerToggle; @@ -23,8 +21,6 @@ import android.support.v7.widget.Toolbar; import android.view.MenuItem; import android.view.View; -import at.bitfire.davdroid.App; -import at.bitfire.davdroid.BuildConfig; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.R; import at.bitfire.davdroid.ui.setup.LoginActivity; @@ -56,13 +52,6 @@ public class AccountsActivity extends AppCompatActivity implements NavigationVie NavigationView navigationView = (NavigationView)findViewById(R.id.nav_view); navigationView.setNavigationItemSelectedListener(this); navigationView.setItemIconTintList(null); - - if (savedInstanceState == null && !getPackageName().equals(getCallingPackage())) { - FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); - for (StartupDialogFragment fragment : StartupDialogFragment.getStartupDialogs(this)) - ft.add(fragment, null); - ft.commit(); - } } @Override @@ -83,20 +72,17 @@ public class AccountsActivity extends AppCompatActivity implements NavigationVie case R.id.nav_app_settings: startActivity(new Intent(this, AppSettingsActivity.class)); break; - case R.id.nav_twitter: - startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp"))); - break; case R.id.nav_website: startActivity(new Intent(Intent.ACTION_VIEW, Constants.webUri)); break; case R.id.nav_faq: - startActivity(new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("faq/").build())); + startActivity(new Intent(Intent.ACTION_VIEW, Constants.faqUri)); break; - case R.id.nav_forums: - startActivity(new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("forums/").build())); + case R.id.nav_report_issue: + startActivity(new Intent(Intent.ACTION_VIEW, Constants.reportIssueUri)); break; - case R.id.nav_donate: - startActivity(new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("donate/").build())); + case R.id.nav_contact: + startActivity(new Intent(Intent.ACTION_VIEW, Constants.contactUri)); break; } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.java index 7e47d77c..3aa6d26f 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/CreateAddressBookActivity.java @@ -9,36 +9,21 @@ package at.bitfire.davdroid.ui; import android.accounts.Account; -import android.content.Context; import android.content.Intent; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; import android.os.Bundle; -import android.support.v4.app.LoaderManager; import android.support.v4.app.NavUtils; -import android.support.v4.content.AsyncTaskLoader; -import android.support.v4.content.Loader; import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; import android.view.Menu; import android.view.MenuItem; -import android.widget.ArrayAdapter; import android.widget.EditText; -import android.widget.Spinner; import org.apache.commons.lang3.StringUtils; -import java.util.LinkedList; -import java.util.List; -import java.util.UUID; - import at.bitfire.davdroid.R; import at.bitfire.davdroid.model.CollectionInfo; -import at.bitfire.davdroid.model.ServiceDB; -import lombok.Cleanup; -import okhttp3.HttpUrl; -public class CreateAddressBookActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks { +public class CreateAddressBookActivity extends AppCompatActivity { public static final String EXTRA_ACCOUNT = "account"; protected Account account; @@ -51,8 +36,6 @@ public class CreateAddressBookActivity extends AppCompatActivity implements Load getSupportActionBar().setDisplayHomeAsUpEnabled(true); setContentView(R.layout.activity_create_address_book); - - getSupportLoaderManager().initLoader(0, getIntent().getExtras(), this); } @Override @@ -76,9 +59,6 @@ public class CreateAddressBookActivity extends AppCompatActivity implements Load boolean ok = true; CollectionInfo info = new CollectionInfo(); - Spinner spinner = (Spinner)findViewById(R.id.home_sets); - String homeSet = (String)spinner.getSelectedItem(); - EditText edit = (EditText)findViewById(R.id.display_name); info.displayName = edit.getText().toString(); if (TextUtils.isEmpty(info.displayName)) { @@ -91,71 +71,8 @@ public class CreateAddressBookActivity extends AppCompatActivity implements Load if (ok) { info.type = CollectionInfo.Type.ADDRESS_BOOK; - info.url = HttpUrl.parse(homeSet).resolve(UUID.randomUUID().toString() + "/").toString(); - CreateCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null); - } - } - - @Override - public Loader onCreateLoader(int id, Bundle args) { - return new AccountInfoLoader(this, account); - } - - @Override - public void onLoadFinished(Loader loader, AccountInfo info) { - if (info != null) { - Spinner spinner = (Spinner)findViewById(R.id.home_sets); - spinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)); - } - } - - @Override - public void onLoaderReset(Loader loader) { - } - - protected static class AccountInfo { - List homeSets = new LinkedList<>(); - } - - protected static class AccountInfoLoader extends AsyncTaskLoader { - private final Account account; - private final ServiceDB.OpenHelper dbHelper; - - public AccountInfoLoader(Context context, Account account) { - super(context); - this.account = account; - dbHelper = new ServiceDB.OpenHelper(context); - } - - @Override - protected void onStartLoading() { - forceLoad(); - } - - @Override - public AccountInfo loadInBackground() { - final AccountInfo info = new AccountInfo(); - - // find DAV service and home sets - SQLiteDatabase db = dbHelper.getReadableDatabase(); - try { - @Cleanup Cursor cursorService = db.query(ServiceDB.Services._TABLE, new String[] { ServiceDB.Services.ID }, - ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", - new String[] { account.name, ServiceDB.Services.SERVICE_CARDDAV }, null, null, null); - if (!cursorService.moveToNext()) - return null; - String strServiceID = cursorService.getString(0); - - @Cleanup Cursor cursorHomeSets = db.query(ServiceDB.HomeSets._TABLE, new String[] { ServiceDB.HomeSets.URL }, - ServiceDB.HomeSets.SERVICE_ID + "=?", new String[] { strServiceID }, null, null, null); - while (cursorHomeSets.moveToNext()) - info.homeSets.add(cursorHomeSets.getString(0)); - } finally { - dbHelper.close(); - } - - return info; + CreateCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null); } } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.java index 0ecf52e1..6f85c7a2 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/CreateCalendarActivity.java @@ -9,22 +9,15 @@ package at.bitfire.davdroid.ui; import android.accounts.Account; -import android.content.Context; import android.content.Intent; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; -import android.support.v4.app.LoaderManager; import android.support.v4.app.NavUtils; -import android.support.v4.content.AsyncTaskLoader; -import android.support.v4.content.Loader; import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; import android.view.Menu; import android.view.MenuItem; import android.view.View; -import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.RadioGroup; import android.widget.Spinner; @@ -33,20 +26,12 @@ import net.fortuna.ical4j.model.Calendar; import org.apache.commons.lang3.StringUtils; -import java.util.LinkedList; -import java.util.List; -import java.util.TimeZone; -import java.util.UUID; - import at.bitfire.davdroid.R; import at.bitfire.davdroid.model.CollectionInfo; -import at.bitfire.davdroid.model.ServiceDB; import at.bitfire.ical4android.DateUtils; -import lombok.Cleanup; -import okhttp3.HttpUrl; import yuku.ambilwarna.AmbilWarnaDialog; -public class CreateCalendarActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks { +public class CreateCalendarActivity extends AppCompatActivity { public static final String EXTRA_ACCOUNT = "account"; protected Account account; @@ -64,7 +49,7 @@ public class CreateCalendarActivity extends AppCompatActivity implements LoaderM colorSquare.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { - new AmbilWarnaDialog(CreateCalendarActivity.this, ((ColorDrawable)colorSquare.getBackground()).getColor(), true, new AmbilWarnaDialog.OnAmbilWarnaListener() { + new AmbilWarnaDialog(CreateCalendarActivity.this, ((ColorDrawable) colorSquare.getBackground()).getColor(), true, new AmbilWarnaDialog.OnAmbilWarnaListener() { @Override public void onCancel(AmbilWarnaDialog dialog) { } @@ -76,8 +61,6 @@ public class CreateCalendarActivity extends AppCompatActivity implements LoaderM }).show(); } }); - - getSupportLoaderManager().initLoader(0, null, this); } @Override @@ -101,122 +84,33 @@ public class CreateCalendarActivity extends AppCompatActivity implements LoaderM boolean ok = true; CollectionInfo info = new CollectionInfo(); - Spinner spinner = (Spinner)findViewById(R.id.home_sets); - String homeSet = (String)spinner.getSelectedItem(); + Spinner spinner; - EditText edit = (EditText)findViewById(R.id.display_name); + EditText edit = (EditText) findViewById(R.id.display_name); info.displayName = edit.getText().toString(); if (TextUtils.isEmpty(info.displayName)) { edit.setError(getString(R.string.create_collection_display_name_required)); ok = false; } - edit = (EditText)findViewById(R.id.description); + edit = (EditText) findViewById(R.id.description); info.description = StringUtils.trimToNull(edit.getText().toString()); View view = findViewById(R.id.color); - info.color = ((ColorDrawable)view.getBackground()).getColor(); + info.color = ((ColorDrawable) view.getBackground()).getColor(); - spinner = (Spinner)findViewById(R.id.time_zone); - net.fortuna.ical4j.model.TimeZone tz = DateUtils.tzRegistry.getTimeZone((String)spinner.getSelectedItem()); + spinner = (Spinner) findViewById(R.id.time_zone); + net.fortuna.ical4j.model.TimeZone tz = DateUtils.tzRegistry.getTimeZone((String) spinner.getSelectedItem()); if (tz != null) { Calendar cal = new Calendar(); cal.getComponents().add(tz.getVTimeZone()); info.timeZone = cal.toString(); } - RadioGroup typeGroup = (RadioGroup)findViewById(R.id.type); - switch (typeGroup.getCheckedRadioButtonId()) { - case R.id.type_events: - info.supportsVEVENT = true; - break; - case R.id.type_tasks: - info.supportsVTODO = true; - break; - case R.id.type_events_and_tasks: - info.supportsVEVENT = true; - info.supportsVTODO = true; - break; - } - if (ok) { info.type = CollectionInfo.Type.CALENDAR; - info.url = HttpUrl.parse(homeSet).resolve(UUID.randomUUID().toString() + "/").toString(); - CreateCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null); - } - } - - - @Override - public Loader onCreateLoader(int id, Bundle args) { - return new AccountInfoLoader(this, account); - } - - @Override - public void onLoadFinished(Loader loader, AccountInfo info) { - Spinner spinner = (Spinner)findViewById(R.id.time_zone); - String[] timeZones = TimeZone.getAvailableIDs(); - spinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, timeZones)); - // select system time zone - String defaultTimeZone = TimeZone.getDefault().getID(); - for (int i = 0; i < timeZones.length; i++) - if (timeZones[i].equals(defaultTimeZone)) { - spinner.setSelection(i); - break; - } - - if (info != null) { - spinner = (Spinner)findViewById(R.id.home_sets); - spinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)); - } - } - - @Override - public void onLoaderReset(Loader loader) { - } - - protected static class AccountInfo { - List homeSets = new LinkedList<>(); - } - - protected static class AccountInfoLoader extends AsyncTaskLoader { - private final Account account; - private final ServiceDB.OpenHelper dbHelper; - public AccountInfoLoader(Context context, Account account) { - super(context); - this.account = account; - dbHelper = new ServiceDB.OpenHelper(context); - } - - @Override - protected void onStartLoading() { - forceLoad(); - } - - @Override - public AccountInfo loadInBackground() { - final AccountInfo info = new AccountInfo(); - - // find DAV service and home sets - SQLiteDatabase db = dbHelper.getReadableDatabase(); - try { - @Cleanup Cursor cursorService = db.query(ServiceDB.Services._TABLE, new String[] { ServiceDB.Services.ID }, - ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", - new String[] { account.name, ServiceDB.Services.SERVICE_CALDAV }, null, null, null); - if (!cursorService.moveToNext()) - return null; - String strServiceID = cursorService.getString(0); - - @Cleanup Cursor cursorHomeSets = db.query(ServiceDB.HomeSets._TABLE, new String[] { ServiceDB.HomeSets.URL }, - ServiceDB.HomeSets.SERVICE_ID + "=?", new String[] { strServiceID }, null, null, null); - while (cursorHomeSets.moveToNext()) - info.homeSets.add(cursorHomeSets.getString(0)); - } finally { - dbHelper.close(); - } - - return info; + CreateCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null); } } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.java index 852ceaf2..cdaf13e3 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/CreateCollectionFragment.java @@ -23,26 +23,16 @@ import android.support.v4.app.LoaderManager; import android.support.v4.content.AsyncTaskLoader; import android.support.v4.content.Loader; -import org.apache.commons.lang3.BooleanUtils; -import org.xmlpull.v1.XmlSerializer; - -import java.io.IOException; -import java.io.StringWriter; -import java.util.logging.Level; - -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.XmlUtils; -import at.bitfire.dav4android.exception.HttpException; -import at.bitfire.davdroid.App; -import at.bitfire.davdroid.DavUtils; +import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Exceptions; +import at.bitfire.davdroid.journalmanager.JournalManager; import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.davdroid.model.ServiceDB; import lombok.Cleanup; import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; public class CreateCollectionFragment extends DialogFragment implements LoaderManager.LoaderCallbacks { private static final String @@ -127,95 +117,9 @@ public class CreateCollectionFragment extends DialogFragment implements LoaderMa @Override public Exception loadInBackground() { - StringWriter writer = new StringWriter(); - try { - XmlSerializer serializer = XmlUtils.newSerializer(); - serializer.setOutput(writer); - serializer.startDocument("UTF-8", null); - serializer.setPrefix("", XmlUtils.NS_WEBDAV); - serializer.setPrefix("CAL", XmlUtils.NS_CALDAV); - serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV); - - serializer.startTag(XmlUtils.NS_WEBDAV, "mkcol"); - serializer.startTag(XmlUtils.NS_WEBDAV, "set"); - serializer.startTag(XmlUtils.NS_WEBDAV, "prop"); - serializer.startTag(XmlUtils.NS_WEBDAV, "resourcetype"); - serializer.startTag(XmlUtils.NS_WEBDAV, "collection"); - serializer.endTag(XmlUtils.NS_WEBDAV, "collection"); - if (info.type == CollectionInfo.Type.ADDRESS_BOOK) { - serializer.startTag(XmlUtils.NS_CARDDAV, "addressbook"); - serializer.endTag(XmlUtils.NS_CARDDAV, "addressbook"); - } else if (info.type == CollectionInfo.Type.CALENDAR) { - serializer.startTag(XmlUtils.NS_CALDAV, "calendar"); - serializer.endTag(XmlUtils.NS_CALDAV, "calendar"); - } - serializer.endTag(XmlUtils.NS_WEBDAV, "resourcetype"); - if (info.displayName != null) { - serializer.startTag(XmlUtils.NS_WEBDAV, "displayname"); - serializer.text(info.displayName); - serializer.endTag(XmlUtils.NS_WEBDAV, "displayname"); - } - - // addressbook-specific properties - if (info.type == CollectionInfo.Type.ADDRESS_BOOK) { - if (info.description != null) { - serializer.startTag(XmlUtils.NS_CARDDAV, "addressbook-description"); - serializer.text(info.description); - serializer.endTag(XmlUtils.NS_CARDDAV, "addressbook-description"); - } - } - - // calendar-specific properties - if (info.type == CollectionInfo.Type.CALENDAR) { - if (info.description != null) { - serializer.startTag(XmlUtils.NS_CALDAV, "calendar-description"); - serializer.text(info.description); - serializer.endTag(XmlUtils.NS_CALDAV, "calendar-description"); - } - - if (info.color != null) { - serializer.startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color"); - serializer.text(DavUtils.ARGBtoCalDAVColor(info.color)); - serializer.endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color"); - } - - if (info.timeZone != null) { - serializer.startTag(XmlUtils.NS_CALDAV, "calendar-timezone"); - serializer.cdsect(info.timeZone); - serializer.endTag(XmlUtils.NS_CALDAV, "calendar-timezone"); - } - - serializer.startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set"); - if (BooleanUtils.isTrue(info.supportsVEVENT)) { - serializer.startTag(XmlUtils.NS_CALDAV, "comp"); - serializer.attribute(null, "name", "VEVENT"); - serializer.endTag(XmlUtils.NS_CALDAV, "comp"); - } - if (BooleanUtils.isTrue(info.supportsVTODO)) { - serializer.startTag(XmlUtils.NS_CALDAV, "comp"); - serializer.attribute(null, "name", "VTODO"); - serializer.endTag(XmlUtils.NS_CALDAV, "comp"); - } - serializer.endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set"); - } - - serializer.endTag(XmlUtils.NS_WEBDAV, "prop"); - serializer.endTag(XmlUtils.NS_WEBDAV, "set"); - serializer.endTag(XmlUtils.NS_WEBDAV, "mkcol"); - serializer.endDocument(); - } catch (IOException e) { - App.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e); - } - ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); try { - OkHttpClient client = HttpClient.create(getContext(), account); - DavResource collection = new DavResource(client, HttpUrl.parse(info.url)); - - // create collection on remote server - collection.mkCol(writer.toString()); - // now insert collection into database: SQLiteDatabase db = dbHelper.getWritableDatabase(); @@ -235,11 +139,19 @@ public class CreateCollectionFragment extends DialogFragment implements LoaderMa throw new IllegalStateException(); long serviceID = c.getLong(0); + AccountSettings settings = new AccountSettings(getContext(), account); + HttpUrl principal = HttpUrl.get(settings.getUri()); + + JournalManager journalManager = new JournalManager(HttpClient.create(getContext(), account), principal); + journalManager.putJournal(new JournalManager.Journal(settings.password(), info.toJson(), info.url)); + // 2. add collection to service ContentValues values = info.toDB(); values.put(ServiceDB.Collections.SERVICE_ID, serviceID); db.insert(ServiceDB.Collections._TABLE, null, values); - } catch(InvalidAccountException|IOException|HttpException|IllegalStateException e) { + } catch(IllegalStateException|Exceptions.HttpException e) { + return e; + } catch (InvalidAccountException e) { return e; } finally { dbHelper.close(); diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java index aab4a0c0..dd8b128d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java @@ -38,13 +38,13 @@ import java.io.IOException; import java.util.Date; import java.util.logging.Level; -import at.bitfire.dav4android.exception.HttpException; import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.App; import at.bitfire.davdroid.BuildConfig; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Exceptions.HttpException; import at.bitfire.davdroid.model.ServiceDB; import lombok.Cleanup; @@ -159,21 +159,21 @@ public class DebugInfoActivity extends AppCompatActivity implements LoaderManage String logs = null, authority = null; Account account = null; - int phase = -1; + String phase = null; if (extras != null) { throwable = (Throwable)extras.getSerializable(KEY_THROWABLE); logs = extras.getString(KEY_LOGS); account = extras.getParcelable(KEY_ACCOUNT); authority = extras.getString(KEY_AUTHORITY); - phase = extras.getInt(KEY_PHASE, -1); + phase = extras.getString(KEY_PHASE, null); } StringBuilder report = new StringBuilder(); // begin with most specific information - if (phase != -1) + if (phase != null) report.append("SYNCHRONIZATION INFO\nSynchronization phase: ").append(phase).append("\n"); if (account != null) report.append("Account name: ").append(account.name).append("\n"); @@ -181,11 +181,13 @@ public class DebugInfoActivity extends AppCompatActivity implements LoaderManage report.append("Authority: ").append(authority).append("\n"); if (throwable instanceof HttpException) { + /* FIXME HttpException http = (HttpException)throwable; if (http.request != null) report.append("\nHTTP REQUEST:\n").append(http.request).append("\n\n"); if (http.response != null) report.append("HTTP RESPONSE:\n").append(http.response).append("\n"); + */ } if (throwable != null) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.java index 2c63a1f5..3c7574db 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/DeleteCollectionFragment.java @@ -24,17 +24,15 @@ import android.support.v4.content.Loader; import android.support.v7.app.AlertDialog; import android.text.TextUtils; -import java.io.IOException; - -import at.bitfire.dav4android.DavResource; -import at.bitfire.dav4android.exception.HttpException; +import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.HttpClient; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Exceptions; +import at.bitfire.davdroid.journalmanager.JournalManager; import at.bitfire.davdroid.model.CollectionInfo; import at.bitfire.davdroid.model.ServiceDB; import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; public class DeleteCollectionFragment extends DialogFragment implements LoaderManager.LoaderCallbacks { protected static final String @@ -67,7 +65,7 @@ public class DeleteCollectionFragment extends DialogFragment implements LoaderMa @Override public Loader onCreateLoader(int id, Bundle args) { account = args.getParcelable(ARG_ACCOUNT); - collectionInfo = (CollectionInfo)args.getSerializable(ARG_COLLECTION_INFO); + collectionInfo = (CollectionInfo) args.getSerializable(ARG_COLLECTION_INFO); return new DeleteCollectionLoader(getContext(), account, collectionInfo); } @@ -82,7 +80,7 @@ public class DeleteCollectionFragment extends DialogFragment implements LoaderMa else { Activity activity = getActivity(); if (activity instanceof AccountActivity) - ((AccountActivity)activity).reload(); + ((AccountActivity) activity).reload(); } } @@ -111,18 +109,21 @@ public class DeleteCollectionFragment extends DialogFragment implements LoaderMa @Override public Exception loadInBackground() { try { - OkHttpClient httpClient = HttpClient.create(getContext(), account); - DavResource collection = new DavResource(httpClient, HttpUrl.parse(collectionInfo.url)); - - // delete collection from server - collection.delete(null); - // delete collection locally SQLiteDatabase db = dbHelper.getWritableDatabase(); - db.delete(ServiceDB.Collections._TABLE, ServiceDB.Collections.ID + "=?", new String[] { String.valueOf(collectionInfo.id) }); + + AccountSettings settings = new AccountSettings(getContext(), account); + HttpUrl principal = HttpUrl.get(settings.getUri()); + + JournalManager journalManager = new JournalManager(HttpClient.create(getContext(), account), principal); + journalManager.deleteJournal(new JournalManager.Journal(settings.password(), collectionInfo.toJson(), collectionInfo.url)); + + db.delete(ServiceDB.Collections._TABLE, ServiceDB.Collections.ID + "=?", new String[]{String.valueOf(collectionInfo.id)}); return null; - } catch (InvalidAccountException|IOException|HttpException e) { + } catch (Exceptions.HttpException e) { + return e; + } catch (InvalidAccountException e) { return e; } finally { dbHelper.close(); @@ -145,7 +146,7 @@ public class DeleteCollectionFragment extends DialogFragment implements LoaderMa @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { - CollectionInfo collectionInfo = (CollectionInfo)getArguments().getSerializable(ARG_COLLECTION_INFO); + CollectionInfo collectionInfo = (CollectionInfo) getArguments().getSerializable(ARG_COLLECTION_INFO); String name = TextUtils.isEmpty(collectionInfo.displayName) ? collectionInfo.url : collectionInfo.displayName; return new AlertDialog.Builder(getContext()) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.java index b39400f3..68c3e207 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/ExceptionInfoFragment.java @@ -19,8 +19,8 @@ import android.support.v7.app.AlertDialog; import java.io.IOException; -import at.bitfire.dav4android.exception.HttpException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Exceptions.HttpException; public class ExceptionInfoFragment extends DialogFragment { protected static final String diff --git a/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.java index d0db8c3e..31ad0cc4 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/StartupDialogFragment.java @@ -14,9 +14,6 @@ import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.sqlite.SQLiteDatabase; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -28,7 +25,6 @@ import android.support.v7.app.AlertDialog; import java.util.Collections; import java.util.LinkedList; import java.util.List; -import java.util.logging.Level; import at.bitfire.davdroid.App; import at.bitfire.davdroid.BuildConfig; @@ -139,7 +135,7 @@ public class StartupDialogFragment extends DialogFragment { .setNeutralButton(R.string.startup_development_version_give_feedback, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { - startActivity(new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("forums/").build())); + startActivity(new Intent(Intent.ACTION_VIEW, Constants.feedbackUri)); } }) .create(); diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.java index 403b891b..f8089e20 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.java @@ -10,61 +10,25 @@ package at.bitfire.davdroid.ui.setup; import android.content.Context; import android.support.annotation.NonNull; -import org.xbill.DNS.Lookup; -import org.xbill.DNS.Record; -import org.xbill.DNS.SRVRecord; -import org.xbill.DNS.TXTRecord; -import org.xbill.DNS.Type; - -import java.io.IOException; import java.io.Serializable; import java.net.URI; -import java.net.URISyntaxException; import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; import java.util.Map; -import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; -import at.bitfire.dav4android.Constants; -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.exception.NotFoundException; -import at.bitfire.dav4android.property.AddressbookDescription; -import at.bitfire.dav4android.property.AddressbookHomeSet; -import at.bitfire.dav4android.property.CalendarColor; -import at.bitfire.dav4android.property.CalendarDescription; -import at.bitfire.dav4android.property.CalendarHomeSet; -import at.bitfire.dav4android.property.CalendarTimezone; -import at.bitfire.dav4android.property.CalendarUserAddressSet; -import at.bitfire.dav4android.property.CurrentUserPrincipal; -import at.bitfire.dav4android.property.CurrentUserPrivilegeSet; -import at.bitfire.dav4android.property.DisplayName; -import at.bitfire.dav4android.property.ResourceType; -import at.bitfire.dav4android.property.SupportedCalendarComponentSet; import at.bitfire.davdroid.HttpClient; +import at.bitfire.davdroid.journalmanager.Exceptions; +import at.bitfire.davdroid.journalmanager.JournalAuthenticator; import at.bitfire.davdroid.log.StringHandler; import at.bitfire.davdroid.model.CollectionInfo; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.ToString; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; public class DavResourceFinder { - public enum Service { - CALDAV("caldav"), - CARDDAV("carddav"); - - final String name; - Service(String name) { this.name = name;} - @Override public String toString() { return name; } - } - protected final Context context; protected final LoginCredentials credentials; @@ -81,304 +45,42 @@ public class DavResourceFinder { log.addHandler(logBuffer); httpClient = HttpClient.create(context, log); - httpClient = HttpClient.addAuthentication(httpClient, credentials.userName, credentials.password); } public Configuration findInitialConfiguration() { - final Configuration.ServiceInfo - cardDavConfig = findInitialConfiguration(Service.CARDDAV), - calDavConfig = findInitialConfiguration(Service.CALDAV); - - return new Configuration( - credentials.userName, credentials.password, - cardDavConfig, calDavConfig, - logBuffer.toString() - ); - } - - protected Configuration.ServiceInfo findInitialConfiguration(@NonNull Service service) { - // user-given base URI (either mailto: URI or http(s):// URL) - final URI baseURI = credentials.uri; - - // domain for service discovery - String discoveryFQDN = null; - - // put discovered information here - final Configuration.ServiceInfo config = new Configuration.ServiceInfo(); - log.info("Finding initial " + service.name + " service configuration"); - - if ("http".equalsIgnoreCase(baseURI.getScheme()) || "https".equalsIgnoreCase(baseURI.getScheme())) { - final HttpUrl baseURL = HttpUrl.get(baseURI); - - // remember domain for service discovery - // try service discovery only for https:// URLs because only secure service discovery is implemented - if ("https".equalsIgnoreCase(baseURL.scheme())) - discoveryFQDN = baseURI.getHost(); - - checkUserGivenURL(baseURL, service, config); - - if (config.principal == null) - try { - config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.name), service); - } catch (IOException|HttpException|DavException e) { - log.log(Level.FINE, "Well-known URL detection failed", e); - } - - } else if ("mailto".equalsIgnoreCase(baseURI.getScheme())) { - String mailbox = baseURI.getSchemeSpecificPart(); - - int posAt = mailbox.lastIndexOf("@"); - if (posAt != -1) - discoveryFQDN = mailbox.substring(posAt + 1); - } - - // Step 2: If user-given URL didn't reveal a principal, search for it: SERVICE DISCOVERY - if (config.principal == null && discoveryFQDN != null) { - log.info("No principal found at user-given URL, trying to discover"); - try { - config.principal = discoverPrincipalUrl(discoveryFQDN, service); - } catch (IOException|HttpException|DavException e) { - log.log(Level.FINE, service.name + " service discovery failed", e); - } - } - - if (config.principal != null && service == Service.CALDAV) { - // query email address (CalDAV scheduling: calendar-user-address-set) - DavResource davPrincipal = new DavResource(httpClient, HttpUrl.get(config.principal), log); - try { - davPrincipal.propfind(0, CalendarUserAddressSet.NAME); - CalendarUserAddressSet addressSet = (CalendarUserAddressSet)davPrincipal.properties.get(CalendarUserAddressSet.NAME); - if (addressSet != null) - for (String href : addressSet.hrefs) - try { - URI uri = new URI(href); - if ("mailto".equals(uri.getScheme())) - config.email = uri.getSchemeSpecificPart(); - } catch(URISyntaxException e) { - Constants.log.log(Level.WARNING, "Unparseable user address", e); - } - } catch(IOException | HttpException | DavException e) { - Constants.log.log(Level.WARNING, "Couldn't query user email address", e); - } - } - - // return config or null if config doesn't contain useful information - boolean serviceAvailable = config.principal != null || !config.homeSets.isEmpty() || !config.collections.isEmpty(); - return serviceAvailable ? config : null; - } - - protected void checkUserGivenURL(@NonNull HttpUrl baseURL, @NonNull Service service, @NonNull Configuration.ServiceInfo config) { - log.info("Checking user-given URL: " + baseURL.toString()); - - HttpUrl principal = null; - try { - DavResource davBase = new DavResource(httpClient, baseURL, log); - - if (service == Service.CARDDAV) { - davBase.propfind(0, - ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, - AddressbookHomeSet.NAME, - CurrentUserPrincipal.NAME - ); - rememberIfAddressBookOrHomeset(davBase, config); - - } else if (service == Service.CALDAV) { - davBase.propfind(0, - ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, - CalendarHomeSet.NAME, - CurrentUserPrincipal.NAME - ); - rememberIfCalendarOrHomeset(davBase, config); - } - - // check for current-user-principal - CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal)davBase.properties.get(CurrentUserPrincipal.NAME); - if (currentUserPrincipal != null && currentUserPrincipal.href != null) - principal = davBase.location.resolve(currentUserPrincipal.href); - - // check for resource type "principal" - if (principal == null) { - ResourceType resourceType = (ResourceType)davBase.properties.get(ResourceType.NAME); - if (resourceType != null && resourceType.types.contains(ResourceType.PRINCIPAL)) - principal = davBase.location; - } - - // If a principal has been detected successfully, ensure that it provides the required service. - if (principal != null && providesService(principal, service)) - config.principal = principal.uri(); - - } catch (IOException|HttpException|DavException e) { - log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e); - } - } - - /** - * If #dav is an address book or an address book home set, it will added to - * config.collections or config.homesets. Only evaluates already known properties, - * does not call dav.propfind()! URLs will be stored with trailing "/". - * @param dav resource whose properties are evaluated - * @param config structure where the address book (collection) and/or home set is stored into (if found) - */ - protected void rememberIfAddressBookOrHomeset(@NonNull DavResource dav, @NonNull Configuration.ServiceInfo config) { - // Is the collection an address book? - ResourceType resourceType = (ResourceType)dav.properties.get(ResourceType.NAME); - if (resourceType != null && resourceType.types.contains(ResourceType.ADDRESSBOOK)) { - dav.location = UrlUtils.withTrailingSlash(dav.location); - log.info("Found address book at " + dav.location); - config.collections.put(dav.location.uri(), CollectionInfo.fromDavResource(dav)); - } - - // Does the collection refer to address book homesets? - AddressbookHomeSet homeSets = (AddressbookHomeSet)dav.properties.get(AddressbookHomeSet.NAME); - if (homeSets != null) - for (String href : homeSets.hrefs) { - HttpUrl location = UrlUtils.withTrailingSlash(dav.location.resolve(href)); - log.info("Found addressbook home-set at " + location); - config.homeSets.add(location.uri()); - } - } + boolean failed = false; + Configuration.ServiceInfo + cardDavConfig = findInitialConfiguration(CollectionInfo.Type.ADDRESS_BOOK), + calDavConfig = findInitialConfiguration(CollectionInfo.Type.CALENDAR); - protected void rememberIfCalendarOrHomeset(@NonNull DavResource dav, @NonNull Configuration.ServiceInfo config) { - // Is the collection a calendar collection? - ResourceType resourceType = (ResourceType)dav.properties.get(ResourceType.NAME); - if (resourceType != null && resourceType.types.contains(ResourceType.CALENDAR)) { - dav.location = UrlUtils.withTrailingSlash(dav.location); - log.info("Found calendar collection at " + dav.location); - config.collections.put(dav.location.uri(), CollectionInfo.fromDavResource(dav)); - } - - // Does the collection refer to calendar homesets? - CalendarHomeSet homeSets = (CalendarHomeSet)dav.properties.get(CalendarHomeSet.NAME); - if (homeSets != null) - for (String href : homeSets.hrefs) - config.homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)).uri()); - } + JournalAuthenticator authenticator = new JournalAuthenticator(httpClient, HttpUrl.get(credentials.uri)); - - protected boolean providesService(HttpUrl url, Service service) throws IOException { - DavResource davPrincipal = new DavResource(httpClient, url, log); + String authtoken = null; try { - davPrincipal.options(); - - if ((service == Service.CARDDAV && davPrincipal.capabilities.contains("addressbook")) || - (service == Service.CALDAV && davPrincipal.capabilities.contains("calendar-access"))) - return true; + authtoken = authenticator.getAuthToken(credentials.userName, credentials.password); + } catch (Exceptions.HttpException e) { + log.warning(e.getMessage()); - } catch (HttpException|DavException e) { - log.log(Level.SEVERE, "Couldn't detect services on " + url, e); + failed = true; } - return false; - } - - - /** - * Try to find the principal URL by performing service discovery on a given domain name. - * Only secure services (caldavs, carddavs) will be discovered! - * @param domain domain name, e.g. "icloud.com" - * @param service service to discover (CALDAV or CARDDAV) - * @return principal URL, or null if none found - */ - protected URI discoverPrincipalUrl(@NonNull String domain, @NonNull Service service) throws IOException, HttpException, DavException { - String scheme; - String fqdn; - Integer port = 443; - List paths = new LinkedList<>(); // there may be multiple paths to try - - final String query = "_" + service.name + "s._tcp." + domain; - log.fine("Looking up SRV records for " + query); - Record[] records = new Lookup(query, Type.SRV).run(); - if (records != null && records.length >= 1) { - // choose SRV record to use (query may return multiple SRV records) - SRVRecord srv = selectSRVRecord(records); - - scheme = "https"; - fqdn = srv.getTarget().toString(true); - port = srv.getPort(); - log.info("Found " + service + " service at https://" + fqdn + ":" + port); - - } else { - // no SRV records, try domain name as FQDN - log.info("Didn't find " + service + " service, trying at https://" + domain + ":" + port); - - scheme = "https"; - fqdn = domain; - } - - // look for TXT record too (for initial context path) - records = new Lookup(query, Type.TXT).run(); - if (records != null) - for (Record record : records) - if (record instanceof TXTRecord) - for (String segment : (List)((TXTRecord)record).getStrings()) - if (segment.startsWith("path=")) { - paths.add(segment.substring(5)); - log.info("Found TXT record; initial context path=" + paths); - break; - } - // if there's TXT record and if it it's wrong, try well-known - paths.add("/.well-known/" + service.name); - // if this fails, too, try "/" - paths.add("/"); - - for (String path : paths) - try { - HttpUrl initialContextPath = new HttpUrl.Builder() - .scheme(scheme) - .host(fqdn).port(port) - .encodedPath(path) - .build(); - - log.info("Trying to determine principal from initial context path=" + initialContextPath); - URI principal = getCurrentUserPrincipal(initialContextPath, service); - - if (principal != null) - return principal; - } catch(NotFoundException|IllegalArgumentException e) { - log.log(Level.WARNING, "No resource found", e); - } - return null; + return new Configuration( + credentials.uri, + credentials.userName, authtoken, + cardDavConfig, calDavConfig, + logBuffer.toString(), failed + ); } - /** - * Queries a given URL for current-user-principal - * @param url URL to query with PROPFIND (Depth: 0) - * @param service required service (may be null, in which case no service check is done) - * @return current-user-principal URL that provides required service, or null if none - */ - public URI getCurrentUserPrincipal(HttpUrl url, Service service) throws IOException, HttpException, DavException { - DavResource dav = new DavResource(httpClient, url, log); - dav.propfind(0, CurrentUserPrincipal.NAME); - - CurrentUserPrincipal currentUserPrincipal = (CurrentUserPrincipal)dav.properties.get(CurrentUserPrincipal.NAME); - if (currentUserPrincipal != null && currentUserPrincipal.href != null) { - HttpUrl principal = dav.location.resolve(currentUserPrincipal.href); - if (principal != null) { - log.info("Found current-user-principal: " + principal); - - // service check - if (service != null && !providesService(principal, service)) { - log.info(principal + " doesn't provide required " + service + " service"); - principal = null; - } + protected Configuration.ServiceInfo findInitialConfiguration(@NonNull CollectionInfo.Type service) { + // put discovered information here + final Configuration.ServiceInfo config = new Configuration.ServiceInfo(); + log.info("Finding initial " + service.toString() + " service configuration"); - return principal != null ? principal.uri() : null; - } - } - return null; + return config; } - - // helpers - - private SRVRecord selectSRVRecord(Record[] records) { - if (records.length > 1) - log.warning("Multiple SRV records not supported yet; using first one"); - return (SRVRecord)records[0]; - } - - // data classes @RequiredArgsConstructor @@ -388,20 +90,22 @@ public class DavResourceFinder { @ToString public static class ServiceInfo implements Serializable { - public URI principal; - public final Set homeSets = new HashSet<>(); - public final Map collections = new HashMap<>(); - - public String email; + public final Map collections = new HashMap<>(); } - public final String userName, password; + public final URI url; + + public final String userName, authtoken; + public String rawPassword; + public String password; public final ServiceInfo cardDAV; public final ServiceInfo calDAV; public final String logs; + @Getter + private final boolean failed; } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.java index 455bc3fc..49a28bee 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.java @@ -64,7 +64,7 @@ public class DetectConfigurationFragment extends DialogFragment implements Loade @Override public void onLoadFinished(Loader loader, Configuration data) { if (data != null) { - if (data.calDAV == null && data.cardDAV == null) + if (data.isFailed()) // no service found: show error message getFragmentManager().beginTransaction() .add(NothingDetectedFragment.newInstance(data.logs), null) @@ -72,7 +72,7 @@ public class DetectConfigurationFragment extends DialogFragment implements Loade else // service found: continue getFragmentManager().beginTransaction() - .replace(android.R.id.content, AccountDetailsFragment.newInstance(data)) + .replace(android.R.id.content, EncryptionDetailsFragment.newInstance(data)) .addToBackStack(null) .commitAllowingStateLoss(); } else diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/EncryptionDetailsFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/EncryptionDetailsFragment.java new file mode 100644 index 00000000..a7bd6f97 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/EncryptionDetailsFragment.java @@ -0,0 +1,83 @@ +/* + * Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.bitfire.davdroid.ui.setup; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import at.bitfire.davdroid.R; +import at.bitfire.davdroid.ui.widget.EditPassword; + +public class EncryptionDetailsFragment extends Fragment { + + private static final String KEY_CONFIG = "config"; + EditPassword editPassword = null; + + + public static EncryptionDetailsFragment newInstance(DavResourceFinder.Configuration config) { + EncryptionDetailsFragment frag = new EncryptionDetailsFragment(); + Bundle args = new Bundle(1); + args.putSerializable(KEY_CONFIG, config); + frag.setArguments(args); + return frag; + } + + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View v = inflater.inflate(R.layout.login_encryption_details, container, false); + + Button btnBack = (Button)v.findViewById(R.id.back); + btnBack.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + getFragmentManager().popBackStack(); + } + }); + + final DavResourceFinder.Configuration config = (DavResourceFinder.Configuration)getArguments().getSerializable(KEY_CONFIG); + + TextView accountName = (TextView)v.findViewById(R.id.account_name); + accountName.setText(getString(R.string.login_encryption_account_label) + " " + config.userName); + + editPassword = (EditPassword) v.findViewById(R.id.encryption_password); + + Button btnCreate = (Button)v.findViewById(R.id.create_account); + btnCreate.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (validateEncryptionData(config) == null) { + return; + } + + SetupEncryptionFragment.newInstance(config).show(getFragmentManager(), null); + } + }); + + return v; + } + + private DavResourceFinder.Configuration validateEncryptionData(DavResourceFinder.Configuration config) { + boolean valid = true; + String password = editPassword.getText().toString(); + if (password.isEmpty()) { + editPassword.setError(getString(R.string.login_password_required)); + valid = false; + } + + config.rawPassword = password; + + return valid ? config : null; + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.java index 8121f56e..48691790 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.java @@ -23,16 +23,8 @@ import at.bitfire.davdroid.R; * Fields for server/user data can be pre-filled with extras in the Intent. */ public class LoginActivity extends AppCompatActivity { - - /** - * When set, "login by URL" will be activated by default, and the URL field will be set to this value. - * When not set, "login by email" will be activated by default. - */ - public static final String EXTRA_URL = "url"; - /** * When set, and {@link #EXTRA_PASSWORD} is set too, the user name field will be set to this value. - * When set, and {@link #EXTRA_URL} is not set, the email address field will be set to this value. */ public static final String EXTRA_USERNAME = "username"; @@ -79,6 +71,6 @@ public class LoginActivity extends AppCompatActivity { } public void showHelp(MenuItem item) { - startActivity(new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("configuration/").build())); + startActivity(new Intent(Intent.ACTION_VIEW, Constants.helpUri)); } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginCredentialsFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginCredentialsFragment.java index 48fc2194..6bc6e1f0 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginCredentialsFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginCredentialsFragment.java @@ -17,32 +17,24 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Button; -import android.widget.CompoundButton; import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.RadioButton; import org.apache.commons.lang3.StringUtils; import java.net.IDN; +import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URL; import java.util.logging.Level; -import at.bitfire.dav4android.Constants; +import at.bitfire.davdroid.App; +import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.R; import at.bitfire.davdroid.ui.widget.EditPassword; -public class LoginCredentialsFragment extends Fragment implements CompoundButton.OnCheckedChangeListener { - - RadioButton radioUseEmail; - LinearLayout emailDetails; - EditText editEmailAddress; - EditPassword editEmailPassword; - - RadioButton radioUseURL; - LinearLayout urlDetails; - EditText editBaseURL, editUserName; +public class LoginCredentialsFragment extends Fragment { + EditText editUserName; EditPassword editUrlPassword; @@ -50,47 +42,33 @@ public class LoginCredentialsFragment extends Fragment implements CompoundButton public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.login_credentials_fragment, container, false); - radioUseEmail = (RadioButton)v.findViewById(R.id.login_type_email); - emailDetails = (LinearLayout)v.findViewById(R.id.login_type_email_details); - editEmailAddress = (EditText)v.findViewById(R.id.email_address); - editEmailPassword = (EditPassword)v.findViewById(R.id.email_password); - - radioUseURL = (RadioButton)v.findViewById(R.id.login_type_url); - urlDetails = (LinearLayout)v.findViewById(R.id.login_type_url_details); - editBaseURL = (EditText)v.findViewById(R.id.base_url); - editUserName = (EditText)v.findViewById(R.id.user_name); - editUrlPassword = (EditPassword)v.findViewById(R.id.url_password); - - radioUseEmail.setOnCheckedChangeListener(this); - radioUseURL.setOnCheckedChangeListener(this); + editUserName = (EditText) v.findViewById(R.id.user_name); + editUrlPassword = (EditPassword) v.findViewById(R.id.url_password); if (savedInstanceState == null) { - // first call - Activity activity = getActivity(); Intent intent = (activity != null) ? activity.getIntent() : null; if (intent != null) { // we've got initial login data - String url = intent.getStringExtra(LoginActivity.EXTRA_URL), - username = intent.getStringExtra(LoginActivity.EXTRA_USERNAME), + String username = intent.getStringExtra(LoginActivity.EXTRA_USERNAME), password = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD); - if (url != null) { - radioUseURL.setChecked(true); - editBaseURL.setText(url); - editUserName.setText(username); - editUrlPassword.setText(password); - } else { - radioUseEmail.setChecked(true); - editEmailAddress.setText(username); - editEmailPassword.setText(password); - } - - } else - radioUseEmail.setChecked(true); + editUserName.setText(username); + editUrlPassword.setText(password); + } } - final Button login = (Button)v.findViewById(R.id.login); + final Button createAccount = (Button) v.findViewById(R.id.create_account); + createAccount.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Uri createUri = Constants.registrationUrl.buildUpon().appendQueryParameter("email", editUserName.getText().toString()).build(); + Intent intent = new Intent(Intent.ACTION_VIEW, createUri); + startActivity(intent); + } + }); + + final Button login = (Button) v.findViewById(R.id.login); login.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -103,88 +81,29 @@ public class LoginCredentialsFragment extends Fragment implements CompoundButton return v; } - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - if (isChecked) { - boolean loginByEmail = buttonView == radioUseEmail; - emailDetails.setVisibility(loginByEmail ? View.VISIBLE : View.GONE); - urlDetails.setVisibility(loginByEmail ? View.GONE : View.VISIBLE); - (loginByEmail ? editEmailAddress : editBaseURL).requestFocus(); - } - } - protected LoginCredentials validateLoginData() { - if (radioUseEmail.isChecked()) { - URI uri = null; - boolean valid = true; - - String email = editEmailAddress.getText().toString(); - if (!email.matches(".+@.+")) { - editEmailAddress.setError(getString(R.string.login_email_address_error)); - valid = false; - } else - try { - uri = new URI("mailto", email, null); - } catch (URISyntaxException e) { - editEmailAddress.setError(e.getLocalizedMessage()); - valid = false; - } - - String password = editEmailPassword.getText().toString(); - if (password.isEmpty()) { - editEmailPassword.setError(getString(R.string.login_password_required)); - valid = false; - } + boolean valid = true; - return valid ? new LoginCredentials(uri, email, password) : null; - - } else if (radioUseURL.isChecked()) { - URI uri = null; - boolean valid = true; - - Uri baseUrl = Uri.parse(editBaseURL.getText().toString()); - String scheme = baseUrl.getScheme(); - if ("https".equalsIgnoreCase(scheme) || "http".equalsIgnoreCase(scheme)) { - String host = baseUrl.getHost(); - if (StringUtils.isEmpty(host)) { - editBaseURL.setError(getString(R.string.login_url_host_name_required)); - valid = false; - } else - try { - host = IDN.toASCII(host); - } catch(IllegalArgumentException e) { - Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e); - } - - String path = baseUrl.getEncodedPath(); - int port = baseUrl.getPort(); - try { - uri = new URI(baseUrl.getScheme(), null, host, port, path, null, null); - } catch (URISyntaxException e) { - editBaseURL.setError(e.getLocalizedMessage()); - valid = false; - } - } else { - editBaseURL.setError(getString(R.string.login_url_must_be_http_or_https)); - valid = false; - } - - String userName = editUserName.getText().toString(); - if (userName.isEmpty()) { - editUserName.setError(getString(R.string.login_user_name_required)); - valid = false; - } + URI uri = null; + try { + uri = new URI(Constants.serviceUrl.toString()); + } catch (URISyntaxException e) { + App.log.severe("Should never happen, it's a constant"); + } - String password = editUrlPassword.getText().toString(); - if (password.isEmpty()) { - editUrlPassword.setError(getString(R.string.login_password_required)); - valid = false; - } + String userName = editUserName.getText().toString(); + if (userName.isEmpty()) { + editUserName.setError(getString(R.string.login_user_name_required)); + valid = false; + } - return valid ? new LoginCredentials(uri, userName, password) : null; + String password = editUrlPassword.getText().toString(); + if (password.isEmpty()) { + editUrlPassword.setError(getString(R.string.login_password_required)); + valid = false; } - return null; + return valid ? new LoginCredentials(uri, userName, password) : null; } } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java b/app/src/main/java/at/bitfire/davdroid/ui/setup/SetupEncryptionFragment.java similarity index 54% rename from app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java rename to app/src/main/java/at/bitfire/davdroid/ui/setup/SetupEncryptionFragment.java index e75647df..2d76a52d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/SetupEncryptionFragment.java @@ -11,23 +11,22 @@ package at.bitfire.davdroid.ui.setup; import android.accounts.Account; import android.accounts.AccountManager; import android.app.Activity; +import android.app.Dialog; +import android.app.ProgressDialog; import android.content.ContentResolver; import android.content.ContentValues; +import android.content.Context; import android.content.Intent; import android.database.sqlite.SQLiteDatabase; import android.os.Bundle; import android.provider.CalendarContract; import android.provider.ContactsContract; -import android.support.design.widget.Snackbar; -import android.support.v4.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.Spinner; - -import java.net.URI; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.AsyncTaskLoader; +import android.support.v4.content.Loader; + import java.util.logging.Level; import at.bitfire.davdroid.AccountSettings; @@ -36,78 +35,93 @@ import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.DavService; import at.bitfire.davdroid.InvalidAccountException; import at.bitfire.davdroid.R; +import at.bitfire.davdroid.journalmanager.Helpers; import at.bitfire.davdroid.model.CollectionInfo; -import at.bitfire.davdroid.model.ServiceDB.Collections; -import at.bitfire.davdroid.model.ServiceDB.HomeSets; -import at.bitfire.davdroid.model.ServiceDB.OpenHelper; -import at.bitfire.davdroid.model.ServiceDB.Services; +import at.bitfire.davdroid.model.ServiceDB; import at.bitfire.davdroid.resource.LocalTaskList; +import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration; import at.bitfire.ical4android.TaskProvider; -import at.bitfire.vcard4android.GroupMethod; import lombok.Cleanup; -public class AccountDetailsFragment extends Fragment { - +public class SetupEncryptionFragment extends DialogFragment implements LoaderManager.LoaderCallbacks { private static final String KEY_CONFIG = "config"; - Spinner spnrGroupMethod; - - - public static AccountDetailsFragment newInstance(DavResourceFinder.Configuration config) { - AccountDetailsFragment frag = new AccountDetailsFragment(); + public static SetupEncryptionFragment newInstance(DavResourceFinder.Configuration config) { + SetupEncryptionFragment frag = new SetupEncryptionFragment(); Bundle args = new Bundle(1); args.putSerializable(KEY_CONFIG, config); frag.setArguments(args); return frag; } + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + ProgressDialog progress = new ProgressDialog(getActivity()); + progress.setTitle(R.string.login_encryption_setup_title); + progress.setMessage(getString(R.string.login_encryption_setup)); + progress.setIndeterminate(true); + progress.setCanceledOnTouchOutside(false); + setCancelable(false); + return progress; + } @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - final View v = inflater.inflate(R.layout.login_account_details, container, false); - - Button btnBack = (Button)v.findViewById(R.id.back); - btnBack.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - getFragmentManager().popBackStack(); - } - }); - - DavResourceFinder.Configuration config = (DavResourceFinder.Configuration)getArguments().getSerializable(KEY_CONFIG); - - final EditText editName = (EditText)v.findViewById(R.id.account_name); - editName.setText((config.calDAV != null && config.calDAV.email != null) ? config.calDAV.email : config.userName); - - // CardDAV-specific - v.findViewById(R.id.carddav).setVisibility(config.cardDAV != null ? View.VISIBLE : View.GONE); - spnrGroupMethod = (Spinner)v.findViewById(R.id.contact_group_method); - - Button btnCreate = (Button)v.findViewById(R.id.create_account); - btnCreate.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - String name = editName.getText().toString(); - if (name.isEmpty()) - editName.setError(getString(R.string.login_account_name_required)); - else { - if (createAccount(name, (DavResourceFinder.Configuration)getArguments().getSerializable(KEY_CONFIG))) { - getActivity().setResult(Activity.RESULT_OK); - getActivity().finish(); - } else - Snackbar.make(v, R.string.login_account_not_created, Snackbar.LENGTH_LONG).show(); - } - } - }); + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getLoaderManager().initLoader(0, getArguments(), this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new SetupEncryptionLoader(getContext(), (Configuration)args.getSerializable(KEY_CONFIG)); + } + + @Override + public void onLoadFinished(Loader loader, Configuration config) { + if (createAccount(config.userName, config)) { + getActivity().setResult(Activity.RESULT_OK); + getActivity().finish(); + } else { + App.log.severe("Account creation failed!"); + } - return v; + dismissAllowingStateLoss(); } + @Override + public void onLoaderReset(Loader loader) { + } + + static class SetupEncryptionLoader extends AsyncTaskLoader { + final Context context; + final Configuration config; + + public SetupEncryptionLoader(Context context, Configuration config) { + super(context); + this.context = context; + this.config = config; + } + + @Override + protected void onStartLoading() { + forceLoad(); + } + + @Override + public Configuration loadInBackground() { + config.password = Helpers.deriveKey(config.userName, config.rawPassword); + return config; + } + } + + protected boolean createAccount(String accountName, DavResourceFinder.Configuration config) { Account account = new Account(accountName, Constants.ACCOUNT_TYPE); // create Android account - Bundle userData = AccountSettings.initialUserData(config.userName); + Bundle userData = AccountSettings.initialUserData(config.url, config.userName); App.log.log(Level.INFO, "Creating Android account with initial config", new Object[] { account, userData }); AccountManager accountManager = AccountManager.get(getContext()); @@ -116,7 +130,7 @@ public class AccountDetailsFragment extends Fragment { // add entries for account to service DB App.log.log(Level.INFO, "Writing account configuration to database", config); - @Cleanup OpenHelper dbHelper = new OpenHelper(getContext()); + @Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(getContext()); SQLiteDatabase db = dbHelper.getWritableDatabase(); try { AccountSettings settings = new AccountSettings(getContext(), account); @@ -124,19 +138,16 @@ public class AccountDetailsFragment extends Fragment { Intent refreshIntent = new Intent(getActivity(), DavService.class); refreshIntent.setAction(DavService.ACTION_REFRESH_COLLECTIONS); + settings.setAuthToken(config.authtoken); + if (config.cardDAV != null) { // insert CardDAV service - long id = insertService(db, accountName, Services.SERVICE_CARDDAV, config.cardDAV); + long id = insertService(db, accountName, ServiceDB.Services.SERVICE_CARDDAV, config.cardDAV); // start CardDAV service detection (refresh collections) refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id); getActivity().startService(refreshIntent); - // initial CardDAV account settings - int idx = spnrGroupMethod.getSelectedItemPosition(); - String groupMethodName = getResources().getStringArray(R.array.settings_contact_group_method_values)[idx]; - settings.setGroupMethod(GroupMethod.valueOf(groupMethodName)); - // enable contact sync ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1); settings.setSyncInterval(ContactsContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL); @@ -144,7 +155,7 @@ public class AccountDetailsFragment extends Fragment { if (config.calDAV != null) { // insert CalDAV service - long id = insertService(db, accountName, Services.SERVICE_CALDAV, config.calDAV); + long id = insertService(db, accountName, ServiceDB.Services.SERVICE_CALDAV, config.calDAV); // start CalDAV service detection (refresh collections) refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id); @@ -173,28 +184,17 @@ public class AccountDetailsFragment extends Fragment { ContentValues values = new ContentValues(); // insert service - values.put(Services.ACCOUNT_NAME, accountName); - values.put(Services.SERVICE, service); - if (info.principal != null) - values.put(Services.PRINCIPAL, info.principal.toString()); - long serviceID = db.insertWithOnConflict(Services._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE); - - // insert home sets - for (URI homeSet : info.homeSets) { - values.clear(); - values.put(HomeSets.SERVICE_ID, serviceID); - values.put(HomeSets.URL, homeSet.toString()); - db.insertWithOnConflict(HomeSets._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE); - } + values.put(ServiceDB.Services.ACCOUNT_NAME, accountName); + values.put(ServiceDB.Services.SERVICE, service); + long serviceID = db.insertWithOnConflict(ServiceDB.Services._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE); // insert collections for (CollectionInfo collection : info.collections.values()) { values = collection.toDB(); - values.put(Collections.SERVICE_ID, serviceID); - db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE); + values.put(ServiceDB.Collections.SERVICE_ID, serviceID); + db.insertWithOnConflict(ServiceDB.Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE); } return serviceID; } - } diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml new file mode 100644 index 00000000..986da822 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_email_black.xml b/app/src/main/res/drawable/ic_email_black.xml new file mode 100644 index 00000000..b5a773fa --- /dev/null +++ b/app/src/main/res/drawable/ic_email_black.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/account_caldav_item.xml b/app/src/main/res/layout/account_caldav_item.xml index 5fea3723..e9cff432 100644 --- a/app/src/main/res/layout/account_caldav_item.xml +++ b/app/src/main/res/layout/account_caldav_item.xml @@ -58,16 +58,4 @@ android:layout_height="32dp" android:background="@drawable/ic_remove_circle_dark"/> - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/account_carddav_item.xml b/app/src/main/res/layout/account_carddav_item.xml index 7024b74b..1d6e266d 100644 --- a/app/src/main/res/layout/account_carddav_item.xml +++ b/app/src/main/res/layout/account_carddav_item.xml @@ -14,15 +14,6 @@ android:padding="8dp" android:gravity="center_vertical"> - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/login_credentials_fragment.xml b/app/src/main/res/layout/login_credentials_fragment.xml index 3ea46896..b27ed169 100644 --- a/app/src/main/res/layout/login_credentials_fragment.xml +++ b/app/src/main/res/layout/login_credentials_fragment.xml @@ -13,90 +13,42 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - - + android:orientation="vertical"> - + style="@style/login_type_headline" + android:text="@string/login_enter_service_details" + android:layout_marginBottom="14dp"/> - - - - - + android:text="@string/login_service_details_description" + android:layout_marginBottom="14dp"/> - - - + - - - - - - + android:hint="@string/login_password"/> - + +