/* * 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.resource; import android.content.ContentProviderOperation; import android.content.ContentUris; import android.content.ContentValues; import android.database.Cursor; import android.os.Parcel; import android.os.RemoteException; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.GroupMembership; import android.provider.ContactsContract.Groups; import android.provider.ContactsContract.RawContacts; 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.davdroid.App; import at.bitfire.vcard4android.AndroidAddressBook; import at.bitfire.vcard4android.AndroidGroup; import at.bitfire.vcard4android.AndroidGroupFactory; import at.bitfire.vcard4android.BatchOperation; 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; public LocalGroup(AndroidAddressBook addressBook, long id, String fileName, String eTag) { super(addressBook, id, fileName, eTag); } public LocalGroup(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) { 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 { assertID(); ContentValues values = new ContentValues(2); values.put(Groups.DIRTY, 0); values.put(COLUMN_ETAG, this.eTag = eTag); update(values); // update cached group memberships BatchOperation batch = new BatchOperation(addressBook.provider); // delete cached group memberships batch.enqueue(new BatchOperation.Operation( ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) .withSelection( CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?", new String[] { CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) } ) )); // insert updated cached group memberships for (long member : getMembers()) batch.enqueue(new BatchOperation.Operation( ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) .withValue(CachedGroupMembership.RAW_CONTACT_ID, member) .withValue(CachedGroupMembership.GROUP_ID, id) .withYieldAllowed(true) )); batch.commit(); } @Override public void updateFileNameAndUID(String uid) throws ContactsStorageException { String newFileName = uid + ".vcf"; ContentValues values = new ContentValues(2); values.put(COLUMN_FILENAME, newFileName); values.put(COLUMN_UID, uid); update(values); fileName = newFileName; } @Override protected ContentValues contentValues() { ContentValues values = super.contentValues(); @Cleanup("recycle") Parcel members = Parcel.obtain(); members.writeStringList(contact.members); values.put(COLUMN_PENDING_MEMBERS, members.marshall()); return values; } /** * Marks all members of the current group as dirty. */ public void markMembersDirty() throws ContactsStorageException { assertID(); BatchOperation batch = new BatchOperation(addressBook.provider); for (long member : getMembers()) batch.enqueue(new BatchOperation.Operation( ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member))) .withValue(RawContacts.DIRTY, 1) .withYieldAllowed(true) )); batch.commit(); } /** * Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships * are (if possible) applied, keeping cached memberships in sync. * @param addressBook address book to take groups from * @throws ContactsStorageException on contact provider errors */ public static void applyPendingMemberships(LocalAddressBook addressBook) throws ContactsStorageException { try { @Cleanup Cursor cursor = addressBook.provider.query( addressBook.syncAdapterURI(Groups.CONTENT_URI), new String[] { Groups._ID, COLUMN_PENDING_MEMBERS }, COLUMN_PENDING_MEMBERS + " IS NOT NULL", new String[] {}, null ); BatchOperation batch = new BatchOperation(addressBook.provider); while (cursor != null && cursor.moveToNext()) { long id = cursor.getLong(0); App.log.fine("Assigning members to group " + id); // delete all memberships and cached memberships for this group batch.enqueue(new BatchOperation.Operation( ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI)) .withSelection( "(" + GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?) OR (" + CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?)", new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id), CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) }) .withYieldAllowed(true) )); // extract list of member UIDs List members = new LinkedList<>(); byte[] raw = cursor.getBlob(1); @Cleanup("recycle") Parcel parcel = Parcel.obtain(); parcel.unmarshall(raw, 0, raw.length); parcel.setDataPosition(0); parcel.readStringList(members); // insert memberships for (String uid : members) { App.log.fine("Assigning member: " + uid); try { LocalContact member = addressBook.findContactByUID(uid); member.addToGroup(batch, id); } catch(FileNotFoundException e) { App.log.log(Level.WARNING, "Group member not found: " + uid, e); } } // remove pending memberships batch.enqueue(new BatchOperation.Operation( ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id))) .withValue(COLUMN_PENDING_MEMBERS, null) .withYieldAllowed(true) )); batch.commit(); } } catch(RemoteException e) { throw new ContactsStorageException("Couldn't get pending memberships", e); } } // helpers private void assertID() { if (id == null) throw new IllegalStateException("Group has not been saved yet"); } /** * Lists all members of this group. * @return list of all members' raw contact IDs * @throws ContactsStorageException on contact provider errors */ protected long[] getMembers() throws ContactsStorageException { assertID(); List members = new LinkedList<>(); try { @Cleanup Cursor cursor = addressBook.provider.query( addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI), new String[] { Data.RAW_CONTACT_ID }, GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?", new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) }, null ); while (cursor != null && cursor.moveToNext()) members.add(cursor.getLong(0)); } catch(RemoteException e) { throw new ContactsStorageException("Couldn't list group members", e); } return ArrayUtils.toPrimitive(members.toArray(new Long[members.size()])); } // factory static class Factory extends AndroidGroupFactory { static final Factory INSTANCE = new Factory(); @Override public LocalGroup newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) { return new LocalGroup(addressBook, id, fileName, eTag); } @Override public LocalGroup newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) { return new LocalGroup(addressBook, contact, fileName, eTag); } @Override public LocalGroup[] newArray(int size) { return new LocalGroup[size]; } } }