Contact synchronization logic

* use VERSION_CODE and buildTime from BuildConfig
* new HTTP User-Agent, VCard PRODID values
* contact sync: store CTag in SyncState
* sync logic: upload contacts, check CTag, multiget
pull/2/head
Ricki Hirner 9 years ago
parent 4f7f3b851a
commit d024cdb495

@ -17,6 +17,11 @@ android {
applicationId "at.bitfire.davdroid"
minSdkVersion 14
targetSdkVersion 23
versionCode 73
versionName "0.9-alpha1"
buildConfigField "java.util.Date", "buildTime", "new java.util.Date()"
}
buildTypes {

@ -9,7 +9,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="at.bitfire.davdroid"
android:versionCode="72" android:versionName="0.8.4.1"
android:installLocation="internalOnly">
<uses-permission android:name="android.permission.INTERNET" />

@ -14,13 +14,12 @@ import org.slf4j.LoggerFactory;
public class Constants {
public static final String
APP_VERSION = "0.8.4.1",
ACCOUNT_TYPE = "bitfire.at.davdroid",
WEB_URL_MAIN = "https://davdroid.bitfire.at/?pk_campaign=davdroid-app",
WEB_URL_HELP = "https://davdroid.bitfire.at/configuration?pk_campaign=davdroid-app",
WEB_URL_VIEW_LOGS = "https://github.com/bitfireAT/davdroid/wiki/How-to-view-the-logs";
public static final ProdId ICAL_PRODID = new ProdId("-//bitfire web engineering//DAVdroid " + Constants.APP_VERSION + " (ical4j 2.0-beta1)//EN");
public static final ProdId ICAL_PRODID = new ProdId("-//bitfire web engineering//DAVdroid " + BuildConfig.VERSION_CODE + " (ical4j 2.0-beta1)//EN");
public static final Logger log = LoggerFactory.getLogger("DAVdroid");
}

@ -8,6 +8,8 @@
package at.bitfire.davdroid;
import android.os.Build;
import com.squareup.okhttp.Authenticator;
import com.squareup.okhttp.Credentials;
import com.squareup.okhttp.Interceptor;
@ -18,6 +20,7 @@ import com.squareup.okhttp.logging.HttpLoggingInterceptor;
import java.io.IOException;
import java.net.Proxy;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.concurrent.TimeUnit;
@ -26,8 +29,24 @@ import lombok.RequiredArgsConstructor;
public class HttpClient extends OkHttpClient {
protected static final String
HEADER_AUTHORIZATION = "Authorization";
protected static final String HEADER_AUTHORIZATION = "Authorization";
final static UserAgentInterceptor userAgentInterceptor = new UserAgentInterceptor();
final static HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
@Override
public void log(String message) {
Constants.log.trace(message);
}
});
static {
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
}
static final String userAgent;
static {
String date = new SimpleDateFormat("yyyy/MM/dd").format(BuildConfig.buildTime);
userAgent = "DAVdroid/" + BuildConfig.VERSION_NAME + " (" + date + "; dav4android) Android/" + Build.VERSION.RELEASE;
}
public HttpClient() {
@ -40,13 +59,13 @@ public class HttpClient extends OkHttpClient {
super();
initialize();
// authentication and User-Agent
enableLogs();
// authentication
if (preemptive)
networkInterceptors().add(new PreemptiveAuthenticationInterceptor(username, password));
else
setAuthenticator(new DavAuthenticator(username, password));
enableLogs();
}
@ -54,21 +73,27 @@ public class HttpClient extends OkHttpClient {
// don't follow redirects automatically because this may rewrite DAV methods to GET
setFollowRedirects(false);
// timeouts
setConnectTimeout(20, TimeUnit.SECONDS);
setWriteTimeout(15, TimeUnit.SECONDS);
setReadTimeout(45, TimeUnit.SECONDS);
// add User-Agent to every request
networkInterceptors().add(userAgentInterceptor);
}
protected void enableLogs() {
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
@Override
public void log(String message) {
at.bitfire.dav4android.Constants.log.trace(message);
}
});
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
interceptors().add(logging);
interceptors().add(loggingInterceptor);
}
static class UserAgentInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request().newBuilder()
.header("User-Agent", userAgent)
.build();
return chain.proceed(request);
}
}
@ -78,8 +103,7 @@ public class HttpClient extends OkHttpClient {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
request = request.newBuilder()
Request request = chain.request().newBuilder()
.header("Authorization", Credentials.basic(username, password))
.build();
return chain.proceed(request);

@ -9,6 +9,8 @@ package at.bitfire.davdroid.resource;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.os.Bundle;
import android.os.Parcel;
import android.provider.ContactsContract;
import at.bitfire.davdroid.Constants;
@ -18,14 +20,25 @@ import at.bitfire.vcard4android.AndroidContactFactory;
import at.bitfire.vcard4android.AndroidGroupFactory;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
import lombok.Synchronized;
public class LocalAddressBook extends AndroidAddressBook {
protected static final String SYNC_STATE_CTAG = "ctag";
private Bundle syncState = new Bundle();
public LocalAddressBook(Account account, ContentProviderClient provider) {
super(account, provider, AndroidGroupFactory.INSTANCE, LocalContact.Factory.INSTANCE);
}
/**
* Returns an array of local contacts, excluding those which have been modified locally (and not uploaded yet).
*/
public LocalContact[] getAll() throws ContactsStorageException {
LocalContact contacts[] = (LocalContact[])queryContacts(null, null);
return contacts;
@ -35,14 +48,14 @@ public class LocalAddressBook extends AndroidAddressBook {
* Returns an array of local contacts which have been deleted locally. (DELETED != 0).
*/
public LocalContact[] getDeleted() throws ContactsStorageException {
return (LocalContact[])queryContacts(ContactsContract.RawContacts.DELETED + " != 0", null);
return (LocalContact[])queryContacts(ContactsContract.RawContacts.DELETED + "!=0", null);
}
/**
* Returns an array of local contacts which have been changed locally (DIRTY != 0).
*/
public LocalContact[] getDirty() throws ContactsStorageException {
return (LocalContact[])queryContacts(ContactsContract.RawContacts.DIRTY + " != 0", null);
return (LocalContact[])queryContacts(ContactsContract.RawContacts.DIRTY + "!=0", null);
}
/**
@ -52,4 +65,35 @@ public class LocalAddressBook extends AndroidAddressBook {
return (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null);
}
protected void readSyncState() throws ContactsStorageException {
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
byte[] raw = getSyncState();
if (raw != null) {
parcel.unmarshall(raw, 0, raw.length);
parcel.setDataPosition(0);
syncState = parcel.readBundle();
} else
syncState.clear();
}
public String getCTag() throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
return syncState.getString(SYNC_STATE_CTAG);
}
}
public void setCTag(String cTag) throws ContactsStorageException {
synchronized (syncState) {
readSyncState();
syncState.putString(SYNC_STATE_CTAG, cTag);
// write sync state bundle
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
parcel.writeBundle(syncState);
setSyncState(parcel.marshall());
}
}
}

@ -12,13 +12,18 @@ import android.content.ContentValues;
import android.os.RemoteException;
import android.provider.ContactsContract;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.vcard4android.AndroidAddressBook;
import at.bitfire.vcard4android.AndroidContact;
import at.bitfire.vcard4android.AndroidContactFactory;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.Ezvcard;
public class LocalContact extends AndroidContact {
static {
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION;
}
protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
super(addressBook, id, fileName, eTag);
@ -28,6 +33,17 @@ public class LocalContact extends AndroidContact {
super(addressBook, contact, fileName, eTag);
}
public void clearDirty(String eTag) throws ContactsStorageException {
try {
ContentValues values = new ContentValues(1);
values.put(COLUMN_ETAG, eTag);
values.put(ContactsContract.RawContacts.DIRTY, 0);
addressBook.provider.update(rawContactSyncURI(), values, null, null);
} catch (RemoteException e) {
throw new ContactsStorageException("Couldn't clear dirty flag", e);
}
}
public void updateUID(String uid) throws ContactsStorageException {
try {
ContentValues values = new ContentValues(1);

@ -11,6 +11,7 @@ import android.accounts.Account;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
@ -26,30 +27,43 @@ import com.squareup.okhttp.ResponseBody;
import org.apache.commons.io.Charsets;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
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 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.SupportedAddressData;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalContact;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import ezvcard.VCardVersion;
import ezvcard.property.Uid;
import lombok.Cleanup;
public class ContactsSyncAdapterService extends Service {
private static ContactsSyncAdapter syncAdapter;
protected static final int MAX_MULTIGET = 10;
@Override
public void onCreate() {
if (syncAdapter == null)
@ -68,7 +82,6 @@ public class ContactsSyncAdapterService extends Service {
private static class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
public ContactsSyncAdapter(Context context) {
super(context, false);
}
@ -88,7 +101,7 @@ public class ContactsSyncAdapterService extends Service {
// prepare remote address book
boolean hasVCard4 = false;
dav.propfind(0, SupportedAddressData.NAME);
dav.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData)dav.properties.get(SupportedAddressData.NAME);
if (supportedAddressData != null)
for (MediaType type : supportedAddressData.types)
@ -142,11 +155,24 @@ public class ContactsSyncAdapterService extends Service {
remote.put(vCard, local.eTag, null);
}
// reset DIRTY
GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME);
local.clearDirty(newETag != null ? newETag.eTag : null);
}
// check CTag (ignore on forced sync)
if (true) {
// check CTag (ignore on manual sync)
String currentCTag = null;
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
Constants.log.info("Manual sync, ignoring CTag");
else {
GetCTag getCTag = (GetCTag) dav.properties.get(GetCTag.NAME);
if (getCTag != null)
currentCTag = getCTag.cTag;
}
if (currentCTag != null && !(currentCTag.equals(addressBook.getCTag()))) {
Constants.log.info("Remote address book didn't change (CTag=" + currentCTag + "), no need to list VCards");
} else {
// fetch list of local contacts and build hash table to index file name
localList = addressBook.getAll();
Map<String, LocalContact> localContacts = new HashMap<>(localList.length);
@ -199,41 +225,92 @@ public class ContactsSyncAdapterService extends Service {
toDownload.addAll(remoteContacts.values());
}
Constants.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)");
// download new/updated VCards from server
for (DavResource remoteContact : toDownload) {
Constants.log.info("Downloading " + remoteContact.location);
String fileName = remoteContact.fileName();
ResponseBody body = remoteContact.get("text/vcard;q=0.5, text/vcard;charset=utf-8;q=0.8, text/vcard;version=4.0");
String remoteETag = ((GetETag)remoteContact.properties.get(GetETag.NAME)).eTag;
Contact contacts[] = Contact.fromStream(body.byteStream(), body.contentType().charset(Charsets.UTF_8));
if (contacts.length == 1) {
Contact newData = contacts[0];
// delete local contact, if it exists
LocalContact localContact = localContacts.get(fileName);
if (localContact != null) {
Constants.log.info("Updating " + fileName + " in local address book");
localContact.eTag = remoteETag;
localContact.update(newData);
} else {
Constants.log.info("Adding " + fileName + " to local address book");
localContact = new LocalContact(addressBook, newData, fileName, remoteETag);
localContact.add();
}
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
Constants.log.info("Downloading " + TextUtils.join(" + ", bunch));
if (bunch.length == 1) {
// only one contact, use GET
DavResource remote = bunch[0];
String fileName = remote.fileName();
ResponseBody body = remote.get("text/vcard;q=0.5, text/vcard;charset=utf-8;q=0.8, text/vcard;version=4.0");
String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag;
// add the new contact
} else
Constants.log.error("Received VCard with not exactly one VCARD, ignoring " + fileName);
@Cleanup InputStream stream = body.byteStream();
processVCard(addressBook, localContacts, remote.fileName(), eTag, stream, body.contentType().charset(Charsets.UTF_8));
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.location);
dav.multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
// process multiget results
for (DavResource remote : dav.members) {
String eTag = null;
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(addressBook, localContacts, remote.fileName(), eTag, stream, charset);
}
}
}
/* 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. */
Constants.log.info("Saving sync state: CTag=" + currentCTag);
addressBook.setCTag(currentCTag);
}
} catch (Exception e) {
Log.e("davdroid", "querying member etags", e);
Log.e("davdroid", "XXX", e);
}
Constants.log.info("Sync complete for authority " + authority);
}
private void processVCard(LocalAddressBook addressBook, Map<String, LocalContact>localContacts, String fileName, String eTag, InputStream stream, Charset charset) throws IOException, ContactsStorageException {
Contact contacts[] = Contact.fromStream(stream, charset);
if (contacts.length == 1) {
Contact newData = contacts[0];
// delete local contact, if it exists
LocalContact localContact = localContacts.get(fileName);
if (localContact != null) {
Constants.log.info("Updating " + fileName + " in local address book");
localContact.eTag = eTag;
localContact.update(newData);
} else {
Constants.log.info("Adding " + fileName + " to local address book");
localContact = new LocalContact(addressBook, newData, fileName, eTag);
localContact.add();
}
} else
Constants.log.error("Received VCard with not exactly one VCARD, ignoring " + fileName);
}
}
}

@ -20,6 +20,7 @@ import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.ui.settings.SettingsActivity;
@ -41,7 +42,7 @@ public class MainActivity extends Activity {
}
TextView tvInfo = (TextView)findViewById(R.id.text_info);
tvInfo.setText(Html.fromHtml(getString(R.string.html_main_info, Constants.APP_VERSION)));
tvInfo.setText(Html.fromHtml(getString(R.string.html_main_info, BuildConfig.VERSION_NAME)));
tvInfo.setMovementMethod(LinkMovementMethod.getInstance());
}

@ -20,6 +20,7 @@ import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import at.bitfire.davdroid.BuildConfig;
import at.bitfire.davdroid.Constants;
@ -57,7 +58,7 @@ public class DavHttpClient {
.setDefaultRequestConfig(defaultRqConfig)
.setRetryHandler(DavHttpRequestRetryHandler.INSTANCE)
.setRedirectStrategy(DavRedirectStrategy.INSTANCE)
.setUserAgent("DAVdroid/" + Constants.APP_VERSION);
.setUserAgent("DAVdroid/" + BuildConfig.VERSION_NAME);
if (Log.isLoggable("Wire", Log.DEBUG)) {
Log.i(TAG, "Wire logging active, disabling HTTP compression");

@ -1 +1 @@
Subproject commit 84a2cf0bbad257274e362851020da3822957449d
Subproject commit a6975918ed614eef93c222451fd0981c60ec3ad9
Loading…
Cancel
Save