package com.zulip.android.networking; import android.content.Intent; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.util.Log; import android.widget.Toast; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.dao.RuntimeExceptionDao; import com.j256.ormlite.misc.TransactionManager; import com.j256.ormlite.stmt.UpdateBuilder; import com.zulip.android.R; import com.zulip.android.ZulipApp; import com.zulip.android.activities.LoginActivity; import com.zulip.android.activities.RecyclerMessageAdapter; import com.zulip.android.activities.ZulipActivity; import com.zulip.android.models.Message; import com.zulip.android.models.MessageRange; import com.zulip.android.models.Person; import com.zulip.android.models.Reaction; import com.zulip.android.models.Stream; import com.zulip.android.networking.response.UserConfigurationResponse; import com.zulip.android.networking.response.events.EditMessageWrapper; import com.zulip.android.networking.response.events.EventsBranch; import com.zulip.android.networking.response.events.GetEventResponse; import com.zulip.android.networking.response.events.MessageWrapper; import com.zulip.android.networking.response.events.MutedTopicsWrapper; import com.zulip.android.networking.response.events.ReactionWrapper; import com.zulip.android.networking.response.events.StarWrapper; import com.zulip.android.networking.response.events.StreamWrapper; import com.zulip.android.networking.response.events.SubscriptionWrapper; import com.zulip.android.networking.response.events.UpdateMessageWrapper; import com.zulip.android.util.MutedTopics; import com.zulip.android.util.TypeSwapper; import com.zulip.android.util.ZLog; import com.zulip.android.viewholders.MessageHeaderParent; import com.zulip.android.widget.ZulipWidget; import org.json.JSONException; import java.io.IOException; import java.net.SocketTimeoutException; import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; /** * A background task which asynchronously fetches the updates from the server. * The run method {@link #run()} which has an infinite loop and keeps fetching the latest updates * and handles the events appropriately. * If the user is not registered to a queue this registers {@link #register()} for a new lastEventId and queueID * <p> * lastEventId {@link ZulipApp#lastEventId} which is used to fetch after this ID events from the server. */ public class AsyncGetEvents extends Thread { private static final String TAG = "AsyncGetEvents"; private static final String ASYNC_GET_EVENTS = "asyncGetEvents"; private boolean keepThisRunning = true; private HTTPRequest request; private int failures = 0; private boolean registeredOrGotEventsThisRun; private MutedTopics mMutedTopics; private ZulipApp app; private ZulipActivity mActivity; private int mInterval = 1000; public AsyncGetEvents(ZulipActivity activity) { super(); mActivity = activity; init(); } public AsyncGetEvents(int interval) { super(); mInterval = interval; init(); } private void init() { app = ZulipApp.get(); mMutedTopics = MutedTopics.get(); request = new HTTPRequest(app); } public void start() { registeredOrGotEventsThisRun = false; if (mActivity != null) { super.start(); } } public void abort() { // TODO: does this have race conditions? (if the thread is not in a // request when called) Log.i(ASYNC_GET_EVENTS, "Interrupting thread"); keepThisRunning = false; request.abort(); } private void backoff(Exception e) { if (e != null) { ZLog.logException(e); } failures += 1; long backoff = (long) (Math.exp(failures / 2.0) * 1000); Log.e(ASYNC_GET_EVENTS, "Failure " + failures + ", sleeping for " + backoff); SystemClock.sleep(backoff); } /** * Registers for a new event queue with the Zulip API */ private void register() throws JSONException, IOException { retrofit2.Response<UserConfigurationResponse> response = app.getZulipServices() .register(true) .execute(); if (response.isSuccessful()) { UserConfigurationResponse res = response.body(); app.tester = app.getEventQueueId(); app.setEventQueueId(res.getQueueId()); app.setLastEventId(res.getLastEventId()); app.setPointer(res.getPointer()); app.setMaxMessageId(res.getMaxMessageId()); app.setMessageContentEditParams(res.getRealmMessageContentEditLimitSeconds(), res.isRealmAllowMessageEditing()); registeredOrGotEventsThisRun = true; processRegister(res); } } private void processRegister(final UserConfigurationResponse response) { // In task thread try { TransactionManager.callInTransaction(app.getDatabaseHelper() .getConnectionSource(), new Callable<Void>() { public Void call() throws Exception { // Get subscriptions List<Stream> subscriptions = response.getSubscriptions(); RuntimeExceptionDao<Stream, Object> streamDao = app .getDao(Stream.class); Log.i("stream", "" + subscriptions.size() + " subscribed streams"); // Mark all existing subscriptions as not subscribed streamDao.updateBuilder().updateColumnValue( Stream.SUBSCRIBED_FIELD, false); for (int i = 0; i < subscriptions.size(); i++) { Stream stream = subscriptions.get(i); stream.getParsedColor(); stream.setSubscribed(true); try { streamDao.createOrUpdate(stream); } catch (Exception e) { ZLog.logException(e); } } // get unsubscribed streams List<Stream> unsubscribed = response.getUnsubscribed(); for (Stream stream : unsubscribed) { stream.getParsedColor(); stream.setSubscribed(false); try { streamDao.createOrUpdate(stream); } catch (Exception e) { ZLog.logException(e); } } // get rest of the streams in the realm List<Stream> streams = response.getStreams(); for (Stream stream : streams) { try { // use default color for not subscribed streams stream.setDefaultColor(); streamDao.createIfNotExists(stream); } catch (Exception e) { ZLog.logException(e); } } //MUST UPDATE AFTER SUBSCRIPTIONS ARE STORED IN DB mMutedTopics.addToMutedTopics(response.getMutedTopics()); // Get people List<Person> people = response.getRealmUsers(); RuntimeExceptionDao<Person, Object> personDao = app .getDao(Person.class); // Mark all existing people as inactive personDao.updateBuilder().updateColumnValue( Person.ISACTIVE_FIELD, false); for (int i = 0; i < people.size(); i++) { Person person = people.get(i); person.setActive(true); try { if (person.getEmail().equalsIgnoreCase(app.getYou().getEmail())) { // set app.you to point to current user app.setYou(person); } personDao.createOrUpdate(person); } catch (Exception e) { ZLog.logException(e); } } return null; } }); if (mActivity != null) { mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.getPeopleAdapter().refresh(); mActivity.onReadyToDisplay(true); mActivity.checkAndSetupStreamsDrawer(); if (mActivity.commonProgressDialog != null && mActivity.commonProgressDialog.isShowing()) { mActivity.commonProgressDialog.dismiss(); } } }); } } catch (SQLException e) { ZLog.logException(e); } } public void run() { try { while (keepThisRunning) { try { request.clearProperties(); if (app.getEventQueueId() == null) { register(); } retrofit2.Response<GetEventResponse> eventResponse = app.getZulipServices() .getEvents(registeredOrGotEventsThisRun ? null : true, app.getLastEventId(), app.getEventQueueId()) .execute(); GetEventResponse body = eventResponse.body(); if (!eventResponse.isSuccessful()) { NetworkError errorBody = eventResponse.errorBody() != null ? app.getGson().fromJson(eventResponse.errorBody().string(), NetworkError.class) : null; if (eventResponse.code() == 400 && ((errorBody != null && errorBody.getMsg().contains("Bad event queue id")) || eventResponse.message().contains("too old"))) { // Queue dead. Register again. Log.w(ASYNC_GET_EVENTS, "Queue dead"); app.setEventQueueId(null); continue; } else if (eventResponse.code() == 401) { Handler handler = new Handler(Looper.getMainLooper()); handler.post(new Runnable() { @Override public void run() { Toast.makeText(app.getBaseContext(), R.string.force_logged_out, Toast.LENGTH_LONG).show(); app.logOut(); Intent i = new Intent(app, LoginActivity.class); i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK); app.startActivity(i); } }); break; } backoff(null); } else { if (body.getEvents().size() > 0) { this.processEvents(body); app.setLastEventId(body.getEvents().get(body.getEvents().size() - 1).getId()); failures = 0; } if (!registeredOrGotEventsThisRun && mActivity != null) { registeredOrGotEventsThisRun = true; mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.onReadyToDisplay(false); } }); } } } catch (SocketTimeoutException e) { Log.e(TAG, e.getMessage(), e); ZLog.logException(e); // Retry without backoff, since it's already been a while } catch (IOException e) { if (request.aborting) { Log.i(ASYNC_GET_EVENTS, "Thread aborted"); return; } else { backoff(e); } } catch (JSONException e) { backoff(e); } Thread.sleep(mInterval); } } catch (Exception e) { ZLog.logException(e); } } /** * Handles any event returned by the server that we care about. * * @param events sent by server */ private void processEvents(GetEventResponse events) { // In task thread // get subscription events List<EventsBranch> subscriptions = events.getEventsOfBranchType(EventsBranch.BranchType.SUBSCRIPTIONS); if (!subscriptions.isEmpty()) { Log.i("AsyncGetEvents", "Received " + subscriptions.size() + " streams event"); try { processSubsciptions(subscriptions); } catch (Exception e) { ZLog.logException(e); } } // get muted topics events List<EventsBranch> mutedTopics = events.getEventsOfBranchType(EventsBranch.BranchType.MUTED_TOPICS); if (!mutedTopics.isEmpty()) { Log.i("AsyncGetEvents", "Received " + mutedTopics.size() + " muted_topics event"); processMutedTopics(mutedTopics); } // get messages events List<Message> messages = events.getEventsOf(EventsBranch.BranchType.MESSAGE, new TypeSwapper<MessageWrapper, Message>() { @Override public Message convert(MessageWrapper messageWrapper) { return messageWrapper.getMessage(); } }); if (messages != null && !messages.isEmpty()) { Log.i("AsyncGetEvents", "Received " + messages.size() + " messages"); Message.createMessages(app, messages); processMessages(messages); } // update message List<EventsBranch> updateMessageEvents = events.getEventsOfBranchType(EventsBranch.BranchType.UPDATE_MESSAGE); if (!updateMessageEvents.isEmpty()) { Log.i("AsyncGetEvents", "Received " + updateMessageEvents.size() + " update message events"); processUpdateMessages(updateMessageEvents); } // get message time limit events List<EventsBranch> messageTimeLimit = events.getEventsOfBranchType(EventsBranch.BranchType.EDIT_MESSAGE_TIME_LIMIT); if (!messageTimeLimit.isEmpty()) { Log.i("AsyncGetEvents", "Received " + messageTimeLimit.size() + " realm event"); processMessageEditParam(messageTimeLimit); } // get stream events List<EventsBranch> streamEvents = events.getEventsOfBranchType(EventsBranch.BranchType.STREAM); if (!streamEvents.isEmpty()) { Log.i("AsyncGetEvents", "Received " + streamEvents.size() + " stream event"); processStreams(streamEvents); } //get star events List<EventsBranch> starEvents = events.getEventsOfBranchType(EventsBranch.BranchType.STAR_MESSAGE); if (!starEvents.isEmpty()) { processStarEvents(starEvents); } // get reaction events List<EventsBranch> reactionEvents = events.getEventsOfBranchType(EventsBranch.BranchType.REACTION); if (!reactionEvents.isEmpty()) { processReactionEvents(reactionEvents); } } /** * Add messages to the list {@link com.zulip.android.activities.MessageListFragment} which are already added to the database * * @param messages List of messages to be added */ private void processMessages(final List<Message> messages) { // In task thread int lastMessageId = messages.get(messages.size() - 1).getID(); MessageRange.updateNewMessagesRange(app, lastMessageId); if (mActivity != null) { mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.onNewMessages(messages.toArray(new Message[messages.size()])); } }); } else { if (!messages.isEmpty()) { //Reload Widget on new messages Log.d(TAG, "New messages recieved for calledFromWidget: " + messages.size()); final Intent refreshIntent = new Intent(app, ZulipWidget.class); refreshIntent.setAction(ZulipWidget.WIDGET_REFRESH); app.startActivity(refreshIntent); } } } /** * Create or update streams extract from {@param subscriptionWrapperList}. This function takes * care of 3 cases : add, remove and update subscription streams * * @param subscriptionWrapperList list of {@link SubscriptionWrapper} */ private void processSubsciptions(List<EventsBranch> subscriptionWrapperList) throws Exception { RuntimeExceptionDao<Stream, Object> streamDao = app .getDao(Stream.class); for (EventsBranch wrapper : subscriptionWrapperList) { SubscriptionWrapper subscriptionwrapper = (SubscriptionWrapper) wrapper; List<Stream> streams = subscriptionwrapper.getStreams(); if (subscriptionwrapper.getOperation().equalsIgnoreCase(SubscriptionWrapper.OPERATION_ADD)) { // add new subscriptions for (Stream stream : streams) { stream.getParsedColor(); stream.setSubscribed(true); streamDao.createOrUpdate(stream); } } else if (subscriptionwrapper.getOperation().equalsIgnoreCase(SubscriptionWrapper.OPERATION_UPDATE)) { // update existing subscriptions Stream stream = subscriptionwrapper.getUpdatedStream(app); streamDao.createOrUpdate(stream); } else if (subscriptionwrapper.getOperation().equalsIgnoreCase(SubscriptionWrapper.OPERATION_REMOVE)) { // unsubscribe streams for (Stream stream : streams) { stream.getParsedColor(); stream.setSubscribed(false); streamDao.createOrUpdate(stream); } } else { Log.d("AsyncEvents", "unknown operation for subscription type event"); return; } } // update the message list and streams drawer mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.onReadyToDisplay(true); mActivity.checkAndSetupStreamsDrawer(); } }); } /** * Update the muted topics list from {@param genericMutedTopics} * * @param genericMutedTopics list of {@link MutedTopicsWrapper} */ private void processMutedTopics(List<EventsBranch> genericMutedTopics) { // update muted topics from events for (EventsBranch wrapper : genericMutedTopics) { MutedTopicsWrapper mutedTopics = (MutedTopicsWrapper) wrapper; mMutedTopics.addToMutedTopics(mutedTopics.getMutedTopics()); } // update the message list and streams drawer mActivity.runOnUiThread(new Runnable() { @Override public void run() { mActivity.onReadyToDisplay(true); mActivity.checkAndSetupStreamsDrawer(); } }); } /** * Get updated edit time limit from event of type {@link EventsBranch.BranchType#EDIT_MESSAGE_TIME_LIMIT} * * @param messageEditLimitEvents list of events {@link EventsBranch} */ private void processMessageEditParam(List<EventsBranch> messageEditLimitEvents) { for (EventsBranch wrapper : messageEditLimitEvents) { EditMessageWrapper timeLimitResponse = (EditMessageWrapper) wrapper; app.setMessageContentEditParams( timeLimitResponse.getData().getMessageContentEditLimitSeconds(), timeLimitResponse.getData().isMessageEditingAllowed() ); } } /** * Update messages in database from list of {@link EventsBranch.BranchType#UPDATE_MESSAGE} type * events {@link EventsBranch}. * * @param updateEvents list of events {@link EventsBranch.BranchType#UPDATE_MESSAGE} */ private void processUpdateMessages(List<EventsBranch> updateEvents) { // map containing sets of message and boolean values for if topic was changed for that message final Map<Message, Boolean> messages = new HashMap<>(); for (EventsBranch event : updateEvents) { UpdateMessageWrapper updateEvent = (UpdateMessageWrapper) event; for (int id : updateEvent.getMessageIds()) { Message message = updateEvent.getMessage(id); if (message != null) { // update the message in database Dao<Message, Integer> messageDao = app.getDao(Message.class); try { messageDao.update(message); messages.put(message, updateEvent.containsSubject()); } catch (SQLException e) { ZLog.logException(e); } } } } mActivity.runOnUiThread(new Runnable() { @Override public void run() { RecyclerMessageAdapter adapter = mActivity.getCurrentMessageList().getAdapter(); for (Map.Entry<Message, Boolean> message : messages.entrySet()) { // notify adapter message item changed adapter.notifyItemChanged(adapter.getItemIndex(message.getKey().getId())); if (message.getValue()) { if (adapter.getItem(adapter.getItemIndex(message.getKey().getId()) - 1) instanceof MessageHeaderParent) // update header adapter.notifyItemChanged(adapter.getItemIndex(message.getKey().getId()) - 1); else { // add new header adapter.addNewHeader(adapter.getItemIndex(message.getKey().getId()), message.getKey()); } } } } }); } public void processStreams(List<EventsBranch> events) { for (EventsBranch event : events) { StreamWrapper streamEvent = (StreamWrapper) event; if (streamEvent.getOperation().equalsIgnoreCase(StreamWrapper.OP_OCCUPY)) { Dao<Stream, Integer> streamDao = app.getDao(Stream.class); List<Stream> streams = streamEvent.getStreams(); for (Stream stream : streams) { try { // use default color for not subscribed streams stream.setDefaultColor(); streamDao.createIfNotExists(stream); } catch (SQLException e) { ZLog.logException(e); } } } else { // TODO: handle other operations of stream event Log.d("AsyncEvents", "unhandled operation for stream type event"); return; } } } private void processStarEvents(List<EventsBranch> starEvents) { for (EventsBranch wrapper : starEvents) { StarWrapper starResponse = (StarWrapper) wrapper; if (starResponse.getFlag().equals("starred")) { UpdateBuilder<Message, Object> updateBuilder = app.getDao(Message.class).updateBuilder(); if (starResponse.getOperation().equals("add")) { try { Log.i("AsyncGetEvents", "Star add: " + starResponse.getMessageId().get(0) + " || " + starResponse.getOperation() + " || " + starResponse.getFlag()); updateBuilder.where().eq(Message.ID_FIELD, starResponse.getMessageId().get(0)); updateBuilder.updateColumnValue(Message.MESSAGE_STAR_FIELD, true); updateBuilder.update(); } catch (SQLException e) { ZLog.logException(e); } } else if (starResponse.getOperation().equals("remove")) { try { Log.i("AsyncGetEvents", "Star remove: " + starResponse.getMessageId().get(0) + " || " + starResponse.getOperation() + " || " + starResponse.getFlag()); updateBuilder.where().eq(Message.ID_FIELD, starResponse.getMessageId().get(0)); updateBuilder.updateColumnValue(Message.MESSAGE_STAR_FIELD, false); updateBuilder.update(); } catch (SQLException e) { ZLog.logException(e); } } } } } public void processReactionEvents(List<EventsBranch> genEvents) { final List<Integer> messageIds = new ArrayList<>(); for (EventsBranch event : genEvents) { ReactionWrapper reactionWrapper = (ReactionWrapper) event; Dao<Message, Integer> dao = app.getDao(Message.class); try { Message msg = dao.queryForId(reactionWrapper.getMessageId()); if (msg == null) { // If we don't have the message in cache, do nothing; if we // ever fetch it from the server, it'll come with the // latest reactions attached continue; } Reaction reaction = reactionWrapper.getReaction(); switch (reactionWrapper.getOperation()) { case ReactionWrapper.OPERATION_ADD: msg.addReaction(reaction); break; case ReactionWrapper.OPERATION_REMOVE: msg.removeReaction(reaction); break; default: Log.d("AsyncEvents", "unhandled operation for reaction type event"); return; } dao.update(msg); } catch (SQLException e) { ZLog.logException(e); continue; } // update only when a successful database update has been made messageIds.add(reactionWrapper.getMessageId()); } mActivity.runOnUiThread(new Runnable() { @Override public void run() { RecyclerMessageAdapter adapter = mActivity.getCurrentMessageList().getAdapter(); for (int id : messageIds) { // notify adapter data item changed adapter.getItemIndex(id); adapter.notifyItemChanged(adapter.getItemIndex(id)); } } }); } }