/* * Copyright 2016 OpenMarket Ltd * Copyright 2017 Vector Creations Ltd * * 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 org.matrix.androidsdk.data; import android.os.Looper; import android.text.TextUtils; import org.matrix.androidsdk.util.Log; import com.google.gson.JsonObject; import org.matrix.androidsdk.MXDataHandler; import org.matrix.androidsdk.data.store.IMXStore; import org.matrix.androidsdk.data.store.MXMemoryStore; import org.matrix.androidsdk.rest.callback.ApiCallback; import org.matrix.androidsdk.rest.callback.SimpleApiCallback; import org.matrix.androidsdk.rest.model.Event; import org.matrix.androidsdk.rest.model.EventContent; import org.matrix.androidsdk.rest.model.EventContext; import org.matrix.androidsdk.rest.model.MatrixError; import org.matrix.androidsdk.rest.model.ReceiptData; import org.matrix.androidsdk.rest.model.RoomMember; import org.matrix.androidsdk.rest.model.RoomResponse; import org.matrix.androidsdk.rest.model.Sync.RoomSync; import org.matrix.androidsdk.rest.model.Sync.InvitedRoomSync; import org.matrix.androidsdk.rest.model.TokensChunkResponse; import org.matrix.androidsdk.rest.model.bingrules.BingRule; import org.matrix.androidsdk.util.BingRulesManager; import org.matrix.androidsdk.util.EventDisplay; import org.matrix.androidsdk.util.JsonUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * A `EventTimeline` instance represents a contiguous sequence of events in a room. * * There are two kinds of timeline: * * - live timelines: they receive live events from the events stream. You can paginate * backwards but not forwards. * All (live or backwards) events they receive are stored in the store of the current * MXSession. * * - past timelines: they start in the past from an `initialEventId`. They are filled * with events on calls of [MXEventTimeline paginate] in backwards or forwards direction. * Events are stored in a in-memory store (MXMemoryStore). */ public class EventTimeline { private static final String LOG_TAG = "EventTimeline"; /** * The direction from which an incoming event is considered. */ public enum Direction { /** * Forwards when the event is added to the end of the timeline. * These events come from the /sync stream or from forwards pagination. */ FORWARDS, /** * Backwards when the event is added to the start of the timeline. * These events come from a back pagination. */ BACKWARDS } public interface EventTimelineListener { /** * Call when an event has been handled in the timeline. * @param event the event. * @param direction the direction. * @param roomState the room state */ void onEvent(Event event, Direction direction, RoomState roomState); } /** * The initial event id used to initialise the timeline. * null in case of live timeline. */ private String mInitialEventId; /** * Indicate if this timeline is a live one. */ private boolean mIsLiveTimeline; /** * The state of the room at the top most recent event of the timeline. */ private RoomState mState = new RoomState(); /** * The historical state of the room when paginating back. */ private RoomState mBackState = new RoomState(); /** * The associated room. */ private final Room mRoom; /** * the room Id */ private String mRoomId; /** * The store. */ private IMXStore mStore; /** * MXStore does only back pagination. So, the forward pagination token for * past timelines is managed locally. */ private String mForwardsPaginationToken; private boolean mHasReachedHomeServerForwardsPaginationEnd; /** * The data handler : used to retrieve data from the store or to trigger REST requests. */ public MXDataHandler mDataHandler; /** * Pending request statuses */ private boolean mIsBackPaginating = false; private boolean mIsForwardPaginating = false; /** * true if the back history has been retrieved. */ private boolean mCanBackPaginate = true; /** * true if the last back chunck has been received */ private boolean mIsLastBackChunk; /** * the server provides a token even for the first room message (which should never change it is the creator message). * so requestHistory always triggers a remote request which returns an empty json. * try to avoid such behaviour */ private String mBackwardTopToken = "not yet found"; /** * Unique identifier */ private final String mTimelineId = System.currentTimeMillis() + ""; /** * Constructor from room. * @param room the linked room. * @param isLive true if it is a live EventTimeline */ public EventTimeline(Room room, boolean isLive) { mRoom = room; mIsLiveTimeline = isLive; } /** * Constructor from a room Id * @param dataHandler the data handler * @param roomId the room Id */ public EventTimeline(MXDataHandler dataHandler, String roomId) { this(dataHandler, roomId, null); } /** * Constructor from room and event Id * @param dataHandler the data handler * @param roomId the room Id * @param eventId the event id. */ public EventTimeline(MXDataHandler dataHandler, String roomId, String eventId) { mInitialEventId = eventId; mDataHandler = dataHandler; mStore = new MXMemoryStore(dataHandler.getCredentials(), null); mRoom = mDataHandler.getRoom(mStore, roomId, true); mRoom.setLiveTimeline(this); mRoom.setReadyState(true); setRoomId(roomId); mState.setDataHandler(dataHandler); mBackState.setDataHandler(dataHandler); } /** * @return the unique identifier */ public String getTimelineId() { return mTimelineId; } /** * @return the dedicated room */ public Room getRoom() { return mRoom; } /** * @return the used store */ public IMXStore getStore() { return mStore; } /** * @return the initial event id. */ public String getInitialEventId() { return mInitialEventId; } /** * @return true if this timeline is the live one */ public boolean isLiveTimeline() { return mIsLiveTimeline; } /** * Set the room Id * @param roomId the new room id. */ public void setRoomId(String roomId) { mRoomId = roomId; mState.roomId = roomId; mBackState.roomId = roomId; } /** * Set the data handler. * @param dataHandler the data handler. */ public void setDataHandler(MXDataHandler dataHandler) { mStore = dataHandler.getStore(); mDataHandler = dataHandler; mState.setDataHandler(dataHandler); mBackState.setDataHandler(dataHandler); } /** * Reset the back state so that future history requests start over from live. * Must be called when opening a room if interested in history. */ public void initHistory() { mBackState = mState.deepCopy(); mCanBackPaginate = true; mIsBackPaginating = false; mIsForwardPaginating = false; // sanity check if ((null != mDataHandler) && (null != mDataHandler.getDataRetriever())) { mDataHandler.resetReplayAttackCheckInTimeline(getTimelineId()); mDataHandler.getDataRetriever().cancelHistoryRequest(mRoomId); } } /** * Init the history with a list of stateEvents * @param stateEvents the state events */ private void initHistory(List<Event> stateEvents) { // clear the states mState = new RoomState(); mState.roomId = mRoomId; mState.setDataHandler(mDataHandler); if (null != stateEvents) { for (Event event : stateEvents) { try { processStateEvent(event, Direction.FORWARDS); } catch (Exception e) { Log.e(LOG_TAG, "initHistory failed " + e.getMessage()); } } } mStore.storeLiveStateForRoom(mRoomId); initHistory(); // warn that there was a flush mDataHandler.onRoomFlush(mRoomId); } /** * @return The state of the room at the top most recent event of the timeline. */ public RoomState getState() { return mState; } /** * Update the state. * @param state the new state. */ public void setState(RoomState state) { mState = state; } /** * @return the back state. */ private RoomState getBackState() { return mBackState; } /** * Make a deep copy or the dedicated state. * @param direction the room state direction to deep copy. */ private void deepCopyState(Direction direction) { if (direction == Direction.FORWARDS) { mState = mState.deepCopy(); } else { mBackState = mBackState.deepCopy(); } } /** * Process a state event to keep the internal live and back states up to date. * @param event the state event * @param direction the direction; ie. forwards for live state, backwards for back state * @return true if the event has been processed. */ private boolean processStateEvent(Event event, Direction direction) { RoomState affectedState = (direction == Direction.FORWARDS) ? mState : mBackState; boolean isProcessed = affectedState.applyState(event, direction); if ((isProcessed) && (direction == Direction.FORWARDS)) { mStore.storeLiveStateForRoom(mRoomId); } return isProcessed; } /** * Handle the invitation room events * @param invitedRoomSync the invitation room events. */ public void handleInvitedRoomSync(InvitedRoomSync invitedRoomSync) { // Handle the state events as live events (the room state will be updated, and the listeners (if any) will be notified). if ((null != invitedRoomSync) && (null != invitedRoomSync.inviteState) && (null != invitedRoomSync.inviteState.events)) { for(Event event : invitedRoomSync.inviteState.events) { // Add a fake event id if none in order to be able to store the event if (null == event.eventId) { event.eventId = mRoomId + "-" + System.currentTimeMillis() + "-" + event.hashCode(); } // the roomId is not defined. event.roomId = mRoomId; handleLiveEvent(event, false, true); } } } /** * Manage the joined room events. * @param roomSync the roomSync. * @param isInitialSync true if the sync has been triggered by a global initial sync */ public void handleJoinedRoomSync(RoomSync roomSync, boolean isInitialSync) { String membership = null; String myUserId = mDataHandler.getMyUser().user_id; RoomSummary currentSummary = null; RoomMember selfMember = mState.getMember(mDataHandler.getMyUser().user_id); if (null != selfMember) { membership = selfMember.membership; } boolean isRoomInitialSync = (null == membership) || TextUtils.equals(membership, RoomMember.MEMBERSHIP_INVITE); // Check whether the room was pending on an invitation. if (TextUtils.equals(membership, RoomMember.MEMBERSHIP_INVITE)) { // Reset the storage of this room. An initial sync of the room will be done with the provided 'roomSync'. Log.d(LOG_TAG, "handleJoinedRoomSync: clean invited room from the store " + mRoomId); mStore.deleteRoomData(mRoomId); // clear the states RoomState state = new RoomState(); state.roomId = mRoomId; state.setDataHandler(mDataHandler); this.mBackState = this.mState = state; } if ((null != roomSync.state) && (null != roomSync.state.events) && (roomSync.state.events.size() > 0)) { // Build/Update first the room state corresponding to the 'start' of the timeline. // Note: We consider it is not required to clone the existing room state here, because no notification is posted for these events. // Build/Update first the room state corresponding to the 'start' of the timeline. // Note: We consider it is not required to clone the existing room state here, because no notification is posted for these events. if (mDataHandler.isAlive()) { for (Event event : roomSync.state.events) { try { processStateEvent(event, Direction.FORWARDS); } catch (Exception e) { Log.e(LOG_TAG, "processStateEvent failed " + e.getLocalizedMessage()); } } mRoom.setReadyState(true); } // if it is an initial sync, the live state is initialized here // so the back state must also be initialized if (isRoomInitialSync) { this.mBackState = this.mState.deepCopy(); } } // Handle now timeline.events, the room state is updated during this step too (Note: timeline events are in chronological order) if (null != roomSync.timeline) { if (roomSync.timeline.limited) { if (!isRoomInitialSync) { currentSummary = mStore.getSummary(mRoomId); // define a summary if some messages are left // the unsent messages are often displayed messages. Event oldestEvent = mStore.getOldestEvent(mRoomId); // Flush the existing messages for this room by keeping state events. mStore.deleteAllRoomMessages(mRoomId, true); if (oldestEvent != null) { if (RoomSummary.isSupportedEvent(oldestEvent)) { mStore.storeSummary(oldestEvent.roomId, oldestEvent, mState, myUserId); } } } // if the prev batch is set to null // it implies there is no more data on server side. if (null == roomSync.timeline.prevBatch) { roomSync.timeline.prevBatch = Event.PAGINATE_BACK_TOKEN_END; } // In case of limited timeline, update token where to start back pagination mStore.storeBackToken(mRoomId, roomSync.timeline.prevBatch); // reset the state back token // because it does not make anymore sense // by setting at null, the events cache will be cleared when a requesthistory will be called mBackState.setToken(null); // reset the back paginate lock mCanBackPaginate = true; } // any event ? if ((null != roomSync.timeline.events) && (roomSync.timeline.events.size() > 0)) { List<Event> events = roomSync.timeline.events; // Here the events are handled in forward direction (see [handleLiveEvent:]). // They will be added at the end of the stored events, so we keep the chronological order. for (Event event : events) { // the roomId is not defined. event.roomId = mRoomId; try { boolean isLimited = (null != roomSync.timeline) && roomSync.timeline.limited; // digest the forward event handleLiveEvent(event, !isLimited && !isInitialSync, !isInitialSync && !isRoomInitialSync); } catch (Exception e) { Log.e(LOG_TAG, "timeline event failed " + e.getLocalizedMessage()); } } } } if (isRoomInitialSync) { // any request history can be triggered by now. mRoom.setReadyState(true); } // Finalize initial sync else { if ((null != roomSync.timeline) && roomSync.timeline.limited) { // The room has been synced with a limited timeline mDataHandler.onRoomFlush(mRoomId); } } // the EventTimeLine is used when displaying a room preview // so, the following items should only be called when it is a live one. if (mIsLiveTimeline) { // check if the summary is defined // after a sync, the room summary might not be defined because the latest message did not generate a room summary/ if (null != mStore.getRoom(mRoomId)) { RoomSummary summary = mStore.getSummary(mRoomId); // if there is no defined summary // we have to create a new one if (null == summary) { // define a summary if some messages are left // the unsent messages are often displayed messages. Event oldestEvent = mStore.getOldestEvent(mRoomId); // if there is an oldest event, use it to set a summary if (oldestEvent != null) { // always defined a room summary else the room won't be displayed in the recents mStore.storeSummary(oldestEvent.roomId, oldestEvent, mState, myUserId); mStore.commit(); // if the event is not displayable // back paginate until to find a valid one if (!RoomSummary.isSupportedEvent(oldestEvent)) { Log.e(LOG_TAG, "the room " + mRoomId + " has no valid summary, back paginate once to find a valid one"); } } // use the latest known event else if (null != currentSummary) { mStore.storeSummary(mRoomId, currentSummary.getLatestReceivedEvent(), mState, myUserId); mStore.commit(); } // try to build a summary from the state events else if ((null != roomSync.state) && (null != roomSync.state.events) && (roomSync.state.events.size() > 0)) { ArrayList<Event> events = new ArrayList<>(roomSync.state.events); Collections.reverse(events); for (Event event : events) { event.roomId = mRoomId; if (RoomSummary.isSupportedEvent(event)) { summary = mStore.storeSummary(event.roomId, event, mState, myUserId); String eventType = event.getType(); // Watch for potential room name changes if (Event.EVENT_TYPE_STATE_ROOM_NAME.equals(eventType) || Event.EVENT_TYPE_STATE_ROOM_ALIASES.equals(eventType) || Event.EVENT_TYPE_STATE_ROOM_MEMBER.equals(eventType)) { if (null != summary) { summary.setName(mRoom.getName(myUserId)); } } mStore.commit(); break; } } } } } if (null != roomSync.unreadNotifications) { int notifCount = 0; int highlightCount = 0; if (null != roomSync.unreadNotifications.highlightCount) { highlightCount = roomSync.unreadNotifications.highlightCount; } if (null != roomSync.unreadNotifications.notificationCount) { notifCount = roomSync.unreadNotifications.notificationCount; } boolean isUpdated = (notifCount != mState.getNotificationCount()) || (mState.getHighlightCount() != highlightCount); if (isUpdated) { mState.setNotificationCount(notifCount); mState.setHighlightCount(highlightCount); mStore.storeLiveStateForRoom(mRoomId); } } } } /** * Store an outgoing event. * @param event the event to store */ public void storeOutgoingEvent(Event event) { if (mIsLiveTimeline) { storeEvent(event); } } /** * Store the event and update the dedicated room summary * @param event the event to store */ private void storeEvent(Event event) { String myUserId = mDataHandler.getCredentials().userId; // create dummy read receipt for any incoming event // to avoid not synchronized read receipt and event if ((null != event.getSender()) && (null != event.eventId)) { mRoom.handleReceiptData(new ReceiptData(event.getSender(), event.eventId, event.originServerTs)); } mStore.storeLiveRoomEvent(event); if (RoomSummary.isSupportedEvent(event)) { RoomSummary summary = mStore.storeSummary(event.roomId, event, mState, myUserId); String eventType = event.getType(); // Watch for potential room name changes if (Event.EVENT_TYPE_STATE_ROOM_NAME.equals(eventType) || Event.EVENT_TYPE_STATE_ROOM_ALIASES.equals(eventType) || Event.EVENT_TYPE_STATE_ROOM_MEMBER.equals(eventType)) { if (null != summary) { summary.setName(mRoom.getName(myUserId)); } } } } /** * Store a live room event. * @param event The event to be stored. * @param checkRedactedStateEvent true to check if this event redacts a state event */ private void storeLiveRoomEvent(Event event, boolean checkRedactedStateEvent) { boolean store = false; String myUserId = mDataHandler.getCredentials().userId; if (Event.EVENT_TYPE_REDACTION.equals(event.getType())) { if (event.getRedacts() != null) { Event eventToPrune = mStore.getEvent(event.getRedacts(), event.roomId); // when an event is redacted, some fields must be kept. if (null != eventToPrune) { store = true; // remove expected keys eventToPrune.prune(event); storeEvent(eventToPrune); // the redaction check must not be done during an initial sync // or the redacted event is received with roomSync.timeline.limited if (checkRedactedStateEvent) { checkStateEventRedaction(eventToPrune); } // search the latest displayable event // to replace the summary text ArrayList<Event> events = new ArrayList<>(mStore.getRoomMessages(event.roomId)); for (int index = events.size() - 1; index >= 0; index--) { Event anEvent = events.get(index); if (RoomSummary.isSupportedEvent(anEvent)) { // Decrypt event if necessary if (TextUtils.equals(anEvent.getType(), Event.EVENT_TYPE_MESSAGE_ENCRYPTED)) { if (null != mDataHandler.getCrypto()) { mDataHandler.getCrypto().decryptEvent(anEvent, getTimelineId()); } } EventDisplay eventDisplay = new EventDisplay(mStore.getContext(), anEvent, mState); // ensure that message can be displayed if (!TextUtils.isEmpty(eventDisplay.getTextualDisplay())) { event = anEvent; break; } } } } else { // the redaction check must not be done during an initial sync // or the redacted event is received with roomSync.timeline.limited if (checkRedactedStateEvent) { checkStateEventRedaction(event.getRedacts()); } } } } else { // the candidate events are not stored. store = !event.isCallEvent() || !Event.EVENT_TYPE_CALL_CANDIDATES.equals(event.getType()); // thread issue // if the user leaves a room, if (Event.EVENT_TYPE_STATE_ROOM_MEMBER.equals(event.getType()) && myUserId.equals(event.stateKey)) { String membership = event.getContent().getAsJsonObject().getAsJsonPrimitive("membership").getAsString(); if (RoomMember.MEMBERSHIP_LEAVE.equals(membership) || RoomMember.MEMBERSHIP_BAN.equals(membership)) { store = false; // delete the room and warn the listener of the leave event only at the end of the events chunk processing } } } if (store) { storeEvent(event); } // warn the listener that a new room has been created if (Event.EVENT_TYPE_STATE_ROOM_CREATE.equals(event.getType())) { mDataHandler.onNewRoom(event.roomId); } // warn the listeners that a room has been joined if (Event.EVENT_TYPE_STATE_ROOM_MEMBER.equals(event.getType()) && myUserId.equals(event.stateKey)) { String membership = event.getContent().getAsJsonObject().getAsJsonPrimitive("membership").getAsString(); if (RoomMember.MEMBERSHIP_JOIN.equals(membership)) { mDataHandler.onJoinRoom(event.roomId); } else if (RoomMember.MEMBERSHIP_INVITE.equals(membership)) { mDataHandler.onNewRoom(event.roomId); } } } /** * Trigger a push if there is a dedicated push rules which implies it. * @param event the event */ private void triggerPush(Event event) { BingRule bingRule; boolean outOfTimeEvent = false; JsonObject eventContent = event.getContentAsJsonObject(); if (eventContent.has("lifetime")) { long maxlifetime = eventContent.get("lifetime").getAsLong(); long eventLifeTime = System.currentTimeMillis() - event.getOriginServerTs(); outOfTimeEvent = eventLifeTime > maxlifetime; } BingRulesManager bingRulesManager = mDataHandler.getBingRulesManager(); String eventType = event.getType(); // If the bing rules apply, bing if (!outOfTimeEvent // some events are not bingable && !TextUtils.equals(eventType, Event.EVENT_TYPE_PRESENCE) && !TextUtils.equals(eventType, Event.EVENT_TYPE_TYPING) && !TextUtils.equals(eventType, Event.EVENT_TYPE_REDACTION) && !TextUtils.equals(eventType, Event.EVENT_TYPE_RECEIPT) && !TextUtils.equals(eventType, Event.EVENT_TYPE_TAGS) && (bingRulesManager != null) && (null != (bingRule = bingRulesManager.fulfilledBingRule(event))) && bingRule.shouldNotify()) { Log.d(LOG_TAG, "handleLiveEvent : onBingEvent"); mDataHandler.onBingEvent(event, mState, bingRule); } } /** * Handle events coming down from the event stream. * @param event the live event * @param checkRedactedStateEvent set to true to check if it triggers a state event redaction * @param withPush set to true to trigger pushes when it is required * */ private void handleLiveEvent(Event event, boolean checkRedactedStateEvent, boolean withPush) { MyUser myUser = mDataHandler.getMyUser(); // Decrypt event if necessary mDataHandler.decryptEvent(event, getTimelineId()); // dispatch the call events to the calls manager if (event.isCallEvent()) { mDataHandler.getCallsManager().handleCallEvent(event); storeLiveRoomEvent(event, false); // the candidates events are not tracked // because the users don't need to see the peer exchanges. if (!TextUtils.equals(event.getType(), Event.EVENT_TYPE_CALL_CANDIDATES)) { // warn the listeners // general listeners mDataHandler.onLiveEvent(event, mState); // timeline listeners onEvent(event, Direction.FORWARDS, mState); } // trigger pushes when it is required if (withPush) { triggerPush(event); } } else { Event storedEvent = mStore.getEvent(event.eventId, event.roomId); // avoid processing event twice if (null != storedEvent) { // an event has been echoed if (storedEvent.getAge() == Event.DUMMY_EVENT_AGE) { mStore.deleteEvent(storedEvent); mStore.storeLiveRoomEvent(event); mStore.commit(); Log.d(LOG_TAG, "handleLiveEvent : the event " + event.eventId + " in " + event.roomId + " has been echoed"); } else { Log.d(LOG_TAG, "handleLiveEvent : the event " + event.eventId + " in " + event.roomId + " already exist."); } return; } // Room event if (event.roomId != null) { // check if the room has been joined // the initial sync + the first requestHistory call is done here // instead of being done in the application if (Event.EVENT_TYPE_STATE_ROOM_MEMBER.equals(event.getType()) && TextUtils.equals(event.getSender(), mDataHandler.getUserId())) { EventContent eventContent = JsonUtils.toEventContent(event.getContentAsJsonObject()); EventContent prevEventContent = event.getPrevContent(); String prevMembership = null; if (null != prevEventContent) { prevMembership = prevEventContent.membership; } // if the membership keeps the same value "join". // it should mean that the user profile has been updated. if (!event.isRedacted() && TextUtils.equals(prevMembership, eventContent.membership) && TextUtils.equals(RoomMember.MEMBERSHIP_JOIN, eventContent.membership)) { // check if the user updates his profile from another device. boolean hasAccountInfoUpdated = false; if (!TextUtils.equals(eventContent.displayname, myUser.displayname)) { hasAccountInfoUpdated = true; myUser.displayname = eventContent.displayname; mStore.setDisplayName(myUser.displayname); } if (!TextUtils.equals(eventContent.avatar_url, myUser.getAvatarUrl())) { hasAccountInfoUpdated = true; myUser.setAvatarUrl(eventContent.avatar_url); mStore.setAvatarURL(myUser.avatar_url); } if (hasAccountInfoUpdated) { mDataHandler.onAccountInfoUpdate(myUser); } } } RoomState previousState = mState; if (event.stateKey != null) { // copy the live state before applying any update deepCopyState(Direction.FORWARDS); // check if the event has been processed if (!processStateEvent(event, Direction.FORWARDS)) { // not processed -> do not warn the application // assume that the event is a duplicated one. return; } } storeLiveRoomEvent(event, checkRedactedStateEvent); // warn the listeners // general listeners mDataHandler.onLiveEvent(event, previousState); // timeline listeners onEvent(event, Direction.FORWARDS, previousState); // trigger pushes when it is required if (withPush) { triggerPush(event); } } else { Log.e(LOG_TAG, "Unknown live event type: " + event.getType()); } } } //================================================================================ // History request //================================================================================ private static final int MAX_EVENT_COUNT_PER_PAGINATION = 30; // the storage events are buffered to provide a small bunch of events // the storage can provide a big bunch which slows down the UI. public class SnapshotEvent { public final Event mEvent; public final RoomState mState; public SnapshotEvent(Event event, RoomState state) { mEvent = event; mState = state; } } // avoid adding to many events // the room history request can provide more than expected event. private final ArrayList<SnapshotEvent> mSnapshotEvents = new ArrayList<>(); /** * Send MAX_EVENT_COUNT_PER_PAGINATION events to the caller. * @param callback the callback. */ private void manageBackEvents(final ApiCallback<Integer> callback) { // check if the SDK was not logged out if (!mDataHandler.isAlive()) { Log.d(LOG_TAG, "manageEvents : mDataHandler is not anymore active."); return; } int count = Math.min(mSnapshotEvents.size(), MAX_EVENT_COUNT_PER_PAGINATION); Event latestSupportedEvent = null; for(int i = 0; i < count; i++) { SnapshotEvent snapshotedEvent = mSnapshotEvents.get(0); // in some cases, there is no displayed summary // https://github.com/vector-im/vector-android/pull/354 if ((null == latestSupportedEvent) && RoomSummary.isSupportedEvent(snapshotedEvent.mEvent)) { latestSupportedEvent = snapshotedEvent.mEvent; } mSnapshotEvents.remove(0); onEvent(snapshotedEvent.mEvent, Direction.BACKWARDS, snapshotedEvent.mState); } // https://github.com/vector-im/vector-android/pull/354 // defines a new summary if the known is not supported RoomSummary summary = mStore.getSummary(mRoomId); if ((null != latestSupportedEvent) && ((null == summary) || !RoomSummary.isSupportedEvent(summary.getLatestReceivedEvent()))) { mStore.storeSummary(latestSupportedEvent.roomId, latestSupportedEvent, mState, mDataHandler.getUserId()); } Log.d(LOG_TAG, "manageEvents : commit"); mStore.commit(); if ((mSnapshotEvents.size() < MAX_EVENT_COUNT_PER_PAGINATION) && mIsLastBackChunk) { mCanBackPaginate = false; } if (callback != null) { try { callback.onSuccess(count); } catch (Exception e) { Log.e(LOG_TAG, "requestHistory exception " + e.getMessage()); } } mIsBackPaginating = false; } /** * Add some events in a dedicated direction. * @param events the events list * @param direction the direction */ private void addPaginationEvents(List<Event> events, Direction direction) { final String myUserId = mDataHandler.getUserId(); RoomSummary summary = mStore.getSummary(mRoomId); boolean shouldCommitStore = false; // the backward events have a dedicated management to avoid providing too many events for each request for (Event event : events) { boolean processedEvent = true; if (event.stateKey != null) { deepCopyState(direction); processedEvent = processStateEvent(event, direction); } // Decrypt event if necessary mDataHandler.decryptEvent(event, getTimelineId()); if (processedEvent) { // warn the listener only if the message is processed. // it should avoid duplicated events. if (direction == Direction.BACKWARDS) { if (mIsLiveTimeline) { // update the summary is the event has been received after the oldest known event // it might happen after a timeline update (hole in the chat history) if ((null != summary) && (summary.getLatestReceivedEvent().originServerTs < event.originServerTs) && RoomSummary.isSupportedEvent(event)) { summary = mStore.storeSummary(mRoomId, event, getState(), myUserId); shouldCommitStore = true; } } mSnapshotEvents.add(new SnapshotEvent(event, getBackState())); // onEvent will be called in manageBackEvents } else { onEvent(event, Direction.FORWARDS, getState()); } } } if (shouldCommitStore) { mStore.commit(); } } /** * Add some events in a dedicated direction. * @param events the events list * @param direction the direction * @param callback the callback. */ private void addPaginationEvents(List<Event> events, Direction direction, final ApiCallback<Integer> callback) { addPaginationEvents(events, direction); if (direction == Direction.BACKWARDS) { manageBackEvents(callback); } else { if (null != callback) { callback.onSuccess(events.size()); } } } /** * Tells if a back pagination can be triggered. * @return true if a back pagination can be triggered. */ public boolean canBackPaginate() { return !mIsBackPaginating && // One at a time please mState.canBackPaginated(mDataHandler.getUserId()) && // history_visibility flag management mCanBackPaginate && // If we have already reached the end of history mRoom.isReady(); // If the room is not finished being set up } /** * Request older messages. They will come down the onBackEvent callback. * @param callback callback to implement to be informed that the pagination request has been completed. Can be null. * @return true if request starts */ public boolean backPaginate(final ApiCallback<Integer> callback) { final String myUserId = mDataHandler.getUserId(); if (!canBackPaginate()) { Log.d(LOG_TAG, "cannot requestHistory " + mIsBackPaginating + " " + !getState().canBackPaginated(myUserId) + " " + !mCanBackPaginate + " " + !mRoom.isReady()); return false; } Log.d(LOG_TAG, "backPaginate starts"); // restart the pagination if (null == getBackState().getToken()) { mSnapshotEvents.clear(); } final String fromBackToken = getBackState().getToken(); mIsBackPaginating = true; // enough buffered data if ((mSnapshotEvents.size() >= MAX_EVENT_COUNT_PER_PAGINATION) || TextUtils.equals(fromBackToken, mBackwardTopToken) || TextUtils.equals(fromBackToken, Event.PAGINATE_BACK_TOKEN_END)) { mIsLastBackChunk = TextUtils.equals(fromBackToken, mBackwardTopToken) || TextUtils.equals(fromBackToken, Event.PAGINATE_BACK_TOKEN_END); final android.os.Handler handler = new android.os.Handler(Looper.getMainLooper()); if ((mSnapshotEvents.size() >= MAX_EVENT_COUNT_PER_PAGINATION)) { Log.d(LOG_TAG, "backPaginate : the events are already loaded."); } else { Log.d(LOG_TAG, "backPaginate : reach the history top"); } // call the callback with a delay // to reproduce the same behaviour as a network request. Runnable r = new Runnable() { @Override public void run() { handler.postDelayed(new Runnable() { public void run() { manageBackEvents(callback); } }, 0); } }; Thread t = new Thread(r); t.start(); return true; } mDataHandler.getDataRetriever().paginate(mStore, mRoomId, getBackState().getToken(), Direction.BACKWARDS, new SimpleApiCallback<TokensChunkResponse<Event>>(callback) { @Override public void onSuccess(TokensChunkResponse<Event> response) { if (mDataHandler.isAlive()) { if (null != response.chunk) { Log.d(LOG_TAG, "backPaginate : " + response.chunk.size() + " events are retrieved."); } else { Log.d(LOG_TAG, "backPaginate : there is no event"); } mIsLastBackChunk = ((null != response.chunk) && (0 == response.chunk.size()) && TextUtils.equals(response.end, response.start)) || (null == response.end); if (mIsLastBackChunk && (null != response.end)) { // save its token to avoid useless request mBackwardTopToken = fromBackToken; } else { // the server returns a null pagination token when there is no more available data if (null == response.end) { getBackState().setToken(Event.PAGINATE_BACK_TOKEN_END); } else { getBackState().setToken(response.end); } } addPaginationEvents((null == response.chunk) ? new ArrayList<Event>() : response.chunk, Direction.BACKWARDS, callback); } else { Log.d(LOG_TAG, "mDataHandler is not active."); } } @Override public void onMatrixError(MatrixError e) { Log.d(LOG_TAG, "backPaginate onMatrixError"); // When we've retrieved all the messages from a room, the pagination token is some invalid value if (MatrixError.UNKNOWN.equals(e.errcode)) { mCanBackPaginate = false; } mIsBackPaginating = false; if (null != callback) { callback.onMatrixError(e); } else { super.onMatrixError(e); } } @Override public void onNetworkError(Exception e) { Log.d(LOG_TAG, "backPaginate onNetworkError"); mIsBackPaginating = false; if (null != callback) { callback.onNetworkError(e); } else { super.onNetworkError(e); } } @Override public void onUnexpectedError(Exception e) { Log.d(LOG_TAG, "backPaginate onUnexpectedError"); mIsBackPaginating = false; if (null != callback) { callback.onUnexpectedError(e); } else { super.onUnexpectedError(e); } } }); return true; } /** * Request older messages. They will come down the onBackEvent callback. * @param callback callback to implement to be informed that the pagination request has been completed. Can be null. * @return true if request starts */ public boolean forwardPaginate(final ApiCallback<Integer> callback) { if (mIsLiveTimeline) { Log.d(LOG_TAG, "Cannot forward paginate on Live timeline"); return false; } if (mIsForwardPaginating || mHasReachedHomeServerForwardsPaginationEnd) { Log.d(LOG_TAG, "forwardPaginate " + mIsForwardPaginating + " mHasReachedHomeServerForwardsPaginationEnd " + mHasReachedHomeServerForwardsPaginationEnd); return false; } mIsForwardPaginating = true; mDataHandler.getDataRetriever().paginate(mStore, mRoomId, mForwardsPaginationToken, Direction.FORWARDS, new SimpleApiCallback<TokensChunkResponse<Event>>(callback) { @Override public void onSuccess(TokensChunkResponse<Event> response) { if (mDataHandler.isAlive()) { Log.d(LOG_TAG, "forwardPaginate : " + response.chunk.size() + " are retrieved."); mHasReachedHomeServerForwardsPaginationEnd = (0 == response.chunk.size()) && TextUtils.equals(response.end, response.start); mForwardsPaginationToken = response.end; addPaginationEvents(response.chunk, Direction.FORWARDS, callback); mIsForwardPaginating = false; } else { Log.d(LOG_TAG, "mDataHandler is not active."); } } @Override public void onMatrixError(MatrixError e) { mIsForwardPaginating = false; if (null != callback) { callback.onMatrixError(e); } else { super.onMatrixError(e); } } @Override public void onNetworkError(Exception e) { mIsForwardPaginating = false; if (null != callback) { callback.onNetworkError(e); } else { super.onNetworkError(e); } } @Override public void onUnexpectedError(Exception e) { mIsForwardPaginating = false; if (null != callback) { callback.onUnexpectedError(e); } else { super.onUnexpectedError(e); } } }); return true; } /** * Trigger a pagination in the expected direction. * @param direction the direction. * @param callback the callback. * @return true if the operation succeeds */ public boolean paginate(Direction direction, final ApiCallback<Integer> callback) { if (Direction.BACKWARDS == direction) { return backPaginate(callback); } else { return forwardPaginate(callback); } } /** * Cancel any pending pagination requests */ public void cancelPaginationRequest() { mDataHandler.getDataRetriever().cancelHistoryRequest(mRoomId); mIsBackPaginating = false; mIsForwardPaginating = false; } //============================================================================================================== // pagination methods //============================================================================================================== /** * Reset the pagination timelime and start loading the context around its `initialEventId`. * The retrieved (backwards and forwards) events will be sent to registered listeners. * @param limit the maximum number of messages to get around the initial event. * @param callback the operation callback */ public void resetPaginationAroundInitialEvent(final int limit, final ApiCallback<Void> callback) { // Reset the store mStore.deleteRoomData(mRoomId); mDataHandler.resetReplayAttackCheckInTimeline(getTimelineId()); mForwardsPaginationToken = null; mHasReachedHomeServerForwardsPaginationEnd = false; mDataHandler.getDataRetriever().getRoomsRestClient().getContextOfEvent(mRoomId, mInitialEventId, limit, new ApiCallback<EventContext>() { @Override public void onSuccess(EventContext eventContext) { // the state is the one after the latest event of the chunk i.e. the last message of eventContext.eventsAfter for(Event event : eventContext.state) { processStateEvent(event, Direction.FORWARDS); } // init the room states initHistory(); // build the events list ArrayList<Event> events = new ArrayList<>(); Collections.reverse(eventContext.eventsAfter); events.addAll(eventContext.eventsAfter); events.add(eventContext.event); events.addAll(eventContext.eventsBefore); // add events after addPaginationEvents(events, Direction.BACKWARDS); // create dummy forward events list // to center the selected event id // else if might be out of screen ArrayList<SnapshotEvent> nextSnapshotEvents = new ArrayList<>(mSnapshotEvents.subList(0, (mSnapshotEvents.size() + 1) / 2)); // put in the right order Collections.reverse(nextSnapshotEvents); // send them one by one for(SnapshotEvent snapshotEvent : nextSnapshotEvents) { mSnapshotEvents.remove(snapshotEvent); onEvent(snapshotEvent.mEvent, Direction.FORWARDS, snapshotEvent.mState); } // init the tokens mBackState.setToken(eventContext.start); mForwardsPaginationToken = eventContext.end; // send the back events to complete pagination manageBackEvents(new ApiCallback<Integer>() { @Override public void onSuccess(Integer info) { Log.d(LOG_TAG, "addPaginationEvents succeeds"); } @Override public void onNetworkError(Exception e) { Log.e(LOG_TAG, "addPaginationEvents failed " + e.getLocalizedMessage()); } @Override public void onMatrixError(MatrixError e) { Log.e(LOG_TAG, "addPaginationEvents failed " + e.getLocalizedMessage()); } @Override public void onUnexpectedError(Exception e) { Log.e(LOG_TAG, "addPaginationEvents failed " + e.getLocalizedMessage()); } }); // everything is done callback.onSuccess(null); } @Override public void onNetworkError(Exception e) { callback.onNetworkError(e); } @Override public void onMatrixError(MatrixError e) { callback.onMatrixError(e); } @Override public void onUnexpectedError(Exception e) { callback.onUnexpectedError(e); } }); } //============================================================================================================== // State events redactions //============================================================================================================== /** * Redact an event might require to reload the timeline * because the room states has to be been updated. * @param event the redacted event */ private void checkStateEventRedaction(final Event event) { if (null != event.stateKey) { Log.d(LOG_TAG, "checkStateEventRedaction from event " + event.eventId); // check if the state events is locally known // to avoid triggering a room initial sync mState.getStateEvents(new SimpleApiCallback<List<Event>>() { @Override public void onSuccess(List<Event> stateEvents) { boolean isFound = false; for(int index = 0; index < stateEvents.size(); index++) { Event stateEvent = stateEvents.get(index); if (TextUtils.equals(stateEvent.eventId, event.eventId)) { stateEvents.set(index, event); isFound = true; break; } } // if the room state can be locally pruned // and can create a new valid room state if (isFound) { initHistory(stateEvents); } else { // let the server provides an up to update room state. // we should apply the pruned event to the latest room state // because it might concern an older state. // Else, the current state would be invalid. // eg with this room history // // message_1 : A renames this room to Name1 // message_2 : A renames this room to Name2 // If message_1 is redacted, the room name must not be cleared // If the messages have been room member name updates, // the user must keep his latest name but his name must be updated in the history checkStateEventRedaction(event.eventId); } } }); } } /** * Redact an event might require to reload the timeline * because the room states has to be been updated. * @param eventId the redacted event id */ private void checkStateEventRedaction(String eventId) { Log.d(LOG_TAG, "checkStateEventRedaction from event Id " + eventId); if (!TextUtils.isEmpty(eventId)) { Log.d(LOG_TAG, "checkStateEventRedaction : retrieving the event"); mDataHandler.getDataRetriever().getRoomsRestClient().getContextOfEvent(mRoomId, eventId, 1, new ApiCallback<EventContext>() { @Override public void onSuccess(EventContext eventContext) { if ((null != eventContext.event) && (null != eventContext.event.stateKey)) { Log.d(LOG_TAG, "checkStateEventRedaction : the event is a state event -> get a refreshed roomState"); forceRoomStateServerSync(); } else { Log.d(LOG_TAG, "checkStateEventRedaction : the event is a not state event -> job is done"); } } @Override public void onNetworkError(Exception e) { Log.e(LOG_TAG, "checkStateEventRedaction : onNetworkError " + e.getLocalizedMessage() + "-> get a refreshed roomState"); forceRoomStateServerSync(); } @Override public void onMatrixError(MatrixError e) { Log.e(LOG_TAG, "checkStateEventRedaction : onMatrixError " + e.getLocalizedMessage() + "-> get a refreshed roomState"); forceRoomStateServerSync(); } @Override public void onUnexpectedError(Exception e) { Log.e(LOG_TAG, "checkStateEventRedaction : onUnexpectedError " + e.getLocalizedMessage() + "-> get a refreshed roomState"); forceRoomStateServerSync(); } }); } } /** * Get a fresh room state from the server */ private void forceRoomStateServerSync() { Log.d(LOG_TAG, "forceRoomStateServerSync starts"); final RoomState curRoomState = mState; mDataHandler.getDataRetriever().getRoomsRestClient().initialSync(mRoomId, new ApiCallback<RoomResponse>() { @Override public void onSuccess(RoomResponse roomResponse) { // test if the room state is still the same // else assume the state has already been updated if (curRoomState == mState) { Log.d(LOG_TAG, "forceRoomStateServerSync updates the state"); initHistory(roomResponse.state); } else { Log.d(LOG_TAG, "forceRoomStateServerSync : the room state has been udpated, don't know what to do"); } } @Override public void onNetworkError(Exception e) { Log.e(LOG_TAG, "forceRoomStateServerSync : onNetworkError " + e.getMessage()); mStore.setCorrupted(e.getMessage()); } @Override public void onMatrixError(MatrixError e) { Log.e(LOG_TAG, "forceRoomStateServerSync : onMatrixError " + e.getMessage()); mStore.setCorrupted(e.getMessage()); } @Override public void onUnexpectedError(Exception e) { Log.e(LOG_TAG, "forceRoomStateServerSync : onUnexpectedError " + e.getMessage()); mStore.setCorrupted(e.getMessage()); } }); } //============================================================================================================== // onEvent listener management. //============================================================================================================== private final ArrayList<EventTimelineListener> mEventTimelineListeners = new ArrayList<>(); /** * Add an events listener. * @param listener the listener to add. */ public void addEventTimelineListener(EventTimelineListener listener) { if (null != listener) { synchronized (this) { if (-1 == mEventTimelineListeners.indexOf(listener)) { mEventTimelineListeners.add(listener); } } } } /** * Remove an events listener. * @param listener the listener to remove. */ public void removeEventTimelineListener(EventTimelineListener listener) { if (null != listener) { synchronized (this) { mEventTimelineListeners.remove(listener); } } } /** * Dispatch the onEvent callback. * @param event the event. * @param direction the direction. * @param roomState the roomState. */ private void onEvent(Event event, Direction direction, RoomState roomState) { ArrayList<EventTimelineListener> listeners; synchronized (this) { listeners = new ArrayList<>(mEventTimelineListeners); } for(EventTimelineListener listener : listeners) { try { listener.onEvent(event, direction, roomState); } catch (Exception e) { Log.e(LOG_TAG,"EventTimeline.onEvent " + listener + " crashes " + e.getLocalizedMessage()); } } } }