Adjust DAVdroid to use the EteSync protocol (mostly working)

This commit includes the major changes between DAVdroid and EteSync. It
adjusts the app to use the EteSync protocol and server. It includes some
ugliness still, and it's a squash of many ugly snapshot commits while
hacking on the initial DAVdroid code.

History should be "clean" from this point onwards.
pull/2/head
Tom Hacohen 7 years ago
parent 232eaa1d6d
commit 8b5f87c2d4

3
.gitignore vendored

@ -90,3 +90,6 @@ gradle-app.setting
# Javadoc
javadoc/
### VIM ###
*.swp

9
.gitmodules vendored

@ -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

@ -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

@ -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

@ -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;

@ -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("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
" <displayname>My Contacts</displayname>" +
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
DavResource dav = new DavResource(HttpClient.create(null), server.url("/"));
dav.propfind(0, ResourceType.NAME);
CollectionInfo info = 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("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
"<response>" +
" <href>/</href>" +
" <propstat><prop>" +
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
" </prop></propstat>" +
"</response>" +
"</multistatus>"));
dav = new DavResource(HttpClient.create(null), server.url("/"));
dav.propfind(0, ResourceType.NAME);
info = 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();

@ -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 {

@ -102,26 +102,14 @@
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_calendars"/>
android:resource="@xml/sync_calendars" />
</service>
<service
android:name=".syncadapter.TasksSyncAdapterService"
android:exported="true"
android:process=":sync"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.content.SyncAdapter"/>
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_tasks"/>
</service>
<service
android:name=".DavService"

@ -12,55 +12,38 @@ import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.BroadcastReceiver;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.PeriodicSync;
import android.database.sqlite.SQLiteDatabase;
import android.os.Build;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import lombok.Cleanup;
import okhttp3.HttpUrl;
public class AccountSettings {
private final static int CURRENT_VERSION = 5;
private final static int CURRENT_VERSION = 1;
private final static String
KEY_SETTINGS_VERSION = "version",
KEY_URI = "uri",
KEY_USERNAME = "user_name",
KEY_TOKEN = "auth_token",
KEY_WIFI_ONLY = "wifi_only", // sync on WiFi only (default: false)
KEY_WIFI_ONLY_SSID = "wifi_only_ssid"; // restrict sync to specific WiFi SSID
/** Time range limitation to the past [in days]
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
< 0 (-1) no limit
>= 0 entries more than n days in the past won't be synchronized
/**
* 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<String> collections = new HashSet<>();
Set<HttpUrl> homeSets = new HashSet<>();
client = context.getContentResolver().acquireContentProviderClient(CalendarContract.AUTHORITY);
if (client != null)
try {
LocalCalendar calendars[] = (LocalCalendar[])LocalCalendar.find(account, client, LocalCalendar.Factory.INSTANCE, null, null);
for (LocalCalendar calendar : calendars) {
String url = calendar.getName();
App.log.fine("Migrating calendar " + url);
collections.add(url);
homeSets.add(HttpUrl.parse(url).resolve("../"));
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate calendars", e);
} finally {
client.release();
}
TaskProvider provider = LocalTaskList.acquireTaskProvider(context.getContentResolver());
if (provider != null)
try {
LocalTaskList[] taskLists = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
for (LocalTaskList taskList : taskLists) {
String url = taskList.getSyncId();
App.log.fine("Migrating task list " + url);
collections.add(url);
homeSets.add(HttpUrl.parse(url).resolve("../"));
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't migrate task lists", e);
} finally {
provider.close();
}
if (!collections.isEmpty()) {
// insert CalDAV service
ContentValues values = new ContentValues();
values.put(Services.ACCOUNT_NAME, account.name);
values.put(Services.SERVICE, Services.SERVICE_CALDAV);
serviceCalDAV = db.insert(Services._TABLE, null, values);
// insert collections
for (String url : collections) {
values.clear();
values.put(Collections.SERVICE_ID, serviceCalDAV);
values.put(Collections.URL, url);
values.put(Collections.SYNC, 1);
db.insert(Collections._TABLE, null, values);
}
// insert home sets
for (HttpUrl homeSet : homeSets) {
values.clear();
values.put(HomeSets.SERVICE_ID, serviceCalDAV);
values.put(HomeSets.URL, homeSet.toString());
db.insert(HomeSets._TABLE, null, values);
}
}
} finally {
dbHelper.close();
}
// initiate service detection (refresh) to get display names, colors etc.
Intent refresh = new Intent(context, DavService.class);
refresh.setAction(DavService.ACTION_REFRESH_COLLECTIONS);
if (serviceCardDAV != null) {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCardDAV);
context.startService(refresh);
}
if (serviceCalDAV != null) {
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCalDAV);
context.startService(refresh);
}
}
@SuppressWarnings({ "Recycle", "unused" })
private void update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES);
}
/* Android 7.1.1 OpenTasks fix */
@SuppressWarnings({ "Recycle", "unused" })
private void update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context);
}
public static class AppUpdatedReceiver extends BroadcastReceiver {
@Override

@ -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");
}

@ -1,33 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import java.lang.reflect.Array;
public class ArrayUtils {
@SuppressWarnings("unchecked")
public static <T> T[][] partition(T[] bigArray, int max) {
int nItems = bigArray.length;
int nPartArrays = (nItems + max-1)/max;
T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0);
// nItems is now the number of remaining items
for (int i = 0; nItems > 0; i++) {
int n = (nItems < max) ? nItems : max;
partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n);
System.arraycopy(bigArray, i*max, partArrays[i], 0, n);
nItems -= n;
}
return partArrays;
}
}

@ -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

@ -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<HttpUrl> homeSets = readHomeSets();
HttpUrl principal = readPrincipal();
if (principal != null) {
App.log.fine("Querying principal for home sets");
DavResource dav = new DavResource(httpClient, principal);
queryHomeSets(serviceType, dav, homeSets);
// refresh home sets: calendar-proxy-read/write-for
CalendarProxyReadFor proxyRead = (CalendarProxyReadFor)dav.properties.get(CalendarProxyReadFor.NAME);
if (proxyRead != null)
for (String href : proxyRead.principals) {
App.log.fine("Principal is a read-only proxy for " + href + ", checking for home sets");
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
}
CalendarProxyWriteFor proxyWrite = (CalendarProxyWriteFor)dav.properties.get(CalendarProxyWriteFor.NAME);
if (proxyWrite != null)
for (String href : proxyWrite.principals) {
App.log.fine("Principal is a read-write proxy for " + href + ", checking for home sets");
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
}
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<CollectionInfo> collections = new LinkedList<>();
// now refresh collections (taken from home sets)
Map<HttpUrl, CollectionInfo> collections = readCollections();
// (remember selections before)
Set<HttpUrl> selectedCollections = new HashSet<>();
for (CollectionInfo info : collections.values())
if (info.selected)
selectedCollections.add(HttpUrl.parse(info.url));
for (Iterator<HttpUrl> itHomeSets = homeSets.iterator(); itHomeSets.hasNext(); ) {
HttpUrl homeSet = itHomeSets.next();
App.log.fine("Listing home set " + homeSet);
DavResource dav = new DavResource(httpClient, homeSet);
try {
dav.propfind(1, CollectionInfo.DAV_PROPERTIES);
IteratorChain<DavResource> itCollections = new IteratorChain<>(dav.members.iterator(), new SingletonIterator(dav));
while (itCollections.hasNext()) {
DavResource member = itCollections.next();
CollectionInfo info = CollectionInfo.fromDavResource(member);
info.confirmed = true;
App.log.log(Level.FINE, "Found collection", info);
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type == CollectionInfo.Type.CALENDAR))
collections.put(member.location, info);
}
} catch(HttpException e) {
if (e.status == 403 || e.status == 404 || e.status == 410)
// delete home set only if it was not accessible (40x)
itHomeSets.remove();
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<Map.Entry<HttpUrl, CollectionInfo>> iterator = collections.entrySet().iterator(); iterator.hasNext(); ) {
Map.Entry<HttpUrl, CollectionInfo> entry = iterator.next();
HttpUrl url = entry.getKey();
CollectionInfo info = entry.getValue();
if (!info.confirmed)
try {
DavResource dav = new DavResource(httpClient, url);
dav.propfind(0, CollectionInfo.DAV_PROPERTIES);
info = CollectionInfo.fromDavResource(dav);
info.confirmed = true;
// remove unusable collections
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type != CollectionInfo.Type.CALENDAR))
iterator.remove();
} catch(HttpException e) {
if (e.status == 403 || e.status == 404 || e.status == 410)
// delete collection only if it was not accessible (40x)
iterator.remove();
else
throw e;
}
}
// 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<HttpUrl> homeSets) throws IOException, HttpException, DavException {
if (Services.SERVICE_CARDDAV.equals(serviceType)) {
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME);
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)dav.properties.get(AddressbookHomeSet.NAME);
if (addressbookHomeSet != null)
for (String href : addressbookHomeSet.hrefs)
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
} else if (Services.SERVICE_CALDAV.equals(serviceType)) {
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME);
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)dav.properties.get(CalendarHomeSet.NAME);
if (calendarHomeSet != null)
for (String href : calendarHomeSet.hrefs)
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
}
}
@NonNull
private Account account() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext()) {
return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE);
} else
throw new IllegalArgumentException("Service not found");
}
@NonNull
private String serviceType() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.SERVICE }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext())
return cursor.getString(0);
else
throw new IllegalArgumentException("Service not found");
}
@Nullable
private HttpUrl readPrincipal() {
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.PRINCIPAL }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
if (cursor.moveToNext()) {
String principal = cursor.getString(0);
if (principal != null)
return HttpUrl.parse(cursor.getString(0));
}
return null;
}
@NonNull
private Set<HttpUrl> readHomeSets() {
Set<HttpUrl> homeSets = new LinkedHashSet<>();
@Cleanup Cursor cursor = db.query(HomeSets._TABLE, new String[] { HomeSets.URL }, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext())
homeSets.add(HttpUrl.parse(cursor.getString(0)));
return homeSets;
}
private void saveHomeSets(Set<HttpUrl> homeSets) {
db.delete(HomeSets._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
for (HttpUrl homeSet : homeSets) {
ContentValues values = new ContentValues(1);
values.put(HomeSets.SERVICE_ID, service);
values.put(HomeSets.URL, homeSet.toString());
db.insertOrThrow(HomeSets._TABLE, null, values);
}
}
@NonNull
private Map<HttpUrl, CollectionInfo> readCollections() {
Map<HttpUrl, CollectionInfo> collections = new LinkedHashMap<>();
private Map<String, CollectionInfo> readCollections(SQLiteDatabase db) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
@Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", new String[]{String.valueOf(service)}, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
collections.put(HttpUrl.parse(values.getAsString(Collections.URL)), CollectionInfo.fromDB(values));
collections.put(values.getAsString(Collections.URL), CollectionInfo.fromDB(values));
}
return collections;
}
private void saveCollections(Iterable<CollectionInfo> collections) {
db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
private void saveCollections(SQLiteDatabase db, Iterable<CollectionInfo> collections) {
db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[]{String.valueOf(service)});
for (CollectionInfo collection : collections) {
ContentValues values = collection.toDB();
App.log.log(Level.FINE, "Saving collection", values);

@ -1,41 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import android.support.annotation.NonNull;
import org.apache.commons.lang3.StringUtils;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import okhttp3.HttpUrl;
public class DavUtils {
public static String ARGBtoCalDAVColor(int colorWithAlpha) {
byte alpha = (byte)(colorWithAlpha >> 24);
int color = colorWithAlpha & 0xFFFFFF;
return String.format("#%06X%02X", color, alpha);
}
public static String lastSegmentOfUrl(@NonNull String url) {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
List<String> segments = new LinkedList<>(HttpUrl.parse(url).pathSegments());
Collections.reverse(segments);
for (String segment : segments)
if (!StringUtils.isEmpty(segment))
return segment;
return "/";
}
}

@ -0,0 +1,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<byte[]>, JsonDeserializer<byte[]> {
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));
}
}
}

@ -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

@ -1,69 +0,0 @@
/*
* Copyright © 2013 2016 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid;
import org.apache.commons.collections4.MapIterator;
import org.apache.commons.collections4.keyvalue.MultiKey;
import org.apache.commons.collections4.map.HashedMap;
import org.apache.commons.collections4.map.MultiKeyMap;
import java.util.LinkedList;
import java.util.List;
import okhttp3.Cookie;
import okhttp3.CookieJar;
import okhttp3.HttpUrl;
/**
* Primitive cookie store that stores cookies in a (volatile) hash map.
* Will be sufficient for session cookies.
*/
public class MemoryCookieStore implements CookieJar {
/**
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
* Not thread-safe!
*/
protected final MultiKeyMap<String, Cookie> storage = MultiKeyMap.multiKeyMap(new HashedMap<MultiKey<? extends String>, Cookie>());
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
synchronized(storage) {
for (Cookie cookie : cookies)
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie);
}
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
List<Cookie> cookies = new LinkedList<>();
synchronized(storage) {
MapIterator<MultiKey<? extends String>, Cookie> iter = storage.mapIterator();
while (iter.hasNext()) {
iter.next();
Cookie cookie = iter.getValue();
// remove expired cookies
if (cookie.expiresAt() <= System.currentTimeMillis()) {
iter.remove();
continue;
}
// add applicable cookies
if (cookie.matches(url))
cookies.add(cookie);
}
}
return cookies;
}
}

@ -0,0 +1,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());
}
}
}

@ -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);
}
}
}

@ -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);
}
}
}

@ -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;
}
}

@ -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<List<Entry>>() {
}.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<Entry> 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<Entry> ret = GsonHelper.gson.fromJson(body.charStream(), entryType);
for (Entry entry : ret) {
entry.verify(keyBase64, previousEntry);
previousEntry = entry;
}
return ret;
}
public void putEntries(List<Entry> 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));
}
}
}

@ -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<List<Journal>>() {
}.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<Journal> 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<Journal> 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;
}
}
}

@ -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);

@ -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;
}
}

@ -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);
}

@ -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<LocalResource> dirty = new LinkedList<>();
// get dirty events which are required to have an increased SEQUENCE value
for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "!=0 AND " + Events.ORIGINAL_ID + " IS NULL", null)) {
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)

@ -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;

@ -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<Long>
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];

@ -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);

@ -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);
}
}

@ -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;

@ -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 */

@ -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)

@ -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}).
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*/
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<HttpUrl> 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;
}
}

@ -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<String, CollectionInfo> 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);

@ -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;
}
}
}

@ -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;
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remoted contacts.</li>
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
public class ContactsSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 10;
final private ContentProviderClient provider;
final private CollectionInfo remote;
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<Long> cachedGroups = contact.getCachedGroupMemberships(),
currentGroups = contact.getGroupMemberships();
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
App.log.fine("Marking group as dirty: " + groupID);
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
));
}
} catch(FileNotFoundException ignored) {
}
batch.commit();
}
}
@Override
protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException {
final Contact contact;
if (resource instanceof LocalContact) {
LocalContact local = ((LocalContact)resource);
contact = local.getContact();
if (groupMethod == GroupMethod.CATEGORIES) {
// add groups as CATEGORIES
for (long groupID : local.getGroupMemberships()) {
try {
@Cleanup Cursor c = provider.query(
localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
new String[] { Groups.TITLE },
null, null,
null
);
if (c != null && c.moveToNext()) {
String title = c.getString(0);
if (!TextUtils.isEmpty(title))
contact.categories.add(title);
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e);
}
}
}
} else if (resource instanceof LocalGroup)
contact = ((LocalGroup)resource).getContact();
else
throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup");
App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact);
ByteArrayOutputStream os = new ByteArrayOutputStream();
contact.write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, groupMethod, os);
return RequestBody.create(
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// 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<HttpUrl> 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;

@ -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;
}
}
}

@ -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<String, LocalResource> localResources;
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
protected Map<String, DavResource> remoteResources;
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
protected Set<DavResource> toDownload;
/**
* Syncable local journal entries.
*/
protected List<JournalEntryManager.Entry> localEntries;
/**
* Syncable remote journal entries (fetch from server).
*/
protected List<JournalEntryManager.Entry> remoteEntries;
/**
* sync-able resources in the local collection, as enumerated by {@link #prepareLocal()}
*/
protected Map<String, LocalResource> 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 <ul>
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
* <li><code>false</code> if the remote collection hasn't changed</li>
* </ul>
*/
protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException {
// check CTag (ignore on manual sync)
GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME);
if (getCTag != null)
remoteCTag = getCTag.cTag;
String localCTag = null;
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
App.log.info("Manual sync, ignoring CTag");
else
localCTag = localCollection.getCTag();
if (remoteCTag != null && remoteCTag.equals(localCTag)) {
App.log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children");
return false;
} else
return true;
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:
* <ul>
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
* </ul>
* 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<LocalResource> 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<LocalResource> 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());
}
}
}

@ -1,155 +0,0 @@
/*
* Copyright © 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.dmfs.provider.tasks.TaskContract;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
public class TasksSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new SyncAdapter(this);
}
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
public SyncAdapter(Context context) {
super(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient providerClient, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, providerClient, syncResult);
try {
@Cleanup TaskProvider provider = TaskProvider.acquire(getContext().getContentResolver(), TaskProvider.ProviderName.OpenTasks);
if (provider == null)
throw new CalendarStorageException("Couldn't access OpenTasks provider");
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
updateLocalTaskLists(provider, account, settings);
for (LocalTaskList taskList : (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, TaskContract.TaskLists.SYNC_ENABLED + "!=0", null)) {
App.log.info("Synchronizing task list #" + taskList.getId() + " [" + taskList.getSyncId() + "]");
TasksSyncManager syncManager = new TasksSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, taskList);
syncManager.performSync();
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't enumerate local task lists", e);
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
}
App.log.info("Task sync complete");
}
private void updateLocalTaskLists(TaskProvider provider, Account account, AccountSettings settings) throws CalendarStorageException {
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
// enumerate remote and local task lists
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
Map<String, CollectionInfo> remote = remoteTaskLists(db, service);
LocalTaskList[] local = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
boolean updateColors = settings.getManageCalendarColors();
// delete obsolete local task lists
for (LocalTaskList list : local) {
String url = list.getSyncId();
if (!remote.containsKey(url)) {
App.log.fine("Deleting obsolete local task list" + url);
list.delete();
} else {
// remote CollectionInfo found for this local collection, update data
CollectionInfo info = remote.get(url);
App.log.fine("Updating local task list " + url + " with " + info);
list.update(info, updateColors);
// we already have a local task list for this remote collection, don't take into consideration anymore
remote.remove(url);
}
}
// create new local task lists
for (String url : remote.keySet()) {
CollectionInfo info = remote.get(url);
App.log.info("Adding local task list " + info);
LocalTaskList.create(account, provider, info);
}
} finally {
dbHelper.close();
}
}
@Nullable
Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) {
@Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID },
Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@NonNull
private Map<String, CollectionInfo> remoteTaskLists(@NonNull SQLiteDatabase db, Long service) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
if (service != null) {
@Cleanup Cursor cursor = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VTODO + "!=0 AND " + Collections.SYNC,
new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
CollectionInfo info = CollectionInfo.fromDB(values);
collections.put(info.url, info);
}
}
return collections;
}
}
}

@ -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<HttpUrl> 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);
}
}

@ -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<CollectionInfo> 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<CollectionInfo> readCollections(@NonNull SQLiteDatabase db, long service) {
List<CollectionInfo> 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<Account>() {
@Override
public void run(AccountManagerFuture<Account> future) {
App.log.info("Updating account name references");
// cancel running synchronization
ContentResolver.cancelSync(oldAccount, null);
// update account name references in database
@Cleanup OpenHelper dbHelper = new OpenHelper(getContext());
ServiceDB.onRenameAccount(dbHelper.getWritableDatabase(), oldAccount.name, newName);
// update account_name of local contacts
try {
LocalAddressBook.onRenameAccount(getContext().getContentResolver(), oldAccount.name, newName);
} catch(RemoteException e) {
App.log.log(Level.SEVERE, "Couldn't propagate new account name to contacts provider");
}
// calendar provider doesn't allow changing account_name of Events
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(getContext().getContentResolver(), oldAccount.name, newName);
} catch(RemoteException e) {
App.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider");
}
// synchronize again
requestSync(new Account(newName, oldAccount.type));
}
}, null
);
getActivity().finish();
}
})
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
}
})
.create();
}
}
/* USER ACTIONS */
private void deleteAccount() {

@ -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);
}
}

@ -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;
}

@ -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<CreateAddressBookActivity.AccountInfo> {
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<AccountInfo> onCreateLoader(int id, Bundle args) {
return new AccountInfoLoader(this, account);
}
@Override
public void onLoadFinished(Loader<AccountInfo> 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<AccountInfo> loader) {
}
protected static class AccountInfo {
List<String> homeSets = new LinkedList<>();
}
protected static class AccountInfoLoader extends AsyncTaskLoader<AccountInfo> {
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);
}
}
}

@ -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<CreateCalendarActivity.AccountInfo> {
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<AccountInfo> onCreateLoader(int id, Bundle args) {
return new AccountInfoLoader(this, account);
}
@Override
public void onLoadFinished(Loader<AccountInfo> 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<AccountInfo> loader) {
}
protected static class AccountInfo {
List<String> homeSets = new LinkedList<>();
}
protected static class AccountInfoLoader extends AsyncTaskLoader<AccountInfo> {
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);
}
}
}

@ -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<Exception> {
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();

@ -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)

@ -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<Exception> {
protected static final String
@ -67,7 +65,7 @@ public class DeleteCollectionFragment extends DialogFragment implements LoaderMa
@Override
public Loader<Exception> 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())

@ -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

@ -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();

@ -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<String> 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<String>)((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<URI> homeSets = new HashSet<>();
public final Map<URI, CollectionInfo> collections = new HashMap<>();
public String email;
public final Map<String, CollectionInfo> 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;
}
}

@ -64,7 +64,7 @@ public class DetectConfigurationFragment extends DialogFragment implements Loade
@Override
public void onLoadFinished(Loader<Configuration> 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

@ -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;
}
}

@ -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));
}
}

@ -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;
}
}

@ -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<Configuration> {
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<Configuration> onCreateLoader(int id, Bundle args) {
return new SetupEncryptionLoader(getContext(), (Configuration)args.getSerializable(KEY_CONFIG));
}
@Override
public void onLoadFinished(Loader<Configuration> 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<Configuration> loader) {
}
static class SetupEncryptionLoader extends AsyncTaskLoader<Configuration> {
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;
}
}

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:alpha="0.54" >
<path
android:fillColor="#FF000000"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</vector>

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:alpha="0.54" >
<path
android:fillColor="#FF000000"
android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5L4,6l8,5 8,-5v2z"/>
</vector>

@ -58,16 +58,4 @@
android:layout_height="32dp"
android:background="@drawable/ic_remove_circle_dark"/>
<TextView
android:id="@+id/events"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/ic_today_dark"/>
<TextView
android:id="@+id/tasks"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/ic_alarm_on_dark"/>
</LinearLayout>

@ -14,15 +14,6 @@
android:padding="8dp"
android:gravity="center_vertical">
<RadioButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="4dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="false"
android:id="@+id/checked"/>
<LinearLayout android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"

@ -39,7 +39,7 @@
android:theme="@style/toolbar_theme"
style="@style/toolbar_style"
app:navigationIcon="@drawable/ic_people_light"
app:title="CardDAV"
app:title="Contacts"
android:elevation="2dp" tools:ignore="UnusedAttribute"/>
<ProgressBar
@ -79,7 +79,7 @@
android:theme="@style/toolbar_theme"
style="@style/toolbar_style"
app:navigationIcon="@drawable/ic_event_light"
app:title="CalDAV"
app:title="Calendar"
android:elevation="2dp" tools:ignore="UnusedAttribute"/>
<ProgressBar

@ -16,22 +16,6 @@
android:layout_height="wrap_content"
android:padding="@dimen/activity_margin">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/create_calendar"
android:textAppearance="@style/TextView.Heading"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/create_collection_home_set"/>
<Spinner
android:id="@+id/home_sets"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -85,38 +69,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/create_calendar_type"
android:textAppearance="@style/TextView.Heading"/>
<RadioGroup
android:id="@+id/type"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RadioButton
android:id="@+id/type_events"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/create_calendar_type_only_events"/>
<RadioButton
android:id="@+id/type_tasks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/create_calendar_type_only_tasks"/>
<RadioButton
android:id="@+id/type_events_and_tasks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/create_calendar_type_events_and_tasks"/>
</RadioGroup>
</LinearLayout>
</ScrollView>

@ -13,90 +13,42 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- We don't want the keyboard up when the user arrives in this initial screen -->
<View android:layout_height="0dp"
android:layout_width="0dp"
android:focusable="true"
android:focusableInTouchMode="true"
android:contentDescription="@null"
android:importantForAccessibility="no" tools:ignore="UnusedAttribute">
<requestFocus/>
</View>
<ScrollView android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="@dimen/activity_margin">
<RadioGroup
<LinearLayout
android:id="@+id/login_type_url_details"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:animateLayoutChanges="true">
android:orientation="vertical">
<RadioButton
android:id="@+id/login_type_email"
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/login_type_email"
android:paddingLeft="14dp" tools:ignore="RtlSymmetry"
style="@style/login_type_headline"/>
style="@style/login_type_headline"
android:text="@string/login_enter_service_details"
android:layout_marginBottom="14dp"/>
<LinearLayout
android:id="@+id/login_type_email_details"
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<EditText
android:id="@+id/email_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_email_address"
android:inputType="textEmailAddress"/>
<at.bitfire.davdroid.ui.widget.EditPassword
android:id="@+id/email_password"
android:hint="@string/login_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
android:text="@string/login_service_details_description"
android:layout_marginBottom="14dp"/>
<RadioButton
android:id="@+id/login_type_url"
<EditText
android:id="@+id/user_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/login_type_url"
android:layout_marginTop="16dp"
android:paddingLeft="14dp" tools:ignore="RtlSymmetry"
style="@style/login_type_headline"/>
<LinearLayout
android:id="@+id/login_type_url_details"
android:hint="@string/login_user_name"
android:inputType="textEmailAddress"/>
<at.bitfire.davdroid.ui.widget.EditPassword
android:id="@+id/url_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<EditText
android:id="@+id/base_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_base_url"
android:inputType="textUri"/>
<EditText
android:id="@+id/user_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_user_name"
android:inputType="textEmailAddress"/>
<at.bitfire.davdroid.ui.widget.EditPassword
android:id="@+id/url_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_password"/>
</LinearLayout>
android:hint="@string/login_password"/>
</RadioGroup>
</LinearLayout>
</ScrollView>
<LinearLayout
@ -104,6 +56,13 @@
android:layout_height="wrap_content"
style="@style/stepper_nav_bar">
<Button
android:id="@+id/create_account"
android:layout_width="0dp"
android:layout_weight="1"
android:text="@string/login_signup"
style="@style/stepper_nav_button"/>
<Space
android:layout_width="0dp"
android:layout_weight="1"

@ -27,42 +27,19 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/login_type_headline"
android:text="@string/login_create_account"
android:text="@string/login_enter_encryption_details"
android:layout_marginBottom="14dp"/>
<EditText
<TextView
android:id="@+id/account_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_account_name"
android:inputType="textEmailAddress"/>
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/account_email_hint"
<at.bitfire.davdroid.ui.widget.EditPassword
android:id="@+id/encryption_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/login_account_name_info"/>
<LinearLayout
android:id="@+id/carddav"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/login_account_contact_group_method"/>
<Spinner
android:id="@+id/contact_group_method"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:entries="@array/settings_contact_group_method_entries"/>
</LinearLayout>
android:hint="@string/login_encryption_password"/>
</LinearLayout>
</ScrollView>
@ -85,7 +62,7 @@
android:layout_width="0dp"
android:layout_weight="1"
android:layout_gravity="center"
android:text="@string/login_create_account"
android:text="@string/login_signup"
style="@style/stepper_nav_button"/>
</LinearLayout>

@ -20,10 +20,6 @@
android:title="@string/account_settings"
app:showAsAction="ifRoom"/>
<item android:id="@+id/rename_account"
android:title="@string/account_rename"
app:showAsAction="never"/>
<item android:id="@+id/delete_account"
android:title="@string/account_delete"
app:showAsAction="never"/>

@ -19,16 +19,6 @@
android:icon="@drawable/ic_settings_dark"
android:title="@string/navigation_drawer_settings"/>
<item android:title="@string/navigation_drawer_news_updates">
<menu>
<item
android:id="@+id/nav_twitter"
android:icon="@drawable/twitter"
android:title="\@davdroidapp"
tools:ignore="HardcodedText"/>
</menu>
</item>
<item android:title="@string/navigation_drawer_external_links">
<menu>
<item
@ -40,13 +30,13 @@
android:icon="@drawable/ic_help_dark"
android:title="@string/navigation_drawer_faq"/>
<item
android:id="@+id/nav_forums"
android:icon="@drawable/ic_forum_dark"
android:title="@string/navigation_drawer_forums"/>
android:id="@+id/nav_report_issue"
android:icon="@drawable/ic_bug_report"
android:title="@string/navigation_drawer_report_issue"/>
<item
android:id="@+id/nav_donate"
android:icon="@drawable/ic_attach_money_dark"
android:title="@string/navigation_drawer_donate"/>
android:id="@+id/nav_contact"
android:icon="@drawable/ic_email_black"
android:title="@string/navigation_drawer_contact"/>
</menu>
</item>

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='UTF-8'?>
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!--common strings-->
<string name="app_name">DAVdroid</string>
<string name="help">Ajuda</string>

@ -59,6 +59,8 @@
<string name="navigation_drawer_faq">FAQ</string>
<string name="navigation_drawer_forums">Community</string>
<string name="navigation_drawer_donate">Donate</string>
<string name="navigation_drawer_report_issue">Report issue</string>
<string name="navigation_drawer_contact">Contact developer</string>
<string name="account_list_empty">Welcome to DAVdroid!\n\nYou can add a CalDAV/CardDAV account now.</string>
@ -125,6 +127,7 @@
<string name="login_email_address">Email address</string>
<string name="login_email_address_error">Valid email address required</string>
<string name="login_password">Password</string>
<string name="login_encryption_password">Encryption Password</string>
<string name="login_password_required">Password required</string>
<string name="login_type_url">Login with URL and user name</string>
<string name="login_url_must_be_http_or_https">URL must begin with http(s)://</string>
@ -132,9 +135,13 @@
<string name="login_user_name">User name</string>
<string name="login_user_name_required">User name required</string>
<string name="login_base_url">Base URL</string>
<string name="login_login">Login</string>
<string name="login_login">Log In</string>
<string name="login_signup">Sign Up</string>
<string name="login_back">Back</string>
<string name="login_create_account">Create account</string>
<string name="login_enter_service_details">Enter Login Details</string>
<string name="login_enter_encryption_details">Secret Encryption Password</string>
<string name="login_encryption_account_label">Account:</string>
<string name="login_service_details_description">This is your login password, *not* your encryption password!</string>
<string name="login_account_name">Account name</string>
<string name="login_account_name_info">Use your email address as account name because Android will use the account name as ORGANIZER field for events you create. You can\'t have two accounts with the same name.</string>
<string name="login_account_contact_group_method">Contact group method:</string>
@ -146,14 +153,21 @@
<string name="login_no_caldav_carddav">Couldn\'t find CalDAV or CardDAV service.</string>
<string name="login_view_logs">View logs</string>
<string name="login_encryption_setup_title">Setting up encryption</string>
<string name="login_encryption_setup">Please wait, setting up encryption…</string>
<!-- AccountSettingsActivity -->
<string name="settings_title">Settings: %s</string>
<string name="settings_authentication">Authentication</string>
<string name="settings_encryption">Encryption</string>
<string name="settings_username">User name</string>
<string name="settings_enter_username">Enter user name:</string>
<string name="settings_password">Password</string>
<string name="settings_password_summary">Update the password according to your server.</string>
<string name="settings_password_summary">Change your authentication password.</string>
<string name="settings_enter_password">Enter your password:</string>
<string name="settings_encryption_password">Encryption Password</string>
<string name="settings_encryption_password_summary">Change your encryption password (not implemented)</string>
<string name="settings_enter_encryption_password">Enter your encryption password:</string>
<string name="settings_sync">Synchronization</string>
<string name="settings_sync_interval_contacts">Contacts sync. interval</string>
<string name="settings_sync_summary_manually">Only manually</string>
@ -188,7 +202,7 @@
<string name="settings_sync_wifi_only_ssid_on">Will only synchronize over %s</string>
<string name="settings_sync_wifi_only_ssid_off">All WiFi connections may be used</string>
<string name="settings_sync_wifi_only_ssid_message">Enter the name of a WiFi network (SSID) to restrict synchronization to this network, or leave blank for all WiFi connections.</string>
<string name="settings_carddav">CardDAV</string>
<string name="settings_carddav">Contacts</string>
<string name="settings_contact_group_method">Contact group method</string>
<string-array name="settings_contact_group_method_values">
<item>GROUP_VCARDS</item>
@ -198,7 +212,7 @@
<item>Groups are separate VCards</item>
<item>Groups are per-contact categories</item>
</string-array>
<string name="settings_caldav">CalDAV</string>
<string name="settings_caldav">Calendar</string>
<string name="settings_sync_time_range_past">Past event time limit</string>
<string name="settings_sync_time_range_past_none">All events will be synchronized</string>
<plurals name="settings_sync_time_range_past_days">
@ -220,7 +234,7 @@
<string name="create_calendar_type_only_events">Calendar (only events)</string>
<string name="create_calendar_type_only_tasks">Task list (only tasks)</string>
<string name="create_calendar_type_events_and_tasks">Combined (events and tasks)</string>
<string name="create_collection_color">Set a collection color</string>
<string name="create_collection_color">Set the calendar\'s color</string>
<string name="create_collection_creating">Creating collection</string>
<string name="create_collection_display_name">Display name (title) of this collection:</string>
<string name="create_collection_display_name_required">Title is required</string>

@ -10,14 +10,15 @@
<!-- colors -->
<color name="green700">#388e3c</color>
<!-- FIXME: Colours are no longer correctly named. -->
<color name="green700">#ffa100</color>
<color name="light_green300">#aed581</color>
<color name="light_green500">#8bc34a</color>
<color name="light_green700">#689f38</color>
<color name="light_green300">#ffd54f</color>
<color name="light_green500">#ffc107</color>
<color name="light_green700">#ffa100</color>
<color name="orange400">#ffa726</color>
<color name="orangeA700">#ff6d00</color>
<color name="orange400">#29b6f6</color>
<color name="orangeA700">#0288d1</color>
<color name="grey200">#eeeeee</color>
<color name="grey700">#616161</color>

@ -7,7 +7,7 @@
~ http://www.gnu.org/licenses/gpl.html
-->
<ContactsAccountType xmlns:android="http://schemas.android.com/apk/res/android" >
<ContactsAccountType>
<EditSchema>
<DataKind

@ -11,12 +11,6 @@
<PreferenceCategory android:title="@string/settings_authentication">
<EditTextPreference
android:key="username"
android:title="@string/settings_username"
android:persistent="false"
android:dialogTitle="@string/settings_enter_username" />
<EditTextPreference
android:key="password"
android:title="@string/settings_password"
@ -27,6 +21,19 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_encryption">
<EditTextPreference
android:key="encryption_password"
android:title="@string/settings_encryption_password"
android:persistent="false"
android:inputType="textPassword"
android:enabled="false"
android:summary="@string/settings_encryption_password_summary"
android:dialogTitle="@string/settings_enter_encryption_password" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_sync">
<ListPreference
@ -66,34 +73,4 @@
android:dialogMessage="@string/settings_sync_wifi_only_ssid_message"/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_carddav">
<ListPreference
android:key="contact_group_method"
android:persistent="false"
android:title="@string/settings_contact_group_method"
android:entries="@array/settings_contact_group_method_entries"
android:entryValues="@array/settings_contact_group_method_values"/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_caldav">
<EditTextPreference
android:key="time_range_past_days"
android:persistent="false"
android:title="@string/settings_sync_time_range_past"
android:dialogMessage="@string/settings_sync_time_range_past_message"
android:inputType="number"/>
<SwitchPreferenceCompat
android:key="manage_calendar_colors"
android:persistent="false"
android:title="@string/settings_manage_calendar_colors"
android:summaryOn="@string/settings_manage_calendar_colors_on"
android:summaryOff="@string/settings_manage_calendar_colors_off"/>
</PreferenceCategory>
</PreferenceScreen>

@ -1,42 +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 org.junit.Test;
import java.util.Arrays;
import static org.junit.Assert.assertTrue;
public class ArrayUtilsTest {
@Test
public void testPartition() {
// n == 0
assertTrue(Arrays.deepEquals(
new Long[0][0],
ArrayUtils.partition(new Long[] {}, 5)));
// n < max
assertTrue(Arrays.deepEquals(
new Long[][] { { 1l, 2l } },
ArrayUtils.partition(new Long[] { 1l, 2l }, 5)));
// n == max
assertTrue(Arrays.deepEquals(
new Long[][] { { 1l, 2l }, { 3l, 4l } },
ArrayUtils.partition(new Long[] { 1l, 2l, 3l, 4l }, 2)));
// n > max
assertTrue(Arrays.deepEquals(
new Long[][] { { 1l, 2l, 3l, 4l, 5l }, { 6l, 7l, 8l, 9l, 10l }, { 11l } },
ArrayUtils.partition(new Long[] { 1l, 2l, 3l, 4l, 5l, 6l, 7l, 8l, 9l, 10l, 11l }, 5)));
}
}

@ -1,27 +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.junit.Test;
import static org.junit.Assert.assertEquals;
public class TestDavUtils {
private static final String exampleURL = "http://example.com/";
@Test
public void testLastSegmentOfUrl() {
assertEquals("/", DavUtils.lastSegmentOfUrl(exampleURL));
assertEquals("dir", DavUtils.lastSegmentOfUrl(exampleURL + "dir"));
assertEquals("dir", DavUtils.lastSegmentOfUrl(exampleURL + "dir/"));
assertEquals("file.html", DavUtils.lastSegmentOfUrl(exampleURL + "dir/file.html"));
}
}

@ -1 +0,0 @@
Subproject commit 62ac4921ec5d5f366d0d79169a4bba94eb65ef48

1
doc/.gitignore vendored

@ -1 +0,0 @@
javadoc/

Binary file not shown.

Binary file not shown.

@ -1,560 +0,0 @@
Calendar Server Extension C. Daboo
Apple Computer
May 3, 2007
Calendar User Proxy Functionality in CalDAV
caldav-cu-proxy-02
Abstract
This specification defines an extension to CalDAV that makes it easy
for clients to setup and manage calendar user proxies, using the
WebDAV Access Control List extension as a basis.
Table of Contents
1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2
2. Conventions Used in This Document . . . . . . . . . . . . . . 2
3. Overview . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
3.1. Server . . . . . . . . . . . . . . . . . . . . . . . . . . 3
3.2. Client . . . . . . . . . . . . . . . . . . . . . . . . . . 3
4. Open Issues . . . . . . . . . . . . . . . . . . . . . . . . . 4
5. New features in CalDAV . . . . . . . . . . . . . . . . . . . . 4
5.1. Proxy Principal Resource . . . . . . . . . . . . . . . . . 4
5.2. Privilege Provisioning . . . . . . . . . . . . . . . . . . 8
6. Security Considerations . . . . . . . . . . . . . . . . . . . 9
7. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 9
8. Normative References . . . . . . . . . . . . . . . . . . . . . 9
Appendix A. Acknowledgments . . . . . . . . . . . . . . . . . . . 9
Appendix B. Change History . . . . . . . . . . . . . . . . . . . 10
Author's Address . . . . . . . . . . . . . . . . . . . . . . . . . 10
Daboo [Page 1]
CalDAV Proxy May 2007
1. Introduction
CalDAV [RFC4791] provides a way for calendar users to store calendar
data and exchange this data via scheduling operations. Based on the
WebDAV protocol [RFC2518], it also includes the ability to manage
access to calendar data via the WebDAV ACL extension [RFC3744].
It is often common for a calendar user to delegate some form of
responsibility for their calendar and schedules to another calendar
user (e.g., a boss allows an assistant to check a calendar or to send
and accept scheduling invites on his behalf). The user handling the
calendar data on behalf of someone else is often referred to as a
"calendar user proxy".
Whilst CalDAV does have fine-grained access control features that can
be used to setup complex sharing and management of calendars, often
the proxy behavior required is an "all-or-nothing" approach - i.e.
the proxy has access to all the calendars or to no calendars (in
which case they are of course not a proxy). So a simple way to
manage access to an entire set of calendars and scheduling ability
would be handy.
In addition, calendar user agents will often want to display to a
user who has proxy access to their calendars, or to whom they are
acting as a proxy. Again, CalDAV's access control discovery and
report features can be used to do that, but with fine-grained control
that exists, it can be hard to tell who is a "real" proxy as opposed
to someone just granted rights to some subset of calendars. Again, a
simple way to discover proxy information would be handy.
2. Conventions Used in This Document
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in [RFC2119].
When XML element types in the namespace "DAV:" are referenced in this
document outside of the context of an XML fragment, the string "DAV:"
will be prefixed to the element type names.
When XML element types in the namespaces "DAV:" and
"urn:ietf:params:xml:ns:caldav" are referenced in this document
outside of the context of an XML fragment, the string "DAV:" and
"CALDAV:" will be prefixed to the element type names respectively.
The namespace "http://calendarserver.org/ns/" is used for XML
elements defined in this specification. When XML element types in
Daboo [Page 2]
CalDAV Proxy May 2007
this namespace are referenced in this document outside of the context
of an XML fragment, the string "CS:" will be prefixed to the element
type names respectively.
3. Overview
3.1. Server
For each calendar user principal on the server, the server will
generate two group principals - "proxy groups". One is used to hold
the list of principals who have read-only proxy access to the main
principal's calendars, the other holds the list of principals who
have read-write and scheduling proxy access. NB these new group
principals would have no equivalent in Open Directory.
Privileges on each "proxy group" principal will be set so that the
"owner" has the ability to change property values.
The "proxy group" principals will be child resources of the user
principal resource with specific resource types and thus are easy to
discover. As a result the user principal resources will also be
collection resources.
When provisioning the calendar user home collection, the server will:
a. Add an ACE to the calendar home collection giving the read-only
"proxy group" inheritable read access.
b. Add an ACE to the calendar home collection giving the read-write
"proxy group" inheritable read-write access.
c. Add an ACE to each of the calendar Inbox and Outbox collections
giving the CALDAV:schedule privilege
[I-D.desruisseaux-caldav-sched] to the read-write "proxy group".
3.2. Client
A client can see who the proxies are for the current principal by
examining the principal resource for the two "proxy group" properties
and then looking at the DAV:group-member-set property of each.
The client can edit the list of proxies for the current principal by
editing the DAV:group-member-set property on the relevant "proxy
group" principal resource.
The client can find out who the current principal is a proxy for by
running a DAV:principal-match REPORT on the principal collection.
Daboo [Page 3]
CalDAV Proxy May 2007
Alternatively, the client can find out who the current principal is a
proxy for by examining the DAV:group-membership property on the
current principal resource looking for membership in other users'
"proxy groups".
4. Open Issues
1. Do we want to separate read-write access to calendars vs the
ability to schedule as a proxy?
2. We may want to restrict changing properties on the proxy group
collections to just the DAV:group-member-set property?
3. There is no way for a proxy to be able to manage the list of
proxies. We could allow the main calendar user DAV:write-acl on
their "proxy group" principals, in which case they could grant
others the right to modify the group membership.
4. Should the "proxy group" principals also be collections given
that the regular principal resources will be?
5. New features in CalDAV
5.1. Proxy Principal Resource
Each "regular" principal resource that needs to allow calendar user
proxy support MUST be a collection resource. i.e. in addition to
including the DAV:principal XML element in the DAV:resourcetype
property on the resource, it MUST also include the DAV:collection XML
element.
Each "regular" principal resource MUST contain two child resources
with names "calendar-proxy-read" and "calendar-proxy-write" (note
that these are only suggested names - the server could choose any
unique name for these). These resources are themselves principal
resources that are groups contain the list of principals for calendar
users who can act as a read-only or read-write proxy respectively.
The server MUST include the CS:calendar-proxy-read or CS:calendar-
proxy-write XML elements in the DAV:resourcetype property of the
child resources, respectively. This allows clients to discover the
"proxy group" principals by using a PROPFIND, Depth:1 request on the
current user's principal resource and requesting the DAV:resourcetype
property be returned. The element type declarations are:
Daboo [Page 4]
CalDAV Proxy May 2007
<!ELEMENT calendar-proxy-read EMPTY>
<!ELEMENT calendar-proxy-write EMPTY>
The server MUST allow the "parent" principal to change the DAV:group-
member-set property on each of the "child" "proxy group" principal
resources. When a principal is listed as a member of the "child"
resource, the server MUST include the "child" resource URI in the
DAV:group-membership property on the included principal resource.
Note that this is just "normal" behavior for a group principal.
An example principal resource layout might be:
+ /
+ principals/
+ users/
+ cyrus/
calendar-proxy-read
calendar-proxy-write
+ red/
calendar-proxy-read
calendar-proxy-write
+ wilfredo/
calendar-proxy-read
calendar-proxy-write
If the principal "cyrus" wishes to have the principal "red" act as a
calendar user proxy on his behalf and have the ability to change
items on his calendar or schedule meetings on his behalf, then he
would add the principal resource URI for "red" to the DAV:group-
member-set property of the principal resource /principals/users/
cyrus/calendar-proxy-write, giving:
<DAV:group-member-set>
<DAV:href>/principals/users/red/</DAV:href>
</DAV:group-member-set>
The DAV:group-membership property on the resource /principals/users/
red/ would be:
<DAV:group-membership>
<DAV:href>/principals/users/cyrus/calendar-proxy-write</DAV:href>
</DAV:group-membership>
If the principal "red" was also a read-only proxy for the principal
"wilfredo", then the DA:group-membership property on the resource
/principals/users/red/ would be:
Daboo [Page 5]
CalDAV Proxy May 2007
<DAV:group-membership>
<DAV:href>/principals/users/cyrus/calendar-proxy-write</DAV:href>
<DAV:href>/principals/users/wilfredo/calendar-proxy-read</DAV:href>
</DAV:group-membership>
Thus a client can discover to which principals a particular principal
is acting as a calendar user proxy for by examining the DAV:group-
membership property.
An alternative to discovering which principals a user can proxy as is
to use the WebDAV ACL principal-match report, targeted at the
principal collections available on the server.
Example:
>> Request <<
REPORT /principals/ HTTP/1.1
Host: cal.example.com
Depth: 0
Content-Type: application/xml; charset="utf-8"
Content-Length: xxxx
Authorization: Digest username="red",
realm="cal.example.com", nonce="...",
uri="/principals/", response="...", opaque="..."
<?xml version="1.0" encoding="utf-8" ?>
<D:principal-match xmlns:D="DAV:">
<D:self/>
<D:prop>
<D:resourcetype/>
</D:prop>
</D:principal-match>
Daboo [Page 6]
CalDAV Proxy May 2007
>> Response <<
HTTP/1.1 207 Multi-Status
Date: Fri, 10 Nov 2006 09:32:12 GMT
Content-Type: application/xml; charset="utf-8"
Content-Length: xxxx
<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:"
xmlns:A="http://calendarserver.org/ns/">
<D:response>
<D:href>/principals/users/red/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype>
<D:principal/>
<D:collection/>
</D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/principals/users/cyrus/calendar-proxy-write</D:href>
<D:propstat>
<D:prop>
<D:resourcetype>
<D:principal/>
<A:calendar-proxy-write/>
</D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/principals/users/wilfredo/calendar-proxy-read</D:href>
<D:propstat>
<D:prop>
<D:resourcetype>
<D:principal/>
<A:calendar-proxy-read/>
</D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>
Daboo [Page 7]
CalDAV Proxy May 2007
5.2. Privilege Provisioning
In order for a calendar user proxy to be able to access the calendars
of the user they are proxying for the server MUST ensure that the
privileges on the relevant calendars are setup accordingly:
The DAV:read privilege MUST be granted for read-only and read-
write calendar user proxy principals
The DAV:write privilege MUST be granted for read-write calendar
user proxy principals.
Additionally, the CalDAV scheduling Inbox and Outbox calendar
collections for the user allowing proxy access, MUST have the CALDAV:
schedule privilege [I-D.desruisseaux-caldav-sched] granted for read-
write calendar user proxy principals.
Note that with a suitable repository layout, a server may be able to
grant the appropriate privileges on a parent collection and ensure
that all the contained collections and resources inherit that. For
example, given the following repository layout:
+ /
+ calendars/
+ users/
+ cyrus/
inbox
outbox
home
work
+ red/
inbox
outbox
work
soccer
+ wilfredo/
inbox
outbox
home
work
flying
In order for the principal "red" to act as a read-write proxy for the
principal "cyrus", the following WebDAV ACE will need to be granted
on the resource /calendars/users/cyrus/ and all children of that
resource:
Daboo [Page 8]
CalDAV Proxy May 2007
<DAV:ace>
<DAV:principal>
<DAV:href>/principals/users/cyrus/calendar-proxy-write</DAV:href>
</DAV:principal>
<DAV:privileges>
<DAV:grant><DAV:read/><DAV:write/></DAV:grant>
</DAV:privileges>
</DAV:ace>
6. Security Considerations
TBD
7. IANA Considerations
This document does not require any actions on the part of IANA.
8. Normative References
[I-D.desruisseaux-caldav-sched]
Desruisseaux, B., "Scheduling Extensions to CalDAV",
draft-desruisseaux-caldav-sched-03 (work in progress),
January 2007.
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
Requirement Levels", BCP 14, RFC 2119, March 1997.
[RFC2518] Goland, Y., Whitehead, E., Faizi, A., Carter, S., and D.
Jensen, "HTTP Extensions for Distributed Authoring --
WEBDAV", RFC 2518, February 1999.
[RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web
Distributed Authoring and Versioning (WebDAV) Access
Control Protocol", RFC 3744, May 2004.
[RFC4791] Daboo, C., Desruisseaux, B., and L. Dusseault,
"Calendaring Extensions to WebDAV (CalDAV)", RFC 4791,
March 2007.
Appendix A. Acknowledgments
This specification is the result of discussions between the Apple
calendar server and client teams.
Daboo [Page 9]
CalDAV Proxy May 2007
Appendix B. Change History
Changes from -00:
1. Updated to RFC 4791 reference.
Changes from -00:
1. Added more details on actual CalDAV protocol changes.
2. Changed namespace from http://apple.com/ns/calendarserver/ to
http://calendarserver.org/ns/.
3. Made "proxy group" principals child resources of their "owner"
principals.
4. The "proxy group" principals now have their own resourcetype.
Author's Address
Cyrus Daboo
Apple Computer, Inc.
1 Infinite Loop
Cupertino, CA 95014
USA
Email: cyrus@daboo.name
URI: http://www.apple.com/
Daboo [Page 10]

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1,281 +0,0 @@
Network Working Group W. Sanchez
Request for Comments: 5397 C. Daboo
Category: Standards Track Apple Inc.
December 2008
WebDAV Current Principal Extension
Status of This Memo
This document specifies an Internet standards track protocol for the
Internet community, and requests discussion and suggestions for
improvements. Please refer to the current edition of the "Internet
Official Protocol Standards" (STD 1) for the standardization state
and status of this protocol. Distribution of this memo is unlimited.
Copyright Notice
Copyright (c) 2008 IETF Trust and the persons identified as the
document authors. All rights reserved.
This document is subject to BCP 78 and the IETF Trust's Legal
Provisions Relating to IETF Documents
(http://trustee.ietf.org/license-info) in effect on the date of
publication of this document. Please review these documents
carefully, as they describe your rights and restrictions with respect
to this document.
Abstract
This specification defines a new WebDAV property that allows clients
to quickly determine the principal corresponding to the current
authenticated user.
Table of Contents
1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2
2. Conventions Used in This Document . . . . . . . . . . . . . . . 2
3. DAV:current-user-principal . . . . . . . . . . . . . . . . . . 3
4. Security Considerations . . . . . . . . . . . . . . . . . . . . 4
5. Acknowledgments . . . . . . . . . . . . . . . . . . . . . . . . 4
6. Normative References . . . . . . . . . . . . . . . . . . . . . 4
Sanchez & Daboo Standards Track [Page 1]
RFC 5397 WebDAV Current Principal December 2008
1. Introduction
WebDAV [RFC4918] is an extension to HTTP [RFC2616] to support
improved document authoring capabilities. The WebDAV Access Control
Protocol ("WebDAV ACL") [RFC3744] extension adds access control
capabilities to WebDAV. It introduces the concept of a "principal"
resource, which is used to represent information about authenticated
entities on the system.
Some clients have a need to determine which [RFC3744] principal a
server is associating with the currently authenticated HTTP user.
While [RFC3744] defines a DAV:current-user-privilege-set property for
retrieving the privileges granted to that principal, there is no
recommended way to identify the principal in question, which is
necessary to perform other useful operations. For example, a client
may wish to determine which groups the current user is a member of,
or modify a property of the principal resource associated with the
current user.
The DAV:principal-match REPORT provides some useful functionality,
but there are common situations where the results from that query can
be ambiguous. For example, not only is an individual user principal
returned, but also every group principal that the user is a member
of, and there is no clear way to distinguish which is which.
This specification proposes an extension to WebDAV ACL that adds a
DAV:current-user-principal property to resources under access control
on the server. This property provides a URL to a principal resource
corresponding to the currently authenticated user. This allows a
client to "bootstrap" itself by performing additional queries on the
principal resource to obtain additional information from that
resource, which is the purpose of this extension. Note that while it
is possible for multiple URLs to refer to the same principal
resource, or for multiple principal resources to correspond to a
single principal, this specification only allows for a single http(s)
URL in the DAV:current-user-principal property. If a client wishes
to obtain alternate URLs for the principal, it can query the
principal resource for this information; it is not the purpose of
this extension to provide a complete list of such URLs, but simply to
provide a means to locate a resource which contains that (and other)
information.
2. Conventions Used in This Document
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in [RFC2119].
Sanchez & Daboo Standards Track [Page 2]
RFC 5397 WebDAV Current Principal December 2008
When XML element types in the namespace "DAV:" are referenced in this
document outside of the context of an XML fragment, the string "DAV:"
will be prefixed to the element type names.
Processing of XML by clients and servers MUST follow the rules
defined in Section 17 of WebDAV [RFC4918].
Some of the declarations refer to XML elements defined by WebDAV
[RFC4918].
3. DAV:current-user-principal
Name: current-user-principal
Namespace: DAV:
Purpose: Indicates a URL for the currently authenticated user's
principal resource on the server.
Value: A single DAV:href or DAV:unauthenticated element.
Protected: This property is computed on a per-request basis, and
therefore is protected.
Description: The DAV:current-user-principal property contains either
a DAV:href or DAV:unauthenticated XML element. The DAV:href
element contains a URL to a principal resource corresponding to
the currently authenticated user. That URL MUST be one of the
URLs in the DAV:principal-URL or DAV:alternate-URI-set properties
defined on the principal resource and MUST be an http(s) scheme
URL. When authentication has not been done or has failed, this
property MUST contain the DAV:unauthenticated pseudo-principal.
In some cases, there may be multiple principal resources
corresponding to the same authenticated principal. In that case,
the server is free to choose any one of the principal resource
URIs for the value of the DAV:current-user-principal property.
However, servers SHOULD be consistent and use the same principal
resource URI for each authenticated principal.
COPY/MOVE behavior: This property is computed on a per-request
basis, and is thus never copied or moved.
Definition:
<!ELEMENT current-user-principal (unauthenticated | href)>
<!-- href value: a URL to a principal resource -->
Sanchez & Daboo Standards Track [Page 3]
RFC 5397 WebDAV Current Principal December 2008
Example:
<D:current-user-principal xmlns:D="DAV:">
<D:href>/principals/users/cdaboo</D:href>
</D:current-user-principal>
4. Security Considerations
This specification does not introduce any additional security issues
beyond those defined for HTTP [RFC2616], WebDAV [RFC4918], and WebDAV
ACL [RFC3744].
5. Acknowledgments
This specification is based on discussions that took place within the
Calendaring and Scheduling Consortium's CalDAV Technical Committee.
The authors thank the participants of that group for their input.
The authors thank Julian Reschke for his valuable input via the
WebDAV working group mailing list.
6. Normative References
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
Requirement Levels", BCP 14, RFC 2119, March 1997.
[RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H.,
Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext
Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999.
[RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web
Distributed Authoring and Versioning (WebDAV)
Access Control Protocol", RFC 3744, May 2004.
[RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed
Authoring and Versioning (WebDAV)", RFC 4918, June 2007.
Authors' Addresses
Wilfredo Sanchez
Apple Inc.
1 Infinite Loop
Cupertino, CA 95014
USA
EMail: wsanchez@wsanchez.net
URI: http://www.apple.com/
Sanchez & Daboo Standards Track [Page 4]
RFC 5397 WebDAV Current Principal December 2008
Cyrus Daboo
Apple Inc.
1 Infinite Loop
Cupertino, CA 95014
USA
EMail: cyrus@daboo.name
URI: http://www.apple.com/
Sanchez & Daboo Standards Track [Page 5]

@ -1,451 +0,0 @@
Internet Engineering Task Force (IETF) M. Nottingham
Request for Comments: 5785 E. Hammer-Lahav
Updates: 2616, 2818 April 2010
Category: Standards Track
ISSN: 2070-1721
Defining Well-Known Uniform Resource Identifiers (URIs)
Abstract
This memo defines a path prefix for "well-known locations",
"/.well-known/", in selected Uniform Resource Identifier (URI)
schemes.
Status of This Memo
This is an Internet Standards Track document.
This document is a product of the Internet Engineering Task Force
(IETF). It represents the consensus of the IETF community. It has
received public review and has been approved for publication by the
Internet Engineering Steering Group (IESG). Further information on
Internet Standards is available in Section 2 of RFC 5741.
Information about the current status of this document, any errata,
and how to provide feedback on it may be obtained at
http://www.rfc-editor.org/info/rfc5785.
Copyright Notice
Copyright (c) 2010 IETF Trust and the persons identified as the
document authors. All rights reserved.
This document is subject to BCP 78 and the IETF Trust's Legal
Provisions Relating to IETF Documents
(http://trustee.ietf.org/license-info) in effect on the date of
publication of this document. Please review these documents
carefully, as they describe your rights and restrictions with respect
to this document. Code Components extracted from this document must
include Simplified BSD License text as described in Section 4.e of
the Trust Legal Provisions and are provided without warranty as
described in the Simplified BSD License.
Nottingham & Hammer-Lahav Standards Track [Page 1]
RFC 5785 Defining Well-Known URIs April 2010
Table of Contents
1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.1. Appropriate Use of Well-Known URIs . . . . . . . . . . . . 3
2. Notational Conventions . . . . . . . . . . . . . . . . . . . . 3
3. Well-Known URIs . . . . . . . . . . . . . . . . . . . . . . . . 3
4. Security Considerations . . . . . . . . . . . . . . . . . . . . 4
5. IANA Considerations . . . . . . . . . . . . . . . . . . . . . . 4
5.1. The Well-Known URI Registry . . . . . . . . . . . . . . . . 4
5.1.1. Registration Template . . . . . . . . . . . . . . . . . 5
6. References . . . . . . . . . . . . . . . . . . . . . . . . . . 5
6.1. Normative References . . . . . . . . . . . . . . . . . . . 5
6.2. Informative References . . . . . . . . . . . . . . . . . . 5
Appendix A. Acknowledgements . . . . . . . . . . . . . . . . . . . 7
Appendix B. Frequently Asked Questions . . . . . . . . . . . . . . 7
1. Introduction
It is increasingly common for Web-based protocols to require the
discovery of policy or other information about a host ("site-wide
metadata") before making a request. For example, the Robots
Exclusion Protocol <http://www.robotstxt.org/> specifies a way for
automated processes to obtain permission to access resources;
likewise, the Platform for Privacy Preferences [W3C.REC-P3P-20020416]
tells user-agents how to discover privacy policy beforehand.
While there are several ways to access per-resource metadata (e.g.,
HTTP headers, WebDAV's PROPFIND [RFC4918]), the perceived overhead
(either in terms of client-perceived latency and/or deployment
difficulties) associated with them often precludes their use in these
scenarios.
When this happens, it is common to designate a "well-known location"
for such data, so that it can be easily located. However, this
approach has the drawback of risking collisions, both with other such
designated "well-known locations" and with pre-existing resources.
To address this, this memo defines a path prefix in HTTP(S) URIs for
these "well-known locations", "/.well-known/". Future specifications
that need to define a resource for such site-wide metadata can
register their use to avoid collisions and minimise impingement upon
sites' URI space.
Nottingham & Hammer-Lahav Standards Track [Page 2]
RFC 5785 Defining Well-Known URIs April 2010
1.1. Appropriate Use of Well-Known URIs
There are a number of possible ways that applications could use Well-
known URIs. However, in keeping with the Architecture of the World-
Wide Web [W3C.REC-webarch-20041215], well-known URIs are not intended
for general information retrieval or establishment of large URI
namespaces on the Web. Rather, they are designed to facilitate
discovery of information on a site when it isn't practical to use
other mechanisms; for example, when discovering policy that needs to
be evaluated before a resource is accessed, or when using multiple
round-trips is judged detrimental to performance.
As such, the well-known URI space was created with the expectation
that it will be used to make site-wide policy information and other
metadata available directly (if sufficiently concise), or provide
references to other URIs that provide such metadata.
2. Notational Conventions
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in RFC 2119 [RFC2119].
3. Well-Known URIs
A well-known URI is a URI [RFC3986] whose path component begins with
the characters "/.well-known/", and whose scheme is "HTTP", "HTTPS",
or another scheme that has explicitly been specified to use well-
known URIs.
Applications that wish to mint new well-known URIs MUST register
them, following the procedures in Section 5.1.
For example, if an application registers the name 'example', the
corresponding well-known URI on 'http://www.example.com/' would be
'http://www.example.com/.well-known/example'.
Registered names MUST conform to the segment-nz production in
[RFC3986].
Note that this specification defines neither how to determine the
authority to use for a particular context, nor the scope of the
metadata discovered by dereferencing the well-known URI; both should
be defined by the application itself.
Typically, a registration will reference a specification that defines
the format and associated media type to be obtained by dereferencing
the well-known URI.
Nottingham & Hammer-Lahav Standards Track [Page 3]
RFC 5785 Defining Well-Known URIs April 2010
It MAY also contain additional information, such as the syntax of
additional path components, query strings and/or fragment identifiers
to be appended to the well-known URI, or protocol-specific details
(e.g., HTTP [RFC2616] method handling).
Note that this specification does not define a format or media-type
for the resource located at "/.well-known/" and clients should not
expect a resource to exist at that location.
4. Security Considerations
This memo does not specify the scope of applicability of metadata or
policy obtained from a well-known URI, and does not specify how to
discover a well-known URI for a particular application. Individual
applications using this mechanism must define both aspects.
Applications minting new well-known URIs, as well as administrators
deploying them, will need to consider several security-related
issues, including (but not limited to) exposure of sensitive data,
denial-of-service attacks (in addition to normal load issues), server
and client authentication, vulnerability to DNS rebinding attacks,
and attacks where limited access to a server grants the ability to
affect how well-known URIs are served.
5. IANA Considerations
5.1. The Well-Known URI Registry
This document establishes the well-known URI registry.
Well-known URIs are registered on the advice of one or more
Designated Experts (appointed by the IESG or their delegate), with a
Specification Required (using terminology from [RFC5226]). However,
to allow for the allocation of values prior to publication, the
Designated Expert(s) may approve registration once they are satisfied
that such a specification will be published.
Registration requests should be sent to the
wellknown-uri-review@ietf.org mailing list for review and comment,
with an appropriate subject (e.g., "Request for well-known URI:
example").
Before a period of 14 days has passed, the Designated Expert(s) will
either approve or deny the registration request, communicating this
decision both to the review list and to IANA. Denials should include
an explanation and, if applicable, suggestions as to how to make the
Nottingham & Hammer-Lahav Standards Track [Page 4]
RFC 5785 Defining Well-Known URIs April 2010
request successful. Registration requests that are undetermined for
a period longer than 21 days can be brought to the IESG's attention
(using the iesg@iesg.org mailing list) for resolution.
5.1.1. Registration Template
URI suffix: The name requested for the well-known URI, relative to
"/.well-known/"; e.g., "example".
Change controller: For Standards-Track RFCs, state "IETF". For
others, give the name of the responsible party. Other details
(e.g., postal address, e-mail address, home page URI) may also be
included.
Specification document(s): Reference to the document that specifies
the field, preferably including a URI that can be used to retrieve
a copy of the document. An indication of the relevant sections
may also be included, but is not required.
Related information: Optionally, citations to additional documents
containing further relevant information.
6. References
6.1. Normative References
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
Requirement Levels", BCP 14, RFC 2119, March 1997.
[RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform
Resource Identifier (URI): Generic Syntax", STD 66,
RFC 3986, January 2005.
[RFC5226] Narten, T. and H. Alvestrand, "Guidelines for Writing an
IANA Considerations Section in RFCs", BCP 26, RFC 5226,
May 2008.
6.2. Informative References
[RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H., Masinter,
L., Leach, P., and T. Berners-Lee, "Hypertext Transfer
Protocol -- HTTP/1.1", RFC 2616, June 1999.
[RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed
Authoring and Versioning (WebDAV)", RFC 4918, June 2007.
Nottingham & Hammer-Lahav Standards Track [Page 5]
RFC 5785 Defining Well-Known URIs April 2010
[W3C.REC-P3P-20020416]
Marchiori, M., "The Platform for Privacy Preferences 1.0
(P3P1.0) Specification", World Wide Web Consortium
Recommendation REC-P3P-20020416, April 2002,
<http://www.w3.org/TR/2002/ REC-P3P-20020416>.
[W3C.REC-webarch-20041215]
Jacobs, I. and N. Walsh, "Architecture of the World Wide
Web, Volume One", World Wide Web Consortium
Recommendation REC- webarch-20041215, December 2004,
<http:// www.w3.org/TR/2004/REC-webarch-20041215>.
Nottingham & Hammer-Lahav Standards Track [Page 6]
RFC 5785 Defining Well-Known URIs April 2010
Appendix A. Acknowledgements
We would like to acknowledge the contributions of everyone who
provided feedback and use cases for this document; in particular,
Phil Archer, Dirk Balfanz, Adam Barth, Tim Bray, Brian Eaton, Brad
Fitzpatrick, Joe Gregorio, Paul Hoffman, Barry Leiba, Ashok Malhotra,
Breno de Medeiros, John Panzer, and Drummond Reed. However, they are
not responsible for errors and omissions.
Appendix B. Frequently Asked Questions
1. Aren't well-known locations bad for the Web?
They are, but for various reasons -- both technical and social --
they are commonly used and their use is increasing. This memo
defines a "sandbox" for them, to reduce the risks of collision and
to minimise the impact upon pre-existing URIs on sites.
2. Why /.well-known?
It's short, descriptive, and according to search indices, not
widely used.
3. What impact does this have on existing mechanisms, such as P3P and
robots.txt?
None, until they choose to use this mechanism.
4. Why aren't per-directory well-known locations defined?
Allowing every URI path segment to have a well-known location
(e.g., "/images/.well-known/") would increase the risks of
colliding with a pre-existing URI on a site, and generally these
solutions are found not to scale well, because they're too
"chatty".
Nottingham & Hammer-Lahav Standards Track [Page 7]
RFC 5785 Defining Well-Known URIs April 2010
Authors' Addresses
Mark Nottingham
EMail: mnot@mnot.net
URI: http://www.mnot.net/
Eran Hammer-Lahav
EMail: eran@hueniverse.com
URI: http://hueniverse.com/
Nottingham & Hammer-Lahav Standards Track [Page 8]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1,787 +0,0 @@
Internet Engineering Task Force (IETF) C. Daboo
Request for Comments: 6764 Apple Inc.
Updates: 4791, 6352 February 2013
Category: Standards Track
ISSN: 2070-1721
Locating Services for Calendaring Extensions to
WebDAV (CalDAV) and vCard Extensions to WebDAV (CardDAV)
Abstract
This specification describes how DNS SRV records, DNS TXT records,
and well-known URIs can be used together or separately to locate
CalDAV (Calendaring Extensions to Web Distributed Authoring and
Versioning (WebDAV)) or CardDAV (vCard Extensions to WebDAV)
services.
Status of This Memo
This is an Internet Standards Track document.
This document is a product of the Internet Engineering Task Force
(IETF). It represents the consensus of the IETF community. It has
received public review and has been approved for publication by the
Internet Engineering Steering Group (IESG). Further information on
Internet Standards is available in Section 2 of RFC 5741.
Information about the current status of this document, any errata,
and how to provide feedback on it may be obtained at
http://www.rfc-editor.org/info/rfc6764.
Copyright Notice
Copyright (c) 2013 IETF Trust and the persons identified as the
document authors. All rights reserved.
This document is subject to BCP 78 and the IETF Trust's Legal
Provisions Relating to IETF Documents
(http://trustee.ietf.org/license-info) in effect on the date of
publication of this document. Please review these documents
carefully, as they describe your rights and restrictions with respect
to this document. Code Components extracted from this document must
include Simplified BSD License text as described in Section 4.e of
the Trust Legal Provisions and are provided without warranty as
described in the Simplified BSD License.
Daboo Standards Track [Page 1]
RFC 6764 SRV for CalDAV & CardDAV February 2013
Table of Contents
1. Introduction ....................................................2
2. Conventions Used in This Document ...............................3
3. CalDAV SRV Service Labels .......................................3
4. CalDAV and CardDAV Service TXT Records ..........................4
5. CalDAV and CardDAV Service Well-Known URI .......................4
5.1. Example: Well-Known URI Redirects to Actual
"Context Path" .............................................5
6. Client "Bootstrapping" Procedures ...............................5
7. Guidance for Service Providers ..................................8
8. Security Considerations .........................................9
9. IANA Considerations .............................................9
9.1. Well-Known URI Registrations ...............................9
9.1.1. caldav Well-Known URI Registration .................10
9.1.2. carddav Well-Known URI Registration ................10
9.2. Service Name Registrations ................................10
9.2.1. caldav Service Name Registration ...................10
9.2.2. caldavs Service Name Registration ..................11
9.2.3. carddav Service Name Registration ..................11
9.2.4. carddavs Service Name Registration .................12
10. Acknowledgments ...............................................12
11. References ....................................................12
11.1. Normative References .....................................12
11.2. Informative References ...................................14
1. Introduction
[RFC4791] defines the CalDAV calendar access protocol, based on HTTP
[RFC2616], for accessing calendar data stored on a server. CalDAV
clients need to be able to discover appropriate CalDAV servers within
their local area network and at other domains, e.g., to minimize the
need for end users to know specific details such as the fully
qualified domain name (FQDN) and port number for their servers.
[RFC6352] defines the CardDAV address book access protocol based on
HTTP [RFC2616], for accessing contact data stored on a server. As
with CalDAV, clients also need to be able to discover CardDAV
servers.
[RFC2782] defines a DNS-based service discovery protocol that has
been widely adopted as a means of locating particular services within
a local area network and beyond, using DNS SRV Resource Records
(RRs). This has been enhanced to provide additional service meta-
data by use of DNS TXT RRs as per [RFC6763].
Daboo Standards Track [Page 2]
RFC 6764 SRV for CalDAV & CardDAV February 2013
This specification defines new SRV service types for the CalDAV
protocol and gives an example of how clients can use this together
with other protocol features to enable simple client configuration.
SRV service types for CardDAV are already defined in Section 11 of
[RFC6352].
Another issue with CalDAV or CardDAV service discovery is that the
service might not be located at the "root" URI of the HTTP server
hosting it. Thus, a client needs to be able to determine the
complete path component of the Request-URI to use in HTTP requests:
the "context path". For example, if CalDAV is implemented as a
"servlet" in a web server "container", the servlet "context path"
might be "/caldav/". So the URI for the CalDAV service would be,
e.g., "http://caldav.example.com/caldav/" rather than
"http://caldav.example.com/". SRV RRs by themselves only provide an
FQDN and port number for the service, not a path. Since the client
"bootstrapping" process requires initial access to the "context path"
of the service, there needs to be a simple way for clients to also
discover what that path is.
This specification makes use of the "well-known URI" feature
[RFC5785] of HTTP servers to provide a well-known URI for CalDAV or
CardDAV services that clients can use. The well-known URI will point
to a resource on the server that is simply a "stub" resource that
provides a redirect to the actual "context path" resource
representing the service endpoint.
2. Conventions Used in This Document
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
"SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described in [RFC2119].
3. CalDAV SRV Service Labels
This specification adds two SRV service labels for use with CalDAV:
_caldav: Identifies a CalDAV server that uses HTTP without
Transport Layer Security (TLS) [RFC2818].
_caldavs: Identifies a CalDAV server that uses HTTP with TLS
[RFC2818].
Daboo Standards Track [Page 3]
RFC 6764 SRV for CalDAV & CardDAV February 2013
Clients MUST honor Priority and Weight values in the SRV RRs, as
described by [RFC2782].
Example: service record for server without TLS
_caldav._tcp SRV 0 1 80 calendar.example.com.
Example: service record for server with TLS
_caldavs._tcp SRV 0 1 443 calendar.example.com.
4. CalDAV and CardDAV Service TXT Records
When SRV RRs are used to advertise CalDAV and CardDAV services, it is
also convenient to be able to specify a "context path" in the DNS to
be retrieved at the same time. To enable that, this specification
uses a TXT RR that follows the syntax defined in Section 6 of
[RFC6763] and defines a "path" key for use in that record. The value
of the key MUST be the actual "context path" to the corresponding
service on the server.
A site might provide TXT records in addition to SRV records for each
service. When present, clients MUST use the "path" value as the
"context path" for the service in HTTP requests. When not present,
clients use the ".well-known" URI approach described next.
Example: text record for service with TLS
_caldavs._tcp TXT path=/caldav
5. CalDAV and CardDAV Service Well-Known URI
Two ".well-known" URIs are registered by this specification for
CalDAV and CardDAV services, "caldav" and "carddav" respectively (see
Section 9). These URIs point to a resource that the client can use
as the initial "context path" for the service they are trying to
connect to. The server MUST redirect HTTP requests for that resource
to the actual "context path" using one of the available mechanisms
provided by HTTP (e.g., using a 301, 303, or 307 response). Clients
MUST handle HTTP redirects on the ".well-known" URI. Servers MUST
NOT locate the actual CalDAV or CardDAV service endpoint at the
".well-known" URI as per Section 1.1 of [RFC5785].
Servers SHOULD set an appropriate Cache-Control header value (as per
Section 14.9 of [RFC2616]) in the redirect response to ensure caching
occurs or does not occur as needed or as required by the type of
response generated. For example, if it is anticipated that the
Daboo Standards Track [Page 4]
RFC 6764 SRV for CalDAV & CardDAV February 2013
location of the redirect might change over time, then a "no-cache"
value would be used.
To facilitate "context paths" that might differ from user to user,
the server MAY require authentication when a client tries to access
the ".well-known" URI (i.e., the server would return a 401 status
response to the unauthenticated request from the client, then return
the redirect response only after a successful authentication by the
client).
5.1. Example: Well-Known URI Redirects to Actual "Context Path"
A CalDAV server has a "context path" that is "/servlet/caldav". The
client will use "/.well-known/caldav" as the path for its
"bootstrapping" process after it has first found the FQDN and port
number via an SRV lookup or via manual entry of information by the
user, from which the client can parse suitable information. When the
client makes an HTTP request against "/.well-known/caldav", the
server would issue an HTTP redirect response with a Location response
header using the path "/servlet/caldav". The client would then
"follow" this redirect to the new resource and continue making HTTP
requests there to complete its "bootstrapping" process.
6. Client "Bootstrapping" Procedures
This section describes a procedure that CalDAV or CardDAV clients
SHOULD use to do their initial configuration based on minimal user
input. The goal is to determine an http: or https: URI that
describes the full path to the user's principal-URL [RFC3744].
1. Processing user input:
* For a CalDAV server:
+ Minimal input from a user would consist of a calendar user
address and a password. A calendar user address is defined
by iCalendar [RFC5545] to be a URI [RFC3986]. Provided a
user identifier and a domain name can be extracted from the
URI, this simple "bootstrapping" configuration can be done.
+ If the calendar user address is a "mailto:" [RFC6068] URI,
the "mailbox" portion of the URI is examined, and the
"local-part" and "domain" portions are extracted.
+ If the calendar user address is an "http:" [RFC2616] or
"https:" [RFC2818] URI, the "userinfo" and "host" portion
of the URI [RFC3986] is extracted.
Daboo Standards Track [Page 5]
RFC 6764 SRV for CalDAV & CardDAV February 2013
* For a CardDAV server:
+ Minimal input from a user would consist of their email
address [RFC5322] for the domain where the CardDAV service
is hosted, and a password. The "mailbox" portion of the
email address is examined, and the "local-part" and
"domain" portions are extracted.
2. Determination of service FQDN and port number:
* An SRV lookup for _caldavs._tcp (for CalDAV) or _carddavs._tcp
(for CardDAV) is done with the extracted "domain" as the
service domain.
* If no result is found, the client can try _caldav._tcp (for
CalDAV) or _carddav._tcp (for CardDAV) provided non-TLS
connections are appropriate.
* If an SRV record is returned, the client extracts the target
FQDN and port number. If multiple SRV records are returned,
the client MUST use the Priority and Weight fields in the
record to determine which one to pick (as per [RFC2782]).
* If an SRV record is not found, the client will need to prompt
the user to enter the FQDN and port number information
directly or use some other heuristic, for example, using the
extracted "domain" as the FQDN and default HTTPS or HTTP port
numbers. In this situation, clients MUST first attempt an
HTTP connection with TLS.
3. Determination of initial "context path":
* When an SRV lookup is done and a valid SRV record returned,
the client MUST also query for a corresponding TXT record and
check for the presence of a "path" key in its response. If
present, the value of the "path" key is used for the initial
"context path".
* When an initial "context path" has not been determined from a
TXT record, the initial "context path" is taken to be
"/.well-known/caldav" (for CalDAV) or "/.well-known/carddav"
(for CardDAV).
* If the initial "context path" derived from a TXT record
generates HTTP errors when targeted by requests, the client
SHOULD repeat its "bootstrapping" procedure using the
appropriate ".well-known" URI instead.
Daboo Standards Track [Page 6]
RFC 6764 SRV for CalDAV & CardDAV February 2013
4. Determination of user identifier:
* The client will need to make authenticated HTTP requests to
the service. Typically, a "user identifier" is required for
some form of user/password authentication. When a user
identifier is required, clients MUST first use the "mailbox"
portion of the calendar user address provided by the user in
the case of a "mailto:" address and, if that results in an
authentication failure, SHOULD fall back to using the "local-
part" extracted from the "mailto:" address. For an "http:" or
"https:" calendar user address, the "userinfo" portion is used
as the user identifier for authentication. This is in line
with the guidance outlined in Section 7. If these user
identifiers result in authentication failure, the client
SHOULD prompt the user for a valid identifier.
5. Connecting to the service:
* Subsequent to configuration, the client will make HTTP
requests to the service. When using "_caldavs" or "_carddavs"
services, a TLS negotiation is done immediately upon
connection. The client MUST do certificate verification using
the procedure outlined in Section 6 of [RFC6125] in regard to
verification with an SRV RR as the starting point.
* The client does a "PROPFIND" [RFC4918] request with the
request URI set to the initial "context path". The body of
the request SHOULD include the DAV:current-user-principal
[RFC5397] property as one of the properties to return. Note
that clients MUST properly handle HTTP redirect responses for
the request. The server will use the HTTP authentication
procedure outlined in [RFC2617] or use some other appropriate
authentication schemes to authenticate the user.
* If the server returns a 404 ("Not Found") HTTP status response
to the request on the initial "context path", clients MAY try
repeating the request on the "root" URI "/" or prompt the user
for a suitable path.
* If the DAV:current-user-principal property is returned on the
request, the client uses that value for the principal-URL of
the authenticated user. With that, it can execute a
"PROPFIND" request on the principal-URL and discover
additional properties for configuration (e.g., calendar or
address book "home" collections).
Daboo Standards Track [Page 7]
RFC 6764 SRV for CalDAV & CardDAV February 2013
* If the DAV:current-user-principal property is not returned,
then the client will need to request the principal-URL path
from the user in order to continue with configuration.
Once a successful account discovery step has been done, clients
SHOULD cache the service details that were successfully used (user
identity, principal-URL with full scheme/host/port details) and reuse
those when connecting again at a later time.
If a subsequent connection attempt fails, or authentication fails
persistently, clients SHOULD retry the SRV lookup and account
discovery to "refresh" the cached data.
7. Guidance for Service Providers
Service providers wanting to offer CalDAV or CardDAV services that
can be configured by clients using SRV records need to follow certain
procedures to ensure proper operation.
o CalDAV or CardDAV servers SHOULD be configured to allow
authentication with calendar user addresses (just taking the
"mailbox" portion of any "mailto:" URI) or email addresses
respectively, or with "user identifiers" extracted from them. In
the former case, the addresses MUST NOT conflict with other forms
of a permitted user login name. In the latter case, the extracted
"user identifiers" need to be unique across the server and MUST
NOT conflict with any login name on the server.
o Servers MUST force authentication for "PROPFIND" requests that
retrieve the DAV:current-user-principal property to ensure that
the value of the DAV:current-user-principal property returned
corresponds to the principal-URL of the user making the request.
o If the service provider uses TLS, the service provider MUST ensure
a certificate is installed that can be verified by clients using
the procedure outlined in Section 6 of [RFC6125] in regard to
verification with an SRV RR as the starting point. In particular,
certificates SHOULD include SRV-ID and DNS-ID identifiers as
appropriate, as described in Section 8.
o Service providers should install the appropriate SRV records for
the offered services and optionally include TXT records.
Daboo Standards Track [Page 8]
RFC 6764 SRV for CalDAV & CardDAV February 2013
8. Security Considerations
Clients that support TLS as defined by [RFC2818] SHOULD try the
"_caldavs" or "_carddavs" services first before trying the "_caldav"
or "_carddav" services respectively. If a user has explicitly
requested a connection with TLS, the client MUST NOT use any service
information returned for the "_caldav" or "_carddav" services.
Clients MUST follow the certificate-verification process specified in
[RFC6125].
A malicious attacker with access to the DNS server data, or that is
able to get spoofed answers cached in a recursive resolver, can
potentially cause clients to connect to any server chosen by the
attacker. In the absence of a secure DNS option, clients SHOULD
check that the target FQDN returned in the SRV record matches the
original service domain that was queried. If the target FQDN is not
in the queried domain, clients SHOULD verify with the user that the
SRV target FQDN is suitable for use before executing any connections
to the host. Alternatively, if TLS is being used for the service,
clients MUST use the procedure outlined in Section 6 of [RFC6125] to
verify the service. When the target FQDN does not match the original
service domain that was queried, clients MUST check the SRV-ID
identifier in the server's certificate. If the FQDN does match,
clients MUST check any SRV-ID identifiers in the server's certificate
or, if no SRV-ID identifiers are present, MUST check the DNS-ID
identifiers in the server's certificate.
Implementations of TLS [RFC5246], used as the basis for TLS
([RFC2818]), typically support multiple versions of the protocol as
well as the older SSL (Secure Sockets Layer) protocol. Because of
known security vulnerabilities, clients and servers MUST NOT request,
offer, or use SSL 2.0. See Appendix E.2 of [RFC5246] for further
details.
9. IANA Considerations
9.1. Well-Known URI Registrations
This document defines two ".well-known" URIs using the registration
procedure and template from Section 5.1 of [RFC5785].
Daboo Standards Track [Page 9]
RFC 6764 SRV for CalDAV & CardDAV February 2013
9.1.1. caldav Well-Known URI Registration
URI suffix: caldav
Change controller: IETF
Specification document(s): This RFC
Related information: See also [RFC4791].
9.1.2. carddav Well-Known URI Registration
URI suffix: carddav
Change controller: IETF
Specification document(s): This RFC
Related information: See also [RFC6352].
9.2. Service Name Registrations
This document registers four new service names as per [RFC6335]. Two
are defined in this document, and two are defined in [RFC6352],
Section 11.
9.2.1. caldav Service Name Registration
Service Name: caldav
Transport Protocol(s): TCP
Assignee: IESG <iesg@ietf.org>
Contact: IETF Chair <chair@ietf.org>
Description: Calendaring Extensions to WebDAV (CalDAV) - non-TLS
Reference: [RFC6764]
Assignment Note: This is an extension of the http service. Defined
TXT keys: path=<context path>
Daboo Standards Track [Page 10]
RFC 6764 SRV for CalDAV & CardDAV February 2013
9.2.2. caldavs Service Name Registration
Service Name: caldavs
Transport Protocol(s): TCP
Assignee: IESG <iesg@ietf.org>
Contact: IETF Chair <chair@ietf.org>
Description: Calendaring Extensions to WebDAV (CalDAV) - over TLS
Reference: [RFC6764]
Assignment Note: This is an extension of the https service. Defined
TXT keys: path=<context path>
9.2.3. carddav Service Name Registration
Service Name: carddav
Transport Protocol(s): TCP
Assignee: IESG <iesg@ietf.org>
Contact: IETF Chair <chair@ietf.org>
Description: vCard Extensions to WebDAV (CardDAV) - non-TLS
Reference: [RFC6352]
Assignment Note: This is an extension of the http service. Defined
TXT keys: path=<context path>
Daboo Standards Track [Page 11]
RFC 6764 SRV for CalDAV & CardDAV February 2013
9.2.4. carddavs Service Name Registration
Service Name: carddavs
Transport Protocol(s): TCP
Assignee: IESG <iesg@ietf.org>
Contact: IETF Chair <chair@ietf.org>
Description: vCard Extensions to WebDAV (CardDAV) - over TLS
Reference: [RFC6352]
Assignment Note: This is an extension of the https service. Defined
TXT keys: path=<context path>
10. Acknowledgments
This specification was suggested by discussion that took place within
the Calendaring and Scheduling Consortium's CalDAV Technical
Committee. The author thanks the following for their contributions:
Stuart Cheshire, Bernard Desruisseaux, Eran Hammer-Lahav, Helge Hess,
Arnaud Quillaud, Wilfredo Sanchez, and Joe Touch.
11. References
11.1. Normative References
[RFC2119] Bradner, S., "Key words for use in RFCs to Indicate
Requirement Levels", BCP 14, RFC 2119, March 1997.
[RFC2616] Fielding, R., Gettys, J., Mogul, J., Frystyk, H.,
Masinter, L., Leach, P., and T. Berners-Lee, "Hypertext
Transfer Protocol -- HTTP/1.1", RFC 2616, June 1999.
[RFC2617] Franks, J., Hallam-Baker, P., Hostetler, J., Lawrence, S.,
Leach, P., Luotonen, A., and L. Stewart, "HTTP
Authentication: Basic and Digest Access Authentication",
RFC 2617, June 1999.
[RFC2782] Gulbrandsen, A., Vixie, P., and L. Esibov, "A DNS RR for
specifying the location of services (DNS SRV)", RFC 2782,
February 2000.
[RFC2818] Rescorla, E., "HTTP Over TLS", RFC 2818, May 2000.
Daboo Standards Track [Page 12]
RFC 6764 SRV for CalDAV & CardDAV February 2013
[RFC3744] Clemm, G., Reschke, J., Sedlar, E., and J. Whitehead, "Web
Distributed Authoring and Versioning (WebDAV)
Access Control Protocol", RFC 3744, May 2004.
[RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform
Resource Identifier (URI): Generic Syntax", STD 66,
RFC 3986, January 2005.
[RFC4791] Daboo, C., Desruisseaux, B., and L. Dusseault,
"Calendaring Extensions to WebDAV (CalDAV)", RFC 4791,
March 2007.
[RFC4918] Dusseault, L., "HTTP Extensions for Web Distributed
Authoring and Versioning (WebDAV)", RFC 4918, June 2007.
[RFC5246] Dierks, T. and E. Rescorla, "The Transport Layer Security
(TLS) Protocol Version 1.2", RFC 5246, August 2008.
[RFC5322] Resnick, P., Ed., "Internet Message Format", RFC 5322,
October 2008.
[RFC5397] Sanchez, W. and C. Daboo, "WebDAV Current Principal
Extension", RFC 5397, December 2008.
[RFC5785] Nottingham, M. and E. Hammer-Lahav, "Defining Well-Known
Uniform Resource Identifiers (URIs)", RFC 5785,
April 2010.
[RFC6068] Duerst, M., Masinter, L., and J. Zawinski, "The 'mailto'
URI Scheme", RFC 6068, October 2010.
[RFC6125] Saint-Andre, P. and J. Hodges, "Representation and
Verification of Domain-Based Application Service Identity
within Internet Public Key Infrastructure Using X.509
(PKIX) Certificates in the Context of Transport Layer
Security (TLS)", RFC 6125, March 2011.
[RFC6335] Cotton, M., Eggert, L., Touch, J., Westerlund, M., and S.
Cheshire, "Internet Assigned Numbers Authority (IANA)
Procedures for the Management of the Service Name and
Transport Protocol Port Number Registry", BCP 165,
RFC 6335, August 2011.
[RFC6352] Daboo, C., "CardDAV: vCard Extensions to Web Distributed
Authoring and Versioning (WebDAV)", RFC 6352, August 2011.
[RFC6763] Cheshire, S. and M. Krochmal, "DNS-Based Service
Discovery", RFC 6763, February 2013.
Daboo Standards Track [Page 13]
RFC 6764 SRV for CalDAV & CardDAV February 2013
11.2. Informative References
[RFC5545] Desruisseaux, B., "Internet Calendaring and Scheduling
Core Object Specification (iCalendar)", RFC 5545,
September 2009.
Author's Address
Cyrus Daboo
Apple Inc.
1 Infinite Loop
Cupertino, CA 95014
USA
EMail: cyrus@daboo.name
URI: http://www.apple.com/
Daboo Standards Track [Page 14]

@ -1 +1 @@
org.gradle.jvmargs=-Xmx1536M
org.gradle.jvmargs=-Xmx2048M

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-3.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip

@ -1 +1 @@
Subproject commit 70d40e726d6104e90aa0077db8fd5678328b0745
Subproject commit 9e6bc81c1a4f953915f03bf02b2c173a58421c17

@ -7,7 +7,6 @@
*/
include ':app'
include ':dav4android'
include ':ical4android'
include ':vcard4android'
include ':cert4android'

@ -1 +1 @@
Subproject commit 628ef73fa72bde2d8e4a3326b9683b58bbe11c8b
Subproject commit 3250dc78fac3be16a14a9881a9526ac6ce0eaf58
Loading…
Cancel
Save