/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.client.doodad.selection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Queue;
import org.waveprotocol.wave.client.account.Profile;
import org.waveprotocol.wave.client.account.ProfileListener;
import org.waveprotocol.wave.client.account.ProfileManager;
import org.waveprotocol.wave.client.account.ProfileSession;
import org.waveprotocol.wave.client.common.util.RgbColor;
import org.waveprotocol.wave.client.doodad.selection.CaretView.CaretViewFactory;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter.BoundaryFunction;
import org.waveprotocol.wave.client.editor.content.AnnotationPainter.PaintFunction;
import org.waveprotocol.wave.client.editor.content.PainterRegistry;
import org.waveprotocol.wave.client.editor.content.Registries;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.SchedulerInstance;
import org.waveprotocol.wave.client.scheduler.TimerService;
import org.waveprotocol.wave.model.conversation.AnnotationConstants;
import org.waveprotocol.wave.model.document.AnnotationMutationHandler;
import org.waveprotocol.wave.model.document.MutableDocument;
import org.waveprotocol.wave.model.document.util.DocumentContext;
import org.waveprotocol.wave.model.document.util.LocalDocument;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.ReadableStringMap.ProcV;
import org.waveprotocol.wave.model.util.StringMap;
import org.waveprotocol.wave.model.wave.InvalidParticipantAddress;
import org.waveprotocol.wave.model.wave.ParticipantId;
import com.google.common.annotations.VisibleForTesting;
/**
* Deals with rendering of remote carets (and selections).
* <p>
* This class relies on a {@link ProfileManager} instance to render properly carets.
* <p>
* Modified version of original SelectionAnnotationHandler class.
* <p>
* See {@link SelectionExtractor} to know how following annotatations are generated:
* <p>
* Currently, a user's selection is defined as a group of two or three annotations.
* <br>
* - Data annotation, with the prefix {@link #AnnotationConstants.USER_DATA}
* This annotation always covers the entire document.
* Its value is of the form "address,timestamp[,compositionstate]" where address is
* the user's id, timestamp is the number of milliseconds since the Epoch, UTC.
* An optional composition state may also be included, for indicating uncommitted
* IME composition text.
* - Hotspot annotation, with the prefix {@link #AnnotationConstants.USER_END}
* This annotation starts from where the user's blinking caret would be, and
* extends to the end of the document.
* Its value is their address
* - Range annotation, with the prefix {@link #AnnotationConstants.USER_RANGE}
* This annotation extends over the user's selected range. if their selection
* is collapsed, this annotation is not present.
* Its value is their address.
*
* Each key is suffixed with a globally unique value identifying the current session
* (e.g. one value per browser tab).
*
*
* TODO: write unit test based on SelectionAnnotationHandlerTest
*
* @author danilatos@google.com (Daniel Danilatos)
* @author pablojan@gmail.com (Pablo Ojanguren)
*/
public class CaretAnnotationHandler implements AnnotationMutationHandler, ProfileListener {
/** Time out for not showing stale carets */
public static final int STALE_CARET_TIMEOUT_MS = 6 * 1000;
/**
* Don't do a stale check more frequently than this
*/
private static final int MINIMUM_STALE_CHECK_GAP_MS = Math.max(
STALE_CARET_TIMEOUT_MS / 3, // More frequent than the stale timeout
5 * 1000); // But, lower bound on the frequency, as this is not a high priority thing.
public static final int MAX_NAME_LENGTH_FOR_SELECTION_ANNOTATION = 15;
/**
* Installs this doodad.
*/
public static CaretAnnotationHandler register(Registries registries) {
CaretMarkerRenderer carets = CaretMarkerRenderer.getInstance();
registries.getElementHandlerRegistry().registerRenderer(
CaretMarkerRenderer.FULL_TAGNAME, carets);
return register(registries, SchedulerInstance.getLowPriorityTimer(), carets);
}
@VisibleForTesting
static CaretAnnotationHandler register(Registries registries, TimerService timer,
CaretViewFactory carets) {
CaretAnnotationHandler selection = new CaretAnnotationHandler(
registries.getPaintRegistry(), timer, carets);
registries.getAnnotationHandlerRegistry().
registerHandler(AnnotationConstants.USER_PREFIX, selection);
return selection;
}
/**
* Handy method for getting the full annotation key, given a session id
*
* Session id does not have to be THE session id - it can just be any
* globally unique key for the current client.
*
* @param sessionId
* @return full annotation key
*/
public static String rangeKey(String sessionId) {
return AnnotationConstants.USER_RANGE + sessionId;
}
public static String endKey(String sessionId) {
return AnnotationConstants.USER_END + sessionId;
}
public static String dataKey(String sessionId) {
return AnnotationConstants.USER_DATA + sessionId;
}
public static String rangeSuffix(String rangeKey) {
return rangeKey.substring(AnnotationConstants.USER_RANGE.length());
}
public static String endSuffix(String endKey) {
return endKey.substring(AnnotationConstants.USER_END.length());
}
public static String dataSuffix(String dataKey) {
return dataKey.substring(AnnotationConstants.USER_DATA.length());
}
private final PainterRegistry painterRegistry;
private final TimerService scheduler;
// Used for getting profiles, which are needed for choosing names.
private ProfileManager profileManager;
private final StringMap<String> highlightCache = CollectionUtils.createStringMap();
private final CaretViewFactory markerFactory;
/**
* Information required for book-keeping and managing the logic of rendering
* each session's caret marker and selection.
*/
class CaretData {
/** UI for rendering the marker associated with this user session */
private final CaretView ui;
/** The address of the user session (1:n mapping of address:session) */
private final String address;
/** Session connected to this user. */
private final String sessionId;
/** Assigned colour */
private RgbColor color;
/** Time at which caret will expire */
private double expiry;
/**
* Implementation detail, the value of {@link #expiry} when this object was
* placed in the expiry queue, to ensure queue stability.
*/
private double originallyScheduledExpiry;
/**
* Document in which the session's caret is currently rendered. This is used
* for book-keeping and to be able to re-render relevant sections of the
* document.
*/
private DocumentContext<?, ?, ?> bundle;
/**
* Cache of the name reported in the UI - to avoid re-setting the name if it
* does not change
*/
private String name;
private ProfileSession profileSession;
CaretData(CaretView ui, ProfileSession profileSession, String address, String sessionId) {
if (sessions.containsKey(sessionId)) {
throw new IllegalArgumentException("Session data already exists");
}
this.address = address;
sessions.put(sessionId, this);
this.profileSession = profileSession;
this.ui = ui;
this.sessionId = sessionId;
this.color = profileSession.getColor();
this.ui.setColor(this.color);
updateProfile(profileSession.getProfile());
}
public void updateProfile(Profile profile) {
this.name = profile.getName();
ui.setName(name);
}
public void compositionStateUpdated(String newState) {
ui.setCompositionState(newState);
}
public boolean isStale() {
return scheduler.currentTimeMillis() > expiry;
}
public RgbColor getColour() {
return color;
}
public ProfileSession getProfileSession() {
return profileSession;
}
}
private static RgbColor average(Collection<RgbColor> colors) {
int size = colors.size();
int red = 0, green = 0, blue = 0;
for (RgbColor color : colors) {
red += color.red;
green += color.green;
blue += color.blue;
}
return size == 0 ? RgbColor.BLACK : new RgbColor(red / size, green / size, blue / size);
}
private RgbColor grey = new RgbColor(128, 128, 128);
private String getUsersHighlight(String sessions) {
if (!highlightCache.containsKey(sessions)) {
// comma-split:
String[] sessionIDs = sessions.split(",");
List<RgbColor> colours = new ArrayList<RgbColor>();
for (String id : sessionIDs) {
if (!"".equals(id)) {
ProfileSession session = profileManager.getSession(id, null);
colours.add(session != null ? session.getColor() : grey);
}
}
// average out the colours, then reduce opacity by averaging against white.
RgbColor lighter = average(Arrays.asList(average(colours), RgbColor.WHITE));
highlightCache.put(sessions, lighter.getCssColor());
}
return highlightCache.get(sessions);
}
private final PaintFunction spreadFunc = new PaintFunction() {
public Map<String, String> apply(Map<String, Object> from, boolean isEditing) {
// discover which sessions have highlighted this range:
String sessions = "";
for (Map.Entry<String, Object> entry : from.entrySet()) {
if (entry.getKey().startsWith(AnnotationConstants.USER_RANGE)) {
String sessionId = endSuffix(entry.getKey());
String address = (String) entry.getValue();
if (address == null || getActiveCaretData(sessionId) == null) {
continue;
}
sessions += sessionId + ",";
}
}
// combine them together and highlight the range accordingly:
if (!sessions.equals("")) {
return Collections.singletonMap("backgroundColor", getUsersHighlight(sessions));
} else {
return Collections.emptyMap();
}
}
};
private final BoundaryFunction boundaryFunc = new BoundaryFunction() {
public <N, E extends N, T extends N> E apply(LocalDocument<N, E, T> localDoc, E parent,
N nodeAfter, Map<String, Object> before, Map<String, Object> after, boolean isEditing) {
E ret = null;
E usersContainer = null;
for (Map.Entry<String, Object> entry : after.entrySet()) {
if (entry.getKey().startsWith(AnnotationConstants.USER_END)) {
// get the user's address:
String address = (String) entry.getValue();
if (address == null) {
continue;
}
// get the session ID:
String sessionId = endSuffix(entry.getKey());
CaretData data = getActiveCaretData(sessionId);
if (data == null) {
continue;
}
// if needed, first create a simple container to put caret DOMs into:
if (usersContainer == null) {
ret = localDoc.transparentCreate(
CaretMarkerRenderer.FULL_TAGNAME, Collections.<String, String>emptyMap(),
parent, nodeAfter);
usersContainer = ret;
}
markerFactory.setMarker(usersContainer, data.ui);
}
}
return ret;
}
};
private CaretData getActiveCaretData(String sessionId) {
CaretData data = sessions.get(sessionId);
return data != null && !data.isStale() ? data : null;
}
/** Seed the annotation handler with all required config objects. */
public CaretAnnotationHandler(PainterRegistry registry,
TimerService timer,
CaretViewFactory markerFactory) {
this.painterRegistry = registry;
this.scheduler = timer;
this.markerFactory = markerFactory;
}
/**
* Set the profile manager dependency. The manager depends on
* a service instance.
*
* @param profileManager
*/
public void setProfileManager(ProfileManager profileManager) {
if (this.profileManager != null)
this.profileManager.removeListener(this);
sessions.clear();
expiries.clear();
this.profileManager = profileManager;
if (profileManager != null) {
this.profileManager.addListener(this);
}
}
private void updateCaretData(String sessionId, String value, DocumentContext<?, ?, ?> doc, boolean isCurrentUser) {
String[] components = value.split(",");
if (components.length < 2) {
return; // invalid input
}
double timeStamp;
try {
// split into session address and time
timeStamp = Double.parseDouble(components[1]);
} catch (NumberFormatException nfe) {
return; // invalid input
}
String address = components[0];
ParticipantId participantId;
try {
participantId = ParticipantId.of(address);
} catch (InvalidParticipantAddress e) {
return;
}
String name = components.length >= 4 ? components[3] : null;
// Access directly from the map because the high level getter filters stale carets,
// and this could result in memory leaks.
CaretData data = sessions.get(sessionId);
if (data == null) {
ProfileSession profile = profileManager.getSession(sessionId, participantId);
data = new CaretData(markerFactory.createMarker(), profile, address, sessionId);
if (name != null)
data.getProfileSession().getProfile().setName(name);
}
// Avoid update this for the current user, it is not necessary
if (!isCurrentUser) {
double lastActivityTime = Math.min(timeStamp, scheduler.currentTimeMillis());
double expiry = lastActivityTime + STALE_CARET_TIMEOUT_MS;
activate(data, expiry, doc);
data.compositionStateUpdated(components.length >= 3 ? components[2] : "");
data.getProfileSession().trackActivity(lastActivityTime);
// update the name of remote anonymous users
if (name != null) {
data.getProfileSession().getProfile().setName(name);
}
}
}
private void activate(CaretData data, double expiry, DocumentContext<?, ?, ?> doc) {
data.expiry = expiry;
data.originallyScheduledExpiry = expiry;
if (data.bundle == null) {
expiries.add(data);
}
data.bundle = doc;
if (!scheduler.isScheduled(expiryTask)) {
scheduler.scheduleRepeating(expiryTask, MINIMUM_STALE_CHECK_GAP_MS,
MINIMUM_STALE_CHECK_GAP_MS);
}
}
private final Scheduler.IncrementalTask expiryTask = new Scheduler.IncrementalTask() {
@Override
public boolean execute() {
while (!expiries.isEmpty()) {
CaretData data = expiries.element();
if (data.originallyScheduledExpiry > scheduler.currentTimeMillis()) {
return true;
}
expiries.remove();
if (data.expiry > scheduler.currentTimeMillis()) {
data.originallyScheduledExpiry = data.expiry;
expiries.add(data);
} else {
expire(data);
}
}
return false;
}
};
/**
* Cleanup any state associated with expired selection annotation data
*
* @param data expired data
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
private void expire(CaretData data) {
DocumentContext<?, ?, ?> bundle = data.bundle;
MutableDocument<?, ?, ?> document = bundle.document();
data.bundle = null;
painterRegistry.unregisterBoundaryFunction(
CollectionUtils.newStringSet(AnnotationConstants.USER_END + data.sessionId), boundaryFunc);
painterRegistry.unregisterPaintFunction(
CollectionUtils.newStringSet(AnnotationConstants.USER_RANGE + data.sessionId), spreadFunc);
int size = document.size();
int rangeStart = document.firstAnnotationChange(0, size, AnnotationConstants.USER_RANGE + data.sessionId, null);
int rangeEnd = document.lastAnnotationChange(0, size, AnnotationConstants.USER_RANGE + data.sessionId, null);
int hotSpot = document.firstAnnotationChange(0, size, AnnotationConstants.USER_END + data.sessionId, null);
if (rangeStart == -1) {
rangeStart = rangeEnd = hotSpot;
}
/*
TODO(danilatos): Enable this code. Problems to resolve:
1. It causes mutations just from the renderer. Rather the cleanup is best done
in the same place the annotations are set - move it to another class.
2. It could result in a large number of operations being generated at the same time
by multiple clients
3. It will cause the handleAnnotationChange method to get called, which will
re-register the paint functions we just cleaned up.
if (data.address.equals(currentUserAddress)) {
document.setAnnotation(0, size, AnnotationConstants.USER_DATA + data.sessionId, null);
if (rangeStart >= 0) {
assert rangeEnd > rangeStart;
document.setAnnotation(rangeStart, rangeEnd, AnnotationConstants.USER_RANGE + data.sessionId, null);
}
if (hotSpot >= 0) {
document.setAnnotation(hotSpot, size, AnnotationConstants.USER_END + data.sessionId, null);
}
}
*/
if (hotSpot >= 0) {
AnnotationPainter.maybeScheduleRepaint((DocumentContext) bundle, rangeStart, rangeEnd);
}
}
private final StringMap<CaretData> sessions = CollectionUtils.createStringMap();
private final Queue<CaretData> expiries = new PriorityQueue<CaretData>(10,
new Comparator<CaretData>() {
@Override
public int compare(CaretData o1, CaretData o2) {
return (int) Math.signum(o1.originallyScheduledExpiry - o2.originallyScheduledExpiry);
}
});
@Override
public <N, E extends N, T extends N> void handleAnnotationChange(DocumentContext<N, E, T> bundle,
int start, int end, String key, Object newValue) {
// we can't render carets if we can get participants profile
if (profileManager == null)
return;
boolean isCurrentUser = key.endsWith("/" + profileManager.getCurrentSessionId());
if (key.startsWith(AnnotationConstants.USER_DATA) && newValue != null) {
// User activity
updateCaretData(dataSuffix(key), (String) newValue, bundle, isCurrentUser);
} else if (key.startsWith(AnnotationConstants.USER_RANGE) && !isCurrentUser) {
// The selection
painterRegistry.registerPaintFunction(
CollectionUtils.newStringSet(key), spreadFunc);
painterRegistry.getPainter().scheduleRepaint(bundle, start, end);
} else if (key.startsWith(AnnotationConstants.USER_END) && !isCurrentUser) {
// The caret
painterRegistry.registerBoundaryFunction(
CollectionUtils.newStringSet(key), boundaryFunc);
painterRegistry.getPainter().scheduleRepaint(bundle, start, start + 1);
if (end == bundle.document().size()) {
end--;
}
painterRegistry.getPainter().scheduleRepaint(bundle, end, end + 1);
}
}
public void clear() {
expiries.clear();
sessions.clear();
}
//
// Profile events.
//
@Override
public void onUpdated(final Profile profile) {
final String profileAddress = profile.getAddress();
sessions.each(new ProcV<CaretData>() {
@Override
public void apply(String s, CaretData value) {
if (value.address.equals(profileAddress) && !value.isStale()) {
value.updateProfile(profile);
}
}
});
}
@Override
public void onOffline(ProfileSession profile) {
}
@Override
public void onOnline(ProfileSession profile) {
}
@Override
public void onLoaded(ProfileSession profile) {
}
}