package pro.dbro.ble;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import pro.dbro.airshare.session.Peer;
import pro.dbro.ble.data.DataStore;
import pro.dbro.ble.data.model.DataUtil;
import pro.dbro.ble.data.model.Message;
import pro.dbro.ble.data.model.MessageCollection;
import pro.dbro.ble.protocol.IdentityPacket;
import pro.dbro.ble.protocol.MessagePacket;
import pro.dbro.ble.protocol.NoDataPacket;
import pro.dbro.ble.protocol.OwnedIdentityPacket;
import pro.dbro.ble.protocol.Protocol;
import timber.log.Timber;
/**
* This class orchestrates the flow between two ChatApp Peers, handing network requests and
* updating the {@link pro.dbro.ble.data.DataStore}. The client of this class may use
* {@link ChatPeerFlow.Callback} to update their UI or in-memory application state.
*
* The general gist of the flow:
*
* 1) Client peer writes identity
* 2) Client peer waits for host identity
* 3) Client peer writes outgoing messages
* 4) Client peer waits for incoming messages
* Created by davidbrodsky on 4/16/15.
*/
public class ChatPeerFlow {
public static class UnexpectedDataException extends Exception {
public UnexpectedDataException(String detailMessage) {
super(detailMessage);
}
}
/** Entity responsible for sending data to a peer */
public static interface DataOutlet {
public void sendData(Peer peer, byte[] data);
}
public static interface Callback {
public static enum ConnectionStatus { CONNECTED, DISCONNECTED }
public void onAppPeerStatusUpdated(@NonNull ChatPeerFlow flow,
@NonNull pro.dbro.ble.data.model.Peer peer,
@NonNull ConnectionStatus status);
public void onMessageSent(@NonNull ChatPeerFlow flow,
@NonNull Message message,
@NonNull pro.dbro.ble.data.model.Peer recipient);
public void onMessageReceived(@NonNull ChatPeerFlow flow,
@NonNull Message message,
@Nullable pro.dbro.ble.data.model.Peer sender);
}
private static final int MESSAGES_PER_RESPONSE = 50;
private static final int IDENTITIES_PER_RESPONSE = 10;
public static enum State { CLIENT_WRITE_ID, HOST_WRITE_ID, CLIENT_WRITE_MSGS, HOST_WRITE_MSGS }
private State mState = State.CLIENT_WRITE_ID;
private OwnedIdentityPacket mLocalIdentity;
private Peer mRemoteAirSharePeer;
private Protocol mProtocol;
private DataStore mDataStore;
private DataOutlet mOutlet;
private IdentityPacket mRemoteIdentity;
private Callback mCallback;
private ArrayDeque<MessagePacket> mMessageOutbox = new ArrayDeque<>();
private ArrayDeque<IdentityPacket> mIdentityOutbox = new ArrayDeque<>();
private boolean mPeerIsHost;
private boolean mIsComplete = false;
private boolean mFetchedMessages = false;
private boolean mFetchedIdentities = false;
private boolean mGotRemotePeerIdentity = false;
public ChatPeerFlow(DataStore dataStore,
Protocol protocol,
DataOutlet outlet,
Peer remotePeer,
boolean peerIsHost,
Callback callback) {
mRemoteAirSharePeer = remotePeer;
mOutlet = outlet;
mProtocol = protocol;
mDataStore = dataStore;
mLocalIdentity = (OwnedIdentityPacket) dataStore.getPrimaryLocalPeer().getIdentity();
mPeerIsHost = peerIsHost;
mCallback = callback;
// Client initiates flow
if (mPeerIsHost)
sendIdentity();
}
public boolean isComplete() {
return mIsComplete;
}
public Peer getRemoteAirSharePeer() {
return mRemoteAirSharePeer;
}
public void queueMessage(MessagePacket message) {
mMessageOutbox.add(message);
}
/**
* Called when data is acknowledged as sent to the peer passed to this instance's constructor
* @return whether this flow is complete and should not receive further events.
*/
public boolean onDataSent(byte[] data) throws UnexpectedDataException {
// When data is ack'd we should be in a local-peer writing state
if ((!mPeerIsHost && (mState == State.CLIENT_WRITE_ID || (mState == State.CLIENT_WRITE_MSGS && !mIsComplete))) ||
(mPeerIsHost && (mState == State.HOST_WRITE_ID || (mState == State.HOST_WRITE_MSGS && !mIsComplete)))) {
throw new IllegalStateException(String.format("onDataSent invalid state %s for local as %s", mState, mPeerIsHost ? "client" : "host"));
}
Timber.d("Sent data %s", DataUtil.bytesToHex(data));
byte type = mProtocol.getPacketType(data);
// TODO : Perhaps we should cache last sent item to avoid deserializing bytes we've
// just serialized in sendData
switch (mState) {
case HOST_WRITE_ID:
case CLIENT_WRITE_ID:
switch(type) {
case IdentityPacket.TYPE:
IdentityPacket sentIdPkt = mProtocol.deserializeIdentity(data);
mDataStore.createOrUpdateRemotePeerWithProtocolIdentity(sentIdPkt);
// We can only report the identity sent once we know the peer's identity
// We also always want to send our own identity first
if (mRemoteIdentity != null) {
Timber.d("Marked identity %s delivered to %s", sentIdPkt.alias, mRemoteIdentity.alias);
mDataStore.markIdentityDeliveredToPeer(sentIdPkt, mRemoteIdentity);
}
mIdentityOutbox.poll();
sendAsAppropriate();
break;
case NoDataPacket.TYPE:
incrementStateAndSendAsAppropriate();
break;
default:
throw new UnexpectedDataException(String.format("Expected IdentityPacket (type %d). Got type %d", IdentityPacket.TYPE, type));
}
break;
case HOST_WRITE_MSGS:
case CLIENT_WRITE_MSGS:
switch(type) {
case MessagePacket.TYPE:
MessagePacket msgPkt = mProtocol.deserializeMessageWithIdentity(data, mRemoteIdentity);
Message msg = mDataStore.createOrUpdateMessageWithProtocolMessage(msgPkt);
// Mark incoming messages as delivered to sender
mDataStore.markMessageDeliveredToPeer(msgPkt, mRemoteIdentity);
mCallback.onMessageSent(this, msg, mDataStore.getPeerByPubKey(mRemoteIdentity.publicKey));
mMessageOutbox.poll();
sendAsAppropriate();
break;
case NoDataPacket.TYPE:
incrementStateAndSendAsAppropriate();
break;
default:
throw new UnexpectedDataException(String.format("Expected MessagePacket (type %d). Got type %d", MessagePacket.TYPE, type));
}
break;
default:
Timber.e("Flow received unexpected response from client peer");
}
return mIsComplete;
}
/**
* Called when data is received from the peer passed to this instance's constructor
* @return whether this flow is complete and should not receive further events.
*/
public boolean onDataReceived(byte[] data) throws UnexpectedDataException {
// When data comes in we should be in a remote-peer writing state
if ((!mPeerIsHost && (mState == State.HOST_WRITE_ID || (mState == State.HOST_WRITE_MSGS && !mIsComplete))) ||
(mPeerIsHost && (mState == State.CLIENT_WRITE_ID || (mState == State.CLIENT_WRITE_MSGS && !mIsComplete)))) {
throw new IllegalStateException(String.format("onDataReceived invalid state %s for local as %s", mState, mPeerIsHost ? "client" : "host"));
}
//Timber.d("Received data %s", DataUtil.bytesToHex(data));
byte type = mProtocol.getPacketType(data);
switch (mState) {
case HOST_WRITE_ID:
case CLIENT_WRITE_ID:
switch(type) {
case IdentityPacket.TYPE:
mRemoteIdentity = mProtocol.deserializeIdentity(data);
Timber.d("Got remote identity for %s", mRemoteIdentity.alias);
pro.dbro.ble.data.model.Peer remotePeer = mDataStore.createOrUpdateRemotePeerWithProtocolIdentity(mRemoteIdentity);
// Only treat first identity as that of connected peer
if (!mGotRemotePeerIdentity) {
mCallback.onAppPeerStatusUpdated(this, remotePeer, Callback.ConnectionStatus.CONNECTED);
mGotRemotePeerIdentity = true;
}
break;
case NoDataPacket.TYPE:
Timber.d("Received identity NoData");
incrementStateAndSendAsAppropriate();
break;
default:
throw new UnexpectedDataException(String.format("Expected IdentityPacket (type %d). Got type %d", IdentityPacket.TYPE, type));
}
break;
case HOST_WRITE_MSGS:
case CLIENT_WRITE_MSGS:
switch (type) {
case MessagePacket.TYPE:
MessagePacket msgPkt = mProtocol.deserializeMessageWithIdentity(data, mRemoteIdentity);
Timber.d("Received msg %s", msgPkt.body);
// Mark incoming messages as delivered to sender
boolean isNewMessage = true;
Message existingMessage = mDataStore.getMessageBySignature(msgPkt.signature);
if (existingMessage != null) {
isNewMessage = false;
existingMessage.close();
}
// TODO : Allow updating a message?
Message msg = mDataStore.createOrUpdateMessageWithProtocolMessage(msgPkt);
mDataStore.markMessageDeliveredToPeer(msgPkt, mRemoteIdentity);
if (isNewMessage)
mCallback.onMessageReceived(this, msg, mDataStore.getPeerByPubKey(mRemoteIdentity.publicKey));
break;
case NoDataPacket.TYPE:
Timber.d("Received msg NoData");
incrementStateAndSendAsAppropriate();
break;
default:
throw new UnexpectedDataException(String.format("Expected MessagePacket (type %d). Got type %d", MessagePacket.TYPE, type));
}
break;
default:
Timber.e("Flow received unexpected response from client peer");
}
return mIsComplete;
}
private void sendIdentity() {
if (!mFetchedIdentities) {
// If we're the client, we're initiating the identity flow, and we won't have the remote identity yet
mIdentityOutbox.addAll(getIdentitiesForIdentity(mRemoteIdentity == null ? null : mRemoteIdentity.publicKey,
IDENTITIES_PER_RESPONSE));
mFetchedIdentities = true;
}
Timber.d("Send identity %s", mIdentityOutbox.size() == 0 ? "NoData" : "");
mOutlet.sendData(mRemoteAirSharePeer,
mIdentityOutbox.size() == 0 ?
mProtocol.serializeNoDataPacket(mLocalIdentity).rawPacket :
mIdentityOutbox.peek().rawPacket);
}
private void sendMessage() {
if (!mFetchedMessages) {
mMessageOutbox.addAll(getMessagesForIdentity(mRemoteIdentity.publicKey, MESSAGES_PER_RESPONSE));
mFetchedMessages = true;
}
Timber.d("Send message %s", mMessageOutbox.size() == 0 ? "NoData" : "");
mOutlet.sendData(mRemoteAirSharePeer,
mMessageOutbox.size() == 0 ?
mProtocol.serializeNoDataPacket(mLocalIdentity).rawPacket :
mMessageOutbox.peek().rawPacket);
}
private void incrementStateAndSendAsAppropriate() {
if (mState == State.HOST_WRITE_MSGS) {
Timber.d("ChatPeerFlow complete!");
mIsComplete = true;
return;
}
mState = State.values()[mState.ordinal() + 1];
Timber.d("ChatPeerFlow New State : %s", mState);
sendAsAppropriate();
}
private void sendAsAppropriate() {
switch (mState) {
case CLIENT_WRITE_ID:
if (mPeerIsHost) sendIdentity();
break;
case HOST_WRITE_ID:
if (!mPeerIsHost) sendIdentity();
break;
case CLIENT_WRITE_MSGS:
if (mPeerIsHost) sendMessage();
break;
case HOST_WRITE_MSGS:
if (!mPeerIsHost) sendMessage();
break;
}
}
/**
* Return a queue of message packets for delivery to remote identity with given public key.
*
* If recipientPublicKey is null, queues most recent messages
*/
private ArrayDeque<MessagePacket> getMessagesForIdentity(@Nullable byte[] recipientPublicKey, int maxMessages) {
ArrayDeque<MessagePacket> messagePacketQueue = new ArrayDeque<>();
if (recipientPublicKey != null) {
// Get messages not delievered to peer
pro.dbro.ble.data.model.Peer recipient = mDataStore.getPeerByPubKey(recipientPublicKey);
List<MessagePacket> messages = mDataStore.getOutgoingMessagesForPeer(recipient, maxMessages);
if (messages == null || messages.size() == 0) {
Timber.d("Got no messages for peer with pub key " + DataUtil.bytesToHex(recipientPublicKey));
} else {
messagePacketQueue.addAll(messages);
}
} else {
// Get most recent messages
MessageCollection recentMessages = mDataStore.getRecentMessages();
for (int x = 0; x < Math.min(maxMessages, recentMessages.getCursor().getCount()); x++) {
Message currentMessage = recentMessages.getMessageAtPosition(x);
if (currentMessage != null)
messagePacketQueue.add(currentMessage.getProtocolMessage(mDataStore));
}
recentMessages.close();
}
return messagePacketQueue;
}
/**
* Return a queue of identity packets for delivery to the remote identity with the given
* public key.
*
* If recipientPublicKey is null, or no messages undelivered for recipient,
* the user identity will be queued. As such this method will never return a null
* or empty queue. Thus it should only be called once per flow and should not
* be used as an indication of whether identity transmission with a peer is complete.
*/
private ArrayDeque<IdentityPacket> getIdentitiesForIdentity(@Nullable byte[] recipientPublicKey, int maxIdentities) {
List<IdentityPacket> identities = null;
ArrayDeque<IdentityPacket> identityPacketQueue = new ArrayDeque<>();
if (recipientPublicKey != null) {
// We have a public key for the remote peer, fetch undelivered identities
pro.dbro.ble.data.model.Peer recipient = mDataStore.getPeerByPubKey(recipientPublicKey);
identities = mDataStore.getOutgoingIdentitiesForPeer(recipient, maxIdentities);
}
if (identities == null || identities.size() == 0) {
Timber.d("Got no identities to send for peer %s. Sending own identity", recipientPublicKey == null ? "" : "with pub key " + DataUtil.bytesToHex(recipientPublicKey).substring(2, 6));
// For now, at least send our identity
if (identities == null) identities = new ArrayList<>(1);
identities.add(mDataStore.getPrimaryLocalPeer().getIdentity());
}
identityPacketQueue.addAll(identities);
return identityPacketQueue;
}
}