// 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.client.AppContext;
import com.google.collide.client.code.ParticipantModel;
import com.google.collide.client.collaboration.FileConcurrencyController.CollaboratorDocOpSink;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.status.StatusMessage;
import com.google.collide.client.status.StatusMessage.MessageType;
import com.google.collide.dto.DocOp;
import com.google.collide.dto.DocumentSelection;
import com.google.collide.dto.client.ClientDocOpFactory;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.json.shared.JsonStringMap;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.TextChange;
import com.google.collide.shared.document.Document.TextListener;
import com.google.collide.shared.ot.Composer;
import com.google.collide.shared.ot.DocOpApplier;
import com.google.collide.shared.ot.DocOpBuilder;
import com.google.collide.shared.ot.DocOpUtils;
import com.google.collide.shared.ot.Composer.ComposeException;
import com.google.collide.shared.util.ErrorCallback;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.ListenerRegistrar.RemoverManager;
/**
* Controller that adds real-time collaboration at the document level.
*
* This controller attaches to the document to broadcast any local changes to other collaborators.
* Conversely, it receives other collaborators' changes and applies them to the local document.
*
* Clients must call {@link #initialize}.
*/
public class DocumentCollaborationController implements DocOpRecoveryInitiator {
private final AppContext appContext;
private final ParticipantModel participantModel;
private final Document document;
private final RemoverManager removerManager = new RemoverManager();
private AckWatchdog ackWatchdog;
private FileConcurrencyController fileConcurrencyController;
private Editor editor;
private LocalCursorTracker localCursorTracker;
/** Saves the collaborators' selections to display when we attach to an editor */
private JsonStringMap<DocumentSelection> collaboratorSelections = JsonCollections.createMap();
private CollaboratorCursorController collaboratorCursorController;
private final IncomingDocOpDemultiplexer docOpDemux;
/**
* Used to prevent remote doc ops from being considered as local user edits inside the document
* callback
*/
private boolean isConsumingRemoteDocOp;
private final CollaboratorDocOpSink remoteOpSink = new CollaboratorDocOpSink() {
@Override
public void consume(DocOp docOp, String clientId, DocumentSelection selection) {
isConsumingRemoteDocOp = true;
try {
DocOpApplier.apply(docOp, document);
if (editor == null) {
if (selection != null) {
collaboratorSelections.put(selection.getUserId(), selection);
}
} else {
collaboratorCursorController.handleSelectionChange(clientId, selection);
}
} finally {
isConsumingRemoteDocOp = false;
}
}
};
private final Document.TextListener localTextListener = new TextListener() {
@Override
public void onTextChange(Document document, JsonArray<TextChange> textChanges) {
if (isConsumingRemoteDocOp) {
/*
* These text changes are being caused by the consumption of the remote doc ops. We don't
* want to rebroadcast these.
*/
return;
}
DocOp op = null;
for (int i = 0, n = textChanges.size(); i < n; i++) {
TextChange textChange = textChanges.get(i);
DocOp curOp = DocOpUtils.createFromTextChange(ClientDocOpFactory.INSTANCE, textChange);
try {
op = op != null ? Composer.compose(ClientDocOpFactory.INSTANCE, op, curOp) : curOp;
} catch (ComposeException e) {
if (editor != null) {
editor.setReadOnly(true);
}
new StatusMessage(appContext.getStatusManager(), MessageType.FATAL,
"Problem processing the text changes, please reload.").fire();
return;
}
}
fileConcurrencyController.consumeLocalDocOp(op);
}
};
/**
* Creates an instance of the {@link DocumentCollaborationController}.
*/
public DocumentCollaborationController(AppContext appContext, ParticipantModel participantModel,
IncomingDocOpDemultiplexer docOpDemux, Document document,
JsonArray<DocumentSelection> selections) {
this.appContext = appContext;
this.participantModel = participantModel;
this.docOpDemux = docOpDemux;
this.document = document;
for (int i = 0, n = selections.size(); i < n; i++) {
DocumentSelection selection = selections.get(i);
collaboratorSelections.put(selection.getUserId(), selection);
}
}
public void initialize(String fileEditSessionKey, int ccRevision) {
ackWatchdog = new AckWatchdog(
appContext.getStatusManager(), appContext.getWindowUnloadingController(), this);
fileConcurrencyController = FileConcurrencyController.create(appContext,
fileEditSessionKey,
document.getId(),
docOpDemux,
remoteOpSink,
ackWatchdog,
this);
fileConcurrencyController.start(ccRevision);
removerManager.track(document.getTextListenerRegistrar().add(localTextListener));
}
@Override
public void teardown() {
detachFromEditor();
removerManager.remove();
/*
* Replace the concurrency controller instance to ensure there isn't any internal state leftover
* from the previous file. (At the time of this writing, the concurrency control library has
* internal state that cannot be reset completely via its public API.)
*/
fileConcurrencyController.stop();
fileConcurrencyController = null;
ackWatchdog.teardown();
ackWatchdog = null;
}
public void attachToEditor(Editor editor) {
this.editor = editor;
ackWatchdog.setEditor(editor);
/*
* TODO: when supporting multiple editors, we'll need to encapsulate these in a
* POJO keyed off editor ID. For now, assume only a single editor.
*/
localCursorTracker = new LocalCursorTracker(this, editor.getSelection());
localCursorTracker.forceSendingSelection();
collaboratorCursorController = new CollaboratorCursorController(
appContext, document, editor.getBuffer(), participantModel, collaboratorSelections);
fileConcurrencyController.setDocOpCreationParticipant(localCursorTracker);
// Send our document selection
ensureQueuedDocOp();
}
public void detachFromEditor() {
/*
* The "!= null" checks are for when detachFromEditor is called from teardown because we can be
* torndown before the document is detached from the editor.
*/
fileConcurrencyController.setDocOpCreationParticipant(null);
if (collaboratorCursorController != null) {
collaboratorSelections = collaboratorCursorController.getSelectionsMap();
collaboratorCursorController.teardown();
collaboratorCursorController = null;
}
if (localCursorTracker != null) {
localCursorTracker.teardown();
localCursorTracker = null;
}
ackWatchdog.setEditor(null);
this.editor = null;
}
FileConcurrencyController getFileConcurrencyController() {
return fileConcurrencyController;
}
void ensureQueuedDocOp() {
if (fileConcurrencyController.getQueuedClientOpCount() == 0) {
// There aren't any queued doc ops, create and send a noop doc op
DocOp noopDocOp = new DocOpBuilder(ClientDocOpFactory.INSTANCE, false).retainLine(
document.getLineCount()).build();
fileConcurrencyController.consumeLocalDocOp(noopDocOp);
}
}
@Override
public void recover() {
fileConcurrencyController.recover(new ErrorCallback() {
@Override
public void onError() {
StatusMessage fatal = new StatusMessage(appContext.getStatusManager(), MessageType.FATAL,
"There was a problem synchronizing with the server.");
fatal.addAction(StatusMessage.RELOAD_ACTION);
fatal.setDismissable(false);
fatal.fire();
}
});
}
void handleTransportReconnectedSuccessfully() {
recover();
}
}