/* * 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.ContentProviderClient; import android.content.ContentValues; import android.content.Context; import android.content.SyncResult; import android.os.Bundle; import android.provider.ContactsContract; import org.apache.commons.codec.Charsets; import org.apache.commons.io.IOUtils; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.logging.Level; import at.bitfire.davdroid.AccountSettings; import at.bitfire.davdroid.App; 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.Contact; import at.bitfire.vcard4android.ContactsStorageException; import lombok.Cleanup; import lombok.RequiredArgsConstructor; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; /** *

Synchronization manager for CardDAV collections; handles contacts and groups.

*/ public class ContactsSyncManager extends SyncManager { protected static final int MAX_MULTIGET = 10; final private ContentProviderClient provider; final private HttpUrl remote; final private CollectionInfo info; 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 = principal; this.info = info; } @Override protected int notificationId() { return Constants.NOTIFICATION_CONTACTS_SYNC; } @Override protected String getSyncErrorTitle() { return context.getString(R.string.sync_error_contacts, account.name); } @Override protected void prepare() throws ContactsStorageException, CalendarStorageException { // prepare local address book localCollection = new LocalAddressBook(account, provider); LocalAddressBook localAddressBook = localAddressBook(); localAddressBook.setURL(info.url); // set up Contacts Provider Settings ContentValues values = new ContentValues(2); values.put(ContactsContract.Settings.SHOULD_SYNC, 1); values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1); localAddressBook.updateSettings(values); journal = new JournalEntryManager(httpClient, remote, info.url); } @Override protected void applyLocalEntries() throws IOException, ContactsStorageException, CalendarStorageException { } @Override protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { super.prepareDirty(); LocalAddressBook addressBook = localAddressBook(); /* groups as separate VCards: thtere are group contacts and individual contacts */ // mark groups with changed members as dirty // FIXME: add back } @Override protected void postProcess() throws CalendarStorageException, ContactsStorageException { /* VCard4 group handling: there are group contacts and individual contacts */ App.log.info("Assigning memberships of downloaded contact groups"); LocalGroup.applyPendingMemberships(localAddressBook()); } // helpers 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); 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"); Contact contact = contacts[0]; if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { LocalResource local = processContact(contact); 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(uuid); if (local != null) { 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 = uuid; group.updateFromServer(newData); syncResult.stats.numUpdates++; } else if (local instanceof LocalContact && !newData.group) { // update contact LocalContact contact = (LocalContact) local; contact.eTag = uuid; contact.update(newData); syncResult.stats.numUpdates++; } else { // group has become an individual contact or vice versa try { local.delete(); local = null; } catch (CalendarStorageException e) { // CalendarStorageException is not used by LocalGroup and LocalContact } } } if (local == null) { if (newData.group) { App.log.log(Level.INFO, "Creating local group", newData); 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, uuid, null); contact.create(); local = contact; } syncResult.stats.numInserts++; } return local; } // downloader helper class @RequiredArgsConstructor private class ResourceDownloader implements Contact.Downloader { final HttpUrl baseUrl; @Override public byte[] download(String url, String accepts) { HttpUrl httpUrl = HttpUrl.parse(url); if (httpUrl == null) { App.log.log(Level.SEVERE, "Invalid external resource URL", url); return null; } String host = httpUrl.host(); if (host == null) { App.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url); return null; } 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()); // allow redirects resourceClient = resourceClient.newBuilder() .followRedirects(true) .build(); try { Response response = resourceClient.newCall(new Request.Builder() .get() .url(httpUrl) .build()).execute(); ResponseBody body = response.body(); if (body != null) { @Cleanup InputStream stream = body.byteStream(); if (response.isSuccessful() && stream != null) { return IOUtils.toByteArray(stream); } else App.log.severe("Couldn't download external resource"); } } catch (IOException e) { App.log.log(Level.SEVERE, "Couldn't download external resource", e); } return null; } } }