package org.shininet.bukkit.itemrenamer.meta; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.zip.DeflaterOutputStream; import java.util.zip.InflaterInputStream; import org.bukkit.ChatColor; import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder; import com.google.common.base.Charsets; import com.google.common.base.Preconditions; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.io.ByteStreams; import com.google.common.primitives.Bytes; import com.google.common.primitives.Ints; import com.google.common.primitives.Shorts; /** * Represents an encoder/decoder that can store arbitrary information as ChatColor symbols. * * @author Kristian */ public class CharCodeStore { private static short MAGIC_CODE = 0x29F0; private static char SEGMENT_DELIMITER_CHAR = '�'; private static String SEGMENT_DELIMITER = ChatColor.COLOR_CHAR + "" + SEGMENT_DELIMITER_CHAR;; private static int HEADER_SIZE = 8; // bytes /* * Format: * Base 64-encoded byte array, interlaced with � so that its skipped by the Minecraft client. * * Segment header: * Bits | 0 - 15 | 16 - 31 | 32 - 61 | <Until delimiter> | * Field | MAGIC CODE | Uncompressed size | Plugin ID | Compressed payload | * Comment | Always the same | Excluding header. | | | * * Segment start and end delimiter: * �� */ private abstract class Segment { /** * Insert the textual representation of this segment into the given output. * @param previous - the previous element, or NULL if this is the start. */ public abstract void pipeTo(StringBuilder output, Segment previous); /** * Whether or not this segment can delimit the next data segment. * @return TRUE if it can, FALSE otherwise. */ public abstract boolean needDelimiter(); } /** * Represents a text segment. * @author Kristian */ private class TextSegment extends Segment { private final String text; public TextSegment(String text) { this.text = text; } @Override public void pipeTo(StringBuilder output, Segment previous) { output.append(text); } @Override public boolean needDelimiter() { return text != null && text.endsWith(SEGMENT_DELIMITER); } } /** * Represents a owned segment. * @author Kristian */ public class DataSegment extends Segment { private final int pluginId; private byte[] data; /** * Construct a new data segment. * @param pluginId - the owner plugin ID. * @param data - the data to store. */ private DataSegment(int pluginId, byte[] data) { this.pluginId = pluginId; this.data = data; } /** * Retrieve the data stored in this segment. * @return The data stored in the segment. */ public byte[] getBytes() { return data; } /** * Set the data stored in this segment. * @param data - the new data to be stored in the segment. */ public void setBytes(byte[] data) { this.data = data; } /** * Retrieve the data stored in this segment as a string. * @return The string data in this segment. */ public String getString() { return new String(data, Charsets.UTF_8); } /** * Set the data stored in this segment as a string. * @param data - the data to be stored. */ public void setString(String data) { Preconditions.checkNotNull(data, "data cannot be NULL."); this.data = data.getBytes(Charsets.UTF_8); } /** * Retrieve the unique ID of the plugin that generated this segment. * @return The unique plugin ID. */ public int getPluginId() { return pluginId; } @Override public boolean needDelimiter() { return true; } @Override public void pipeTo(StringBuilder output, Segment previous) { if (!(previous != null && previous.needDelimiter())) output.append(SEGMENT_DELIMITER); encode(output, pluginId, data); output.append(SEGMENT_DELIMITER); } } /** * Represents a generic converter between the stored payload and the actual payload. * * @author Kristian */ public interface PayloadStore { /** * Retrieve the output stream that is used to compress the payload. * @param storage - the payload byte array as a stream. * @return The output stream that will be used to compress the payload. */ public OutputStream getPayloadOutputStream(OutputStream storage); /** * Retrieve the input stream that is used to decompress the payload. * @param storage - the payload byte array as a stream. * @return The input stream that will be used to decompress the payload. */ public InputStream getPayloadInputStream(InputStream input); /*** * Retrieve the payload from a given input stream. * @param uncompressedSize - the uncompressed size of the payload. * @param input - the input stream. * @return The payload itself. * @throws IOException If anything went wrong. */ public byte[] getPayload(int uncompressedSize, DataInputStream input) throws IOException; } /** * Yields access to compressed payload. * @author Kristian */ public static class CompressedPayloadStore implements PayloadStore { public static final CompressedPayloadStore INSTANCE = new CompressedPayloadStore(); @Override public OutputStream getPayloadOutputStream(OutputStream storage) { return new DeflaterOutputStream(storage); } @Override public InputStream getPayloadInputStream(InputStream input) { return new InflaterInputStream(input); } @Override public byte[] getPayload(int uncompressedSize, DataInputStream input) throws IOException { InputStream payload = getPayloadInputStream(input); byte[] result = new byte[uncompressedSize]; ByteStreams.readFully(payload, result); return result; } } /** * Yields access to uncompressed payload. * @author Kristian */ public static class RawPayloadStore implements PayloadStore { public static final RawPayloadStore INSTANCE = new RawPayloadStore(); @Override public OutputStream getPayloadOutputStream(OutputStream storage) { return storage; } @Override public InputStream getPayloadInputStream(InputStream input) { return input; } @Override public byte[] getPayload(int uncompressedSize, DataInputStream input) throws IOException { byte[] output = new byte[uncompressedSize]; // Only read as much as we can ByteStreams.readFully(input, output, 0, Math.min(uncompressedSize, input.available())); return output; } } protected final int pluginId; protected List<Segment> segments = Lists.newArrayList(); protected Map<Integer, DataSegment> lookup = Maps.newHashMap(); protected PayloadStore payloadStore = CompressedPayloadStore.INSTANCE; /** * Construct a new encoder with the given plugin ID. * <p> * Please go to <a href="http://www.random.org/cgi-bin/randbyte?nbytes=4&format=h ">Random.org</a> to get a new unique ID for your plugin. * <p> * Note that {@link #save()} is not implemented. * @param pluginId - a unique ID identifying the owner plugin. */ public CharCodeStore(int pluginId) { this.pluginId = pluginId; } /** * Construct a new encoder with the given plugin ID. * @param pluginId - a unique ID identifying the owner plugin. * @param payloadStore - how to transform the payload data. */ public CharCodeStore(int pluginId, PayloadStore store) { this.pluginId = pluginId; this.payloadStore = store; } /** * Parse the given text for segments. * @param text - the text to parse. * @return This store, for chaining. */ public CharCodeStore parse(String text) { segments = decode(text); for (DataSegment segment : getDataSegments()) { lookup.put(segment.getPluginId(), segment); } return this; } /** * Clear the current store for data segments. */ public void clear() { for (Iterator<Segment> it = segments.iterator(); it.hasNext(); ) { if (it.next() instanceof DataSegment) { it.remove(); } } lookup.clear(); } /** * Retrieve or create a data segment for the current plugin. * @return The data segment. */ public DataSegment getData() { return getData(pluginId); } /** * Retrieve or create a data segment for a plugin by the given ID. * @param pluginId - the ID of the plugin. * @return The data segment created or already associated with the given plugin. */ public DataSegment getData(int pluginId) { DataSegment segment = lookup.get(pluginId); if (segment == null) { segments.add(segment = new DataSegment(pluginId, new byte[0])); lookup.put(pluginId, segment); } return segment; } /** * Remove the data associated with the given plugin. * @param pluginId - ID of the plugin whose data will be removed. * @return TRUE if the data was removed, FALSE otherwise. */ public boolean removeData(int pluginId) { DataSegment segment = lookup.remove(pluginId); if (segment != null) { segments.remove(segment); return true; } return false; } /** * Determine if the current plugin has stored any data in the character store. * @return TRUE if it has, FALSE otherwise. */ public boolean hasData() { return hasData(pluginId); } /** * Determine if the given plugin has any associated data. * @param pluginId - the plugin to look for. * @return TRUE if it does, FALSE otherwise. */ public boolean hasData(int pluginId) { return lookup.containsKey(pluginId); } /** * Retrieve an immutable collection of the ID of all the plugins in this store. * @return Collection of all the IDs. */ public Collection<Integer> plugins() { return Collections.unmodifiableCollection(lookup.keySet()); } /** * Retrieve the unique ID of the plugin that controls this encoder. * @return Unique plugin ID. */ public int getPluginId() { return pluginId; } /** * Convert this store to the equivalent string. */ @Override public String toString() { StringBuilder output = new StringBuilder(); Segment last = null; // Build the output for (Segment segment : segments) { segment.pipeTo(output, last); last = segment; } return output.toString(); } /** * Save the store to the source. * <p> * Optional operation. */ public void save() { throw new UnsupportedOperationException("Call toString() instead."); } /** * Retrieve every data segment in this store. * @return Every data segment. */ protected Iterable<DataSegment> getDataSegments() { return Iterables.filter(segments, DataSegment.class); } /** * Encode an array of bytes into a CharCoded text. * @param prefix - a unique prefix to identify this chunk of data. Use NULL to skip. * @param data - the data to encode. */ private void encode(StringBuilder result, int pluginId, byte[] data) { try { ByteArrayOutputStream storage = new ByteArrayOutputStream(); OutputStream output = payloadStore.getPayloadOutputStream(storage); output.write(data); output.close(); putEncodedData(result, pluginId, storage.toByteArray(), data.length); } catch (IOException e) { throw new RuntimeException("Unable to compress data.", e); } } /** * Store a data segment into the given string builder. * @param output - the output. * @param pluginId - the unique ID. * @param data - the compressed data. * @param length - the uncompressed data lenght. */ private void putEncodedData(StringBuilder output, int pluginId, byte[] data, int length) { // Store the length of the byte array itself char[] base = Base64Coder.encode(Bytes.concat( Shorts.toByteArray(MAGIC_CODE), Shorts.toByteArray((short) length), Ints.toByteArray(pluginId), data )); putEncoded(output, base); } /** * Encode the given character array by interlacing it with the COLOR_CHAR symbol. * @param output - the string output. * @param characters - the characters to interlace. */ private void putEncoded(StringBuilder output, char[] characters) { for (char character : characters) { output.append(ChatColor.COLOR_CHAR); output.append(character); } } /** * Extract and remove every character from the builder. * @param input - the string builder. * @return The characters that were removed from the builder. */ private char[] pullChars(StringBuilder input) { char[] result = input.toString().toCharArray(); input.setLength(0); return result; } /** * Decode every encoded byte arrays. * @param data - the data as text. * @param onlyOurs - if segments by other plugins should be excluded. * @return Decoded blocks of data. */ private List<Segment> decode(String data) { List<Segment> output = Lists.newArrayList(); StringBuilder dataCandidate = new StringBuilder(); StringBuilder textCandidate = new StringBuilder(); StringBuilder missingText = new StringBuilder(); char[] characters = data.toCharArray(); for (int i = 0; i < characters.length; i++) { if (characters[i] == ChatColor.COLOR_CHAR) { // This is probably caused by a substring() - assume we got the end anyways boolean missingData = (i >= characters.length - 1); char current = missingData ? SEGMENT_DELIMITER_CHAR : characters[++i]; // Build segment until the end if (current == SEGMENT_DELIMITER_CHAR) { DataSegment decoded = decodeDataSegment(dataCandidate.toString()); // Add it to the output if (decoded != null) { if (textCandidate.length() > 0) { output.add(new TextSegment(textCandidate.toString())); textCandidate.setLength(0); } dataCandidate.setLength(0); output.add(decoded); continue; } else { // Don't miss this character missingText.append(SEGMENT_DELIMITER_CHAR); } } else { dataCandidate.append(current); continue; } } // Unexpected end - must be a text segment then if (dataCandidate.length() > 0) putEncoded(textCandidate, pullChars(dataCandidate)); if (missingText.length() > 0) textCandidate.append(pullChars(missingText)); textCandidate.append(characters[i]); } // Leftovers if (dataCandidate.length() > 0) putEncoded(textCandidate, pullChars(dataCandidate)); if (textCandidate.length() > 0) output.add(new TextSegment(textCandidate.toString())); return output; } /** * Decode a single segment from a string. * <p> * Segment delimiter must be excluded. * @param data - the segment. * @return The decoded segment. */ private DataSegment decodeDataSegment(String data) { // Correct missing padding while ((data.length() % 4) != 0) { data += "A"; } byte[] match = Shorts.toByteArray(MAGIC_CODE); byte[] decoded = Base64Coder.decode(data); DataInputStream input = new DataInputStream(new ByteArrayInputStream(decoded)); // We can trust available in ByteArrayInputStreams try { // May have to skip a lot of crap while (input.available() >= HEADER_SIZE) { if (input.readByte() != match[0] || input.readByte() != match[1]) continue; int uncompressed = input.readShort(); int pluginId = input.readInt(); byte[] payload = payloadStore.getPayload(uncompressed, input); input.close(); return new DataSegment(pluginId, payload); } return null; } catch (IOException e) { throw new RuntimeException("Unexpected IOException.", e); } } }