/* * 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.service; import java.util.Date; import java.util.LinkedList; import java.util.List; import mobisocial.crypto.IBHashedIdentity.Authority; import mobisocial.musubi.App; import mobisocial.musubi.model.MFeed; import mobisocial.musubi.model.MFeedMember; import mobisocial.musubi.model.MIdentity; import mobisocial.musubi.model.MMyAccount; import mobisocial.musubi.model.helpers.FeedManager; import mobisocial.musubi.model.helpers.IdentitiesManager; import mobisocial.musubi.model.helpers.MyAccountManager; import mobisocial.musubi.objects.ProfileObj; import mobisocial.musubi.provider.MusubiContentProvider; import mobisocial.musubi.provider.MusubiContentProvider.Provided; import mobisocial.musubi.ui.SettingsActivity; import mobisocial.socialkit.Obj; import mobisocial.socialkit.obj.MemObj; import org.javatuples.Pair; import org.json.JSONException; import org.json.JSONObject; import android.content.ContentValues; import android.content.Context; import android.database.ContentObserver; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteDoneException; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteStatement; import android.net.Uri; import android.os.Handler; import android.os.HandlerThread; import android.util.Log; //TODO: this probably doesn't deal with the case where we need to send //a profile to an identity twice (since it tracks the sentProfile time and //after sending it via one persona, the other persona will seem to not need //an update /** * Scans the list of identities for entries that need this user's latest * profile. * * @See MusubiService */ public class ProfilePushProcessor extends ContentObserver { //at most twice / minute private static final int ONCE_PER_PERIOD = 30 * 1000; private final boolean DBG = MusubiService.DBG; private final String TAG = "ProfilePushProcessor"; private final Context mContext; private final SQLiteOpenHelper mHelper; private final FeedManager mFeedManager; private final MyAccountManager mAccountManager; private final IdentitiesManager mIdentityManager; private final ProfileUpdateObserver mProfileUpdatedObserver; final HandlerThread mThread; private SQLiteStatement mSqlPrepareProfile; private SQLiteStatement mSqlCheckRecipients; private SQLiteStatement mSqlMarkIdentitiesSynced; private Date mLastRun; private boolean mScheduled; public static ProfilePushProcessor newInstance(Context context, SQLiteOpenHelper dbh) { HandlerThread thread = new HandlerThread("ProfileSyncThread"); thread.setPriority(Thread.MIN_PRIORITY); thread.start(); return new ProfilePushProcessor(context, dbh, thread, new Handler(thread.getLooper())); } private ProfilePushProcessor(Context context, SQLiteOpenHelper dbh, HandlerThread thread, Handler handler) { super(handler); mContext = context; mThread = thread; mHelper = dbh; mAccountManager = new MyAccountManager(mHelper); mFeedManager = new FeedManager(mHelper); mIdentityManager = new IdentitiesManager(mHelper); mProfileUpdatedObserver = new ProfileUpdateObserver(handler); context.getContentResolver().registerContentObserver(MusubiService.FORCE_PROFILE_PUSH, false, new ContentObserver(new Handler(thread.getLooper())) { @Override public void onChange(boolean selfChange) { mLastRun = null; ProfilePushProcessor.this.dispatchChange(false); } }); } @Override public void onChange(boolean selfChange) { if(mLastRun != null && mLastRun.getTime() + ONCE_PER_PERIOD > new Date().getTime()) { //wake up when the period expires if(!mScheduled) { new Handler(mThread.getLooper()).postDelayed(new Runnable() { @Override public void run() { mScheduled = false; dispatchChange(false); } }, mLastRun.getTime() + ONCE_PER_PERIOD - mLastRun.getTime()); } mScheduled = true; //skip this update return; } boolean includePrincipal = mContext.getSharedPreferences(SettingsActivity.PREFS_NAME, 0) .getBoolean(SettingsActivity.PREF_SHARE_CONTACT_ADDRESS, SettingsActivity.PREF_SHARE_CONTACT_ADDRESS_DEFAULT); LinkedList<Pair<long[], Long>> ids_synced = new LinkedList<Pair<long[], Long>>(); try { MMyAccount[] accounts = mAccountManager.getMyAccounts(); for (MMyAccount account : accounts) { if (MusubiService.DBG) Log.d(TAG, "Pushing profile to " + account.accountName_); MIdentity sendAs = null; if(account.identityId_ != null) { sendAs = mIdentityManager.getIdentityForId(account.identityId_); } if(sendAs != null && sendAs.owned_ == false) { //if we have claimed we have to just use the default identity. sendAs = null; } if (sendAs == null) { List<MIdentity> idents = mIdentityManager.getOwnedIdentities(); for(MIdentity ident : idents) { if(ident.type_ != Authority.Local) { sendAs = ident; break; } } if(sendAs == null) { Log.w(TAG, " No identity linked to account. And no claimed identity, skipping push"); continue; } else { Log.w(TAG, " No identity linked to account. Using default identity " + sendAs.principal_); } } //no profile was set if(sendAs.receivedProfileVersion_ == 0) { continue; } for (boolean sync : new boolean[] { true, false}) { Obj myProfile = profileObjForLocalUser(sendAs, sync, includePrincipal); long version = myProfile.getJson().optLong(ProfileObj.VERSION); MFeed feed = prepareFeedForSync(account, sendAs, version, sync); if (feed != null) { if (DBG) Log.d(TAG, "Syncing profiles with replyRequest: " + sync); // this isn't done in the db, because encoding deletes the one shot feed // so we have to store the members and the version they should see long[] ids = mFeedManager.getFeedMembers(feed.id_); ids_synced.add(Pair.with(ids, version)); Uri feedUri = MusubiContentProvider.uriForItem(Provided.FEEDS, feed.id_); App.getMusubi(mContext).getFeed(feedUri).postObj(myProfile); } } } } finally { //in case we crash in the above process, prefer losing a profile obj over resending for ever. for(Pair<long[], Long> people_version : ids_synced) { markIdentitiesSynced(people_version.getValue0(), people_version.getValue1()); } mLastRun = new Date(); } } /** * Creates a one-time-use feed with membership of the account's known * identities that require a profile sync * * @param account The account whose profile is to be synced. * @param onlyUnsynced true to only select accounts that have not been * sent a profile from this account. */ MFeed prepareFeedForSync(MMyAccount account, MIdentity sendAs, long version, boolean onlyUnsynced) { assert (account.feedId_ != null); if (account.feedId_ == null) { return null; } SQLiteDatabase db = mHelper.getWritableDatabase(); if (mSqlCheckRecipients == null) { synchronized (this) { if (mSqlCheckRecipients == null) { /** * INSERT INTO feed_members(feed_id,identity_id) * SELECT #newFeedId#, identity_id * FROM feed_members * INNER JOIN identities ON identities._id = feed_members.identity_id * WHERE 1=1 * --AND identities.claimed=1 * AND sent_profile_version #syncConstraint# * AND feed_members.feed_id = #accountFeedId# */ StringBuilder sql = new StringBuilder(100).append("INSERT INTO ") .append(MFeedMember.TABLE).append("(").append(MFeedMember.COL_FEED_ID) .append(",").append(MFeedMember.COL_IDENTITY_ID).append(")") .append(" SELECT ?,").append(MFeedMember.COL_IDENTITY_ID) .append(" FROM ").append(MFeedMember.TABLE) .append(" INNER JOIN ").append(MIdentity.TABLE).append(" ON ") .append(MFeedMember.TABLE).append(".").append(MFeedMember.COL_IDENTITY_ID).append("=") .append(MIdentity.TABLE).append(".").append(MIdentity.COL_ID) .append(" WHERE 1=1") //.append(" AND ").append(MIdentity.TABLE).append(".").append(MIdentity.COL_CLAIMED).append("=1") .append(" AND ").append(MIdentity.COL_SENT_PROFILE_VERSION) .append("< ?").append(" AND ").append(MFeedMember.COL_FEED_ID) .append("=?"); mSqlPrepareProfile = db.compileStatement(sql.toString()); sql.setLength(0); sql.append(" SELECT ").append(MFeedMember.COL_ID).append(" FROM ") .append(MFeedMember.TABLE).append(" WHERE ") .append(MFeedMember.COL_FEED_ID + " = ? LIMIT 1"); mSqlCheckRecipients = db.compileStatement(sql.toString()); } } } try { MFeed newFeed = new MFeed(); newFeed.type_ = MFeed.FeedType.ONE_TIME_USE; newFeed.id_ = -1; db.beginTransaction(); mFeedManager.insertFeed(newFeed); assert (newFeed.id_ != -1); long accountFeedId = account.feedId_; long newFeedId = newFeed.id_; synchronized (mSqlPrepareProfile) { mSqlPrepareProfile.bindLong(1, newFeedId); if (onlyUnsynced) { // Unsynced profiles have version 0 < 1L. mSqlPrepareProfile.bindLong(2, 1L); } else { // Otherwise look for version # < currentVersion. mSqlPrepareProfile.bindLong(2, version); } mSqlPrepareProfile.bindLong(3, accountFeedId); mSqlPrepareProfile.execute(); } try { synchronized (mSqlCheckRecipients) { mSqlCheckRecipients.bindLong(1, newFeedId); mSqlCheckRecipients.simpleQueryForLong(); } // At least one recipient: mFeedManager.ensureFeedMember(newFeedId, sendAs.id_); db.setTransactionSuccessful(); return newFeed; } catch (SQLiteDoneException e) { // No recipients: // unsuccessful transaction discards feed return null; } } finally { db.endTransaction(); } } /** * Marks the members of the given feed as synced inefficiently. */ void markIdentitiesSynced(long[] identityIds, long profileVersion) { long start = System.currentTimeMillis(); SQLiteDatabase db = mHelper.getWritableDatabase(); if (mSqlMarkIdentitiesSynced == null) { synchronized (this) { String sql = new StringBuilder(100).append(" UPDATE ").append(MIdentity.TABLE) .append(" SET ").append(MIdentity.COL_SENT_PROFILE_VERSION).append("=?") .append(" WHERE ").append(MIdentity.COL_ID).append("=?") .append(" AND ").append(MIdentity.COL_SENT_PROFILE_VERSION).append("<?").toString(); mSqlMarkIdentitiesSynced = db.compileStatement(sql.toString()); } } synchronized (mSqlMarkIdentitiesSynced) { final int batch = 20; for(int i = 0; i < identityIds.length; ++i) { if (i % batch == 0) { if (i > 0) { db.setTransactionSuccessful(); db.endTransaction(); } db.beginTransaction(); } mSqlMarkIdentitiesSynced.bindLong(1, profileVersion); mSqlMarkIdentitiesSynced.bindLong(2, identityIds[i]); mSqlMarkIdentitiesSynced.bindLong(3, profileVersion); mSqlMarkIdentitiesSynced.execute(); } db.setTransactionSuccessful(); db.endTransaction(); } long time = System.currentTimeMillis() - start; if (DBG) Log.d(TAG, "Synced " + identityIds.length + " profiles in " + time); } /** * Marks the members of the given feed as synced. */ void markIdentitiesSyncedOneShot(long[] identityIds, long profileVersion) { long start = System.currentTimeMillis(); SQLiteDatabase db = mHelper.getWritableDatabase(); StringBuilder sql = new StringBuilder(200).append(MIdentity.COL_ID).append(" in ("); boolean first = true; for (long id : identityIds) { if (!first) sql.append(","); sql.append(id); first = false; } sql.append(")").append(" AND ").append(MIdentity.COL_SENT_PROFILE_VERSION).append("<").append(profileVersion); ContentValues values = new ContentValues(); values.put(MIdentity.COL_SENT_PROFILE_VERSION, profileVersion); int count = db.update(MIdentity.TABLE, values, sql.toString(), null); Log.d(TAG, "marked " + count + " profiles as synced"); long time = System.currentTimeMillis() - start; Log.d(TAG, "synced profiles in " + time); } /** * @See ProfileObj */ Obj profileObjForLocalUser(MIdentity sendAs, boolean replyRequested, boolean includePrincipal) { assert (sendAs != null); JSONObject json = new JSONObject(); byte[] thumbnail = null; try { mIdentityManager.getMusubiThumbnail(sendAs); json.put(ProfileObj.NAME, sendAs.musubiName_); //TODO: reuse of this field could cause something weird one day... json.put(ProfileObj.VERSION, sendAs.receivedProfileVersion_); json.put(ProfileObj.REPLY, replyRequested); if(includePrincipal) json.put(ProfileObj.PRINCIPAL, sendAs.principal_); thumbnail = sendAs.musubiThumbnail_; // TODO: Add local device properties like bluetooth address /** @see ProfileObj **/ } catch (JSONException e) { e.printStackTrace(); } return new MemObj(ProfileObj.TYPE, json, thumbnail); } public ContentObserver getProfileUpdateObserver() { return mProfileUpdatedObserver; } /** * Listens for changes to the user's local profile and flags all identities * as needing a profile update. * */ class ProfileUpdateObserver extends ContentObserver { public ProfileUpdateObserver(Handler handler) { super(handler); } @Override public void onChange(boolean selfChange) { flagIdentitiesForProfileUpdate(); ProfilePushProcessor.this.dispatchChange(false); } /** * Flags the user's account identities as having been just updated. */ void flagIdentitiesForProfileUpdate() { MMyAccount[] accounts = mAccountManager.getMyAccounts(); StringBuilder builder = new StringBuilder(); for (MMyAccount a : accounts) { if (a.identityId_ != null) { builder.append(",").append(a.identityId_); } } if (builder.length() == 0) { Log.w(TAG, "No linked identities for profile update"); return; } String myAccountIdentities = builder.substring(1); SQLiteDatabase db = App.getDatabaseSource(mContext).getWritableDatabase(); String table = MIdentity.TABLE; ContentValues values = new ContentValues(); values.put(MIdentity.COL_RECEIVED_PROFILE_VERSION, new Date().getTime()); String whereClause = MIdentity.COL_ID + " in (" + myAccountIdentities + ")"; String[] whereArgs = null; db.update(table, values, whereClause, whereArgs); } } public static Obj getProfileRequestObj() { JSONObject json = new JSONObject(); try { json.put(ProfileObj.REPLY, true); } catch (JSONException e) { e.printStackTrace(); } return new MemObj(ProfileObj.TYPE, json, null); }; }