/*
* Copyright (C) 2014 Eric Butler
*
* 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 com.tapchatapp.android.client;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.text.TextUtils;
import android.util.Log;
import com.google.common.base.Objects;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.gson.Gson;
import com.google.gson.stream.JsonReader;
import com.squareup.otto.Bus;
import com.squareup.otto.Produce;
import com.squareup.otto.Subscribe;
import com.tapchatapp.android.app.TapchatApp;
import com.tapchatapp.android.app.event.BufferSelectedEvent;
import com.tapchatapp.android.app.event.ConnectionAddedEvent;
import com.tapchatapp.android.app.event.ConnectionRemovedEvent;
import com.tapchatapp.android.app.event.ServiceDestroyedEvent;
import com.tapchatapp.android.app.event.ServiceErrorEvent;
import com.tapchatapp.android.app.event.ServiceReadyEvent;
import com.tapchatapp.android.app.event.ServiceStateChangedEvent;
import com.tapchatapp.android.client.message.BacklogCompleteMessage;
import com.tapchatapp.android.client.message.ConnectionDeletedMessage;
import com.tapchatapp.android.client.message.HeaderMessage;
import com.tapchatapp.android.client.message.HeartbeatEchoMessage;
import com.tapchatapp.android.client.message.IdleMessage;
import com.tapchatapp.android.client.message.MakeServerMessage;
import com.tapchatapp.android.client.message.Message;
import com.tapchatapp.android.client.message.OobIncludeMessage;
import com.tapchatapp.android.client.message.ResponseMessage;
import com.tapchatapp.android.client.message.StatUserMessage;
import com.tapchatapp.android.client.message.SysMsgsMessage;
import com.tapchatapp.android.client.message.request.AddServerMessage;
import com.tapchatapp.android.client.message.request.HeartbeatMessage;
import com.tapchatapp.android.client.model.Buffer;
import com.tapchatapp.android.client.model.Connection;
import com.tapchatapp.android.client.model.HeartbeatState;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
import java.util.TreeMap;
import javax.inject.Inject;
import retrofit.client.Response;
public class TapchatService extends Service implements TapchatBouncerConnection.Callback {
private static final String TAG = "TapchatService";
public static final int STATE_DISCONNECTED = 0;
public static final int STATE_CONNECTING = 1;
public static final int STATE_CONNECTED = 2;
public static final int STATE_LOADING = 3;
public static final int STATE_LOADED = 4;
private static final int RECOMMENDED_SERVER_VERSION = 41;
private final IBinder mBinder = new LocalBinder();
private final List<Message> mMessageCache = Lists.newArrayList();
private final Map<Integer, PostCallbackInfo> mResponseHandlers = Maps.newHashMap();
private final Map<Long, Connection> mConnections = Collections.synchronizedMap(new TreeMap<Long, Connection>());
private final Map<String, MessageHandler> mMessageHandlers = ImmutableMap.<String, MessageHandler>builder()
.put(HeaderMessage.TYPE, new MessageHandler<HeaderMessage>() {
@Override public void handleMessage(HeaderMessage message) throws Exception {
// FIXME: mTimeOffset = new Date() - message.getLong("time");
// mMaxIdle = message.getLong("idle_interval");
mLoadingOobBacklog = false;
setConnectionState(STATE_LOADING);
if (!TextUtils.isEmpty(message.version_name) && message.version_code != null) {
mServerVersionName = message.version_name;
mServerVersionCode = message.version_code;
}
TapchatApp app = TapchatApp.get();
if (!TextUtils.isEmpty(message.push_id) && !TextUtils.isEmpty(message.push_key)) {
app.setPushInfo(message.push_id, message.push_key);
} else {
app.setPushInfo(null, null);
}
}
})
.put(StatUserMessage.TYPE, new MessageHandler<StatUserMessage>() {
@Override public void handleMessage(StatUserMessage message) throws Exception {
// FIXME: Store user info
mActiveConnections = message.num_active_connections;
}
})
.put(OobIncludeMessage.TYPE, new MessageHandler<OobIncludeMessage>() {
@Override public void handleMessage(OobIncludeMessage message) throws Exception {
oobInclude(message.url);
}
})
.put(BacklogCompleteMessage.TYPE, new MessageHandler<BacklogCompleteMessage>() {
@Override public void handleMessage(BacklogCompleteMessage message) throws Exception {
for (Connection connection : mConnections.values()) {
if (!connection.exists()) {
removeConnection(connection);
}
}
setConnectionState(STATE_LOADED);
startHeartbeat();
}
})
.put(MakeServerMessage.TYPE, new MessageHandler<MakeServerMessage>() {
@Override public void handleMessage(MakeServerMessage message) throws Exception {
Connection connection = mConnections.get(message.cid);
if (connection != null) {
Log.i("Connection", "Re-using connection!");
connection.reload(message);
} else {
connection = new Connection(TapchatService.this, message);
mConnections.put(connection.getId(), connection);
mBus.post(new ConnectionAddedEvent(connection));
}
}
})
.put(ConnectionDeletedMessage.TYPE, new MessageHandler<ConnectionDeletedMessage>() {
@Override public void handleMessage(ConnectionDeletedMessage message) throws Exception {
Connection connection = mConnections.get(message.cid);
removeConnection(connection);
}
})
.put(HeartbeatEchoMessage.TYPE, new MessageHandler<HeartbeatEchoMessage>() {
@Override public void handleMessage(HeartbeatEchoMessage message) throws Exception {
Map<String, Map<String, Long>> seenEids = message.seenEids;
for (String cid : seenEids.keySet()) {
Connection connection = mConnections.get(Long.valueOf(cid));
if (connection != null) {
Map<String, Long> buffers = seenEids.get(cid);
for (String bid : buffers.keySet()) {
Buffer buffer = connection.getBuffer(Long.valueOf(bid));
if (buffer != null) {
buffer.markRead(buffers.get(bid));
}
}
}
}
}
})
.put(IdleMessage.TYPE, new MessageHandler<IdleMessage>() {
@Override public void handleMessage(IdleMessage message) throws Exception {
// Ignore, mLastMessageAt will still be updated above.
}
})
.put(SysMsgsMessage.TYPE, new MessageHandler<SysMsgsMessage>() {
@Override public void handleMessage(SysMsgsMessage message) throws Exception {
// FIXME
// {"bid":-1,"eid":-1,"type":"sys_msgs","time":1332374270,"highlight":false,"hardzombie":1332021930}
}
})
.build();
private int mReqId = 0;
private int mConnectionState;
private int mActiveConnections;
private boolean mLoadingOobBacklog;
private int mServerVersionCode = -1;
private Buffer mSelectedBuffer;
private Date mLastMessageAt;
private Handler mHandler;
private HeartbeatState mState;
private String mServerVersionName;
private TapchatBouncerConnection mBouncerConnection;
private Timer mHeartbeatTimer;
@Inject Bus mBus;
@Inject Gson mGson;
@Inject TapchatAPI mAPI;
@Inject TapchatSession mSession;
public void addServer(String name, String hostname, String nickname, String port, String realname, boolean useSSL,
String password, TapchatService.PostCallback callback) {
AddServerMessage message = new AddServerMessage();
message.name = name;
message.hostname = hostname;
message.nickname = nickname;
message.port = port;
message.realname = realname;
message.ssl = useSSL ? "1" : "0";
message.server_pass = password;
post(message, callback);
}
public void connect() {
if (mConnectionState != STATE_DISCONNECTED)
return;
setConnectionState(STATE_CONNECTING);
if (mBouncerConnection == null) {
mBouncerConnection = new TapchatBouncerConnection(mSession, this);
}
mBouncerConnection.start();
}
public void disconnect() {
if (mConnectionState == STATE_DISCONNECTED)
return;
setConnectionState(STATE_DISCONNECTED);
mConnections.clear();
if (mHeartbeatTimer != null) {
mHeartbeatTimer.cancel();
mHeartbeatTimer = null;
}
if (mBouncerConnection != null) {
mBouncerConnection.stop();
mBouncerConnection = null;
}
}
public Buffer getBuffer(long connectionId, long bufferId) {
Connection connection = getConnection(connectionId);
if (connection == null) {
return null;
}
return connection.getBuffer(bufferId);
}
public Connection getConnection(long id) {
return mConnections.get(id);
}
public int getConnectionCount() {
return mConnections.size();
}
public int getConnectionState() {
return mConnectionState;
}
private void setConnectionState(int state) {
mConnectionState = state;
if (state == TapchatService.STATE_DISCONNECTED) {
synchronized (mConnections) {
for (Connection connection : mConnections.values()) {
connection.serviceDisconnected();
}
}
}
mBus.post(new ServiceStateChangedEvent(this));
}
public List<Connection> getConnections() {
return new ArrayList<>(mConnections.values());
}
public int getNextReqId() {
return ++mReqId;
}
public Buffer getSelectedBuffer() {
return mSelectedBuffer;
}
public boolean isServerOutdated() {
return mServerVersionCode != -1 && mServerVersionCode < RECOMMENDED_SERVER_VERSION;
}
public void logout() {
disconnect();
TapchatApp.get().setLoggedOut();
}
public void post(Message message, PostCallback callback) {
message.session = mSession.getSessionId();
message._reqid = getNextReqId();
mResponseHandlers.put(message._reqid, new PostCallbackInfo(message, callback));
if (mBouncerConnection == null) {
throw new IllegalStateException("No connection");
}
mBouncerConnection.send(message);
}
public void postToBus(Object event) {
mBus.post(event);
}
public void updateLoadingProgress() {
int numFinished = 0;
synchronized (mConnections) {
for (Connection conn : mConnections.values()) {
if (!conn.isBacklog())
numFinished++;
}
}
// numFinished mActiveConnections
}
@Override public void onBouncerConnect() {
setConnectionState(STATE_CONNECTED);
}
@Override public void onBouncerReceiveMessage(Message message) {
processMessage(message, false);
}
@Override public void onBouncerError(Exception ex) {
handleError(ex);
}
@Override public void onBouncerDisconnect() {
setConnectionState(STATE_DISCONNECTED);
if (mHeartbeatTimer != null) {
mHeartbeatTimer.cancel();
mHeartbeatTimer = null;
}
}
@Override public void onCreate() {
super.onCreate();
TapchatApp.get().inject(this);
mHandler = new Handler();
if (!TapchatApp.get().isConfigured()) {
throw new RuntimeException("Server was started before being configured!");
}
mBus.register(this);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String sessionId = prefs.getString(TapchatApp.PREF_SESSION_ID, null);
String hostname = prefs.getString(TapchatApp.PREF_SERVER_HOST, null);
int port = prefs.getInt(TapchatApp.PREF_SERVER_PORT, -1);
Uri.Builder builder = new Uri.Builder()
.scheme("https")
.encodedAuthority(String.format("%s:%s", Uri.encode(hostname), port));
mSession.setSessionId(sessionId);
mSession.setUri(builder.build());
connect();
mBus.post(new ServiceReadyEvent(this));
}
@Override public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
@Override public void onDestroy() {
super.onDestroy();
disconnect();
mBus.post(new ServiceDestroyedEvent(this));
mBus.unregister(this);
}
@Override public boolean onUnbind(Intent intent) {
return false;
}
@Override public IBinder onBind(Intent intent) {
return mBinder;
}
@Override public void onRebind(Intent intent) {
super.onRebind(intent);
}
@Produce public ServiceReadyEvent produceServiceReadyEvent() {
return new ServiceReadyEvent(this);
}
@Produce public ServiceStateChangedEvent produceServiceStateChangedEvent() {
if (mConnectionState != STATE_DISCONNECTED) {
return new ServiceStateChangedEvent(this);
}
return null;
}
@Subscribe public void onBufferSelected(BufferSelectedEvent event) {
Buffer buffer = getBuffer(event.getConnectionId(), event.getBufferId());
if (buffer == null) {
mSelectedBuffer = null;
return;
}
if (event.isSelected()) {
mSelectedBuffer = buffer;
mSelectedBuffer.markAllRead();
} else {
if (mSelectedBuffer == buffer) {
mSelectedBuffer = null;
}
}
}
private synchronized void processMessage(Message message, boolean oob) {
try {
if (message.error != null && message.error.equals("temp_unavailable")) {
throw new Exception("temporarily unavailable");
}
if ((!mLoadingOobBacklog) || oob) {
handleMessage(message);
} else {
cacheMessage(message);
}
} catch (Exception ex) {
handleError(ex);
}
}
private synchronized void handleMessage(Message message) throws Exception {
mLastMessageAt = new Date();
if (message._reqid != null) {
ResponseMessage responseMessage = (ResponseMessage) message;
int reqid = responseMessage._reqid;
if (responseMessage.msg != null) {
message = responseMessage.msg;
}
if (mResponseHandlers.containsKey(reqid)) {
final PostCallbackInfo info = mResponseHandlers.remove(reqid);
if (message.cid != null) {
Connection connection = getConnection(message.cid);
if (connection != null) {
connection.handleResponse(responseMessage, info.request);
}
}
if (info.callback != null) {
final ResponseMessage finalMessage = responseMessage;
mHandler.post(new Runnable() {
@Override
public void run() {
info.callback.run(finalMessage, info.request);
}
});
}
}
return;
}
String type = message.type;
if (message.type == null) {
Log.w(TAG, "Message has no type: " + message);
return;
}
if (mMessageHandlers.containsKey(type)) {
mMessageHandlers.get(type).handleMessage(message);
}
if (message.cid != null) {
Connection connection = mConnections.get(message.cid);
if (connection != null) {
connection.processMessage(message);
}
}
}
private void oobInclude(String path) throws Exception {
mLoadingOobBacklog = true;
Response response = mAPI.oobInclude(path.substring(1));
JsonReader reader = new JsonReader(new InputStreamReader(response.getBody().in()));
reader.beginArray();
while (reader.hasNext()) {
Message message = mGson.fromJson(reader, Message.class);
handleMessage(message);
}
reader.endArray();
reader.close();
mLoadingOobBacklog = false;
handleMessageCache();
}
private void cacheMessage(Message message) {
mMessageCache.add(message);
}
private void handleMessageCache() throws Exception {
for (Message message : mMessageCache) {
handleMessage(message);
}
mMessageCache.clear();
}
private void handleError(final Exception ex) {
Log.e("TapchatService", "ERROR!!!", ex);
disconnect();
mBus.post(new ServiceErrorEvent(ex));
}
private void startHeartbeat() {
mState = getHeartbeatState();
if (mHeartbeatTimer != null) {
mHeartbeatTimer.cancel();
}
mHeartbeatTimer = new Timer();
mHeartbeatTimer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
sendHeartbeat();
}
}, 0, 2000);
}
private void sendHeartbeat() {
HeartbeatState newState = getHeartbeatState();
Map stateDiff = diffHeartbeatState(mState, newState);
mState = newState;
if (!stateDiff.isEmpty()) {
HeartbeatMessage message = new HeartbeatMessage();
Map<String, Map<String, Long>> seenEids = (Map<String, Map<String, Long>>) stateDiff.get("seenEids");
if (seenEids != null) {
message.seenEids = seenEids;
} else {
// IRCCloud wants this
message.seenEids = new HashMap<>();
}
if (mSelectedBuffer != null) {
message.selectedBuffer = mSelectedBuffer.getId();
}
if (mBouncerConnection == null) {
// Race condition with the timer.
return;
}
post(message, null);
}
}
private HeartbeatState getHeartbeatState() {
HeartbeatState state = new HeartbeatState();
synchronized (mConnections) {
for (Connection connection : mConnections.values()) {
Map<String, Long> connObj = new HashMap<>();
for (Buffer buffer : connection.getBuffers()) {
connObj.put(String.valueOf(buffer.getId()), buffer.getLastSeenEid());
}
state.seenEids.put(String.valueOf(connection.getId()), connObj);
}
}
if (mSelectedBuffer != null) {
state.selectedBuffer = mSelectedBuffer.getId();
}
return state;
}
// FIXME: ughhhh
private Map diffHeartbeatState(HeartbeatState oldState, HeartbeatState newState) {
Map<String, Object> diffState = new HashMap<>();
if (!Objects.equal(newState.selectedBuffer, oldState.selectedBuffer)) {
diffState.put("selectedBuffer", newState.selectedBuffer);
}
Map<String, Map<String, Long>> newSeenEids = newState.seenEids;
if (newSeenEids != null) {
if (oldState.seenEids == null) {
diffState.put("seenEids", newSeenEids);
} else {
Map<String, Object> diffSeenEids = new HashMap<>();
Map<String, Map<String, Long>> oldSeenEids = oldState.seenEids;
for (String cid : newSeenEids.keySet()) {
Map<String, Long> newConnectionEids = newSeenEids.get(cid);
Map<String, Long> oldConnectionEids = oldSeenEids.get(cid);
if (!oldSeenEids.containsKey(cid)) {
diffSeenEids.put(cid, newConnectionEids);
} else {
Map<String, Long> diffConnectionEids = (Map<String, Long>) diffState.get(cid);
Map<String, Long> newBuffers = newSeenEids.get(cid);
for (String bid : newBuffers.keySet()) {
long newBufferSeenEid = newConnectionEids.containsKey(bid) ? newConnectionEids.get(bid) : 0;
long oldBufferSeenEid = oldConnectionEids.containsKey(bid) ? oldConnectionEids.get(bid) : 0;
if (newBufferSeenEid != oldBufferSeenEid) {
if (diffConnectionEids == null) {
diffConnectionEids = new HashMap<>();
diffSeenEids.put(cid, diffConnectionEids);
}
diffConnectionEids.put(bid, newBufferSeenEid);
}
}
}
}
if (!diffSeenEids.isEmpty()) {
diffState.put("seenEids", diffSeenEids);
}
}
}
return diffState;
}
private void removeConnection(Connection connection) {
mConnections.remove(connection.getId());
mBus.post(new ConnectionRemovedEvent(connection));
}
public interface PostCallback {
public void run(ResponseMessage message, Message request);
}
public class LocalBinder extends Binder {
public TapchatService getService() {
return TapchatService.this;
}
}
private class PostCallbackInfo {
public final Message request;
public final PostCallback callback;
public PostCallbackInfo(Message request, PostCallback callback) {
this.request = request;
this.callback = callback;
}
}
}