/*
* 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 gnu.trove.list.linked.TLongLinkedList;
import gnu.trove.map.hash.TLongLongHashMap;
import gnu.trove.procedure.TLongProcedure;
import gnu.trove.set.hash.TLongHashSet;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import mobisocial.crypto.IBHashedIdentity;
import mobisocial.crypto.IBHashedIdentity.Authority;
import mobisocial.musubi.App;
import mobisocial.musubi.model.MEncodedMessage;
import mobisocial.musubi.model.MIdentity;
import mobisocial.musubi.model.helpers.DeviceManager;
import mobisocial.musubi.model.helpers.EncodedMessageManager;
import mobisocial.musubi.model.helpers.FeedManager;
import mobisocial.musubi.model.helpers.IdentitiesManager;
import mobisocial.musubi.protocol.Message;
import mobisocial.musubi.provider.MusubiContentProvider;
import mobisocial.musubi.provider.MusubiContentProvider.Provided;
import org.codehaus.jackson.map.ObjectMapper;
import android.app.Service;
import android.content.ContentResolver;
import android.content.Intent;
import android.database.ContentObserver;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.os.Binder;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Process;
import android.util.Base64;
import android.util.Log;
import com.rabbitmq.client.AMQP.BasicProperties;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.ConfirmListener;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DefaultConsumer;
import com.rabbitmq.client.Envelope;
import com.rabbitmq.client.ShutdownListener;
import com.rabbitmq.client.ShutdownSignalException;
import de.undercouch.bson4jackson.BsonFactory;
//TODO:XXX
//amqp is not quite perfect so this implementation delivers the routing
//properties we want but not the security properties.
public class AMQPService extends Service {
public static boolean DBG = false;
public static final String TAG = AMQPService.class.getName();
static final String AMQP_QUEUE_GLOBAL_PREFIX = "msb";
static final String AMQP_SERVICE = "bumblebee.musubi.us";
ConnectionFactory mConnectionFactory;
Connection mConnection;
Channel mIncomingChannel;
Channel mOutgoingChannel;
Channel mGroupProbeChannel;
//we need to do operations on the connection object in background thread
//because some of them are blocking.
HandlerThread mThread;
IdentitiesManager mIdentitiesManager;
DeviceManager mDeviceManager;
EncodedMessageManager mEncodedMessageManager;
SQLiteOpenHelper mDatabaseSource;
//always run the message sender when the service first boots
TLongHashSet mMessageWaitingForAck;
TLongLongHashMap mMessageWaitingForAckByTag;
Long mNeedAMessageBy;
Handler mAMQPHandler;
ObjectMapper mMapper;
HashSet<String> mDeclaredGroups;
boolean mConnectionReady = false;
final static long MIN_DELAY = 10 * 1000;
final static long MAX_DELAY = 30 * 60 * 1000;
long mFailureDelay = MIN_DELAY;
enum FailedOperationType {
FailedNone,
FailedConnect,
FailedPublish,
FailedReceive,
}
FailedOperationType mFailedOperation = FailedOperationType.FailedConnect;
public AMQPService() {
super();
}
//we create one directly that
AMQPService(SQLiteOpenHelper databaseSource) {
mDatabaseSource = databaseSource;
initializeAMQP();
}
public boolean isConnected() {
return mConnectionReady;
}
class SendableMessageAvailableHandler extends ContentObserver {
final Handler mHandler;
public SendableMessageAvailableHandler(Handler h) {
super(h);
mHandler = h;
}
@Override
public void onChange(boolean selfChange) {
sendMessages();
}
}
class DropConnectionAndReconnectHandler extends ContentObserver {
final Handler mHandler;
public DropConnectionAndReconnectHandler(Handler handler) {
super(handler);
mHandler = handler;
}
@Override
public void onChange(boolean selfChange) {
closeConnection(FailedOperationType.FailedNone);
initiateConnection();
}
}
class ResetBackOffAndReconnectIfNotConnected extends ContentObserver {
final Handler mHandler;
public ResetBackOffAndReconnectIfNotConnected(Handler handler) {
super(handler);
mHandler = handler;
}
@Override
public void onChange(boolean selfChange) {
mFailureDelay = MIN_DELAY;
if(!mConnectionReady) {
initiateConnection();
}
}
}
@Override
public void onCreate() {
mDatabaseSource = App.getDatabaseSource(this);
initializeAMQP();
ContentResolver resolver = getContentResolver();
resolver.registerContentObserver(MusubiService.PREPARED_ENCODED, false,
new SendableMessageAvailableHandler(mAMQPHandler));
resolver.registerContentObserver(MusubiService.OWNED_IDENTITY_AVAILABLE, false,
new DropConnectionAndReconnectHandler(mAMQPHandler));
resolver.registerContentObserver(MusubiService.NETWORK_CHANGED, false,
new ResetBackOffAndReconnectIfNotConnected(mAMQPHandler));
resolver.registerContentObserver(MusubiService.USER_ACTIVITY_RESUME, false,
new ResetBackOffAndReconnectIfNotConnected(mAMQPHandler));
Log.w(TAG, "service is now running");
}
private void initializeAMQP() {
mIdentitiesManager = new IdentitiesManager(mDatabaseSource);
mDeviceManager = new DeviceManager(mDatabaseSource);
mEncodedMessageManager = new EncodedMessageManager(mDatabaseSource);
mConnectionFactory = new ConnectionFactory();
mConnectionFactory.setHost(AMQP_SERVICE);
mConnectionFactory.setConnectionTimeout(30 * 1000);
mConnectionFactory.setRequestedHeartbeat(30);
mThread = new HandlerThread("AMQP");
mThread.setPriority(Thread.MIN_PRIORITY);
mThread.start();
mAMQPHandler = new Handler(mThread.getLooper());
//start the connection
mAMQPHandler.post(new Runnable() {
@Override
public void run() {
initiateConnection();
}
});
}
String encodeAMQPname(String prefix, byte[] key) {
if (AMQP_QUEUE_GLOBAL_PREFIX != null) {
return new StringBuilder().append(AMQP_QUEUE_GLOBAL_PREFIX)
.append(prefix).append(Base64.encodeToString(key, Base64.URL_SAFE))
.toString();
}
return prefix + Base64.encodeToString(key, Base64.URL_SAFE);
}
void closeConnection(FailedOperationType failure) {
assert(mThread.getThreadId() == Process.myTid());
if(mConnection != null) {
Log.i(TAG, "closing connection");
mConnectionReady = false;
mMessageWaitingForAck = null;
mMessageWaitingForAckByTag = null;
mDeclaredGroups = null;
try {
mConnection.abort();
} catch(Throwable t) {
//never fail on a close
}
mOutgoingChannel = null;
mIncomingChannel = null;
mGroupProbeChannel = null;
mConnection = null;
}
if(failure != FailedOperationType.FailedNone) {
mFailedOperation = failure;
mFailureDelay = Math.min(mFailureDelay * 2, MAX_DELAY);
Log.i(TAG, mFailedOperation.toString() + " retry delay now " + mFailureDelay + "ms");
mAMQPHandler.postDelayed(new Runnable() {
@Override
public void run() {
initiateConnection();
}
}, mFailureDelay);
}
}
void sendMessages() {
if(!mConnectionReady) {
//we should always be trying to reconnect already
//so that we can receive new messages
return;
}
TLongLinkedList potentiallUnsent = mEncodedMessageManager.getUnsentOutboundIdsNotPending();
potentiallUnsent.forEach(new TLongProcedure() {
@Override
public boolean execute(long id) {
if(mMessageWaitingForAck.contains(id))
return true;
try {
byte[] encodedBytes = mEncodedMessageManager.lookupEncodedDataById(id);
byte[] group_exchange_name_bytes;
//TODO: load this from an in memory cache of recently encoded messages
//TODO: major performance gain on sending
IBHashedIdentity[] hid_for_queue;
try {
Message m = getObjectMapper().readValue(encodedBytes, Message.class);
MIdentity[] ids = new MIdentity[m.r.length];
hid_for_queue = new IBHashedIdentity[m.r.length];
for(int i = 0; i < ids.length; ++i) {
hid_for_queue[i] = new IBHashedIdentity(m.r[i].i).at(0);
ids[i] = new MIdentity();
ids[i].principalHash_ = hid_for_queue[i].hashed_;
ids[i].type_ = Authority.values()[hid_for_queue[i].authority_.ordinal()];
}
group_exchange_name_bytes = FeedManager.computeFixedIdentifier(ids);
} catch (IOException e) {
Log.e(TAG, "failed to compute group exchange name");
return true;
}
String group_exchange_name = encodeAMQPname("ibetgroup-", group_exchange_name_bytes);
if(!mDeclaredGroups.contains(group_exchange_name)) {
if(DBG) Log.v(TAG, "exchangeDeclare " + group_exchange_name);
mOutgoingChannel.exchangeDeclare(group_exchange_name, "fanout", false);
for (IBHashedIdentity recipient : hid_for_queue){
String dest = encodeAMQPname("ibeidentity-", recipient.identity_);
if(DBG) Log.v(TAG, "exchangeDeclarePassive " + dest);
try {
if(mGroupProbeChannel == null)
mGroupProbeChannel = mConnection.createChannel();
if(DBG) Log.v(TAG, "exchangeDeclarePassive " + dest);
mGroupProbeChannel.exchangeDeclarePassive(dest);
} catch(IOException e) {
mGroupProbeChannel = null;
//TODO: XXX hack
//if the user hasn't connected yet, we have to dump their messages
//into a specific well known queue, because we don't know what the name
//of their device key is
if(DBG) Log.v(TAG, "queueDeclare " + "initial-" + dest);
mOutgoingChannel.queueDeclare("initial-" + dest, true, false, false, null);
if(DBG) Log.v(TAG, "exchangeDeclare " + dest);
mOutgoingChannel.exchangeDeclare(dest, "fanout", true);
if(DBG) Log.v(TAG, "queueBind " + "initial-" + dest + " " + dest);
mOutgoingChannel.queueBind("initial-" + dest, dest, "");
}
if(DBG) Log.v(TAG, "exchangeBind " + dest + " " + group_exchange_name);
mOutgoingChannel.exchangeBind(dest, group_exchange_name, "");
}
mDeclaredGroups.add(group_exchange_name);
}
if(DBG) Log.v(TAG, "basicPublish => " + group_exchange_name);
long delivery_tag = mOutgoingChannel.getNextPublishSeqNo();
mOutgoingChannel.basicPublish(group_exchange_name, "", true, false, null, encodedBytes);
mMessageWaitingForAck.add(id);
mMessageWaitingForAckByTag.put(delivery_tag, id);
if(mFailedOperation == FailedOperationType.FailedPublish) {
mFailureDelay = MIN_DELAY;
mFailedOperation = FailedOperationType.FailedNone;
}
return true;
} catch (Throwable e) {
Log.e(TAG, "Failed to send message, aborting connection", e);
closeConnection(FailedOperationType.FailedPublish);
return false;
}
}
});
}
void attachToQueues() throws IOException {
Log.i(TAG, "Setting up identity exchange and device queue");
DefaultConsumer consumer = new DefaultConsumer(mIncomingChannel) {
@Override
public void handleDelivery(final String consumerTag, final Envelope envelope,
final BasicProperties properties, final byte[] body) throws IOException
{
if(DBG) Log.i(TAG, "recevied message: " + envelope.getExchange());
assert(body != null);
//TODO: throttle if we have too many incoming?
//TODO: check blacklist up front?
//TODO: check hash up front?
MEncodedMessage encoded = new MEncodedMessage();
encoded.encoded_ = body;
mEncodedMessageManager.insertEncoded(encoded);
getContentResolver().notifyChange(MusubiService.ENCODED_RECEIVED, null);
//we have to do this in our AMQP thread, or add synchronization logic
//for all of the AMQP related fields.
mAMQPHandler.post(new Runnable() {
public void run() {
if(mIncomingChannel == null) {
//it can close in this time window
return;
}
try {
mIncomingChannel.basicAck(envelope.getDeliveryTag(), false);
if(mFailedOperation == FailedOperationType.FailedReceive) {
mFailureDelay = MIN_DELAY;
mFailedOperation = FailedOperationType.FailedNone;
}
} catch (Throwable e) {
Log.e(TAG, "failed to ack message on AMQP", e);
//next connection that receives a packet wins
closeConnection(FailedOperationType.FailedReceive);
}
}
});
}
};
byte[] device_name = new byte[8];
ByteBuffer.wrap(device_name).putLong(mDeviceManager.getLocalDeviceName());
String device_queue_name = encodeAMQPname("ibedevice-", device_name);
//leaving these since they mark the beginning of the connection and shouldn't be too frequent (once per drop)
Log.v(TAG, "queueDeclare " + device_queue_name);
mIncomingChannel.queueDeclare(device_queue_name, true, false, false, null);
//TODO: device_queue_name needs to involve the identities some how? or be a larger byte array
List<MIdentity> mine = mIdentitiesManager.getOwnedIdentities();
for(MIdentity me : mine) {
IBHashedIdentity id = IdentitiesManager.toIBHashedIdentity(me, 0);
String identity_exchange_name = encodeAMQPname("ibeidentity-", id.identity_);
Log.v(TAG, "exchangeDeclare " + identity_exchange_name);
mIncomingChannel.exchangeDeclare(identity_exchange_name, "fanout", true);
Log.v(TAG, "queueBind " + device_queue_name + " " + identity_exchange_name);
mIncomingChannel.queueBind(device_queue_name, identity_exchange_name, "");
try {
Log.v(TAG, "queueDeclarePassive " + "initial-" + identity_exchange_name);
Channel incoming_initial = mConnection.createChannel();
incoming_initial.queueDeclarePassive("initial-" + identity_exchange_name);
try {
Log.v(TAG, "queueUnbind " + "initial-" + identity_exchange_name + " " + identity_exchange_name);
// we now have claimed our identity, unbind this queue
incoming_initial.queueUnbind("initial-" + identity_exchange_name, identity_exchange_name, "");
//but also drain it
} catch(IOException e) {
}
Log.v(TAG, "basicConsume " + "initial-" + identity_exchange_name);
mIncomingChannel.basicConsume("initial-" + identity_exchange_name, consumer);
} catch(IOException e) {
//no one sent up messages before we joined
//IF we deleted it: we already claimed our identity, so we ate this queue up
}
}
Log.v(TAG, "basicConsume " + device_queue_name);
mIncomingChannel.basicConsume(device_queue_name, false, "", true, true, null, consumer);
}
void initiateConnection() {
if(mConnection != null) {
//just for information sake, this is a legitimate event
Log.i(TAG, "Already connected when triggered to initiate connection");
return;
}
Log.i(TAG, "Network is up connection is being attempted");
try {
mConnection = mConnectionFactory.newConnection();
mConnection.addShutdownListener(new ShutdownListener() {
@Override
public void shutdownCompleted(ShutdownSignalException cause) {
if(!cause.isInitiatedByApplication()) {
//start the connection
mAMQPHandler.post(new Runnable() {
@Override
public void run() {
closeConnection(FailedOperationType.FailedConnect);
}
});
}
}
});
mDeclaredGroups = new HashSet<String>();
mMessageWaitingForAck = new TLongHashSet();
mMessageWaitingForAckByTag = new TLongLongHashMap();
mIncomingChannel = mConnection.createChannel();
mIncomingChannel.basicQos(10);
attachToQueues();
mOutgoingChannel = mConnection.createChannel();
//TODO: these callbacks run in another thread, so this is not correct
//we need some synchronized or a customized ExecutorService that
//posts to the handler (though, that may not be possible if they demand
//n threads to run on
mOutgoingChannel.addConfirmListener(new ConfirmListener() {
@Override
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
//don't immediately try to resend, just flag it, it will be rescanned later
//this probably only happens if the server is temporarily out of space
long encoded_id = mMessageWaitingForAckByTag.get(deliveryTag);
mMessageWaitingForAckByTag.remove(deliveryTag);
mMessageWaitingForAck.remove(encoded_id);
}
@Override
public void handleAck(long deliveryTag, boolean multiple) throws IOException {
//delivered!
long encoded_id = mMessageWaitingForAckByTag.get(deliveryTag);
//mark the db entry as processed
MEncodedMessage encoded = mEncodedMessageManager.lookupMetadataById(encoded_id);
assert(encoded.outbound_);
encoded.processed_ = true;
encoded.processedTime_ = new Date().getTime();
mEncodedMessageManager.updateEncodedMetadata(encoded);
mMessageWaitingForAckByTag.remove(deliveryTag);
mMessageWaitingForAck.remove(encoded_id);
long feedId = mEncodedMessageManager.getFeedIdForEncoded(encoded_id);
if (feedId != -1) {
Uri feedUri = MusubiContentProvider.uriForItem(Provided.FEEDS_ID, feedId);
getContentResolver().notifyChange(feedUri, null);
}
}
});
mOutgoingChannel.confirmSelect();
mConnectionReady = true;
//once we have successfully done our work, we can
//reset the failure delay, FYI, internal exceptions in the
//message sender will cause backoff to MAX_DELAY
if(mFailedOperation == FailedOperationType.FailedConnect) {
mFailureDelay = MIN_DELAY;
mFailedOperation = FailedOperationType.FailedNone;
}
} catch (Throwable e) {
closeConnection(FailedOperationType.FailedConnect);
Log.e(TAG, "Failed to connect to AMQP", e);
}
//slight downside here is that if publish a message causes the fault,
//then we will always reconnect and disconnect
sendMessages();
}
private ObjectMapper getObjectMapper() {
if (mMapper == null) {
mMapper = new ObjectMapper(new BsonFactory());
}
return mMapper;
}
public boolean isConnectionReady() {
return mConnectionReady;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "Received start id " + startId + ": " + intent);
return START_STICKY;
}
@Override
public void onDestroy() {
//kick the background thread into shutdown mode
mAMQPHandler.post(new Runnable() {
@Override
public void run() {
closeConnection(FailedOperationType.FailedNone);
//give it half a second to close down
mAMQPHandler.postDelayed(new Runnable() {
public void run() {
mThread.getLooper().quit();
}
}, 500);
}
});
//wait for it to clean up
try {
mAMQPHandler.getLooper().getThread().join();
} catch (InterruptedException e) {}
}
public class AMQPServiceBinder extends Binder {
public AMQPService getService() {
return AMQPService.this;
}
}
private final IBinder mBinder = new AMQPServiceBinder();
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
}