/** * Copyright 2009 Google Inc. * * 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.waveprotocol.wave.examples.fedone.waveclient.common; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.protobuf.RpcCallback; import com.google.protobuf.RpcController; import org.waveprotocol.wave.examples.fedone.common.CommonConstants; import org.waveprotocol.wave.examples.fedone.common.DocumentConstants; import org.waveprotocol.wave.examples.fedone.common.HashedVersion; import org.waveprotocol.wave.examples.fedone.common.WaveletOperationSerializer; import org.waveprotocol.wave.examples.fedone.model.util.HashedVersionZeroFactoryImpl; import org.waveprotocol.wave.examples.fedone.rpc.ClientRpcChannel; import org.waveprotocol.wave.examples.fedone.rpc.WebSocketClientRpcChannel; import org.waveprotocol.wave.examples.fedone.util.BlockingSuccessFailCallback; import org.waveprotocol.wave.examples.fedone.util.Log; import org.waveprotocol.wave.examples.fedone.util.SuccessFailCallback; import org.waveprotocol.wave.examples.fedone.util.URLEncoderDecoderBasedPercentEncoderDecoder; import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.ProtocolOpenRequest; import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.ProtocolSubmitRequest; import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.ProtocolSubmitResponse; import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.ProtocolWaveClientRpc; import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.ProtocolWaveletUpdate; import org.waveprotocol.wave.examples.fedone.waveserver.WaveClientRpc.WaveletSnapshot; import org.waveprotocol.wave.federation.Proto.ProtocolWaveletDelta; import org.waveprotocol.wave.model.document.operation.Attributes; import org.waveprotocol.wave.model.document.operation.impl.DocOpBuilder; import org.waveprotocol.wave.model.id.IdURIEncoderDecoder; import org.waveprotocol.wave.model.id.URIEncoderDecoder.EncodingException; import org.waveprotocol.wave.model.id.WaveId; import org.waveprotocol.wave.model.id.WaveletName; import org.waveprotocol.wave.model.operation.OperationException; import org.waveprotocol.wave.model.operation.wave.AddParticipant; import org.waveprotocol.wave.model.operation.wave.NoOp; import org.waveprotocol.wave.model.operation.wave.RemoveParticipant; import org.waveprotocol.wave.model.operation.wave.WaveletDelta; import org.waveprotocol.wave.model.operation.wave.WaveletDocumentOperation; import org.waveprotocol.wave.model.operation.wave.WaveletOperation; import org.waveprotocol.wave.model.util.Pair; import org.waveprotocol.wave.model.wave.ParticipantId; import org.waveprotocol.wave.model.wave.data.WaveletData; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.InetSocketAddress; import java.util.List; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.logging.Handler; import java.util.logging.SimpleFormatter; import java.util.logging.StreamHandler; /** * The "backend" of a basic wave client, designed for user interfaces to interact with. * * */ public class ClientBackend { /** * Container for data to {@code WaveletOperationListener}s on events. */ private static class WaveletEventData { private final String author; private final WaveletData waveletData; private final WaveletOperation waveletOperation; // Hack to mark whether this is actually the "end of a delta sequence", in which case the // above data is irrelevant. private final boolean isDeltaSequenceEnd; /** * Standard constructor. */ WaveletEventData(String author, WaveletData waveletData, WaveletOperation waveletOperation) { this.author = author; this.waveletData = waveletData; this.waveletOperation = waveletOperation; this.isDeltaSequenceEnd = false; } /** * Constructor to set this to a delta sequence end marker. */ WaveletEventData(WaveletData waveletData) { this.author = null; this.waveletData = waveletData; this.waveletOperation = null; this.isDeltaSequenceEnd = true; } String getAuthor() { return author; } WaveletData getWaveletData() { return waveletData; } WaveletOperation getWaveletOperation() { return waveletOperation; } boolean isDeltaSequenceEnd() { return isDeltaSequenceEnd; } } private static final Log LOG = Log.get(ClientBackend.class); private static final ByteArrayOutputStream logOutput = new ByteArrayOutputStream(); static { LOG.getLogger().addHandler(new StreamHandler(logOutput, new SimpleFormatter())); LOG.getLogger().setUseParentHandlers(false); } /** User id of the user of the backend (encapsulating both user and server). */ private final ParticipantId userId; /** Waves this backend is aware of. */ private final Map<WaveId, ClientWaveView> waves = Maps.newHashMap(); /** RPC controllers for the open wave connections. */ private final Map<WaveId, RpcController> waveControllers = Maps.newHashMap(); /** Listeners waiting on wave updates. */ private final List<WaveletOperationListener> waveletOperationListeners = Lists.newArrayList(); /** Id generator used for this (server, user) pair. */ private final ClientIdGenerator idGenerator; /** Id URI encoder and decoder. */ private final IdURIEncoderDecoder uriCodec; /** RPC stub for communicating with server. */ private final ProtocolWaveClientRpc.Stub rpcServer; /** RPC channel for communicating with server. */ private final ClientRpcChannel rpcChannel; /** Producer/consumer event queue. */ private final BlockingQueue<WaveletEventData> eventQueue = new LinkedBlockingQueue<WaveletEventData>(); /** * Create new client backend tied permanently to a given server and user, open that client's * index, and begin managing waves it has access to. * * @param userAtDomain the user and their domain (for example, foo@bar.org) * @param server the server to connect to (for example, acmewave.com) * @param port port to connect to server with * @throws IOException if we can't connect to the server */ public ClientBackend(String userAtDomain, String server, int port) throws IOException { if (userAtDomain.split("@").length != 2) { throw new IllegalArgumentException("userAtName must be in form user@domain"); } this.userId = new ParticipantId(userAtDomain); this.idGenerator = new RandomIdGenerator(userId.getDomain()); this.uriCodec = new IdURIEncoderDecoder(new URLEncoderDecoderBasedPercentEncoderDecoder()); this.rpcChannel = new ClientRpcChannel(new InetSocketAddress(server, port)); this.rpcServer = ProtocolWaveClientRpc.newStub(rpcChannel); // Opening the index wave will kickstart the process of receiving waves openWave(CommonConstants.INDEX_WAVE_ID, ""); // Start pushing events to listeners in a separate thread. new Thread() { @Override public void run() { for (;;) { try { WaveletEventData nextEvent = eventQueue.take(); if (nextEvent.isDeltaSequenceEnd()) { for (WaveletOperationListener listener : waveletOperationListeners) { listener.onDeltaSequenceEnd(nextEvent.getWaveletData()); } } else { notifyWaveletOperationListeners(nextEvent.getAuthor(), nextEvent.getWaveletData(), nextEvent.getWaveletOperation()); } } catch (InterruptedException e) { // TODO: stop? } } } }.start(); } /** * @return the client backend log. */ public static String getLog() { for (Handler handler : LOG.getLogger().getHandlers()) { handler.flush(); } return logOutput.toString(); } /** * Clear the log. */ public static void clearLog() { logOutput.reset(); } /** * Gracefully shut down the backend, closing all connections to the server and clearing the * collection of waves (since they will no longer be valid). This renders the backend relatively * useless since no more updates from the index wave will be received, but it is still valid. */ public void shutdown() { for (RpcController rpcController : waveControllers.values()) { LOG.info("Cancelling RpcController " + rpcController); rpcController.startCancel(); } waves.clear(); waveControllers.clear(); } /** * Open a wave. This method will return immediately and updates will be delivered internally * from the RPC interface, and externally to {@link WaveletOperationListener}s. * * @param waveId of wave to open * @param waveletIdPrefix filter such that the server will send wavelet updates for ids that * match any of this prefix */ private void openWave(WaveId waveId, String waveletIdPrefix) { if (waveControllers.containsKey(waveId)) { throw new IllegalArgumentException(waveId + " is already open"); } else { // May already be there if created with createNewWave if (!waves.containsKey(waveId)) { createWave(waveId); } } ProtocolOpenRequest.Builder openRequest = ProtocolOpenRequest.newBuilder(); openRequest.setParticipantId(getUserId().getAddress()); openRequest.setWaveId(waveId.serialise()); openRequest.addWaveletIdPrefix(waveletIdPrefix); openRequest.setSnapshots(true); final RpcController rpcController = rpcChannel.newRpcController(); waveControllers.put(waveId, rpcController); LOG.info("Opening wave " + waveId + " for prefix \"" + waveletIdPrefix + '"'); rpcServer.open( rpcController, openRequest.build(), new RpcCallback<ProtocolWaveletUpdate>() { @Override public void run(ProtocolWaveletUpdate update) { if (update == null) { LOG.warning("RPC failed: " + rpcController.errorText()); } else { receiveWaveletUpdate(update); } } } ); } /** * Create a new conversation wave and tell the server about it, by adding ourselves as a * participant on the conversation root. * * @param callback callback invoked when the server rpc is complete * @return the {@link ClientWaveView} created */ public ClientWaveView createConversationWave( SuccessFailCallback<ProtocolSubmitResponse, String> callback) { return createConversationWave(getIdGenerator().newWaveId(), callback); } /** * Create a new conversation wave with a given wave id and tell the server about it, by adding * ourselves as a participant on the conversation root. * * @param newWaveId the id to give the new wave * @param callback callback invoked when the server rpc is complete * @return the {@link ClientWaveView} created */ private ClientWaveView createConversationWave(WaveId newWaveId, SuccessFailCallback<ProtocolSubmitResponse, String> callback) { ClientWaveView waveView = createWave(newWaveId); WaveletData convRoot = waveView.createWavelet(getIdGenerator().newConversationRootWaveletId()); // Add ourselves in the first operation AddParticipant addUserOp = new AddParticipant(getUserId()); // Create a document manifest in the second operation WaveletDocumentOperation addManifestOp = new WaveletDocumentOperation( DocumentConstants.MANIFEST_DOCUMENT_ID, new DocOpBuilder() .elementStart(DocumentConstants.CONVERSATION, Attributes.EMPTY_MAP) .elementEnd().build()); sendWaveletDelta(convRoot.getWaveletName(), new WaveletDelta(getUserId(), ImmutableList.of(addUserOp, addManifestOp)), callback); return waveView; } /** * @param id of wave to get * @return wave with the given id, or null if not found */ public ClientWaveView getWave(WaveId id) { return waves.get(id); } /** * @return the special wave containing the index data */ public ClientWaveView getIndexWave() { return getWave(CommonConstants.INDEX_WAVE_ID); } /** * Send a single wavelet operation over the wire. * * @param waveletName of the wavelet to apply the operation to * @param op to send * @param callback callback invoked when the server rpc is complete */ public void sendWaveletOperation(WaveletName waveletName, WaveletOperation op, SuccessFailCallback<ProtocolSubmitResponse, String> callback) { sendWaveletDelta(waveletName, new WaveletDelta(getUserId(), ImmutableList.of(op)), callback); } /** * Send a single wavelet operation over the wire and wait for the roundtrip success: the submit * callback from our local server, and the wavelet to be updated to the version given in the * response. * * @param waveletName of the wavelet to apply the operation to * @param op to send * @param timeout used twice, so real timeout may be up to twice that specified * @param unit of timeout * @return true if the roundtrip trip was successful, false otherwise */ public boolean sendAndAwaitWaveletOperation(WaveletName waveletName, WaveletOperation op, long timeout, TimeUnit unit) { return sendAndAwaitWaveletDelta(waveletName, new WaveletDelta(getUserId(), ImmutableList.of(op)), timeout, unit); } /** * Send a wavelet delta over the wire. * * @param waveletName of the wavelet that the delta applies to * @param delta to send * @param callback callback invoked when the server rpc is complete */ public void sendWaveletDelta(WaveletName waveletName, WaveletDelta delta, final SuccessFailCallback<ProtocolSubmitResponse, String> callback) { // Build the submit request ProtocolSubmitRequest.Builder submitRequest = ProtocolSubmitRequest.newBuilder(); try { submitRequest.setWaveletName(uriCodec.waveletNameToURI(waveletName)); } catch (EncodingException e) { throw new IllegalArgumentException(e); } ClientWaveView wave = waves.get(waveletName.waveId); submitRequest.setDelta(WaveletOperationSerializer.serialize(delta, wave.getWaveletVersion(waveletName.waveletId))); final RpcController rpcController = rpcChannel.newRpcController(); LOG.info("Sending delta " + delta + " for " + waveletName); rpcServer.submit(rpcController, submitRequest.build(), new RpcCallback<ProtocolSubmitResponse>() { @Override public void run(ProtocolSubmitResponse response) { if (response == null) { callback.onFailure("null response"); } else if (rpcController.failed()) { callback.onFailure(rpcController.errorText()); } else { callback.onSuccess(response); } } }); } /** * Send a wavelet delta over the wire and wait for the roundtrip success: the submit callback * from our local server, and the wavelet to be updated to the version given in the response. * * @param waveletName of the wavelet that the delta applies to * @param delta to send * @param timeout used twice, so real timeout may be up to twice that specified * @param unit of timeout * @return true if the roundtrip trip was successful, false otherwise */ public boolean sendAndAwaitWaveletDelta(WaveletName waveletName, WaveletDelta delta, long timeout, TimeUnit unit) { BlockingSuccessFailCallback<ProtocolSubmitResponse, String> callback = BlockingSuccessFailCallback.create(); sendWaveletDelta(waveletName, delta, callback); Pair<ProtocolSubmitResponse, String> result = callback.await(timeout, unit); if (result == null) { LOG.warning("Error: submit result pair for " + waveletName + " was null, timed out?"); return false; } else if (result.getFirst() == null) { LOG.warning("Error: submit response to " + waveletName + " was null: " + result.getSecond()); return false; } else { return getWave(waveletName.waveId).awaitWaveletVersion(waveletName.waveletId, result.getFirst().getHashedVersionAfterApplication().getVersion(), timeout, unit); } } /** * Receive a protocol wavelet update from the wave server. * * @param waveletUpdate the wavelet update */ public void receiveWaveletUpdate(ProtocolWaveletUpdate waveletUpdate) { LOG.info("Received update " + waveletUpdate); WaveletName waveletName; try { waveletName = uriCodec.uriToWaveletName(waveletUpdate.getWaveletName()); } catch (EncodingException e) { throw new IllegalArgumentException(e); } ClientWaveView wave = waves.get(waveletName.waveId); if (wave == null) { // The wave view should always be present, since openWave adds them immediately. throw new AssertionError("Received update on absent waveId " + waveletName.waveId); } WaveletData wavelet = wave.getWavelet(waveletName.waveletId); if (wavelet == null) { wavelet = wave.createWavelet(waveletName.waveletId); } // Apply operations to the wavelet. List<Pair<String, WaveletOperation>> successfulOps = Lists.newArrayList(); if (waveletUpdate.hasSnapshot()) { Preconditions.checkArgument(waveletUpdate.hasResultingVersion()); final WaveletSnapshot snapshot = waveletUpdate.getSnapshot(); // Kinda bogus - we need something better here. // TODO(arb): talk to soren about this - what should we do about contributors? final String creator = snapshot.getParticipantId(0); for (WaveletOperation op : WaveletOperationSerializer.deserialize(snapshot)) { try { op.apply(wavelet); successfulOps.add(Pair.of(creator, op)); } catch (OperationException e) { // It should be okay (if cheeky) for the client to just ignore failed ops. In any case, // this should never happen if our server is behaving correctly. LOG.severe("OperationException when applying snapshot " + op + " to " + wavelet); } } } else if (!waveletUpdate.getAppliedDeltaList().isEmpty()) { Preconditions.checkArgument(waveletUpdate.hasResultingVersion()); Preconditions.checkArgument(!waveletUpdate.getAppliedDeltaList().isEmpty()); for (ProtocolWaveletDelta protobufDelta : waveletUpdate.getAppliedDeltaList()) { Pair<WaveletDelta, HashedVersion> deltaAndVersion = WaveletOperationSerializer.deserialize(protobufDelta); List<WaveletOperation> ops = deltaAndVersion.first.getOperations(); for (WaveletOperation op : ops) { try { op.apply(wavelet); successfulOps.add(Pair.of(protobufDelta.getAuthor(), op)); } catch (OperationException e) { // It should be okay (if cheeky) for the client to just ignore failed ops. In any case, // this should never happen if our server is behaving correctly. LOG.severe("OperationException when applying " + op + " to " + wavelet); } } } } if (waveletUpdate.hasResultingVersion()) { wave.setWaveletVersion(waveletName.waveletId, WaveletOperationSerializer .deserialize(waveletUpdate.getResultingVersion())); } // If we have been removed from this wavelet then remove the data too, since if we're re-added // then we will get a fresh snapshot or deltas from version 0, not the latest version we've // seen. if (!wavelet.getParticipants().contains(getUserId())) { wave.removeWavelet(waveletName.waveletId); } // If it was an update to the index wave, might need to open/close some more waves. if (wave.getWaveId().equals(CommonConstants.INDEX_WAVE_ID)) { syncWithIndexWave(wave); } // Push the events to the event queue. for (Pair<String, WaveletOperation> authorAndOp : successfulOps) { eventQueue.offer(new WaveletEventData(authorAndOp.first, wavelet, authorAndOp.second)); } eventQueue.offer(new WaveletEventData(wavelet)); if (waveletUpdate.hasCommitNotice()) { for (WaveletOperationListener listener : waveletOperationListeners) { listener.onCommitNotice(wavelet, WaveletOperationSerializer .deserialize(waveletUpdate.getCommitNotice())); } } } /** * Creates a new, empty wave view and stores it in {@code waves}. * @param waveId the new wave id * @return the new wave's {@code ClientWaveView} */ private ClientWaveView createWave(WaveId waveId) { ClientWaveView wave = new ClientWaveView(new HashedVersionZeroFactoryImpl(), waveId); waves.put(waveId, wave); return wave; } /** * Synchronise with the index wave by opening any waves that appear in the index but that we * don't have an RPC open request to. * * @param indexWave to synchronise with */ private void syncWithIndexWave(ClientWaveView indexWave) { List<IndexEntry> indexEntries = ClientUtils.getIndexEntries(indexWave); for (IndexEntry indexEntry : indexEntries) { if (!waveControllers.containsKey(indexEntry.getWaveId())) { WaveId waveId = indexEntry.getWaveId(); openWave(waveId, ClientUtils.getConversationRootId(waveId).serialise()); } } } /** * Notify all wavelet operation listeners of a wavelet operation. * * @param author the author who caused the operation * @param wavelet operated on * @param op the operation */ private void notifyWaveletOperationListeners(String author, WaveletData wavelet, WaveletOperation op) { for (WaveletOperationListener listener : waveletOperationListeners) { try { if (op instanceof WaveletDocumentOperation) { listener.waveletDocumentUpdated(author, wavelet, (WaveletDocumentOperation) op); } else if (op instanceof AddParticipant) { listener.participantAdded(author, wavelet, ((AddParticipant) op).getParticipantId()); } else if (op instanceof RemoveParticipant) { listener.participantRemoved(author, wavelet, ((RemoveParticipant) op).getParticipantId()); } else if (op instanceof NoOp) { listener.noOp(author, wavelet); } } catch (RuntimeException e) { LOG.severe("RuntimeException for listener " + listener, e); } } } /** * @return the {@link ParticipantId} id of the user */ public ParticipantId getUserId() { return userId; } /** * @return the id generator which generates wave, wavelet, and document ids */ public ClientIdGenerator getIdGenerator() { return idGenerator; } /** * Add a {@link WaveletOperationListener} to be notified whenever a wave is updated. * * @param listener new listener */ public void addWaveletOperationListener(WaveletOperationListener listener) { waveletOperationListeners.add(listener); } /** * @param listener listener to be removed */ public void removeWaveletOperationListener(WaveletOperationListener listener) { waveletOperationListeners.remove(listener); } }