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"/> - + +