blob: e8f0a1c8d4975a0f7880958eb487c3c52e4afbc2 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package io.v.syncslidepresenter;
import com.beust.jcommander.JCommander;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.ParameterException;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import io.v.v23.VIterable;
import org.joda.time.Duration;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Image;
import java.awt.Window;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.Set;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.rmi.AlreadyBoundException;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.WindowConstants;
import io.v.android.apps.syncslides.db.VCurrentSlide;
import io.v.android.apps.syncslides.db.VSlide;
import io.v.android.apps.syncslides.discovery.ParticipantClient;
import io.v.android.apps.syncslides.discovery.ParticipantClientFactory;
import io.v.android.apps.syncslides.discovery.Presentation;
import io.v.impl.google.naming.NamingUtil;
import io.v.impl.google.services.syncbase.SyncbaseServer;
import io.v.v23.V;
import io.v.v23.context.VContext;
import io.v.v23.namespace.Namespace;
import io.v.v23.naming.Endpoint;
import io.v.v23.naming.GlobReply;
import io.v.v23.naming.MountEntry;
import io.v.v23.naming.MountedServer;
import io.v.v23.rpc.Server;
import io.v.v23.security.BlessingPattern;
import io.v.v23.security.access.AccessList;
import io.v.v23.security.access.Permissions;
import io.v.v23.services.syncbase.nosql.KeyValue;
import io.v.v23.services.syncbase.nosql.SyncgroupMemberInfo;
import io.v.v23.services.watch.ResumeMarker;
import io.v.v23.syncbase.Syncbase;
import io.v.v23.syncbase.SyncbaseApp;
import io.v.v23.syncbase.SyncbaseService;
import io.v.v23.syncbase.nosql.BatchDatabase;
import io.v.v23.syncbase.nosql.Database;
import io.v.v23.syncbase.nosql.RowRange;
import io.v.v23.syncbase.nosql.Syncgroup;
import io.v.v23.syncbase.nosql.Table;
import io.v.v23.syncbase.nosql.WatchChange;
import io.v.v23.verror.VException;
import io.v.v23.vom.VomUtil;
/**
* The entry point for syncslidepresenter. To run:
*
* <pre>
* cd $JIRI_ROOT/release/java
* ./gradlew :projects:syncslidepresenter:installDist
* ./projects/syncslidepresenter/build/install/syncslidepresenter/bin/syncslidepresenter
* </pre>
*
* <p>For reasons not yet fully understood, applications running both Swing and Vanadium freeze
* randomly (at least on Mac OS X). We work around this by running two JVMs. The main JVM will
* create a sub-JVM in a separate thread, and will then proceed to join a syncgroup and look for
* slide data. Once a new slide is detected, the bytes representing the slide will be sent to
* the sub-JVM via RMI.
*
* <p>The sub-JVM simply listens for slide bytes and then, using {@link ImageIO#read}, decodes
* those bytes and displays the resulting image in a JFrame.
*/
public class Main {
private static final Logger logger = Logger.getLogger(Main.class.getName());
private static final String SYNCBASE_APP = "syncslides";
private static final String SYNCBASE_DB = "syncslides";
private static final String PRESENTATIONS_TABLE = "Presentations";
private static final String DECKS_TABLE = "Decks";
private static final int MAX_PORT_PICKER_ATTEMPTS = 100;
private final Table presentations;
private final Table decks;
private final ImageViewer viewer;
private Database db;
private VContext context;
public static void main(String[] args)
throws SyncbaseServer.StartException, VException, IOException, NotBoundException,
AlreadyBoundException {
Options options = new Options();
JCommander commander = new JCommander(options);
try {
commander.parse(args);
} catch (ParameterException e) {
logger.warning("Could not parse parameters: " + e.getMessage());
commander.usage();
return;
}
if (options.help) {
commander.usage();
return;
}
ImageViewer viewer = null;
if (options.swing) {
// We're just going to be running Swing. Do that and then stop.
startSwingServer();
return;
}
// Otherwise, we're a Vanadium client. Connect to the swing server and then
// set up vanadium.
viewer = startAndConnectToSwingServer(options.swingServerTimeoutSeconds);
VContext baseContext = V.init();
AccessList acl = new AccessList(ImmutableList.of(new BlessingPattern("...")),
ImmutableList.<String>of());
Permissions permissions = new Permissions(ImmutableMap.of("1", acl));
String name = NamingUtil.join(options.mountPrefix, UUID.randomUUID().toString());
logger.info("Mounting new syncbase server at " + name);
VContext mountContext = SyncbaseServer.withNewServer(baseContext,
new SyncbaseServer.Params().withPermissions(permissions).withName(name)
.withStorageRootDir(options.storageRootDir));
final Server server = V.getServer(mountContext);
if (server.getStatus().getEndpoints().length > 0) {
logger.info("Mounted syncbase server at the following endpoints: ");
for (Endpoint e : server.getStatus().getEndpoints()) {
logger.info("\t" + e);
}
logger.info("End of endpoint list");
SyncbaseService service
= Syncbase.newService("/" + server.getStatus().getEndpoints()[0]);
SyncbaseApp app = service.getApp(SYNCBASE_APP);
if (!app.exists(baseContext)) {
app.create(baseContext, permissions);
}
Database db = app.getNoSqlDatabase(SYNCBASE_DB, null);
if (!db.exists(baseContext)) {
db.create(baseContext, permissions);
}
Table decks = db.getTable(DECKS_TABLE);
if (!decks.exists(baseContext)) {
decks.create(baseContext, permissions);
}
Table presentations = db.getTable(PRESENTATIONS_TABLE);
if (!presentations.exists(baseContext)) {
presentations.create(baseContext, permissions);
}
Main m = new Main(baseContext, viewer, db, decks, presentations);
Presentation presentation = new Discovery(
baseContext, options.mountPrefix, options.deckPrefix).getPresentation();
logger.info("Using presentation: " + presentation);
m.joinPresentation(presentation, options.joinTimeoutSeconds, options.slideRowFormat);
}
}
private static void startSwingServer() throws IOException, AlreadyBoundException {
logger.info("inside startSwingServer");
// Make command-Q do the same as closing the main frame (i.e. exit).
System.setProperty("apple.eawt.quitStrategy", "CLOSE_ALL_WINDOWS");
System.setProperty("java.rmi.server.hostname", "localhost");
JFrame frame = new JFrame();
enableOSXFullscreen(frame);
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
frame.setVisible(true);
JPanel panel = new JPanel(new GridBagLayout());
ScaleToFitJPanel presentationPanel = new ScaleToFitJPanel();
GridBagConstraints constraints = new GridBagConstraints();
constraints.weightx = 1;
constraints.weighty = 1;
constraints.fill = GridBagConstraints.BOTH;
panel.add(presentationPanel, constraints);
frame.getContentPane().add(panel);
frame.pack();
int port = pickUnusedPort();
LocateRegistry.createRegistry(port);
String name = "//localhost:" + port + "/" + UUID.randomUUID();
logger.info("swing server binding to " + name);
Naming.bind(name, new RemoteImageViewer(presentationPanel));
// The parent JVM will expect to read this value.
System.out.println(name);
}
public Main(VContext context, ImageViewer viewer, Database db, Table decks,
Table presentations) throws VException {
this.context = context;
this.db = db;
this.presentations = presentations;
this.decks = decks;
this.viewer = viewer;
}
public void joinPresentation(final Presentation presentation,
int joinTimeoutSeconds,
String slideRowFormat) throws VException {
Syncgroup syncgroup = db.getSyncgroup(presentation.getSyncgroupName());
syncgroup.join(context.withTimeout(Duration.standardSeconds(joinTimeoutSeconds)),
new SyncgroupMemberInfo((byte) 1));
for (String member : syncgroup.getMembers(context).keySet()) {
logger.info("Member: " + member);
}
for (KeyValue keyValue : presentations.scan(context, RowRange.prefix(""))) {
System.out.println("Presentation: " + keyValue);
}
BatchDatabase batch = db.beginBatch(context, null);
ResumeMarker marker = batch.getResumeMarker(context);
String rowKey = Joiner.on("/").join(presentation.getDeckId(), presentation
.getPresentationId(), "CurrentSlide");
logger.info("going to watch row key " + rowKey);
VIterable<WatchChange> changes = db.watch(context, presentations.name(), rowKey, marker);
for (WatchChange change : changes) {
logger.info("Change detected in " + change.getRowName());
logger.info("Type: " + change.getChangeType());
try {
VCurrentSlide currentSlide = (VCurrentSlide) VomUtil.decode(change.getVomValue(),
VCurrentSlide.class);
logger.info("Current slide: " + currentSlide);
// Read the corresponding slide.
String row = String.format(slideRowFormat, presentation.getDeckId(),
currentSlide.getNum());
VSlide slide = (VSlide) decks.getRow(row).get(context, VSlide.class);
viewer.setImage(slide.getThumbnail());
} catch (IOException | VException e) {
logger.log(Level.WARNING, "exception encountered while handling change event", e);
}
}
if (changes.error() != null) {
logger.log(Level.WARNING, "Premature end of slide changes: " + changes.error());
}
}
private static void enableOSXFullscreen(Window window) {
Preconditions.checkNotNull(window);
try {
// This class may not be present on the system (e.g. if we're not on MacOSX),
// use reflection so that we can make this an optional dependency.
Class util = Class.forName("com.apple.eawt.FullScreenUtilities");
Class params[] = new Class[]{Window.class, Boolean.TYPE};
@SuppressWarnings({"unchecked", "rawtypes"})
Method method = util.getMethod("setWindowCanFullScreen", params);
method.invoke(util, window, true);
} catch (ClassNotFoundException e) {
// Probably not on Mac OS X
} catch (Exception e) {
logger.log(Level.WARNING, "Couldn't enable fullscreen on Mac OS X", e);
}
}
public static class Options {
@Parameter(names = {"-s", "--storageRootDir"},
description = "the root directory to use for local storage")
private String storageRootDir = Joiner.on(File.separator).join(
System.getProperty("java.io.tmpdir"), "syncslidepresenter-storage");
@Parameter(names = {"-d", "--deckPrefix"},
description = "mounttable prefix for live presentations.")
private String deckPrefix = "happyDeck";
@Parameter(names = {"--swing"})
private boolean swing = false;
@Parameter(names = {"-m", "--mountPrefix"}, description = "the base path in the namespace"
+ " where the syncbase service will be mounted")
private String mountPrefix = "/192.168.86.254:8101";
@Parameter(names = {"--joinTimeout"},
description = "the number of seconds to wait to join the presentation")
private int joinTimeoutSeconds = 10;
@Parameter(names = {"-f", "--slideRowFormat"},
description = "a pattern specifying where slide rows are found")
private String slideRowFormat = "%s/slides/%04d";
@Parameter(names = {"-h", "--help"}, description = "display this help message", help = true)
private boolean help = false;
@Parameter(names = {"--swingServerTimeoutSeconds"},
description = "the amount of time to wait for the swing server to start")
private int swingServerTimeoutSeconds = 10;
}
private static class ScaleToFitJPanel extends JPanel implements ImageViewer {
private Image image;
public ScaleToFitJPanel() {
super();
setPreferredSize(new Dimension(250, 250));
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
repaint();
}
});
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
if (image != null) {
int width;
int height;
double containerRatio = 1.0d * getWidth() / getHeight();
double imageRatio = 1.0d * image.getWidth(null) / image.getHeight(null);
if (containerRatio < imageRatio) {
width = getWidth();
height = (int) (getWidth() / imageRatio);
} else {
width = (int) (getHeight() * imageRatio);
height = getHeight();
}
// Center the image in the container.
int x = (int) (((double) getWidth() / 2) - ((double) width / 2));
int y = (int) (((double) getHeight()/ 2) - ((double) height / 2));
g.drawImage(image, x, y, width, height, this);
}
}
@Override
public void setImage(byte[] imageData) throws RemoteException {
try {
this.image = ImageIO.read(new ByteArrayInputStream(imageData));
setPreferredSize(new Dimension(image.getWidth(null), image.getHeight(null)));
repaint();
} catch (IOException e) {
throw new RemoteException("Could not set image", e);
}
}
}
private interface ImageViewer extends Remote {
void setImage(byte[] imageData) throws RemoteException;
}
private static class RemoteImageViewer extends UnicastRemoteObject implements ImageViewer {
private final ImageViewer viewer;
protected RemoteImageViewer(ImageViewer viewer) throws RemoteException {
super();
this.viewer = viewer;
}
@Override
public void setImage(byte[] imageData) throws RemoteException {
viewer.setImage(imageData);
}
}
private static ImageViewer startAndConnectToSwingServer(int timeoutSeconds) throws IOException,
NotBoundException {
String javaHome = System.getProperty("java.home");
if (javaHome == null) {
throw new IllegalStateException("System property java.home is not set");
}
File javaBin = new File(javaHome, Joiner.on(File.separator).join("bin", "java"));
if (!javaBin.exists() || !javaBin.canExecute()) {
throw new IllegalStateException("Java binary " + javaBin
+ " does not exist or is not executable");
}
ProcessBuilder builder = new ProcessBuilder(javaBin.getAbsolutePath(),
"-classpath", System.getProperty("java.class.path"),
Main.class.getCanonicalName(), "--swing");
builder.redirectError(ProcessBuilder.Redirect.INHERIT);
logger.info("starting " + builder.command());
// The first line of output should be the address to which we should connect.
final Process process = builder.start();
logger.info("started " + process);
// Get a timeout timer going.
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
service.schedule(new Runnable() {
@Override
public void run() {
process.destroyForcibly();
}
}, timeoutSeconds, TimeUnit.SECONDS);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String name = reader.readLine();
// If the timeout didn't fire, we don't want it killing our process now!
service.shutdownNow();
if (name != null) {
logger.info("vanadium listener sending slide images to " + name);
ImageViewer viewer = (ImageViewer) Naming.lookup(name);
// Hack: when the swing viewer exits, terminate our main app.
new Thread(new Runnable() {
@Override
public void run() {
do {
try {
process.waitFor();
break;
} catch (InterruptedException e) {
// Ignored.
}
} while (true);
System.exit(0);
}
}, "SwingServerTerminationWatcher").start();
return viewer;
} else {
throw new IOException("could not read slide name");
}
}
private static int pickUnusedPort() throws IOException {
for (int i = 0; i < MAX_PORT_PICKER_ATTEMPTS; i++) {
ServerSocket socket = new ServerSocket();
try {
socket.bind(new InetSocketAddress(0));
int port = ((InetSocketAddress) socket.getLocalSocketAddress()).getPort();
return port;
} catch (IOException e) {
// Couldn't bind, try again.
} finally {
try {
socket.close();
} catch (IOException closeException) {
logger.log(Level.WARNING, "Exception caught during close", closeException);
}
}
}
throw new IOException("Couldn't locate unused port");
}
private static class Discovery {
public static final Duration MT_TIMEOUT =
Duration.standardSeconds(10);
private final VContext context;
private final String mtName;
private final String deckPrefix;
public Discovery(VContext context, String mtName, String deckPrefix) {
this.context = context;
this.mtName = mtName;
this.deckPrefix = deckPrefix;
}
private Set<String> scan(String pattern) {
logger.info("Scanning MT " + mtName + " with pattern \"" + pattern + "\"");
try {
Namespace ns = V.getNamespace(context);
ns.setRoots(ImmutableList.of(mtName));
Set<String> result = new HashSet<String>();
VContext ctx = context.withTimeout(MT_TIMEOUT);
for (GlobReply reply : V.getNamespace(ctx).glob(ctx, pattern)) {
if (reply instanceof GlobReply.Entry) {
MountEntry entry = ((GlobReply.Entry) reply).getElem();
result.add(entry.getName());
for (MountedServer server : entry.getServers()) {
logger.info(" endPoint: \"" + server.getServer() + "\"");
}
}
}
return result;
} catch (VException e) {
throw new IllegalStateException(e);
}
}
private Presentation findPreso(String serviceName) {
V.getNamespace(context).flushCacheEntry(context, serviceName);
ParticipantClient client =
ParticipantClientFactory.getParticipantClient(serviceName);
try {
Presentation p = client.get(context.withTimeout(
Duration.standardSeconds(5)));
return p;
} catch (VException e) {
throw new IllegalStateException(e);
}
}
public Presentation getPresentation() {
Set<String> services = scan(deckPrefix + "/*");
if (services.size() < 1) {
return null;
}
// Just grab the first one.
return findPreso(services.iterator().next());
}
}
}