// Copyright 2012 Google Inc. All Rights Reserved. // // 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.google.collide.client.collaboration; import com.google.collide.dto.client.DtoClientImpls.DocumentSelectionImpl; import com.google.collide.dto.client.DtoClientImpls.FilePositionImpl; import com.google.collide.client.AppContext; import com.google.collide.client.bootstrap.BootstrapSession; import com.google.collide.client.collaboration.cc.GenericOperationChannel; import com.google.collide.client.collaboration.cc.TransformQueue; import com.google.collide.client.status.StatusManager; import com.google.collide.client.status.StatusMessage; import com.google.collide.client.status.StatusMessage.MessageType; import com.google.collide.client.util.logging.Log; import com.google.collide.dto.ClientToServerDocOp; import com.google.collide.dto.DocOp; import com.google.collide.dto.DocumentSelection; import com.google.collide.dto.client.ClientDocOpFactory; import com.google.collide.shared.ot.OperationPair; import com.google.collide.shared.ot.PositionTransformer; import com.google.collide.shared.ot.Transformer; import com.google.collide.shared.util.ErrorCallback; import com.google.collide.shared.util.ListenerManager; import com.google.collide.shared.util.ListenerRegistrar; import com.google.collide.shared.util.ListenerManager.Dispatcher; import com.google.collide.shared.util.Reorderer.TimeoutCallback; import org.waveprotocol.wave.client.scheduler.SchedulerInstance; import java.util.List; /** * Controller that handles the real-time collaboration and concurrency control * for a file. An instance is per file, and is meant to be replaced by a new * instance when switching files. * */ class FileConcurrencyController { private static final int OUT_OF_ORDER_DOC_OP_TIMEOUT_MS = 5000; interface CollaboratorDocOpSink { /** * @param selection as described in * {@link ClientToServerDocOp#getSelection()}, with the exception * that this has been transformed with outstanding client ops so that * it is ready to be applied to the local document */ void consume(DocOp docOp, String clientId, DocumentSelection selection); } interface DocOpListener { void onDocOpAckReceived(int documentId, DocOp serverHistoryDocOp, boolean clean); void onDocOpSent(int documentId, List<DocOp> docOps); } private static class ChannelListener implements GenericOperationChannel.Listener<DocOp> { private FileConcurrencyController controller; private final DocOpSender sender; private final ListenerManager<DocOpListener> docOpListenerManager; private ChannelListener( ListenerManager<DocOpListener> docOpListenerManager, DocOpSender sender) { this.docOpListenerManager = docOpListenerManager; this.sender = sender; } @Override public void onAck(final DocOp serverHistoryOp, final boolean clean) { sender.clearLastClientToServerDocOpMsg(null); docOpListenerManager.dispatch(new Dispatcher<DocOpListener>() { @Override public void dispatch(DocOpListener listener) { listener.onDocOpAckReceived(controller.getDocumentId(), serverHistoryOp, clean); } }); } @Override public void onError(Throwable e) { Log.error(getClass(), "Error from concurrency control", e); } @Override public void onRemoteOp(DocOp serverHistoryOp, List<DocOp> pretransformedUnackedClientOps, List<DocOp> pretransformedQueuedClientOps) { /* * Do not pass the given server history doc op because it hasn't been * transformed for local consumption. Instead, the client calls * GenericOperationChannel.receive(). */ controller.onRemoteOp(pretransformedUnackedClientOps, pretransformedQueuedClientOps); } } private static class OutOfOrderDocOpTimeoutRecoveringCallback implements TimeoutCallback { private final StatusManager statusManager; private DocOpRecoverer recoverer; private final ErrorCallback errorCallback = new ErrorCallback() { @Override public void onError() { StatusMessage fatal = new StatusMessage(statusManager, MessageType.FATAL, "There was a problem syncing with the server."); fatal.addAction(StatusMessage.RELOAD_ACTION); fatal.setDismissable(false); fatal.fire(); } }; OutOfOrderDocOpTimeoutRecoveringCallback(StatusManager statusManager) { this.statusManager = statusManager; } @Override public void onTimeout(int lastVersionDispatched) { recoverer.recover(errorCallback); } } private static final TransformQueue.Transformer<DocOp> transformer = new TransformQueue.Transformer<DocOp>() { @Override public List<DocOp> compact(List<DocOp> clientOps) { // TODO: implement for efficiency return clientOps; } @Override public org.waveprotocol.wave.model.operation.OperationPair<DocOp> transform(DocOp clientOp, DocOp serverOp) { try { OperationPair operationPair = Transformer.transform(ClientDocOpFactory.INSTANCE, clientOp, serverOp); return new org.waveprotocol.wave.model.operation.OperationPair<DocOp>( operationPair.clientOp(), operationPair.serverOp()); } catch (Exception e) { // TODO: stop using RuntimeException and make a custom // exception type Log.error(getClass(), "Error from DocOp transformer", e); throw new RuntimeException(e); } } }; public static FileConcurrencyController create(AppContext appContext, String fileEditSessionKey, int documentId, IncomingDocOpDemultiplexer docOpDemux, CollaboratorDocOpSink remoteOpSink, DocOpListener docOpListener, DocOpRecoveryInitiator docOpRecoveryInitiator) { ListenerManager<DocOpListener> docOpListenerManager = ListenerManager.create(); docOpListenerManager.add(docOpListener); OutOfOrderDocOpTimeoutRecoveringCallback timeoutCallback = new OutOfOrderDocOpTimeoutRecoveringCallback(appContext.getStatusManager()); DocOpReceiver receiver = new DocOpReceiver( docOpDemux, fileEditSessionKey, timeoutCallback, OUT_OF_ORDER_DOC_OP_TIMEOUT_MS); DocOpSender sender = new DocOpSender(appContext.getFrontendApi(), docOpDemux, fileEditSessionKey, documentId, docOpListenerManager, docOpRecoveryInitiator); ChannelListener listener = new ChannelListener(docOpListenerManager, sender); // TODO: implement the Logger interface using our logging utils GenericOperationChannel<DocOp> channel = new GenericOperationChannel<DocOp>( SchedulerInstance.getMediumPriorityTimer(), transformer, receiver, sender, listener); receiver.setRevisionProvider(channel); DocOpRecoverer recoverer = new DocOpRecoverer(fileEditSessionKey, appContext.getFrontendApi().RECOVER_FROM_MISSED_DOC_OPS, receiver, sender, channel); timeoutCallback.recoverer = recoverer; FileConcurrencyController fileConcurrencyController = new FileConcurrencyController(channel, receiver, sender, remoteOpSink, recoverer, docOpListenerManager, documentId); listener.controller = fileConcurrencyController; return fileConcurrencyController; } private final GenericOperationChannel<DocOp> ccChannel; private final DocOpReceiver receiver; private final CollaboratorDocOpSink sink; private final DocOpSender sender; private final DocOpRecoverer recoverer; private final ListenerManager<DocOpListener> docOpListenerManager; private final int documentId; private FileConcurrencyController(GenericOperationChannel<DocOp> ccChannel, DocOpReceiver receiver, DocOpSender sender, CollaboratorDocOpSink sink, DocOpRecoverer recoverer, ListenerManager<DocOpListener> docOpListenerManager, int documentId) { this.ccChannel = ccChannel; this.receiver = receiver; this.sender = sender; this.sink = sink; this.recoverer = recoverer; this.docOpListenerManager = docOpListenerManager; this.documentId = documentId; } int getDocumentId() { return documentId; } void consumeLocalDocOp(DocOp docOp) { ccChannel.send(docOp); } ListenerRegistrar<DocOpListener> getDocOpListenerRegistrar() { return docOpListenerManager; } int getQueuedClientOpCount() { return ccChannel.getQueuedClientOpCount(); } int getUnackedClientOpCount() { return ccChannel.getUnacknowledgedClientOpCount(); } void start(int ccRevision) { ccChannel.connect(ccRevision, BootstrapSession.getBootstrapSession().getActiveClientId()); } void stop() { ccChannel.disconnect(); } void setDocOpCreationParticipant(ClientToServerDocOpCreationParticipant participant) { sender.setDocOpCreationParticipant(participant); } void recover(ErrorCallback errorCallback) { recoverer.recover(errorCallback); } private void onRemoteOp(List<DocOp> pretransformedUnackedClientOps, List<DocOp> pretransformedQueuedClientOps) { DocumentSelection selection = receiver.getSelection(); if (selection != null) { // Transform the remote position with our unacked and queued doc ops selection = transformSelection(selection, pretransformedUnackedClientOps, pretransformedQueuedClientOps); } sink.consume(ccChannel.receive(), receiver.getClientId(), selection); } private DocumentSelection transformSelection(DocumentSelection selection, List<DocOp> pretransformedUnackedClientOps, List<DocOp> pretransformedQueuedClientOps) { PositionTransformer basePositionTransformer = new PositionTransformer(selection.getBasePosition().getLineNumber(), selection .getBasePosition().getColumn()); PositionTransformer cursorPositionTransformer = new PositionTransformer(selection.getCursorPosition().getLineNumber(), selection .getCursorPosition().getColumn()); for (DocOp op : pretransformedUnackedClientOps) { basePositionTransformer.transform(op); cursorPositionTransformer.transform(op); } for (DocOp op : pretransformedQueuedClientOps) { basePositionTransformer.transform(op); cursorPositionTransformer.transform(op); } return DocumentSelectionImpl.make().setBasePosition(makeFilePosition(basePositionTransformer)) .setCursorPosition(makeFilePosition(cursorPositionTransformer)) .setUserId(selection.getUserId()); } private FilePositionImpl makeFilePosition(PositionTransformer transformer) { return FilePositionImpl.make().setLineNumber(transformer.getLineNumber()) .setColumn(transformer.getColumn()); } }