package com.badoo.chateau.example.data.repos.messages; import android.net.Uri; import android.support.annotation.NonNull; import android.support.v4.content.LocalBroadcastManager; import android.util.Log; import com.badoo.chateau.core.repos.messages.MessageDataSource; import com.badoo.chateau.core.repos.messages.MessageQueries; import com.badoo.chateau.core.repos.messages.MessageQueries.LoadQuery; import com.badoo.chateau.core.repos.messages.MessageQueries.SendQuery; import com.badoo.chateau.core.repos.messages.MessageQueries.SubscribeQuery; import com.badoo.chateau.data.models.payloads.ImagePayload; import com.badoo.chateau.data.models.payloads.TextPayload; import com.badoo.chateau.example.Broadcaster; import com.badoo.chateau.example.data.model.ExampleMessage; import com.badoo.chateau.example.data.util.ParseHelper; import com.badoo.chateau.example.data.util.ParseUtils; import com.badoo.chateau.example.data.util.ParseUtils.ChatTable; import com.badoo.chateau.example.data.util.ParseUtils.MessagesTable; import com.parse.ParseObject; import com.parse.ParseQuery; import java.util.Collections; import java.util.Date; import java.util.List; import rx.Observable; import rx.Single; import rx.SingleSubscriber; import rx.functions.Action1; import rx.schedulers.Schedulers; import rx.subjects.PublishSubject; import static com.badoo.chateau.core.repos.messages.MessageQueries.LoadQuery.Type; public class ParseMessageDataSource implements MessageDataSource<ExampleMessage> { private static final String TAG = ParseMessageDataSource.class.getSimpleName(); private final static int MAX_CHUNK_SIZE = 20; @NonNull private final ImageUploader mImageUploader; @NonNull private final ParseHelper mParseHelper; private final PublishSubject<Update<ExampleMessage>> mUpdatePublisher = PublishSubject.create(); // In order to guarantee that any new temporary message is inserted as the last entry of the conversation (even if the device // time is incorrectly set) we keep track of the timestamp of the last message (across loaded conversations). private long mLastMessageTimestamp; private Action1<List<ParseObject>> mSortMessagesAscending = parseObjects -> Collections.sort(parseObjects, (lhs, rhs) -> { // Sort Acceding. final long lhsTimestamp = lhs.getCreatedAt().getTime(); final long rhsTimestamp = rhs.getCreatedAt().getTime(); return lhsTimestamp < rhsTimestamp ? -1 : (lhsTimestamp == rhsTimestamp ? 0 : 1); }); public ParseMessageDataSource(@NonNull LocalBroadcastManager broadcastManager, @NonNull ImageUploader imageUploader, @NonNull ParseHelper parseHelper) { mImageUploader = imageUploader; mParseHelper = parseHelper; Broadcaster.ConversationUpdatedReceiver pullLatestMessagesReceiver = new Broadcaster.ConversationUpdatedReceiver() { @Override public void onConversationUpdated(@NonNull String conversationId, long timestamp) { // Should be greater than or equal, -1 fixes this loadInternal(conversationId, Type.NEWER, timestamp - 1) .subscribe(result -> { for (ExampleMessage message : result.getMessages()) { mUpdatePublisher.onNext(new Update<>(conversationId, Update.Action.ADDED, null, message)); } }); } @Override public void onImageUploaded(@NonNull String conversationId, @NonNull String messageId) { updateMessage(conversationId, messageId); } }; broadcastManager.registerReceiver(pullLatestMessagesReceiver, Broadcaster.getConversationUpdatedFilter()); } @NonNull @Override public Observable<LoadResult<ExampleMessage>> load(@NonNull LoadQuery<ExampleMessage> query) { Log.d(TAG, "Loading: " + query); final long timestamp; if (query.getType() == Type.NEWER && query.getNewest() != null) { timestamp = query.getNewest().getTimestamp(); } else if (query.getType() == Type.OLDER && query.getOldest() != null) { timestamp = query.getOldest().getTimestamp(); } else { timestamp = 0; } return Observable.defer(() -> loadInternal(query.getConversationId(), query.getType(), timestamp)); } private Observable<LoadResult<ExampleMessage>> loadInternal(@NonNull String conversationId, @NonNull LoadQuery.Type type, long timestamp) { final ParseQuery<ParseObject> parseQuery = new ParseQuery<>(MessagesTable.NAME); parseQuery.whereEqualTo(MessagesTable.Fields.CHAT, ParseObject.createWithoutData(ChatTable.NAME, conversationId)); parseQuery.addDescendingOrder(MessagesTable.Fields.CREATED_AT); if (type == Type.OLDER) { parseQuery.whereLessThan(MessagesTable.Fields.CREATED_AT, new Date(timestamp)); } else if (type == Type.NEWER) { parseQuery.whereGreaterThan(MessagesTable.Fields.CREATED_AT, new Date(timestamp)); } parseQuery.setLimit(MAX_CHUNK_SIZE); parseQuery.include(MessagesTable.Fields.IMAGE); return mParseHelper.find(parseQuery) .doOnNext(mSortMessagesAscending) .flatMap(Observable::from) .map(in -> ParseUtils.from(in, mParseHelper)) .toList() .map(messages -> { final boolean canLoadOlder; final boolean canLoadNewer; if (type == Type.OLDER) { canLoadOlder = !messages.isEmpty(); canLoadNewer = true; // This will be ignored in any case since we are loading older messages } else if (type == Type.NEWER) { canLoadOlder = true; // This will be ignored in any case since we are loading newer messages canLoadNewer = !messages.isEmpty(); } else { canLoadOlder = !messages.isEmpty(); canLoadNewer = !messages.isEmpty(); } return new LoadResult<>(messages, canLoadOlder, canLoadNewer); }) .subscribeOn(Schedulers.io()); } @NonNull @Override public Observable<Void> send(@NonNull SendQuery<ExampleMessage> query) { final ExampleMessage message = query.getMessage(); final String conversationId = query.getConversationId(); final ParseObject parseMessage = createOutgoingParseObject(conversationId, message); // Give it a timestamp that ensured that it ends up last (this will be replaced by the real server side timestamp) mLastMessageTimestamp = Math.max(System.currentTimeMillis(), mLastMessageTimestamp + 1); final ExampleMessage unconfirmedMessage = ExampleMessage.createUnconfirmedMessage(message.getLocalId(), mParseHelper.getCurrentUser().getObjectId(), message.getPayload(), mLastMessageTimestamp); mUpdatePublisher.onNext(new Update<>(conversationId, Update.Action.ADDED, null, unconfirmedMessage)); return saveMessage(parseMessage) .observeOn(Schedulers.io()) .doOnError(throwable -> onFailedToSend(conversationId, unconfirmedMessage)) .doOnSuccess(parseObject -> onMessageSent(conversationId, parseObject, unconfirmedMessage)) .toObservable() .ignoreElements() .cast(Void.class); } private ParseObject createOutgoingParseObject(String conversationId, ExampleMessage message) { final ParseObject parseMessage = ParseObject.create(MessagesTable.NAME); parseMessage.put(MessagesTable.Fields.FROM, mParseHelper.getCurrentUser()); if (message.getPayload() instanceof TextPayload) { parseMessage.put(MessagesTable.Fields.TYPE, MessagesTable.Types.TEXT); parseMessage.put(MessagesTable.Fields.MESSAGE, ((TextPayload) message.getPayload()).getMessage()); } else if (message.getPayload() instanceof ImagePayload) { parseMessage.put(MessagesTable.Fields.TYPE, MessagesTable.Types.IMAGE); } parseMessage.put(MessagesTable.Fields.CHAT, ParseObject.createWithoutData(ChatTable.NAME, conversationId)); // Need to ensure the message sent back has the generated local id so it can be matched with responses form the server parseMessage.put(MessagesTable.Fields.LOCAL_ID, message.getLocalId()); return parseMessage; } private void onFailedToSend(String conversationId, ExampleMessage unconfirmedMessage) { mUpdatePublisher.onNext(new Update<>(conversationId, Update.Action.UPDATED, unconfirmedMessage, ExampleMessage.createFailedMessage(unconfirmedMessage))); } private void onMessageSent(String conversationId, ParseObject parseMessage, ExampleMessage message) { if (message.getPayload() instanceof TextPayload) { notifyTextMessageSent(parseMessage, conversationId); } else if (message.getPayload() instanceof ImagePayload) { notifyPhotoMessageSent(parseMessage, Uri.parse(((ImagePayload) message.getPayload()).getImageUrl()), message.getLocalId()); } else { throw new IllegalArgumentException("Unsupported message: " + message); } } /** * Request a single message in a conversation to be updated. */ private void updateMessage(@NonNull String conversationId, @NonNull String messageId) { ParseQuery<ParseObject> query = new ParseQuery<>(MessagesTable.NAME); query.setCachePolicy(ParseQuery.CachePolicy.NETWORK_ONLY); query.include(MessagesTable.Fields.IMAGE); Log.d(TAG, "Updating message: " + messageId); mParseHelper.get(query, messageId) .map(in -> ParseUtils.from(in, mParseHelper)) .toList() .subscribe(messages -> { for (ExampleMessage message : messages) { mUpdatePublisher.onNext(new Update<>(conversationId, Update.Action.UPDATED, null, message)); } }); } @NonNull @Override public Observable<List<ExampleMessage>> getUndelivered(@NonNull MessageQueries.GetUndeliveredQuery<ExampleMessage> query) { throw new UnsupportedOperationException("Not implemented for this provider"); } @NonNull @Override public Observable<Update<ExampleMessage>> subscribe(@NonNull SubscribeQuery<ExampleMessage> query) { return mUpdatePublisher; } private void notifyTextMessageSent(@NonNull ParseObject msg, @NonNull String conversationId) { final long messageTimestamp = msg.getCreatedAt() != null ? msg.getCreatedAt().getTime() : 0; mLastMessageTimestamp = Math.max(messageTimestamp, mLastMessageTimestamp); // Publish the real message back to the listening presenter layer after it is successfully saved (so that it can replace the temporary message) mUpdatePublisher.onNext(new Update<>(conversationId, Update.Action.UPDATED, null, ParseUtils.from(msg, mParseHelper))); } private void notifyPhotoMessageSent(@NonNull ParseObject msg, @NonNull Uri payload, @NonNull String localId) { mLastMessageTimestamp = Math.max(msg.getCreatedAt().getTime(), mLastMessageTimestamp); mImageUploader.uploadImage(localId, payload); } private Single<ParseObject> saveMessage(@NonNull ParseObject message) { return Single.create(new Single.OnSubscribe<ParseObject>() { @Override public void call(SingleSubscriber<? super ParseObject> subscriber) { mParseHelper.save(message); if (subscriber.isUnsubscribed()) { return; } subscriber.onSuccess(message); } }); } public interface ImageUploader { /** * Upload an image to the server for the given local id and uri. */ void uploadImage(@NonNull String localId, @NonNull Uri uri); } }