/*
* Copyright 2012 The Stanford MobiSocial Laboratory
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package mobisocial.musubi.syncadapter;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import mobisocial.musubi.App;
import mobisocial.musubi.R;
import mobisocial.musubi.model.MIdentity;
import mobisocial.musubi.model.helpers.IdentitiesManager;
import mobisocial.musubi.ui.util.UiUtil;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Im;
import android.provider.ContactsContract.CommonDataKinds.Photo;
import android.provider.ContactsContract.CommonDataKinds.StructuredName;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Groups;
import android.provider.ContactsContract.RawContacts;
import android.provider.ContactsContract.StatusUpdates;
import android.util.Log;
public class SyncAdapter extends AbstractThreadedSyncAdapter {
public static final boolean DEBUG = false;
public static final String TAG = "SyncAdapter";
public static final String CUSTOM_PROTOCOL = "MusubiSyncAdapter";
public static final String PREFS_NAME = "SyncAdapterPref";
public static final String LAST_SYNC_MARKER = "lastSyncMarker";
public static final int CLAIMED_GROUP_ID = 0;
public static final int WHITELIST_GROUP_ID = 1;
public static final int GROUP_NUM = 2;
public static final String[] GROUP_TITLES = {"Claimed", "Whitelist"};
private final String accountType;
private final String accountName;
private final Context mContext;
private final IdentitiesManager mIdm;
private final SQLiteOpenHelper mDatabaseSource;
public SyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);
mContext = context;
accountType = context.getString(R.string.account_type);
accountName = context.getString(R.string.account_name);
mDatabaseSource = App.getDatabaseSource(mContext);
mIdm = new IdentitiesManager(mDatabaseSource);
}
/**
* Constructor for test
*
* @param context
* @param autoInitialize
* @param name
*/
public SyncAdapter(Context context, boolean autoInitialize, String type, String name) {
super(context, autoInitialize);
mContext = context;
accountType = type;
accountName = name;
mDatabaseSource = App.getDatabaseSource(mContext);
mIdm = new IdentitiesManager(mDatabaseSource);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority,
ContentProviderClient provider, SyncResult syncRresult) {
Log.i(TAG, "perform sync");
final long startTime = new Date().getTime();
long lastSyncMarker = getSyncMarker(mContext);
final long[] groupIds = createGroups();
if(lastSyncMarker == 0) {
//clear all old data if they click remove account, effectively this is our recovery tool
//TODO: make this into a util that we can use from the upgrade handler for the db or
//something along those lines, so that we can deal with the bugs that inevitable exist
//in the code
mContext.getContentResolver().delete(ContactsContract.Data.CONTENT_URI,
ContactsContract.RawContacts.ACCOUNT_TYPE + "='" + accountType + "'", null);
mContext.getContentResolver().delete(ContactsContract.RawContacts.CONTENT_URI,
ContactsContract.RawContacts.ACCOUNT_TYPE + "='" + accountType + "'", null);
}
// pull updates from identity caches and sync them to address book
List<MIdentity> updatedContacts = getUpdatedContacts(lastSyncMarker);
Log.i(TAG, "found " + String.valueOf(updatedContacts.size()) + " updated contacts");
ContentProviderResult[] results = syncUpdatedContacts(updatedContacts, groupIds, lastSyncMarker);
Log.i(TAG, "done " + String.valueOf(results.length) + " update operations in "
+ String.valueOf((new Date().getTime()-startTime)/1000) + "seconds");
}
public ContentProviderResult[] syncUpdatedContacts(List<MIdentity> updatedContacts, final long[] groupIds,
final long lastSyncMarker) {
final BatchOperation batchOp = new BatchOperation(mContext, mContext.getContentResolver());
ContentProviderResult[] results = new ContentProviderResult[updatedContacts.size()*6];
int last = 0;
long rawId = 0;
long nextSyncMarker = lastSyncMarker;
if(updatedContacts.size() == 0)
return results;
List<MIdentity> try_status_updates = new LinkedList<MIdentity>();
ArrayList<MIdentity> sub_identities = new ArrayList<MIdentity>(8);
Iterator<MIdentity> it = updatedContacts.iterator();
MIdentity next = it.next();
while(next != null) {
//TODO: this should be whitelisted, but that is the next phase of fixing up the support
//for whitelisting identities that are only reachable through musubi
if(next.androidAggregatedContactId_ == null) {
//if the account isn't whitelisted, then we skip it
//TODO: somewhere else we were supposed to have added one
//if we whitelisted a gray list
//TODO: that one will have a flag set on it that we can detect for handling
//TODO: if it was removed from the white list, then
//we need to do some clean up
if(next.updatedAt_ > lastSyncMarker) {
nextSyncMarker = next.updatedAt_;
}
next = it.hasNext() ? it.next() : null;
continue;
}
//we have to batch by raw_contact we ant to add because otherwise the test below
//that performs a query will fail. this is because the batch of operations
//hasnt actually executed yet, and so it will cause multiple raw_contacts to be inserted
//for one logical contact that just has multiple profiles
sub_identities.clear();
sub_identities.add(next);
long contact_aggregation_id = next.androidAggregatedContactId_;
for(;;) {
if(next.updatedAt_ > lastSyncMarker) {
nextSyncMarker = next.updatedAt_;
}
next = it.hasNext() ? it.next() : null;
if(next == null || next.androidAggregatedContactId_ == null || contact_aggregation_id != next.androidAggregatedContactId_)
break;
sub_identities.add(next);
}
assert(sub_identities.size() > 0);
MIdentity id = sub_identities.get(0);
if(id.updatedAt_ > lastSyncMarker) {
nextSyncMarker = id.updatedAt_;
}
//TODO: not here, but this is a reason why some functionality is not here...
//other actions, like sending a message should be done by tapping
//the appropriate intent for "text message" or "email"
boolean existMusubiProfile = false;
final Uri qUri = RawContacts.CONTENT_URI;
final String qSelection = RawContacts.SOURCE_ID + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?";
final String[] qProjection = new String[] {
RawContacts._ID
};
final ContentResolver resolver = mContext.getContentResolver();
Cursor c =
resolver.query(qUri, qProjection, qSelection,
new String[] {id.androidAggregatedContactId_.toString(), accountType}
, null);
try {
while (c.moveToNext()) {
existMusubiProfile = true;
rawId = c.getLong(0);
}
} finally {
c.close();
}
byte[] thumbnail = null;
boolean any_claimed = false;
for(MIdentity anid : sub_identities) {
//copy one profile picture in per raw contact
mIdm.getMusubiThumbnail(anid);
if(anid.musubiThumbnail_ != null)
thumbnail = anid.musubiThumbnail_;
//TODO: one day if we don't hear from someone,
//maybe they will become unclaimed... but right now that never happens
if(anid.claimed_) {
any_claimed = true;
try_status_updates.add(anid);
}
}
//if none of the sub identities are claimed, then we aren't going to
//put this in the address book
//TODO: deletes? unclaims
if(!any_claimed)
continue;
if(!existMusubiProfile) {
// insert new contact
if(DEBUG) Log.i(TAG, "insert contact->" + id.principal_ + " name->" + id.name_ + " hashedPrincipal->"+id.principalHash_);
final ContactOperations contactOp =
ContactOperations.createNewContact(mContext, id.androidAggregatedContactId_, accountName, true, batchOp);
//we want to be aggregated, so specify the same text name
if(id.androidAggregatedContactId_ != null && id.name_ != null) {
contactOp.addName(id.name_);
} else {
contactOp.addName(UiUtil.safeNameForIdentity(id));
}
for(MIdentity anid : sub_identities) {
//TODO: one day if we don't hear from someone,
//maybe they will become unclaimed... but right now that never happens
if(anid.claimed_) {
contactOp.addProfileAction(anid);
contactOp.addGroupMembership(groupIds[CLAIMED_GROUP_ID]);
}
}
//TODO: what if it was deleted? maybe not in this path...
if(thumbnail != null)
contactOp.addPhoto(id.musubiThumbnail_);
} else {
if(DEBUG) Log.i(TAG, "update contact->" + id.principal_ + " name->" + id.name_ + " hashedPrincipal->"+id.principalHash_);
for(MIdentity anid : sub_identities) {
//copy one profile picture in per raw contact
mIdm.getMusubiThumbnail(anid);
if(anid.musubiThumbnail_ != null)
thumbnail = anid.musubiThumbnail_;
//TODO: one day if we don't hear from someone,
//maybe they will become unclaimed... but right now that never happens
if(anid.claimed_) {
try_status_updates.add(anid);
}
}
final ContactOperations contactOp = ContactOperations.updateExistingContact(mContext, rawId, true, batchOp);
c = mContext.getContentResolver().query(Data.CONTENT_URI,
new String[]{
Data._ID,
MusubiProfile.DATA_PID,
StructuredName.DISPLAY_NAME,
Data.MIMETYPE,
},
Data.RAW_CONTACT_ID + "=?",
new String[]{String.valueOf(rawId)}, null
);
try {
while(c.moveToNext()) {
final long dataId = c.getLong(0);
String mime = c.getString(3);
final Uri uri = ContentUris.withAppendedId(Data.CONTENT_URI, dataId);
if(mime.equals(Photo.CONTENT_ITEM_TYPE)) {
if(DEBUG) Log.i(TAG, "update thumbnail");
//TODO: what if it was deleted?
if(thumbnail != null)
contactOp.updatePhoto(thumbnail, uri);
} else if(mime.equals(StructuredName.CONTENT_ITEM_TYPE)) {
//we just use the first name since the high level android contact
//merging algorithm is going to use that.
final String name = c.getString(2);
contactOp.updateName(id.name_, name, uri);
} else if(mime.equals(MusubiProfile.MIME_PROFILE)) {
long profile_id = c.getLong(1);
for(Iterator<MIdentity> jt = sub_identities.iterator(); jt.hasNext();) {
MIdentity possible = jt.next();
if(possible.id_ == profile_id) {
jt.remove();
contactOp.updateProfile(possible, uri);
break;
}
}
}
}
for(MIdentity remaining : sub_identities) {
//add new profile for people who became claimed
if(remaining.claimed_) {
contactOp.addProfileAction(remaining);
}
}
} finally {
c.close();
}
}
if(batchOp.size() >= 50) {
ContentProviderResult[] r = batchOp.execute();
System.arraycopy(r, 0, results, last, r.length);
last += r.length;
}
}
if(batchOp.size()>0) {
ContentProviderResult[] r = batchOp.execute();
System.arraycopy(r, 0, results, last, r.length);
}
final BatchOperation statusOp = new BatchOperation(mContext, mContext.getContentResolver());
for(@SuppressWarnings("unused") MIdentity id : try_status_updates) {
//TODO: actually grab a status message... when we have something appropriate.
//is it a MOTD? away message? etc? latest feed item?
//setStatus(id.id_, id.principal_, accountName, statusOp);
}
statusOp.execute();
Log.i(TAG, "last sync at " + String.valueOf(lastSyncMarker));
Log.i(TAG, "next sync will be after " + String.valueOf(nextSyncMarker));
setSyncMarker(mContext, nextSyncMarker);
return results;
}
public List<MIdentity> getUpdatedContacts(long lastSyncMarker) {
List<MIdentity> updatedIds = new ArrayList<MIdentity>();
MIdentity[] ids = null;
try {
ids = mIdm.getUpdatedIdentities(lastSyncMarker);
} catch (SQLiteException e) {
Log.e(TAG, e.toString());
return updatedIds;
}
for(MIdentity id : ids) {
if(id.updatedAt_ > lastSyncMarker) {
if(DEBUG) Log.i(TAG, "Updated contact: email->"+id.principal_ + " name->" + id.name_ +
" musubiname->" + id.musubiName_ + " hashedPrincipal->" + id.principalHash_.toString());
updatedIds.add(id);
}
}
return updatedIds;
}
public static long getSyncMarker(Context context) {
SharedPreferences settings = context.getSharedPreferences(PREFS_NAME, 0);
return settings.getLong(LAST_SYNC_MARKER, 0);
}
public static void setSyncMarker(Context context, long marker) {
//TODO: This needs to move to the db, as all state needs to be there for testing isolation purposes
SharedPreferences settings = context.getSharedPreferences(PREFS_NAME, 0);
SharedPreferences.Editor editor = settings.edit();
editor.putLong(LAST_SYNC_MARKER, marker);
editor.commit();
}
@SuppressWarnings("unused")
private void setStatus(long id, String handle, String username, BatchOperation batchOp) {
final ContentValues values = new ContentValues();
final long profileId = lookupProfile(id);
if(handle != null && handle.length() > 0) {
values.put(StatusUpdates.DATA_ID, profileId);
values.put(StatusUpdates.STATUS, "status");
values.put(StatusUpdates.PROTOCOL, Im.PROTOCOL_CUSTOM);
values.put(StatusUpdates.CUSTOM_PROTOCOL, CUSTOM_PROTOCOL);
values.put(StatusUpdates.IM_ACCOUNT, username);
values.put(StatusUpdates.IM_HANDLE, handle);
values.put(StatusUpdates.STATUS_RES_PACKAGE, mContext.getPackageName());
values.put(StatusUpdates.STATUS_ICON, R.drawable.icon);
values.put(StatusUpdates.STATUS_LABEL, R.string.app_name);
batchOp.add(ContactOperations.newInsertCpo(StatusUpdates.CONTENT_URI,
false, true).withValues(values).build());
}
}
private long lookupProfile(long userId) {
long profileId = 0;
final Cursor c =
mContext.getContentResolver().query(Data.CONTENT_URI,
new String[]{Data._ID},
Data.MIMETYPE + "='" + MusubiProfile.MIME_PROFILE + "' AND "
+ MusubiProfile.DATA_PID + "=?",
new String[] {String.valueOf(userId)}, null);
try {
if ((c != null) && c.moveToFirst()) {
profileId = c.getLong(0);
}
} finally {
if (c != null) {
c.close();
}
}
return profileId;
}
public long[] createGroups() {
// Lookup the sample group
long[] groupIds = new long[2];
int groupNum = 0;
final Cursor cursor = mContext.getContentResolver().query(Groups.CONTENT_URI, new String[] { Groups.TITLE, Groups._ID },
Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?",
new String[] { accountName, accountType}, null);
if (cursor != null) {
try {
while (cursor.moveToNext()) {
final String groupTitle = cursor.getString(0);
if(groupTitle.equals(GROUP_TITLES[CLAIMED_GROUP_ID])) {
groupIds[CLAIMED_GROUP_ID] = cursor.getLong(1);
++groupNum;
} else if(groupTitle.equals(GROUP_TITLES[WHITELIST_GROUP_ID])) {
groupIds[WHITELIST_GROUP_ID] = cursor.getLong(1);
++groupNum;
}
}
} finally {
cursor.close();
}
}
if (groupNum < GROUP_NUM) {
// group doesn't exist yet, so create it
final ContentValues contentValues = new ContentValues();
for(int i = 0; i < GROUP_NUM; i++) {
contentValues.put(Groups.ACCOUNT_NAME, accountName);
contentValues.put(Groups.ACCOUNT_TYPE, accountType);
contentValues.put(Groups.TITLE, GROUP_TITLES[i]);
if(i == CLAIMED_GROUP_ID || i == WHITELIST_GROUP_ID)
contentValues.put(Groups.GROUP_VISIBLE, 1);
final Uri newGroupUri = mContext.getContentResolver().insert(Groups.CONTENT_URI, contentValues);
groupIds[i] = ContentUris.parseId(newGroupUri);
}
}
return groupIds;
}
}