package com.jotuntech.sketcher.server; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Graphics2D; import java.awt.Image; import java.awt.Rectangle; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; import java.awt.image.ImageObserver; import java.io.DataOutputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.ClosedByInterruptException; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.zip.DeflaterOutputStream; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import com.jotuntech.sketcher.common.BitmapLayer; import com.jotuntech.sketcher.common.Canvas; import com.jotuntech.sketcher.common.Layer; import com.jotuntech.sketcher.common.Log; import com.jotuntech.sketcher.common.StreamableUtils; import com.jotuntech.sketcher.common.TwoWayHashMap; import com.jotuntech.sketcher.common.User; import com.jotuntech.sketcher.server.command.KickCommand; import com.jotuntech.sketcher.server.command.ServerMessageCommand; import com.jotuntech.sketcher.server.command.SignOutCommand; public class Server extends Thread implements ImageObserver { /** Map of client connections */ private TwoWayHashMap connectionMap; /** Buffer for encoding commands before transmission */ private ByteBuffer commandBuffer; /** Array of active users - always replace instead of mutating */ private User[] users; /** Map of connections - always replace instead of muting */ private TwoWayHashMap safeConnectionMap; /** Layers and pixel information */ private Canvas canvas; /** Custom attributes for the host */ private Map attributes = new HashMap(); /** TCP port number */ private int port; /** Flattened image of canvas */ private BufferedImage image; /** Pixel array for image */ private int[] pixels; /** Dirty rectangle since last merge */ private Rectangle mergeDirty; /** Data output stream for animation data */ private DataOutputStream animationDOS; /** Optional access controller for callback to host */ private AccessController accessController; /** Socket channel that the server listens on */ private ServerSocketChannel serverChannel; /** Set to inject command into server loop for broadcasting to clients */ private CommandEntry injectCommand; /** Millisecond time of last layer cleanup */ private long lastLayerClean; /** Voice chat server */ private VoiceServer voiceServer; /** MOTD text */ private String motd = null; public Server(String name, int port, int width, int height) throws IOException { super(name); this.port = port; this.users = new User[0]; this.safeConnectionMap = new TwoWayHashMap(); connectionMap = new TwoWayHashMap(); connectionMap.addChangeListener(new ChangeListener() { public void stateChanged(ChangeEvent e) { /** Find number of connections with signed-in users */ int numUsers = 0; for(Connection c : connectionMap.values()) { if(c.getUser() == null) { continue; } ++numUsers; } /** Allocate array for new user list */ User[] newUsers = new User[numUsers]; /** Populate array with users */ int index = 0; for(Connection c : connectionMap.values()) { if(c.getUser() == null) { continue; } newUsers[index++] = c.getUser(); } /** Replace array atomically */ users = newUsers; /** Replicate connection map */ TwoWayHashMap newMap = connectionMap.copy(); safeConnectionMap = newMap; } }); canvas = new Canvas(width, height); image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); pixels = ((DataBufferInt)image.getRaster().getDataBuffer()).getData(); /** Initialize flattened canvas image by filling it with white */ Graphics2D g2 = (Graphics2D)image.getGraphics(); g2.setColor(Color.WHITE); g2.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); g2.dispose(); /** Create default layers */ TwoWayHashMap layerMap = getCanvas().getLayerMap(); layerMap.put(new BitmapLayer("Sketch")); layerMap.put(new BitmapLayer("Color")); layerMap.put(new BitmapLayer("Shadow")); layerMap.put(new BitmapLayer("Light")); layerMap.put(new BitmapLayer("Ink")); layerMap.put(new BitmapLayer("Doodle")); /** Receive image updates from canvas */ canvas.addImageObserver(this); /** Allocate buffer to hold command length and data */ commandBuffer = ByteBuffer.allocate(65538); /** Start layer cleanup cycle at a random time for smooth operation of multiple servers */ lastLayerClean = System.currentTimeMillis() - Math.round(Math.random() * 30000); /** Open listening socket */ serverChannel = ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.socket().bind(new java.net.InetSocketAddress(port)); Log.info("Sketcher server running on port " + port); /** Start voice chat server */ voiceServer = new VoiceServer(port + 100); voiceServer.start(); } public Canvas getCanvas() { return canvas; } public TwoWayHashMap getConnectionMap() { return connectionMap; } public TwoWayHashMap getSafeConnectionMap() { return safeConnectionMap; } public User[] getUsers() { return users; } public void setAttribute(String key, Object value) { attributes.put(key, value); } public Object getAttribute(String key) { return attributes.get(key); } public int getPort() { return port; } public BufferedImage getImage() { return image; } public void announce(String message) { Log.info("Announce: " + message); injectCommand = new CommandEntry(0, new ServerMessageCommand(message)); } public void kick(String username, String reason) { Log.info("Kick: " + username + " (" + reason + ")"); /** Kick command was initialized with a name, and not a key, so we must look it up */ for(Map.Entry e : getSafeConnectionMap().entrySet()) { User u = e.getValue().getUser(); if(u == null) { continue; } if(username.equalsIgnoreCase(u.getName())) { Command c = new KickCommand(e.getKey(), reason); c.perform(this, null); injectCommand = new CommandEntry(0, c); break; } } } public boolean imageUpdate(Image img, int infoflags, int left, int top, int width, int height) { if(infoflags == ImageObserver.ALLBITS) { /** Entire image needs an update */ left = 0; top = 0; width = getCanvas().getWidth(); height = getCanvas().getHeight(); } else { left = Math.max(0, Math.min(getCanvas().getWidth() - 1, left)); top = Math.max(0, Math.min(getCanvas().getHeight() - 1, top)); width = Math.max(0, Math.min(getCanvas().getWidth(), left + width) - left); height = Math.max(0, Math.min(getCanvas().getHeight(), top + height) - top); } Graphics2D g2 = (Graphics2D)image.getGraphics(); /** Set clipping rectangle */ g2.setClip(left, top, width, height); /** Composite source over destination */ AlphaComposite ac = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1); g2.setComposite(ac); /** Clear with white */ g2.setColor(Color.WHITE); g2.fillRect(left, top, width, height); /** Create a map of layers, showing which of them have phantom layers */ IdentityHashMap> selectedLayers = new IdentityHashMap>(); for(Connection c : connectionMap.values()) { User u = c.getUser(); if(u == null) { continue; } Layer l = u.getLayer(); if(l == null) { continue; } Set us = selectedLayers.get(l); if(us == null) { us = new HashSet(); selectedLayers.put(l, us); } us.add(u.getPhantomLayer()); } /** Paint layers over it */ for(Layer l : canvas.getLayerMap().values()) { Set ls = selectedLayers.get(l); if(l instanceof BitmapLayer && ls != null) { ((BitmapLayer) l).draw(g2, ls); } else { l.draw(g2); } } g2.dispose(); if(mergeDirty == null) { mergeDirty = new Rectangle(left, top, width, height); } else { mergeDirty.union(new Rectangle(left, top, width, height)); } if(animationDOS != null) { synchronized(animationDOS) { try { animationDOS.writeLong(System.currentTimeMillis()); animationDOS.writeInt(left); animationDOS.writeInt(top); animationDOS.writeInt(width); animationDOS.writeInt(height); for(int y = top; y < top + height; y++) { for(int x = left; x < left + width; x++) { animationDOS.writeInt(pixels[y * image.getWidth() + x]); } } } catch (IOException e) { try { animationDOS.close(); } catch (IOException e1) { } e.printStackTrace(); } } } return true; } public void setAccessController(AccessController accessController) { this.accessController = accessController; } public AccessController getAccessController() { return accessController; } public void setAnimationFile(String name) throws IOException { if(animationDOS != null) { synchronized(animationDOS) { animationDOS.close(); } } animationDOS = new DataOutputStream(new DeflaterOutputStream(new FileOutputStream(name, true))); } public void run() { try { while(!interrupted()) { /** Conserve CPU time when idle */ sleep(20); /** Run layer clean-up every 30 seconds */ if(lastLayerClean + 30000 < System.currentTimeMillis()) { /** Cleanup now happens internally and separately on client and server */ for(Layer l : canvas.getLayerMap().values()) { /** Clean up layer */ l.clean(); } /** Time of last layer cleanup is now */ lastLayerClean = System.currentTimeMillis(); //Log.info("Cleaned layers."); } /** Accept and initialize client connections */ for(SocketChannel channel = serverChannel.accept(); channel != null; channel = serverChannel.accept()) { channel.configureBlocking(false); Connection connection = new Connection(channel); Integer connectionKey = connectionMap.put(connection); Log.debug("Accepting connection " + connectionKey + " from " + channel.socket().getRemoteSocketAddress()); } /** Broadcast any injected command */ if(injectCommand != null) { for(Connection c : connectionMap.values()) { c.getSendQueue().offer(injectCommand); } injectCommand = null; } /** Loop through all client connections */ for(Map.Entry entry : connectionMap.entrySet()) { /** Fetch basic information about each connection */ Integer connectionKey = entry.getKey(); Connection connection = entry.getValue(); SocketChannel channel = connection.getChannel(); /** All connection specific errors are trapped by this 'try' statement */ try { if(!channel.isConnected() && !connection.hasTimeOfDeath()) { /** Remove closed connection from map */ connectionMap.remove(connectionKey); /** Remove voice chat client if any */ voiceServer.removeClient(connectionKey); if(connection.getUser() != null) { /** Connection has closed unceremoniously. Broadcast to other clients. */ CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was closed")); for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } Log.debug("Connection " + connectionKey + " had no time of death, sent explicit sign-out."); } Log.debug("Connection " + connectionKey + " was closed."); /** The loop must be broken here to avoid a ConcurrentModificationException */ break; } else if(connection.isTimeOfDeath()) { /** Client signed out earlier. Close the channel. */ try { channel.close(); } catch(IOException e) { } /** Remove from connection map */ connectionMap.remove(connectionKey); /** Remove voice chat client if any */ voiceServer.removeClient(connectionKey); Log.debug("Connection " + connectionKey + " has reached time of death and was closed."); /** The loop must be broken here to avoid a ConcurrentModificationException */ break; } else if(!connection.hasTimeOfDeath() && System.currentTimeMillis() - connection.getLastPing() > Connection.MAX_PING) { connection.setTimeOfDeath(System.currentTimeMillis() + 5000); CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Ping timeout")); for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } if(connection.getUser() != null) { Log.info(connection.getUser().getName() + " has signed out (Ping timeout)."); } } /** Get buffers for networking */ ByteBuffer inputBuffer = connection.getInputBuffer(); ByteBuffer outputBuffer = connection.getOutputBuffer(); ArrayBlockingQueue sendQueue = connection.getSendQueue(); //Random sendRandom = connection.getSendRandom(); //Random recvRandom = connection.getRecvRandom(); //int startPosition = outputBuffer.position(); /** Only check send queue if there is room for a minimal command in the output buffer. */ while(outputBuffer.remaining() >= 2 + 4 + 1 + 2 && sendQueue.size() > 0) { /** Peek at the head of the queue */ CommandEntry e = sendQueue.peek(); /** Get command entry fields */ Integer k = e.getSourceKey(); Command c = e.getCommand(); /** Clear command buffer */ commandBuffer.clear(); /** Write peer key */ commandBuffer.putInt(k); /** Write command name */ String commandName = c.getClass().getSimpleName(); commandBuffer.put((byte) (commandName.length() - 1)); for(int i = 0; i < commandName.length(); i++) { commandBuffer.putChar(commandName.charAt(i)); } /** Encode and write command data */ c.encode(commandBuffer); /** Flip command buffer before reading */ commandBuffer.flip(); /** Discard command if there is no room for it in the output buffer */ if(commandBuffer.remaining() + 2 > outputBuffer.remaining()) { break; } /** Append command length and command data to output buffer */ outputBuffer.putShort((short) (commandBuffer.remaining() - 1)); outputBuffer.put(commandBuffer); /** Remove command from queue */ sendQueue.remove(); } //for(int i = startPosition; i < outputBuffer.position(); i++) { // outputBuffer.put(i, (byte) (outputBuffer.get(i) ^ sendRandom.nextInt(256))); //} /** Flip output buffer */ outputBuffer.flip(); if(outputBuffer.remaining() > 0) { try { /** Flush buffer */ channel.write(outputBuffer); } catch (IOException e) { Log.info("Connection" + connectionKey + " was broken (" + e.getMessage() + "). Sending sign-out."); try { channel.close(); } catch(IOException e2) { } connectionMap.remove(connectionKey); CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was broken: " + e.getMessage())); for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } /** Remove voice chat client if any */ voiceServer.removeClient(connectionKey); break; } } /** Prepare output buffer for append */ outputBuffer.compact(); /** Mortal connections are mute and have no voice chat audio */ if(connection.hasTimeOfDeath()) { continue; } /** Append socket data to input buffer */ //startPosition = inputBuffer.position(); int readResult = channel.read(inputBuffer); if(readResult == -1) { /** Close the channel */ try { channel.close(); } catch(IOException e) { } /** Remove from connection map */ connectionMap.remove(connectionKey); /** Broadcast sign-out to other clients */ CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was closed")); for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } /** Remove voice chat client if any */ voiceServer.removeClient(connectionKey); Log.info("Connection " + connectionKey + " reached end of stream. Sending sign-out."); /** The loop must be broken here to avoid a ConcurrentModificationException */ break; } else if(readResult > 0) { //for(int i = startPosition; i < inputBuffer.position(); i++) { // inputBuffer.put(i, (byte) (inputBuffer.get(i) ^ recvRandom.nextInt(256))); //} /** Prepare input buffer for reading */ inputBuffer.flip(); /** Only check input buffer if long enough to hold a minimal command. */ while(inputBuffer.remaining() >= 2 + 4 + 1 + 2) { /** Peek at length */ int commandLength = (inputBuffer.getShort(inputBuffer.position()) & 0xFFFF) + 1; /** Only read input buffer if long enough to read entire command. */ if(inputBuffer.remaining() < commandLength + 2) { break; } /** We already have the command length */ inputBuffer.getShort(); /** Map data into command buffer */ ByteBuffer commandBuffer = inputBuffer.slice(); commandBuffer.limit(commandLength); /** Skip command data in input buffer */ inputBuffer.position(inputBuffer.position() + commandLength); /** Ignore peer key from client */ commandBuffer.getInt(); /** Read command name */ int commandNameLength = (commandBuffer.get() & 0xFF) + 1; StringBuffer commandNameBuffer = new StringBuffer(); for(int i = 0; i < commandNameLength; i++) { commandNameBuffer.append(commandBuffer.getChar()); } String commandName = commandNameBuffer.toString(); /** Attempt to instanciate command */ Command command = (Command) StreamableUtils.create("com.jotuntech.sketcher.server.command." + commandName, commandBuffer); /** Wrap it in a CommandEntry in case of a broadcast */ CommandEntry commandEntry = new CommandEntry(connectionKey, command); /** Perform the command and check the result */ switch(command.perform(this, connection)) { /** Command wishes to be broadcast to all connections */ case Connection.SEND_ALL: for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } break; /** Command wishes to be broadcast to all but its own connection */ case Connection.SEND_OTHERS: for(Connection c : connectionMap.values()) { if(c != connection) { c.getSendQueue().offer(commandEntry); } } break; /** Command wishes to loop back to the client */ case Connection.SEND_SELF: connection.getSendQueue().offer(commandEntry); break; /** Command wishes to be anonymous */ case Connection.SEND_NONE: break; } } /** Prepare input buffer for appending */ inputBuffer.compact(); } } catch (IOException e) { /** Remove from connection map */ connectionMap.remove(connectionKey); /** Close the channel */ try { channel.close(); } catch(IOException e2) { } if(connection.getUser() != null) { /** Broadcast sign-out to clients */ CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was broken: " + e.getMessage())); for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } } /** Remove voice chat client if any */ voiceServer.removeClient(connectionKey); Log.info("Connection " + connectionKey + " was broken (" + e.getMessage() + "). Sending sign-out."); /** The loop must be broken here to avoid a ConcurrentModificationException */ break; } catch(Throwable t) { /** We have crashed and must let the client know, so we let connection linger a bit */ connection.setTimeOfDeath(System.currentTimeMillis() + 5000); if(connection.getUser() != null) { /** Broadcast sign-out to clients */ CommandEntry commandEntry = new CommandEntry(connectionKey, new SignOutCommand("Connection was crashed: " + t.getClass().getSimpleName())); for(Connection c : connectionMap.values()) { c.getSendQueue().offer(commandEntry); } } Log.error("Connection " + connectionKey + " crashed!"); Log.error(t); /** Remove voice chat client if any */ voiceServer.removeClient(connectionKey); /** The loop must be broken here to avoid a ConcurrentModificationException */ break; } } } } catch(ClosedByInterruptException e) { Log.info("Server interrupted."); } catch(InterruptedException e) { Log.info("Server interrupted."); } catch(Throwable e) { Log.error("Server crashed."); Log.error(e); } /** Shut down the voice server */ voiceServer.interrupt(); /** Close all client connections */ for(Connection c : connectionMap.values()) { try { c.getChannel().close(); } catch(IOException e) { } } /** Close server socket channel */ try { serverChannel.close(); } catch(IOException e) { } /** Empty the user list */ users = new User[0]; /** Clear the connection map */ connectionMap.clear(); } public VoiceServer getVoiceServer() { return voiceServer; } public String getMOTD() { return motd; } public void setMOTD(String motd) { this.motd = motd; } public void setMergeDirty(Rectangle mergeDirty) { this.mergeDirty = mergeDirty; } public Rectangle getMergeDirty() { return mergeDirty; } }