From: Niki Roo Date: Fri, 20 Sep 2019 12:20:56 +0000 (+0200) Subject: Add 'src/be/nikiroo/utils/' from commit '46add0670fdee4bd936a13fe2448c5e20a7ffd0a' X-Git-Url: http://git.nikiroo.be/?p=fanfix.git;a=commitdiff_plain;h=d46b7b96f94e88a776bcd2dfd756549ffb300cc9;hp=c9994f27667bc421bcd448d39e55774fddf5c431 Add 'src/be/nikiroo/utils/' from commit '46add0670fdee4bd936a13fe2448c5e20a7ffd0a' git-subtree-dir: src/be/nikiroo/utils git-subtree-mainline: c9994f27667bc421bcd448d39e55774fddf5c431 git-subtree-split: 46add0670fdee4bd936a13fe2448c5e20a7ffd0a --- diff --git a/src/be/nikiroo/utils/Cache.java b/src/be/nikiroo/utils/Cache.java new file mode 100644 index 0000000..6233082 --- /dev/null +++ b/src/be/nikiroo/utils/Cache.java @@ -0,0 +1,457 @@ +package be.nikiroo.utils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Date; + +import be.nikiroo.utils.streams.MarkableFileInputStream; + +/** + * A generic cache system, with special support for {@link URL}s. + *

+ * This cache also manages timeout information. + * + * @author niki + */ +public class Cache { + private File dir; + private long tooOldChanging; + private long tooOldStable; + private TraceHandler tracer = new TraceHandler(); + + /** + * Only for inheritance. + */ + protected Cache() { + } + + /** + * Create a new {@link Cache} object. + * + * @param dir + * the directory to use as cache + * @param hoursChanging + * the number of hours after which a cached file that is thought + * to change ~often is considered too old (or -1 for + * "never too old") + * @param hoursStable + * the number of hours after which a cached file that is thought + * to change rarely is considered too old (or -1 for + * "never too old") + * + * @throws IOException + * in case of I/O error + */ + public Cache(File dir, int hoursChanging, int hoursStable) + throws IOException { + this.dir = dir; + this.tooOldChanging = 1000L * 60 * 60 * hoursChanging; + this.tooOldStable = 1000L * 60 * 60 * hoursStable; + + if (dir != null && !dir.exists()) { + dir.mkdirs(); + } + + if (dir == null || !dir.exists()) { + throw new IOException("Cannot create the cache directory: " + + (dir == null ? "null" : dir.getAbsolutePath())); + } + } + + /** + * The traces handler for this {@link Cache}. + * + * @return the traces handler + */ + public TraceHandler getTraceHandler() { + return tracer; + } + + /** + * The traces handler for this {@link Cache}. + * + * @param tracer + * the new traces handler + */ + public void setTraceHandler(TraceHandler tracer) { + if (tracer == null) { + tracer = new TraceHandler(false, false, false); + } + + this.tracer = tracer; + } + + /** + * Check the resource to see if it is in the cache. + * + * @param uniqueID + * the resource to check + * @param allowTooOld + * allow files even if they are considered too old + * @param stable + * a stable file (that dones't change too often) -- parameter + * used to check if the file is too old to keep or not + * + * @return TRUE if it is + * + */ + public boolean check(String uniqueID, boolean allowTooOld, boolean stable) { + return check(getCached(uniqueID), allowTooOld, stable); + } + + /** + * Check the resource to see if it is in the cache. + * + * @param url + * the resource to check + * @param allowTooOld + * allow files even if they are considered too old + * @param stable + * a stable file (that dones't change too often) -- parameter + * used to check if the file is too old to keep or not + * + * @return TRUE if it is + * + */ + public boolean check(URL url, boolean allowTooOld, boolean stable) { + return check(getCached(url), allowTooOld, stable); + } + + /** + * Check the resource to see if it is in the cache. + * + * @param cached + * the resource to check + * @param allowTooOld + * allow files even if they are considered too old + * @param stable + * a stable file (that dones't change too often) -- parameter + * used to check if the file is too old to keep or not + * + * @return TRUE if it is + * + */ + private boolean check(File cached, boolean allowTooOld, boolean stable) { + if (cached.exists() && cached.isFile()) { + if (!allowTooOld && isOld(cached, stable)) { + if (!cached.delete()) { + tracer.error("Cannot delete temporary file: " + + cached.getAbsolutePath()); + } + } else { + return true; + } + } + + return false; + } + + /** + * Clean the cache (delete the cached items). + * + * @param onlyOld + * only clean the files that are considered too old for a stable + * resource + * + * @return the number of cleaned items + */ + public int clean(boolean onlyOld) { + long ms = System.currentTimeMillis(); + + tracer.trace("Cleaning cache from old files..."); + + int num = clean(onlyOld, dir, -1); + + tracer.trace(num + "cache items cleaned in " + + (System.currentTimeMillis() - ms) + " ms"); + + return num; + } + + /** + * Clean the cache (delete the cached items) in the given cache directory. + * + * @param onlyOld + * only clean the files that are considered too old for stable + * resources + * @param cacheDir + * the cache directory to clean + * @param limit + * stop after limit files deleted, or -1 for unlimited + * + * @return the number of cleaned items + */ + private int clean(boolean onlyOld, File cacheDir, int limit) { + int num = 0; + File[] files = cacheDir.listFiles(); + if (files != null) { + for (File file : files) { + if (limit >= 0 && num >= limit) { + return num; + } + + if (file.isDirectory()) { + num += clean(onlyOld, file, limit); + file.delete(); // only if empty + } else { + if (!onlyOld || isOld(file, true)) { + if (file.delete()) { + num++; + } else { + tracer.error("Cannot delete temporary file: " + + file.getAbsolutePath()); + } + } + } + } + } + + return num; + } + + /** + * Open a resource from the cache if it exists. + * + * @param uniqueID + * the unique ID + * @param allowTooOld + * allow files even if they are considered too old + * @param stable + * a stable file (that dones't change too often) -- parameter + * used to check if the file is too old to keep or not + * + * @return the opened resource if found, NULL if not + */ + public InputStream load(String uniqueID, boolean allowTooOld, boolean stable) { + return load(getCached(uniqueID), allowTooOld, stable); + } + + /** + * Open a resource from the cache if it exists. + * + * @param url + * the resource to open + * @param allowTooOld + * allow files even if they are considered too old + * @param stable + * a stable file (that doesn't change too often) -- parameter + * used to check if the file is too old to keep or not in the + * cache + * + * @return the opened resource if found, NULL if not + */ + public InputStream load(URL url, boolean allowTooOld, boolean stable) { + return load(getCached(url), allowTooOld, stable); + } + + /** + * Open a resource from the cache if it exists. + * + * @param cached + * the resource to open + * @param allowTooOld + * allow files even if they are considered too old + * @param stable + * a stable file (that dones't change too often) -- parameter + * used to check if the file is too old to keep or not + * + * @return the opened resource if found, NULL if not + */ + private InputStream load(File cached, boolean allowTooOld, boolean stable) { + if (cached.exists() && cached.isFile() + && (allowTooOld || !isOld(cached, stable))) { + try { + return new MarkableFileInputStream(cached); + } catch (FileNotFoundException e) { + return null; + } + } + + return null; + } + + /** + * Save the given resource to the cache. + * + * @param in + * the input data + * @param uniqueID + * a unique ID used to locate the cached resource + * + * @return the number of bytes written + * + * @throws IOException + * in case of I/O error + */ + public long save(InputStream in, String uniqueID) throws IOException { + File cached = getCached(uniqueID); + cached.getParentFile().mkdirs(); + return save(in, cached); + } + + /** + * Save the given resource to the cache. + * + * @param in + * the input data + * @param url + * the {@link URL} used to locate the cached resource + * + * @return the number of bytes written + * + * @throws IOException + * in case of I/O error + */ + public long save(InputStream in, URL url) throws IOException { + File cached = getCached(url); + return save(in, cached); + } + + /** + * Save the given resource to the cache. + *

+ * Will also clean the {@link Cache} from old files. + * + * @param in + * the input data + * @param cached + * the cached {@link File} to save to + * + * @return the number of bytes written + * + * @throws IOException + * in case of I/O error + */ + private long save(InputStream in, File cached) throws IOException { + // We want to force at least an immediate SAVE/LOAD to work for some + // workflows, even if we don't accept cached files (times set to "0" + // -- and not "-1" or a positive value) + clean(true, dir, 10); + cached.getParentFile().mkdirs(); // in case we deleted our own parent + long bytes = IOUtils.write(in, cached); + return bytes; + } + + /** + * Remove the given resource from the cache. + * + * @param uniqueID + * a unique ID used to locate the cached resource + * + * @return TRUE if it was removed + */ + public boolean remove(String uniqueID) { + File cached = getCached(uniqueID); + return cached.delete(); + } + + /** + * Remove the given resource from the cache. + * + * @param url + * the {@link URL} used to locate the cached resource + * + * @return TRUE if it was removed + */ + public boolean remove(URL url) { + File cached = getCached(url); + return cached.delete(); + } + + /** + * Check if the {@link File} is too old according to + * {@link Cache#tooOldChanging}. + * + * @param file + * the file to check + * @param stable + * TRUE to denote stable files, that are not supposed to change + * too often + * + * @return TRUE if it is + */ + private boolean isOld(File file, boolean stable) { + long max = tooOldChanging; + if (stable) { + max = tooOldStable; + } + + if (max < 0) { + return false; + } + + long time = new Date().getTime() - file.lastModified(); + if (time < 0) { + tracer.error("Timestamp in the future for file: " + + file.getAbsolutePath()); + } + + return time < 0 || time > max; + } + + /** + * Return the associated cache {@link File} from this {@link URL}. + * + * @param url + * the {@link URL} + * + * @return the cached {@link File} version of this {@link URL} + */ + private File getCached(URL url) { + File subdir; + + String name = url.getHost(); + if (name == null || name.isEmpty()) { + // File + File file = new File(url.getFile()); + if (file.getParent() == null) { + subdir = new File("+"); + } else { + subdir = new File(file.getParent().replace("..", "__")); + } + subdir = new File(dir, allowedChars(subdir.getPath())); + name = allowedChars(url.getFile()); + } else { + // URL + File subsubDir = new File(dir, allowedChars(url.getHost())); + subdir = new File(subsubDir, "_" + allowedChars(url.getPath())); + name = allowedChars("_" + url.getQuery()); + } + + File cacheFile = new File(subdir, name); + subdir.mkdirs(); + + return cacheFile; + } + + /** + * Get the basic cache resource file corresponding to this unique ID. + *

+ * Note that you may need to add a sub-directory in some cases. + * + * @param uniqueID + * the id + * + * @return the cached version if present, NULL if not + */ + private File getCached(String uniqueID) { + File file = new File(dir, allowedChars(uniqueID)); + File subdir = new File(file.getParentFile(), "_"); + return new File(subdir, file.getName()); + } + + /** + * Replace not allowed chars (in a {@link File}) by "_". + * + * @param raw + * the raw {@link String} + * + * @return the sanitised {@link String} + */ + private String allowedChars(String raw) { + return raw.replace('/', '_').replace(':', '_').replace("\\", "_"); + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/CacheMemory.java b/src/be/nikiroo/utils/CacheMemory.java new file mode 100644 index 0000000..232b632 --- /dev/null +++ b/src/be/nikiroo/utils/CacheMemory.java @@ -0,0 +1,109 @@ +package be.nikiroo.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * A memory only version of {@link Cache}. + * + * @author niki + */ +public class CacheMemory extends Cache { + private Map data = new HashMap(); + + /** + * Create a new {@link CacheMemory}. + */ + public CacheMemory() { + } + + @Override + public boolean check(String uniqueID, boolean allowTooOld, boolean stable) { + return data.containsKey(getKey(uniqueID)); + } + + @Override + public boolean check(URL url, boolean allowTooOld, boolean stable) { + return data.containsKey(getKey(url)); + } + + @Override + public int clean(boolean onlyOld) { + int cleaned = 0; + if (!onlyOld) { + cleaned = data.size(); + data.clear(); + } + + return cleaned; + } + + @Override + public InputStream load(String uniqueID, boolean allowTooOld, boolean stable) { + if (check(uniqueID, allowTooOld, stable)) { + return load(uniqueID, allowTooOld, stable); + } + + return null; + } + + @Override + public InputStream load(URL url, boolean allowTooOld, boolean stable) { + if (check(url, allowTooOld, stable)) { + return load(url, allowTooOld, stable); + } + + return null; + } + + @Override + public boolean remove(String uniqueID) { + return data.remove(getKey(uniqueID)) != null; + } + + @Override + public boolean remove(URL url) { + return data.remove(getKey(url)) != null; + } + + @Override + public long save(InputStream in, String uniqueID) throws IOException { + byte[] bytes = IOUtils.toByteArray(in); + data.put(getKey(uniqueID), bytes); + return bytes.length; + } + + @Override + public long save(InputStream in, URL url) throws IOException { + byte[] bytes = IOUtils.toByteArray(in); + data.put(getKey(url), bytes); + return bytes.length; + } + + /** + * Return a key mapping to the given unique ID. + * + * @param uniqueID + * the unique ID + * + * @return the key + */ + private String getKey(String uniqueID) { + return "_/" + uniqueID; + } + + /** + * Return a key mapping to the given urm. + * + * @param url + * thr url + * + * @return the key + */ + private String getKey(URL url) { + return url.toString(); + } +} diff --git a/src/be/nikiroo/utils/CryptUtils.java b/src/be/nikiroo/utils/CryptUtils.java new file mode 100644 index 0000000..638f82f --- /dev/null +++ b/src/be/nikiroo/utils/CryptUtils.java @@ -0,0 +1,441 @@ +package be.nikiroo.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.SSLException; + +import be.nikiroo.utils.streams.Base64InputStream; +import be.nikiroo.utils.streams.Base64OutputStream; + +/** + * Small utility class to do AES encryption/decryption. + *

+ * It is multi-thread compatible, but beware: + *

+ *

+ * Do not assume it is secure; it just here to offer a more-or-less protected + * exchange of data because anonymous and self-signed certificates backed SSL is + * against Google wishes, and I need Android support. + * + * @author niki + */ +public class CryptUtils { + static private final String AES_NAME = "AES/CFB128/NoPadding"; + + private Cipher ecipher; + private Cipher dcipher; + private byte[] bytes32; + + /** + * Small and lazy-easy way to initialize a 128 bits key with + * {@link CryptUtils}. + *

+ * Some part of the key will be used to generate a 128 bits key and + * initialize the {@link CryptUtils}; even NULL will generate something. + *

+ * This is most probably not secure. Do not use if you actually care + * about security. + * + * @param key + * the {@link String} to use as a base for the key, can be NULL + */ + public CryptUtils(String key) { + try { + init(key2key(key)); + } catch (InvalidKeyException e) { + // We made sure that the key is correct, so nothing here + e.printStackTrace(); + } + } + + /** + * Create a new instance of {@link CryptUtils} with the given 128 bits key. + *

+ * The key must be exactly 128 bits long. + * + * @param bytes32 + * the 128 bits (32 bytes) of the key + * + * @throws InvalidKeyException + * if the key is not an array of 128 bits + */ + public CryptUtils(byte[] bytes32) throws InvalidKeyException { + init(bytes32); + } + + /** + * Wrap the given {@link InputStream} so it is transparently encrypted by + * the current {@link CryptUtils}. + * + * @param in + * the {@link InputStream} to wrap + * @return the auto-encode {@link InputStream} + */ + public InputStream encrypt(InputStream in) { + Cipher ecipher = newCipher(Cipher.ENCRYPT_MODE); + return new CipherInputStream(in, ecipher); + } + + /** + * Wrap the given {@link InputStream} so it is transparently encrypted by + * the current {@link CryptUtils} and encoded in base64. + * + * @param in + * the {@link InputStream} to wrap + * + * @return the auto-encode {@link InputStream} + * + * @throws IOException + * in case of I/O error + */ + public InputStream encrypt64(InputStream in) throws IOException { + return new Base64InputStream(encrypt(in), true); + } + + /** + * Wrap the given {@link OutputStream} so it is transparently encrypted by + * the current {@link CryptUtils}. + * + * @param out + * the {@link OutputStream} to wrap + * + * @return the auto-encode {@link OutputStream} + */ + public OutputStream encrypt(OutputStream out) { + Cipher ecipher = newCipher(Cipher.ENCRYPT_MODE); + return new CipherOutputStream(out, ecipher); + } + + /** + * Wrap the given {@link OutputStream} so it is transparently encrypted by + * the current {@link CryptUtils} and encoded in base64. + * + * @param out + * the {@link OutputStream} to wrap + * + * @return the auto-encode {@link OutputStream} + * + * @throws IOException + * in case of I/O error + */ + public OutputStream encrypt64(OutputStream out) throws IOException { + return encrypt(new Base64OutputStream(out, true)); + } + + /** + * Wrap the given {@link OutputStream} so it is transparently decoded by the + * current {@link CryptUtils}. + * + * @param in + * the {@link InputStream} to wrap + * + * @return the auto-decode {@link InputStream} + */ + public InputStream decrypt(InputStream in) { + Cipher dcipher = newCipher(Cipher.DECRYPT_MODE); + return new CipherInputStream(in, dcipher); + } + + /** + * Wrap the given {@link OutputStream} so it is transparently decoded by the + * current {@link CryptUtils} and decoded from base64. + * + * @param in + * the {@link InputStream} to wrap + * + * @return the auto-decode {@link InputStream} + * + * @throws IOException + * in case of I/O error + */ + public InputStream decrypt64(InputStream in) throws IOException { + return decrypt(new Base64InputStream(in, false)); + } + + /** + * Wrap the given {@link OutputStream} so it is transparently decoded by the + * current {@link CryptUtils}. + * + * @param out + * the {@link OutputStream} to wrap + * @return the auto-decode {@link OutputStream} + */ + public OutputStream decrypt(OutputStream out) { + Cipher dcipher = newCipher(Cipher.DECRYPT_MODE); + return new CipherOutputStream(out, dcipher); + } + + /** + * Wrap the given {@link OutputStream} so it is transparently decoded by the + * current {@link CryptUtils} and decoded from base64. + * + * @param out + * the {@link OutputStream} to wrap + * + * @return the auto-decode {@link OutputStream} + * + * @throws IOException + * in case of I/O error + */ + public OutputStream decrypt64(OutputStream out) throws IOException { + return new Base64OutputStream(decrypt(out), false); + } + + /** + * This method required an array of 128 bits. + * + * @param bytes32 + * the array, which must be of 128 bits (32 bytes) + * + * @throws InvalidKeyException + * if the key is not an array of 128 bits (32 bytes) + */ + private void init(byte[] bytes32) throws InvalidKeyException { + if (bytes32 == null || bytes32.length != 32) { + throw new InvalidKeyException( + "The size of the key must be of 128 bits (32 bytes), it is: " + + (bytes32 == null ? "null" : "" + bytes32.length) + + " bytes"); + } + + this.bytes32 = bytes32; + this.ecipher = newCipher(Cipher.ENCRYPT_MODE); + this.dcipher = newCipher(Cipher.DECRYPT_MODE); + } + + /** + * Create a new {@link Cipher}of the given mode (see + * {@link Cipher#ENCRYPT_MODE} and {@link Cipher#ENCRYPT_MODE}). + * + * @param mode + * the mode ({@link Cipher#ENCRYPT_MODE} or + * {@link Cipher#ENCRYPT_MODE}) + * + * @return the new {@link Cipher} + */ + private Cipher newCipher(int mode) { + try { + // bytes32 = 32 bytes, 32 > 16 + byte[] iv = new byte[16]; + for (int i = 0; i < iv.length; i++) { + iv[i] = bytes32[i]; + } + IvParameterSpec ivspec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance(AES_NAME); + cipher.init(mode, new SecretKeySpec(bytes32, "AES"), ivspec); + return cipher; + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException( + "Cannot initialize encryption sub-system", e); + } + } + + /** + * Encrypt the data. + * + * @param data + * the data to encrypt + * + * @return the encrypted data + * + * @throws SSLException + * in case of I/O error (i.e., the data is not what you assumed + * it was) + */ + public byte[] encrypt(byte[] data) throws SSLException { + synchronized (ecipher) { + try { + return ecipher.doFinal(data); + } catch (IllegalBlockSizeException e) { + throw new SSLException(e); + } catch (BadPaddingException e) { + throw new SSLException(e); + } + } + } + + /** + * Encrypt the data. + * + * @param data + * the data to encrypt + * + * @return the encrypted data + * + * @throws SSLException + * in case of I/O error (i.e., the data is not what you assumed + * it was) + */ + public byte[] encrypt(String data) throws SSLException { + return encrypt(StringUtils.getBytes(data)); + } + + /** + * Encrypt the data, then encode it into Base64. + * + * @param data + * the data to encrypt + * @param zip + * TRUE to also compress the data in GZIP format; remember that + * compressed and not-compressed content are different; you need + * to know which is which when decoding + * + * @return the encrypted data, encoded in Base64 + * + * @throws SSLException + * in case of I/O error (i.e., the data is not what you assumed + * it was) + */ + public String encrypt64(String data) throws SSLException { + return encrypt64(StringUtils.getBytes(data)); + } + + /** + * Encrypt the data, then encode it into Base64. + * + * @param data + * the data to encrypt + * + * @return the encrypted data, encoded in Base64 + * + * @throws SSLException + * in case of I/O error (i.e., the data is not what you assumed + * it was) + */ + public String encrypt64(byte[] data) throws SSLException { + try { + return StringUtils.base64(encrypt(data)); + } catch (IOException e) { + // not exactly true, but we consider here that this error is a crypt + // error, not a normal I/O error + throw new SSLException(e); + } + } + + /** + * Decode the data which is assumed to be encrypted with the same utilities. + * + * @param data + * the encrypted data to decode + * + * @return the original, decoded data + * + * @throws SSLException + * in case of I/O error + */ + public byte[] decrypt(byte[] data) throws SSLException { + synchronized (dcipher) { + try { + return dcipher.doFinal(data); + } catch (IllegalBlockSizeException e) { + throw new SSLException(e); + } catch (BadPaddingException e) { + throw new SSLException(e); + } + } + } + + /** + * Decode the data which is assumed to be encrypted with the same utilities + * and to be a {@link String}. + * + * @param data + * the encrypted data to decode + * + * @return the original, decoded data,as a {@link String} + * + * @throws SSLException + * in case of I/O error + */ + public String decrypts(byte[] data) throws SSLException { + try { + return new String(decrypt(data), "UTF-8"); + } catch (UnsupportedEncodingException e) { + // UTF-8 is required in all conform JVMs + e.printStackTrace(); + return null; + } + } + + /** + * Decode the data which is assumed to be encrypted with the same utilities + * and is a Base64 encoded value. + * + * @param data + * the encrypted data to decode in Base64 format + * @param zip + * TRUE to also uncompress the data from a GZIP format + * automatically; if set to FALSE, zipped data can be returned + * + * @return the original, decoded data + * + * @throws SSLException + * in case of I/O error + */ + public byte[] decrypt64(String data) throws SSLException { + try { + return decrypt(StringUtils.unbase64(data)); + } catch (IOException e) { + // not exactly true, but we consider here that this error is a crypt + // error, not a normal I/O error + throw new SSLException(e); + } + } + + /** + * Decode the data which is assumed to be encrypted with the same utilities + * and is a Base64 encoded value, then convert it into a String (this method + * assumes the data was indeed a UTF-8 encoded {@link String}). + * + * @param data + * the encrypted data to decode in Base64 format + * @param zip + * TRUE to also uncompress the data from a GZIP format + * automatically; if set to FALSE, zipped data can be returned + * + * @return the original, decoded data + * + * @throws SSLException + * in case of I/O error + */ + public String decrypt64s(String data) throws SSLException { + try { + return new String(decrypt(StringUtils.unbase64(data)), "UTF-8"); + } catch (UnsupportedEncodingException e) { + // UTF-8 is required in all conform JVMs + e.printStackTrace(); + return null; + } catch (IOException e) { + // not exactly true, but we consider here that this error is a crypt + // error, not a normal I/O error + throw new SSLException(e); + } + } + + /** + * This is probably NOT secure! + * + * @param input + * some {@link String} input + * + * @return a 128 bits key computed from the given input + */ + static private byte[] key2key(String input) { + return StringUtils.getMd5Hash("" + input).getBytes(); + } +} diff --git a/src/be/nikiroo/utils/Downloader.java b/src/be/nikiroo/utils/Downloader.java new file mode 100644 index 0000000..0487933 --- /dev/null +++ b/src/be/nikiroo/utils/Downloader.java @@ -0,0 +1,478 @@ +package be.nikiroo.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * This class will help you download content from Internet Sites ({@link URL} + * based). + *

+ * It allows you to control some options often required on web sites that do not + * want to simply serve HTML, but actively makes your life difficult with stupid + * checks. + * + * @author niki + */ +public class Downloader { + private String UA; + private CookieManager cookies; + private TraceHandler tracer = new TraceHandler(); + private Cache cache; + private boolean offline; + + /** + * Create a new {@link Downloader}. + * + * @param UA + * the User-Agent to use to download the resources -- note that + * some websites require one, some actively blacklist real UAs + * like the one from wget, some whitelist a couple of browsers + * only (!) + */ + public Downloader(String UA) { + this(UA, null); + } + + /** + * Create a new {@link Downloader}. + * + * @param UA + * the User-Agent to use to download the resources -- note that + * some websites require one, some actively blacklist real UAs + * like the one from wget, some whitelist a couple of browsers + * only (!) + * @param cache + * the {@link Cache} to use for all access (can be NULL) + */ + public Downloader(String UA, Cache cache) { + this.UA = UA; + + cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + CookieHandler.setDefault(cookies); + + setCache(cache); + } + + /** + * This {@link Downloader} is forbidden to try and connect to the network. + *

+ * If TRUE, it will only check the cache if any. + *

+ * Default is FALSE. + * + * @return TRUE if offline + */ + public boolean isOffline() { + return offline; + } + + /** + * This {@link Downloader} is forbidden to try and connect to the network. + *

+ * If TRUE, it will only check the cache if any. + *

+ * Default is FALSE. + * + * @param offline TRUE for offline, FALSE for online + */ + public void setOffline(boolean offline) { + this.offline = offline; + } + + /** + * The traces handler for this {@link Cache}. + * + * @return the traces handler + */ + public TraceHandler getTraceHandler() { + return tracer; + } + + /** + * The traces handler for this {@link Cache}. + * + * @param tracer + * the new traces handler + */ + public void setTraceHandler(TraceHandler tracer) { + if (tracer == null) { + tracer = new TraceHandler(false, false, false); + } + + this.tracer = tracer; + } + + /** + * The {@link Cache} to use for all access (can be NULL). + * + * @return the cache + */ + public Cache getCache() { + return cache; + } + + /** + * The {@link Cache} to use for all access (can be NULL). + * + * @param cache + * the new cache + */ + public void setCache(Cache cache) { + this.cache = cache; + } + + /** + * Clear all the cookies currently in the jar. + *

+ * As long as you don't, the cookies are kept. + */ + public void clearCookies() { + cookies.getCookieStore().removeAll(); + } + + /** + * Open the given {@link URL} and update the cookies. + * + * @param url + * the {@link URL} to open + * @return the {@link InputStream} of the opened page + * + * @throws IOException + * in case of I/O error + **/ + public InputStream open(URL url) throws IOException { + return open(url, false); + } + + /** + * Open the given {@link URL} and update the cookies. + * + * @param url + * the {@link URL} to open + * @param stable + * stable a stable file (that doesn't change too often) -- + * parameter used to check if the file is too old to keep or not + * in the cache (default is false) + * + * @return the {@link InputStream} of the opened page + * + * @throws IOException + * in case of I/O error + **/ + public InputStream open(URL url, boolean stable) throws IOException { + return open(url, url, url, null, null, null, null, stable); + } + + /** + * Open the given {@link URL} and update the cookies. + * + * @param url + * the {@link URL} to open + * @param currentReferer + * the current referer, for websites that needs this info + * @param cookiesValues + * the cookies + * @param postParams + * the POST parameters + * @param getParams + * the GET parameters (priority over POST) + * @param oauth + * OAuth authorization (aka, "bearer XXXXXXX") + * + * @return the {@link InputStream} of the opened page + * + * @throws IOException + * in case of I/O error (including offline mode + not in cache) + */ + public InputStream open(URL url, URL currentReferer, + Map cookiesValues, Map postParams, + Map getParams, String oauth) throws IOException { + return open(url, currentReferer, cookiesValues, postParams, getParams, + oauth, false); + } + + /** + * Open the given {@link URL} and update the cookies. + * + * @param url + * the {@link URL} to open + * @param currentReferer + * the current referer, for websites that needs this info + * @param cookiesValues + * the cookies + * @param postParams + * the POST parameters + * @param getParams + * the GET parameters (priority over POST) + * @param oauth + * OAuth authorization (aka, "bearer XXXXXXX") + * @param stable + * stable a stable file (that doesn't change too often) -- + * parameter used to check if the file is too old to keep or not + * in the cache (default is false) + * + * @return the {@link InputStream} of the opened page + * + * @throws IOException + * in case of I/O error (including offline mode + not in cache) + */ + public InputStream open(URL url, URL currentReferer, + Map cookiesValues, Map postParams, + Map getParams, String oauth, boolean stable) + throws IOException { + return open(url, url, currentReferer, cookiesValues, postParams, + getParams, oauth, stable); + } + + /** + * Open the given {@link URL} and update the cookies. + * + * @param url + * the {@link URL} to open + * @param originalUrl + * the original {@link URL} before any redirection occurs, which + * is also used for the cache ID if needed (so we can retrieve + * the content with this URL if needed) + * @param currentReferer + * the current referer, for websites that needs this info + * @param cookiesValues + * the cookies + * @param postParams + * the POST parameters + * @param getParams + * the GET parameters (priority over POST) + * @param oauth + * OAuth authorisation (aka, "bearer XXXXXXX") + * @param stable + * a stable file (that doesn't change too often) -- parameter + * used to check if the file is too old to keep or not in the + * cache + * + * @return the {@link InputStream} of the opened page + * + * @throws IOException + * in case of I/O error (including offline mode + not in cache) + */ + public InputStream open(URL url, final URL originalUrl, URL currentReferer, + Map cookiesValues, Map postParams, + Map getParams, String oauth, boolean stable) + throws IOException { + + tracer.trace("Request: " + url); + + if (cache != null) { + InputStream in = cache.load(originalUrl, false, stable); + if (in != null) { + tracer.trace("Use the cache: " + url); + tracer.trace("Original URL : " + originalUrl); + return in; + } + } + + String protocol = originalUrl == null ? null : originalUrl + .getProtocol(); + if (isOffline() && !"file".equalsIgnoreCase(protocol)) { + tracer.error("Downloader OFFLINE, cannot proceed to URL: " + url); + throw new IOException("Downloader is currently OFFLINE, cannot download: " + url); + } + + tracer.trace("Download: " + url); + + URLConnection conn = openConnectionWithCookies(url, currentReferer, + cookiesValues); + + // Priority: GET over POST + Map params = getParams; + if (getParams == null) { + params = postParams; + } + + StringBuilder requestData = null; + if ((params != null || oauth != null) + && conn instanceof HttpURLConnection) { + if (params != null) { + requestData = new StringBuilder(); + for (Map.Entry param : params.entrySet()) { + if (requestData.length() != 0) + requestData.append('&'); + requestData.append(URLEncoder.encode(param.getKey(), + "UTF-8")); + requestData.append('='); + requestData.append(URLEncoder.encode( + String.valueOf(param.getValue()), "UTF-8")); + } + + if (getParams == null && postParams != null) { + ((HttpURLConnection) conn).setRequestMethod("POST"); + } + + conn.setRequestProperty("Content-Type", + "application/x-www-form-urlencoded"); + conn.setRequestProperty("Content-Length", + Integer.toString(requestData.length())); + } + + if (oauth != null) { + conn.setRequestProperty("Authorization", oauth); + } + + if (requestData != null) { + conn.setDoOutput(true); + OutputStreamWriter writer = new OutputStreamWriter( + conn.getOutputStream()); + try { + writer.write(requestData.toString()); + writer.flush(); + } finally { + writer.close(); + } + } + } + + // Manual redirection, much better for POST data + if (conn instanceof HttpURLConnection) { + ((HttpURLConnection) conn).setInstanceFollowRedirects(false); + } + + conn.connect(); + + // Check if redirect + // BEWARE! POST data cannot be redirected (some webservers complain) for + // HTTP codes 302 and 303 + if (conn instanceof HttpURLConnection) { + int repCode = 0; + try { + // Can fail in some circumstances + repCode = ((HttpURLConnection) conn).getResponseCode(); + } catch (IOException e) { + } + + if (repCode / 100 == 3) { + String newUrl = conn.getHeaderField("Location"); + return open(new URL(newUrl), originalUrl, currentReferer, + cookiesValues, // + (repCode == 302 || repCode == 303) ? null : postParams, // + getParams, oauth, stable); + } + } + + try { + InputStream in = conn.getInputStream(); + if ("gzip".equals(conn.getContentEncoding())) { + in = new GZIPInputStream(in); + } + + if (in == null) { + throw new IOException("No InputStream!"); + } + + if (cache != null) { + String size = conn.getContentLength() < 0 ? "unknown size" + : StringUtils.formatNumber(conn.getContentLength()) + + "bytes"; + tracer.trace("Save to cache (" + size + "): " + originalUrl); + try { + try { + long bytes = cache.save(in, originalUrl); + tracer.trace("Saved to cache: " + + StringUtils.formatNumber(bytes) + "bytes"); + } finally { + in.close(); + } + in = cache.load(originalUrl, true, true); + } catch (IOException e) { + tracer.error(new IOException( + "Cannot save URL to cache, will ignore cache: " + + url, e)); + } + } + + if (in == null) { + throw new IOException( + "Cannot retrieve the file after storing it in the cache (??)"); + } + + return in; + } catch (IOException e) { + throw new IOException(String.format( + "Cannot find %s (current URL: %s)", originalUrl, url), e); + } + } + + /** + * Open a connection on the given {@link URL}, and manage the cookies that + * come with it. + * + * @param url + * the {@link URL} to open + * + * @return the connection + * + * @throws IOException + * in case of I/O error + */ + private URLConnection openConnectionWithCookies(URL url, + URL currentReferer, Map cookiesValues) + throws IOException { + URLConnection conn = url.openConnection(); + + String cookies = generateCookies(cookiesValues); + if (cookies != null && !cookies.isEmpty()) { + conn.setRequestProperty("Cookie", cookies); + } + + conn.setRequestProperty("User-Agent", UA); + conn.setRequestProperty("Accept-Encoding", "gzip"); + conn.setRequestProperty("Accept", "*/*"); + conn.setRequestProperty("Charset", "utf-8"); + + if (currentReferer != null) { + conn.setRequestProperty("Referer", currentReferer.toString()); + conn.setRequestProperty("Host", currentReferer.getHost()); + } + + return conn; + } + + /** + * Generate the cookie {@link String} from the local {@link CookieStore} so + * it is ready to be passed. + * + * @return the cookie + */ + private String generateCookies(Map cookiesValues) { + StringBuilder builder = new StringBuilder(); + for (HttpCookie cookie : cookies.getCookieStore().getCookies()) { + if (builder.length() > 0) { + builder.append(';'); + } + + builder.append(cookie.toString()); + } + + if (cookiesValues != null) { + for (Map.Entry set : cookiesValues.entrySet()) { + if (builder.length() > 0) { + builder.append(';'); + } + builder.append(set.getKey()); + builder.append('='); + builder.append(set.getValue()); + } + } + + return builder.toString(); + } +} diff --git a/src/be/nikiroo/utils/IOUtils.java b/src/be/nikiroo/utils/IOUtils.java new file mode 100644 index 0000000..e3837e1 --- /dev/null +++ b/src/be/nikiroo/utils/IOUtils.java @@ -0,0 +1,476 @@ +package be.nikiroo.utils; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import be.nikiroo.utils.streams.MarkableFileInputStream; + +/** + * This class offer some utilities based around Streams and Files. + * + * @author niki + */ +public class IOUtils { + /** + * Write the data to the given {@link File}. + * + * @param in + * the data source + * @param target + * the target {@link File} + * + * @return the number of bytes written + * + * @throws IOException + * in case of I/O error + */ + public static long write(InputStream in, File target) throws IOException { + OutputStream out = new FileOutputStream(target); + try { + return write(in, out); + } finally { + out.close(); + } + } + + /** + * Write the data to the given {@link OutputStream}. + * + * @param in + * the data source + * @param out + * the target {@link OutputStream} + * + * @return the number of bytes written + * + * @throws IOException + * in case of I/O error + */ + public static long write(InputStream in, OutputStream out) + throws IOException { + long written = 0; + byte buffer[] = new byte[4096]; + int len = in.read(buffer); + while (len > -1) { + out.write(buffer, 0, len); + written += len; + len = in.read(buffer); + } + + return written; + } + + /** + * Recursively Add a {@link File} (which can thus be a directory, too) to a + * {@link ZipOutputStream}. + * + * @param zip + * the stream + * @param base + * the path to prepend to the ZIP info before the actual + * {@link File} path + * @param target + * the source {@link File} (which can be a directory) + * @param targetIsRoot + * FALSE if we need to add a {@link ZipEntry} for base/target, + * TRUE to add it at the root of the ZIP + * + * @throws IOException + * in case of I/O error + */ + public static void zip(ZipOutputStream zip, String base, File target, + boolean targetIsRoot) throws IOException { + if (target.isDirectory()) { + if (!targetIsRoot) { + if (base == null || base.isEmpty()) { + base = target.getName(); + } else { + base += "/" + target.getName(); + } + zip.putNextEntry(new ZipEntry(base + "/")); + } + + File[] files = target.listFiles(); + if (files != null) { + for (File file : files) { + zip(zip, base, file, false); + } + } + } else { + if (base == null || base.isEmpty()) { + base = target.getName(); + } else { + base += "/" + target.getName(); + } + zip.putNextEntry(new ZipEntry(base)); + FileInputStream in = new FileInputStream(target); + try { + IOUtils.write(in, zip); + } finally { + in.close(); + } + } + } + + /** + * Zip the given source into dest. + * + * @param src + * the source {@link File} (which can be a directory) + * @param dest + * the destination .zip file + * @param srcIsRoot + * FALSE if we need to add a {@link ZipEntry} for src, TRUE to + * add it at the root of the ZIP + * + * @throws IOException + * in case of I/O error + */ + public static void zip(File src, File dest, boolean srcIsRoot) + throws IOException { + OutputStream out = new FileOutputStream(dest); + try { + ZipOutputStream zip = new ZipOutputStream(out); + try { + IOUtils.zip(zip, "", src, srcIsRoot); + } finally { + zip.close(); + } + } finally { + out.close(); + } + } + + /** + * Unzip the given ZIP file into the target directory. + * + * @param zipFile + * the ZIP file + * @param targetDirectory + * the target directory + * + * @return the number of extracted files (not directories) + * + * @throws IOException + * in case of I/O errors + */ + public static long unzip(File zipFile, File targetDirectory) + throws IOException { + long count = 0; + + if (targetDirectory.exists() && targetDirectory.isFile()) { + throw new IOException("Cannot unzip " + zipFile + " into " + + targetDirectory + ": it is not a directory"); + } + + targetDirectory.mkdir(); + if (!targetDirectory.exists()) { + throw new IOException("Cannot create target directory " + + targetDirectory); + } + + FileInputStream in = new FileInputStream(zipFile); + try { + ZipInputStream zipStream = new ZipInputStream(in); + try { + for (ZipEntry entry = zipStream.getNextEntry(); entry != null; entry = zipStream + .getNextEntry()) { + File file = new File(targetDirectory, entry.getName()); + if (entry.isDirectory()) { + file.mkdirs(); + } else { + IOUtils.write(zipStream, file); + count++; + } + } + } finally { + zipStream.close(); + } + } finally { + in.close(); + } + + return count; + } + + /** + * Write the {@link String} content to {@link File}. + * + * @param dir + * the directory where to write the {@link File} + * @param filename + * the {@link File} name + * @param content + * the content + * + * @throws IOException + * in case of I/O error + */ + public static void writeSmallFile(File dir, String filename, String content) + throws IOException { + if (!dir.exists()) { + dir.mkdirs(); + } + + writeSmallFile(new File(dir, filename), content); + } + + /** + * Write the {@link String} content to {@link File}. + * + * @param file + * the {@link File} to write + * @param content + * the content + * + * @throws IOException + * in case of I/O error + */ + public static void writeSmallFile(File file, String content) + throws IOException { + FileOutputStream out = new FileOutputStream(file); + try { + out.write(StringUtils.getBytes(content)); + } finally { + out.close(); + } + } + + /** + * Read the whole {@link File} content into a {@link String}. + * + * @param file + * the {@link File} + * + * @return the content + * + * @throws IOException + * in case of I/O error + */ + public static String readSmallFile(File file) throws IOException { + InputStream stream = new FileInputStream(file); + try { + return readSmallStream(stream); + } finally { + stream.close(); + } + } + + /** + * Read the whole {@link InputStream} content into a {@link String}. + * + * @param stream + * the {@link InputStream} + * + * @return the content + * + * @throws IOException + * in case of I/O error + */ + public static String readSmallStream(InputStream stream) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + write(stream, out); + return out.toString("UTF-8"); + } finally { + out.close(); + } + } + + /** + * Recursively delete the given {@link File}, which may of course also be a + * directory. + *

+ * Will either silently continue or throw an exception in case of error, + * depending upon the parameters. + * + * @param target + * the target to delete + * @param exception + * TRUE to throw an {@link IOException} in case of error, FALSE + * to silently continue + * + * @return TRUE if all files were deleted, FALSE if an error occurred + * + * @throws IOException + * if an error occurred and the parameters allow an exception to + * be thrown + */ + public static boolean deltree(File target, boolean exception) + throws IOException { + List list = deltree(target, null); + if (exception && !list.isEmpty()) { + StringBuilder slist = new StringBuilder(); + for (File file : list) { + slist.append("\n").append(file.getPath()); + } + + throw new IOException("Cannot delete all the files from: <" // + + target + ">:" + slist.toString()); + } + + return list.isEmpty(); + } + + /** + * Recursively delete the given {@link File}, which may of course also be a + * directory. + *

+ * Will silently continue in case of error. + * + * @param target + * the target to delete + * + * @return TRUE if all files were deleted, FALSE if an error occurred + */ + public static boolean deltree(File target) { + return deltree(target, null).isEmpty(); + } + + /** + * Recursively delete the given {@link File}, which may of course also be a + * directory. + *

+ * Will collect all {@link File} that cannot be deleted in the given + * accumulator. + * + * @param target + * the target to delete + * @param errorAcc + * the accumulator to use for errors, or NULL to create a new one + * + * @return the errors accumulator + */ + public static List deltree(File target, List errorAcc) { + if (errorAcc == null) { + errorAcc = new ArrayList(); + } + + File[] files = target.listFiles(); + if (files != null) { + for (File file : files) { + errorAcc = deltree(file, errorAcc); + } + } + + if (!target.delete()) { + errorAcc.add(target); + } + + return errorAcc; + } + + /** + * Open the given /-separated resource (from the binary root). + * + * @param name + * the resource name + * + * @return the opened resource if found, NLL if not + */ + public static InputStream openResource(String name) { + ClassLoader loader = IOUtils.class.getClassLoader(); + if (loader == null) { + loader = ClassLoader.getSystemClassLoader(); + } + + return loader.getResourceAsStream(name); + } + + /** + * Return a resetable {@link InputStream} from this stream, and reset it. + * + * @param in + * the input stream + * @return the resetable stream, which may be the same + * + * @throws IOException + * in case of I/O error + */ + public static InputStream forceResetableStream(InputStream in) + throws IOException { + boolean resetable = in.markSupported(); + if (resetable) { + try { + in.reset(); + } catch (IOException e) { + resetable = false; + } + } + + if (resetable) { + return in; + } + + final File tmp = File.createTempFile(".tmp-stream.", ".tmp"); + try { + write(in, tmp); + in.close(); + + return new MarkableFileInputStream(tmp) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + tmp.delete(); + } + } + }; + } catch (IOException e) { + tmp.delete(); + throw e; + } + } + + /** + * Convert the {@link InputStream} into a byte array. + * + * @param in + * the input stream + * + * @return the array + * + * @throws IOException + * in case of I/O error + */ + public static byte[] toByteArray(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + write(in, out); + return out.toByteArray(); + } finally { + out.close(); + } + } + + /** + * Convert the {@link File} into a byte array. + * + * @param file + * the input {@link File} + * + * @return the array + * + * @throws IOException + * in case of I/O error + */ + public static byte[] toByteArray(File file) throws IOException { + FileInputStream fis = new FileInputStream(file); + try { + return toByteArray(fis); + } finally { + fis.close(); + } + } +} diff --git a/src/be/nikiroo/utils/Image.java b/src/be/nikiroo/utils/Image.java new file mode 100644 index 0000000..4518577 --- /dev/null +++ b/src/be/nikiroo/utils/Image.java @@ -0,0 +1,281 @@ +package be.nikiroo.utils; + +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.io.Serializable; + +import be.nikiroo.utils.streams.Base64InputStream; +import be.nikiroo.utils.streams.MarkableFileInputStream; + +/** + * This class represents an image data. + * + * @author niki + */ +public class Image implements Closeable, Serializable { + static private final long serialVersionUID = 1L; + + static private File tempRoot; + static private TempFiles tmpRepository; + static private long count = 0; + static private Object lock = new Object(); + + private Object instanceLock = new Object(); + private File data; + private long size; + + /** + * Do not use -- for serialisation purposes only. + */ + @SuppressWarnings("unused") + private Image() { + } + + /** + * Create a new {@link Image} with the given data. + * + * @param data + * the data + */ + public Image(byte[] data) { + ByteArrayInputStream in = new ByteArrayInputStream(data); + try { + this.data = getTemporaryFile(); + size = IOUtils.write(in, this.data); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + try { + in.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Create an image from Base64 encoded data. + * + *

+ * Please use {@link Image#Image(InputStream)} when possible instead, with a + * {@link Base64InputStream}; it can be much more efficient. + * + * @param base64EncodedData + * the Base64 encoded data as a String + * + * @throws IOException + * in case of I/O error or badly formated Base64 + */ + public Image(String base64EncodedData) throws IOException { + this(new Base64InputStream(new ByteArrayInputStream( + StringUtils.getBytes(base64EncodedData)), false)); + } + + /** + * Create a new {@link Image} from a stream. + * + * @param in + * the stream + * + * @throws IOException + * in case of I/O error + */ + public Image(InputStream in) throws IOException { + data = getTemporaryFile(); + size = IOUtils.write(in, data); + } + + /** + * The size of the enclosed image in bytes. + * + * @return the size + */ + public long getSize() { + return size; + } + + /** + * Generate an {@link InputStream} that you can {@link InputStream#reset()} + * for this {@link Image}. + *

+ * This {@link InputStream} will (always) be a new one, and you are + * responsible for it. + *

+ * Note: take care that the {@link InputStream} must not live past + * the {@link Image} life time! + * + * @return the stream + * + * @throws IOException + * in case of I/O error + */ + public InputStream newInputStream() throws IOException { + return new MarkableFileInputStream(data); + } + + /** + * Read the actual image data, as a byte array. + * + * @deprecated if possible, prefer the {@link Image#newInputStream()} + * method, as it can be more efficient + * + * @return the image data + */ + @Deprecated + public byte[] getData() { + try { + InputStream in = newInputStream(); + try { + return IOUtils.toByteArray(in); + } finally { + in.close(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Convert the given {@link Image} object into a Base64 representation of + * the same {@link Image} object. + * + * @deprecated Please use {@link Image#newInputStream()} instead, it is more + * efficient + * + * @return the Base64 representation + */ + @Deprecated + public String toBase64() { + try { + Base64InputStream stream = new Base64InputStream(newInputStream(), + true); + try { + return IOUtils.readSmallStream(stream); + } finally { + stream.close(); + } + } catch (IOException e) { + return null; + } + } + + /** + * Closing the {@link Image} will delete the associated temporary file on + * disk. + *

+ * Note that even if you don't, the program will still try to delete + * all the temporary files at JVM termination. + */ + @Override + public void close() throws IOException { + synchronized (instanceLock) { + if (size >= 0) { + size = -1; + data.delete(); + data = null; + + synchronized (lock) { + count--; + if (count <= 0) { + count = 0; + tmpRepository.close(); + tmpRepository = null; + } + } + } + } + } + + @Override + protected void finalize() throws Throwable { + try { + close(); + } finally { + super.finalize(); + } + } + + /** + * Return a newly created temporary file to work on. + * + * @return the file + * + * @throws IOException + * in case of I/O error + */ + private File getTemporaryFile() throws IOException { + synchronized (lock) { + if (tmpRepository == null) { + tmpRepository = new TempFiles(tempRoot, "images"); + count = 0; + } + + count++; + + return tmpRepository.createTempFile("image"); + } + } + + /** + * Write this {@link Image} for serialization purposes; that is, write the + * content of the backing temporary file. + * + * @param out + * the {@link OutputStream} to write to + * + * @throws IOException + * in case of I/O error + */ + private void writeObject(ObjectOutputStream out) throws IOException { + InputStream in = newInputStream(); + try { + IOUtils.write(in, out); + } finally { + in.close(); + } + } + + /** + * Read an {@link Image} written by + * {@link Image#writeObject(java.io.ObjectOutputStream)}; that is, create a + * new temporary file with the saved content. + * + * @param in + * the {@link InputStream} to read from + * @throws IOException + * in case of I/O error + * @throws ClassNotFoundException + * will not be thrown by this method + */ + @SuppressWarnings("unused") + private void readObject(ObjectInputStream in) throws IOException, + ClassNotFoundException { + data = getTemporaryFile(); + IOUtils.write(in, data); + } + + /** + * Change the temporary root directory used by the program. + *

+ * Caution: the directory will be owned by the system, all its files + * now belong to us (and will most probably be deleted). + *

+ * Note: it may take some time until the new temporary root is used, we + * first need to make sure the previous one is not used anymore (i.e., we + * must reach a point where no unclosed {@link Image} remains in memory) to + * switch the temporary root. + * + * @param root + * the new temporary root, which will be owned by the + * system + */ + public static void setTemporaryFilesRoot(File root) { + tempRoot = root; + } +} diff --git a/src/be/nikiroo/utils/ImageUtils.java b/src/be/nikiroo/utils/ImageUtils.java new file mode 100644 index 0000000..fb86929 --- /dev/null +++ b/src/be/nikiroo/utils/ImageUtils.java @@ -0,0 +1,220 @@ +package be.nikiroo.utils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import be.nikiroo.utils.serial.SerialUtils; + +/** + * This class offer some utilities based around images. + * + * @author niki + */ +public abstract class ImageUtils { + private static ImageUtils instance = newObject(); + + /** + * Get a (unique) instance of an {@link ImageUtils} compatible with your + * system. + * + * @return an {@link ImageUtils} + */ + public static ImageUtils getInstance() { + return instance; + } + + /** + * Save the given resource as an image on disk using the given image format + * for content, or with "png" format if it fails. + * + * @param img + * the resource + * @param target + * the target file + * @param format + * the file format ("png", "jpeg", "bmp"...) + * + * @throws IOException + * in case of I/O error + */ + public abstract void saveAsImage(Image img, File target, String format) + throws IOException; + + /** + * Return the EXIF transformation flag of this image if any. + * + *

+ * Note: this code has been found on internet; thank you anonymous coder. + *

+ * + * @param in + * the data {@link InputStream} + * + * @return the transformation flag if any + * + * @throws IOException + * in case of IO error + */ + protected static int getExifTransorm(InputStream in) throws IOException { + int[] exif_data = new int[100]; + int set_flag = 0; + int is_motorola = 0; + + /* Read File head, check for JPEG SOI + Exif APP1 */ + for (int i = 0; i < 4; i++) + exif_data[i] = in.read(); + + if (exif_data[0] != 0xFF || exif_data[1] != 0xD8 + || exif_data[2] != 0xFF || exif_data[3] != 0xE1) + return -2; + + /* Get the marker parameter length count */ + int length = (in.read() << 8 | in.read()); + + /* Length includes itself, so must be at least 2 */ + /* Following Exif data length must be at least 6 */ + if (length < 8) + return -1; + length -= 8; + /* Read Exif head, check for "Exif" */ + for (int i = 0; i < 6; i++) + exif_data[i] = in.read(); + + if (exif_data[0] != 0x45 || exif_data[1] != 0x78 + || exif_data[2] != 0x69 || exif_data[3] != 0x66 + || exif_data[4] != 0 || exif_data[5] != 0) + return -1; + + /* Read Exif body */ + length = length > exif_data.length ? exif_data.length : length; + for (int i = 0; i < length; i++) + exif_data[i] = in.read(); + + if (length < 12) + return -1; /* Length of an IFD entry */ + + /* Discover byte order */ + if (exif_data[0] == 0x49 && exif_data[1] == 0x49) + is_motorola = 0; + else if (exif_data[0] == 0x4D && exif_data[1] == 0x4D) + is_motorola = 1; + else + return -1; + + /* Check Tag Mark */ + if (is_motorola == 1) { + if (exif_data[2] != 0) + return -1; + if (exif_data[3] != 0x2A) + return -1; + } else { + if (exif_data[3] != 0) + return -1; + if (exif_data[2] != 0x2A) + return -1; + } + + /* Get first IFD offset (offset to IFD0) */ + int offset; + if (is_motorola == 1) { + if (exif_data[4] != 0) + return -1; + if (exif_data[5] != 0) + return -1; + offset = exif_data[6]; + offset <<= 8; + offset += exif_data[7]; + } else { + if (exif_data[7] != 0) + return -1; + if (exif_data[6] != 0) + return -1; + offset = exif_data[5]; + offset <<= 8; + offset += exif_data[4]; + } + if (offset > length - 2) + return -1; /* check end of data segment */ + + /* Get the number of directory entries contained in this IFD */ + int number_of_tags; + if (is_motorola == 1) { + number_of_tags = exif_data[offset]; + number_of_tags <<= 8; + number_of_tags += exif_data[offset + 1]; + } else { + number_of_tags = exif_data[offset + 1]; + number_of_tags <<= 8; + number_of_tags += exif_data[offset]; + } + if (number_of_tags == 0) + return -1; + offset += 2; + + /* Search for Orientation Tag in IFD0 */ + for (;;) { + if (offset > length - 12) + return -1; /* check end of data segment */ + /* Get Tag number */ + int tagnum; + if (is_motorola == 1) { + tagnum = exif_data[offset]; + tagnum <<= 8; + tagnum += exif_data[offset + 1]; + } else { + tagnum = exif_data[offset + 1]; + tagnum <<= 8; + tagnum += exif_data[offset]; + } + if (tagnum == 0x0112) + break; /* found Orientation Tag */ + if (--number_of_tags == 0) + return -1; + offset += 12; + } + + /* Get the Orientation value */ + if (is_motorola == 1) { + if (exif_data[offset + 8] != 0) + return -1; + set_flag = exif_data[offset + 9]; + } else { + if (exif_data[offset + 9] != 0) + return -1; + set_flag = exif_data[offset + 8]; + } + if (set_flag > 8) + return -1; + + return set_flag; + } + + /** + * Check that the class can operate (for instance, that all the required + * libraries or frameworks are present). + * + * @return TRUE if it works + */ + abstract protected boolean check(); + + /** + * Create a new {@link ImageUtils}. + * + * @return the {@link ImageUtils} + */ + private static ImageUtils newObject() { + for (String clazz : new String[] { "be.nikiroo.utils.ui.ImageUtilsAwt", + "be.nikiroo.utils.android.ImageUtilsAndroid" }) { + try { + ImageUtils obj = (ImageUtils) SerialUtils.createObject(clazz); + if (obj.check()) { + return obj; + } + } catch (Throwable e) { + } + } + + return null; + } +} diff --git a/src/be/nikiroo/utils/MarkableFileInputStream.java b/src/be/nikiroo/utils/MarkableFileInputStream.java new file mode 100644 index 0000000..3f28064 --- /dev/null +++ b/src/be/nikiroo/utils/MarkableFileInputStream.java @@ -0,0 +1,22 @@ +package be.nikiroo.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +/** + * Class was moved to {@link be.nikiroo.utils.streams.MarkableFileInputStream}. + * + * @author niki + */ +@Deprecated +public class MarkableFileInputStream extends + be.nikiroo.utils.streams.MarkableFileInputStream { + public MarkableFileInputStream(File file) throws FileNotFoundException { + super(file); + } + + public MarkableFileInputStream(FileInputStream fis) { + super(fis); + } +} diff --git a/src/be/nikiroo/utils/Progress.java b/src/be/nikiroo/utils/Progress.java new file mode 100644 index 0000000..dea6be3 --- /dev/null +++ b/src/be/nikiroo/utils/Progress.java @@ -0,0 +1,433 @@ +package be.nikiroo.utils; + +import java.util.ArrayList; +import java.util.EventListener; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * Progress reporting system, possibly nested. + * + * @author niki + */ +public class Progress { + /** + * This event listener is designed to report progress events from + * {@link Progress}. + * + * @author niki + */ + public interface ProgressListener extends EventListener { + /** + * A progression event. + * + * @param progress + * the {@link Progress} object that generated it, not + * necessarily the same as the one where the listener was + * attached (it could be a child {@link Progress} of this + * {@link Progress}). + * @param name + * the first non-null name of the {@link Progress} step that + * generated this event + */ + public void progress(Progress progress, String name); + } + + private Progress parent = null; + private Object lock = new Object(); + private String name; + private Map children; + private List listeners; + private int min; + private int max; + private double relativeLocalProgress; + private double relativeProgress; // children included + + /** + * Create a new default unnamed {@link Progress}, from 0 to 100. + */ + public Progress() { + this(null); + } + + /** + * Create a new default {@link Progress}, from 0 to 100. + * + * @param name + * the name of this {@link Progress} step + */ + public Progress(String name) { + this(name, 0, 100); + } + + /** + * Create a new unnamed {@link Progress}, from min to max. + * + * @param min + * the minimum progress value (and starting value) -- must be + * non-negative + * @param max + * the maximum progress value + */ + public Progress(int min, int max) { + this(null, min, max); + } + + /** + * Create a new {@link Progress}, from min to max. + * + * @param name + * the name of this {@link Progress} step + * @param min + * the minimum progress value (and starting value) -- must be + * non-negative + * @param max + * the maximum progress value + */ + public Progress(String name, int min, int max) { + this.name = name; + this.children = new HashMap(); + this.listeners = new ArrayList(); + setMinMax(min, max); + setProgress(min); + } + + /** + * The name of this {@link Progress} step. + * + * @return the name + */ + public String getName() { + return name; + } + + /** + * The name of this {@link Progress} step. + * + * @param name + * the new name + */ + public void setName(String name) { + this.name = name; + changed(this, name); + } + + /** + * The minimum progress value. + * + * @return the min + */ + public int getMin() { + return min; + } + + /** + * The minimum progress value. + * + * @param min + * the min to set + * + * + * @throws RuntimeException + * if min < 0 or if min > max + */ + public void setMin(int min) { + if (min < 0) { + throw new RuntimeException("negative values not supported"); + } + + synchronized (lock) { + if (min > max) { + throw new RuntimeException( + "The minimum progress value must be <= the maximum progress value"); + } + + this.min = min; + } + } + + /** + * The maximum progress value. + * + * @return the max + */ + public int getMax() { + return max; + } + + /** + * The maximum progress value (must be >= the minimum progress value). + * + * @param max + * the max to set + * + * + * @throws RuntimeException + * if max < min + */ + public void setMax(int max) { + synchronized (lock) { + if (max < min) { + throw new Error( + "The maximum progress value must be >= the minimum progress value"); + } + + this.max = max; + } + } + + /** + * Set both the minimum and maximum progress values. + * + * @param min + * the min + * @param max + * the max + * + * @throws RuntimeException + * if min < 0 or if min > max + */ + public void setMinMax(int min, int max) { + if (min < 0) { + throw new RuntimeException("negative values not supported"); + } + + if (min > max) { + throw new RuntimeException( + "The minimum progress value must be <= the maximum progress value"); + } + + synchronized (lock) { + this.min = min; + this.max = max; + } + } + + /** + * Get the total progress value (including the optional children + * {@link Progress}) on a {@link Progress#getMin()} to + * {@link Progress#getMax()} scale. + * + * @return the progress the value + */ + public int getProgress() { + return (int) Math.round(relativeProgress * (max - min)); + } + + /** + * Set the local progress value (not including the optional children + * {@link Progress}), on a {@link Progress#getMin()} to + * {@link Progress#getMax()} scale. + * + * @param progress + * the progress to set + */ + public void setProgress(int progress) { + synchronized (lock) { + double childrenProgress = relativeProgress - relativeLocalProgress; + + relativeLocalProgress = ((double) progress) / (max - min); + + setRelativeProgress(this, name, relativeLocalProgress + + childrenProgress); + } + } + + /** + * Get the total progress value (including the optional children + * {@link Progress}) on a 0.0 to 1.0 scale. + * + * @return the progress + */ + public double getRelativeProgress() { + return relativeProgress; + } + + /** + * Set the total progress value (including the optional children + * {@link Progress}), on a 0 to 1 scale. + * + * @param pg + * the {@link Progress} to report as the progression emitter + * @param name + * the current name (if it is NULL, the first non-null name in + * the hierarchy will overwrite it) of the {@link Progress} who + * emitted this change + * @param relativeProgress + * the progress to set + */ + private void setRelativeProgress(Progress pg, String name, + double relativeProgress) { + synchronized (lock) { + relativeProgress = Math.max(0, relativeProgress); + relativeProgress = Math.min(1, relativeProgress); + this.relativeProgress = relativeProgress; + + changed(pg, name); + } + } + + /** + * Get the total progress value (including the optional children + * {@link Progress}) on a 0 to 1 scale. + * + * @return the progress the value + */ + private int getLocalProgress() { + return (int) Math.round(relativeLocalProgress * (max - min)); + } + + /** + * Add some value to the current progression of this {@link Progress}. + * + * @param step + * the amount to add + */ + public void add(int step) { + synchronized (lock) { + setProgress(getLocalProgress() + step); + } + } + + /** + * Check if the action corresponding to this {@link Progress} is done (i.e., + * if its progress value == its max value). + * + * @return TRUE if it is + */ + public boolean isDone() { + return getProgress() == max; + } + + /** + * Mark the {@link Progress} as done by setting its value to max. + */ + public void done() { + synchronized (lock) { + double childrenProgress = relativeProgress - relativeLocalProgress; + relativeLocalProgress = 1 - childrenProgress; + setRelativeProgress(this, name, 1d); + } + } + + /** + * Return the list of direct children of this {@link Progress}. + * + * @return the children (Who will think of the children??) + */ + public List getChildren() { + synchronized (lock) { + return new ArrayList(children.keySet()); + } + } + + /** + * Notify the listeners that this {@link Progress} changed value. + * + * @param pg + * the emmiter, that is, the (sub-){link Progress} that just + * reported some change, not always the same as this + * @param name + * the current name (if it is NULL, the first non-null name in + * the hierarchy will overwrite it) of the {@link Progress} who + * emitted this change + */ + private void changed(Progress pg, String name) { + if (pg == null) { + pg = this; + } + + if (name == null) { + name = this.name; + } + + synchronized (lock) { + for (ProgressListener l : listeners) { + l.progress(pg, name); + } + } + } + + /** + * Add a {@link ProgressListener} that will trigger on progress changes. + *

+ * Note: the {@link Progress} that will be reported will be the active + * progress, not necessarily the same as the current one (it could be a + * child {@link Progress} of this {@link Progress}). + * + * @param l + * the listener + */ + public void addProgressListener(ProgressListener l) { + synchronized (lock) { + this.listeners.add(l); + } + } + + /** + * Remove a {@link ProgressListener} that would trigger on progress changes. + * + * @param l + * the listener + * + * @return TRUE if it was found (and removed) + */ + public boolean removeProgressListener(ProgressListener l) { + synchronized (lock) { + return this.listeners.remove(l); + } + } + + /** + * Add a child {@link Progress} of the given weight. + * + * @param progress + * the child {@link Progress} to add + * @param weight + * the weight (on a {@link Progress#getMin()} to + * {@link Progress#getMax()} scale) of this child + * {@link Progress} in relation to its parent + * + * @throws RuntimeException + * if weight exceed {@link Progress#getMax()} or if progress + * already has a parent + */ + public void addProgress(Progress progress, double weight) { + if (weight < min || weight > max) { + throw new RuntimeException(String.format( + "Progress object %s cannot have a weight of %f, " + + "it is outside of its parent (%s) range (%d)", + progress.name, weight, name, max)); + } + + if (progress.parent != null) { + throw new RuntimeException(String.format( + "Progress object %s cannot be added to %s, " + + "as it already has a parent (%s)", progress.name, + name, progress.parent.name)); + } + + ProgressListener progressListener = new ProgressListener() { + @Override + public void progress(Progress pg, String name) { + synchronized (lock) { + double total = relativeLocalProgress; + for (Entry entry : children.entrySet()) { + total += (entry.getValue() / (max - min)) + * entry.getKey().getRelativeProgress(); + } + + setRelativeProgress(pg, name, total); + } + } + }; + + synchronized (lock) { + progress.parent = this; + this.children.put(progress, weight); + progress.addProgressListener(progressListener); + } + } +} diff --git a/src/be/nikiroo/utils/Proxy.java b/src/be/nikiroo/utils/Proxy.java new file mode 100644 index 0000000..750b3ee --- /dev/null +++ b/src/be/nikiroo/utils/Proxy.java @@ -0,0 +1,150 @@ +package be.nikiroo.utils; + +import java.net.Authenticator; +import java.net.PasswordAuthentication; + +/** + * Simple proxy helper to select a default internet proxy. + * + * @author niki + */ +public class Proxy { + /** + * Use the proxy described by this string: + *

    + *
  • ((user(:pass)@)proxy:port)
  • + *
  • System proxy is noted :
  • + *
+ * Some examples: + *
    + *
  • → do not use any proxy
  • + *
  • : → use the system proxy
  • + *
  • user@prox.com → use the proxy "prox.com" with default port + * and user "user"
  • + *
  • prox.com:8080 → use the proxy "prox.com" on port 8080
  • + *
  • user:pass@prox.com:8080 → use "prox.com" on port 8080 + * authenticated as "user" with password "pass"
  • + *
  • user:pass@: → use the system proxy authenticated as user + * "user" with password "pass"
  • + *
+ * + * @param proxy + * the proxy + */ + static public void use(String proxy) { + if (proxy != null && !proxy.isEmpty()) { + String user = null; + String password = null; + int port = 8080; + + if (proxy.contains("@")) { + int pos = proxy.indexOf("@"); + user = proxy.substring(0, pos); + proxy = proxy.substring(pos + 1); + if (user.contains(":")) { + pos = user.indexOf(":"); + password = user.substring(pos + 1); + user = user.substring(0, pos); + } + } + + if (proxy.equals(":")) { + proxy = null; + } else if (proxy.contains(":")) { + int pos = proxy.indexOf(":"); + try { + port = Integer.parseInt(proxy.substring(0, pos)); + proxy = proxy.substring(pos + 1); + } catch (Exception e) { + } + } + + if (proxy == null) { + Proxy.useSystemProxy(user, password); + } else { + Proxy.useProxy(proxy, port, user, password); + } + } + } + + /** + * Use the system proxy. + */ + static public void useSystemProxy() { + useSystemProxy(null, null); + } + + /** + * Use the system proxy with the given login/password, for authenticated + * proxies. + * + * @param user + * the user name or login + * @param password + * the password + */ + static public void useSystemProxy(String user, String password) { + System.setProperty("java.net.useSystemProxies", "true"); + auth(user, password); + } + + /** + * Use the give proxy. + * + * @param host + * the proxy host name or IP address + * @param port + * the port to use + */ + static public void useProxy(String host, int port) { + useProxy(host, port, null, null); + } + + /** + * Use the given proxy with the given login/password, for authenticated + * proxies. + * + * @param user + * the user name or login + * @param password + * the password + * @param host + * the proxy host name or IP address + * @param port + * the port to use + * @param user + * the user name or login + * @param password + * the password + */ + static public void useProxy(String host, int port, String user, + String password) { + System.setProperty("http.proxyHost", host); + System.setProperty("http.proxyPort", Integer.toString(port)); + auth(user, password); + } + + /** + * Select the default authenticator for proxy requests. + * + * @param user + * the user name or login + * @param password + * the password + */ + static private void auth(final String user, final String password) { + if (user != null && password != null) { + Authenticator proxy = new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY) { + return new PasswordAuthentication(user, + password.toCharArray()); + } + return null; + } + }; + Authenticator.setDefault(proxy); + } + } +} diff --git a/src/be/nikiroo/utils/StringJustifier.java b/src/be/nikiroo/utils/StringJustifier.java new file mode 100644 index 0000000..ed20291 --- /dev/null +++ b/src/be/nikiroo/utils/StringJustifier.java @@ -0,0 +1,286 @@ +/* + * This file was taken from: + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2017 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + * + * I added some changes to integrate it here. + * @author Niki + */ +package be.nikiroo.utils; + +import java.util.LinkedList; +import java.util.List; + +/** + * StringJustifier contains methods to convert one or more long lines of strings + * into justified text paragraphs. + */ +class StringJustifier { + /** + * Process the given text into a list of left-justified lines of a given + * max-width. + * + * @param data + * the text to justify + * @param width + * the maximum width of a line + * + * @return the list of justified lines + */ + static List left(final String data, final int width) { + return left(data, width, false); + } + + /** + * Right-justify a string into a list of lines. + * + * @param str + * the string + * @param n + * the maximum number of characters in a line + * @return the list of lines + */ + static List right(final String str, final int n) { + List result = new LinkedList(); + + /* + * Same as left(), but preceed each line with spaces to make it n chars + * long. + */ + List lines = left(str, n); + for (String line : lines) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < n - line.length(); i++) { + sb.append(' '); + } + sb.append(line); + result.add(sb.toString()); + } + + return result; + } + + /** + * Center a string into a list of lines. + * + * @param str + * the string + * @param n + * the maximum number of characters in a line + * @return the list of lines + */ + static List center(final String str, final int n) { + List result = new LinkedList(); + + /* + * Same as left(), but preceed/succeed each line with spaces to make it + * n chars long. + */ + List lines = left(str, n); + for (String line : lines) { + StringBuilder sb = new StringBuilder(); + int l = (n - line.length()) / 2; + int r = n - line.length() - l; + for (int i = 0; i < l; i++) { + sb.append(' '); + } + sb.append(line); + for (int i = 0; i < r; i++) { + sb.append(' '); + } + result.add(sb.toString()); + } + + return result; + } + + /** + * Fully-justify a string into a list of lines. + * + * @param str + * the string + * @param n + * the maximum number of characters in a line + * @return the list of lines + */ + static List full(final String str, final int n) { + List result = new LinkedList(); + + /* + * Same as left(true), but insert spaces between words to make each line + * n chars long. The "algorithm" here is pretty dumb: it performs a + * split on space and then re-inserts multiples of n between words. + */ + List lines = left(str, n, true); + for (int lineI = 0; lineI < lines.size() - 1; lineI++) { + String line = lines.get(lineI); + String[] words = line.split(" "); + if (words.length > 1) { + int charCount = 0; + for (int i = 0; i < words.length; i++) { + charCount += words[i].length(); + } + int spaceCount = n - charCount; + int q = spaceCount / (words.length - 1); + int r = spaceCount % (words.length - 1); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < words.length - 1; i++) { + sb.append(words[i]); + for (int j = 0; j < q; j++) { + sb.append(' '); + } + if (r > 0) { + sb.append(' '); + r--; + } + } + for (int j = 0; j < r; j++) { + sb.append(' '); + } + sb.append(words[words.length - 1]); + result.add(sb.toString()); + } else { + result.add(line); + } + } + if (lines.size() > 0) { + result.add(lines.get(lines.size() - 1)); + } + + return result; + } + + /** + * Process the given text into a list of left-justified lines of a given + * max-width. + * + * @param data + * the text to justify + * @param width + * the maximum width of a line + * @param minTwoWords + * use 2 words per line minimum if the text allows it + * + * @return the list of justified lines + */ + static private List left(final String data, final int width, + boolean minTwoWords) { + List lines = new LinkedList(); + + for (String dataLine : data.split("\n")) { + String line = rightTrim(dataLine.replace("\t", " ")); + + if (width > 0 && line.length() > width) { + while (line.length() > 0) { + int i = Math.min(line.length(), width - 1); // -1 for "-" + + boolean needDash = true; + // find the best space if any and if needed + int prevSpace = 0; + if (i < line.length()) { + prevSpace = -1; + int space = line.indexOf(' '); + int numOfSpaces = 0; + + while (space > -1 && space <= i) { + prevSpace = space; + space = line.indexOf(' ', space + 1); + numOfSpaces++; + } + + if (prevSpace > 0 && (!minTwoWords || numOfSpaces >= 2)) { + i = prevSpace; + needDash = false; + } + } + // + + // no dash before space/dash + if ((i + 1) < line.length()) { + char car = line.charAt(i); + char nextCar = line.charAt(i + 1); + if (car == ' ' || car == '-' || nextCar == ' ') { + needDash = false; + } else if (i > 0) { + char prevCar = line.charAt(i - 1); + if (prevCar == ' ' || prevCar == '-') { + needDash = false; + i--; + } + } + } + + // if the space freed by the removed dash allows it, or if + // it is the last char, add the next char + if (!needDash || i >= line.length() - 1) { + int checkI = Math.min(i + 1, line.length()); + if (checkI == i || checkI <= width) { + needDash = false; + i = checkI; + } + } + + // no dash before parenthesis (but cannot add one more + // after) + if ((i + 1) < line.length()) { + char nextCar = line.charAt(i + 1); + if (nextCar == '(' || nextCar == ')') { + needDash = false; + } + } + + if (needDash) { + lines.add(rightTrim(line.substring(0, i)) + "-"); + } else { + lines.add(rightTrim(line.substring(0, i))); + } + + // full trim (remove spaces when cutting) + line = line.substring(i).trim(); + } + } else { + lines.add(line); + } + } + + return lines; + } + + /** + * Trim the given {@link String} on the right only. + * + * @param data + * the source {@link String} + * @return the right-trimmed String or Empty if it was NULL + */ + static private String rightTrim(String data) { + if (data == null) + return ""; + + return ("|" + data).trim().substring(1); + } +} diff --git a/src/be/nikiroo/utils/StringUtils.java b/src/be/nikiroo/utils/StringUtils.java new file mode 100644 index 0000000..b3c1071 --- /dev/null +++ b/src/be/nikiroo/utils/StringUtils.java @@ -0,0 +1,1162 @@ +package be.nikiroo.utils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map.Entry; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import org.unbescape.html.HtmlEscape; +import org.unbescape.html.HtmlEscapeLevel; +import org.unbescape.html.HtmlEscapeType; + +import be.nikiroo.utils.streams.Base64InputStream; +import be.nikiroo.utils.streams.Base64OutputStream; + +/** + * This class offer some utilities based around {@link String}s. + * + * @author niki + */ +public class StringUtils { + /** + * This enum type will decide the alignment of a {@link String} when padding + * or justification is applied (if there is enough horizontal space for it + * to be aligned). + */ + public enum Alignment { + /** Aligned at left. */ + LEFT, + /** Centered. */ + CENTER, + /** Aligned at right. */ + RIGHT, + /** Full justified (to both left and right). */ + JUSTIFY, + + // Old Deprecated values: + + /** DEPRECATED: please use LEFT. */ + @Deprecated + Beginning, + /** DEPRECATED: please use CENTER. */ + @Deprecated + Center, + /** DEPRECATED: please use RIGHT. */ + @Deprecated + End; + + /** + * Return the non-deprecated version of this enum if needed (or return + * self if not). + * + * @return the non-deprecated value + */ + Alignment undeprecate() { + if (this == Beginning) + return LEFT; + if (this == Center) + return CENTER; + if (this == End) + return RIGHT; + return this; + } + } + + static private Pattern marks = getMarks(); + + /** + * Fix the size of the given {@link String} either with space-padding or by + * shortening it. + * + * @param text + * the {@link String} to fix + * @param width + * the size of the resulting {@link String} or -1 for a noop + * + * @return the resulting {@link String} of size size + */ + static public String padString(String text, int width) { + return padString(text, width, true, null); + } + + /** + * Fix the size of the given {@link String} either with space-padding or by + * optionally shortening it. + * + * @param text + * the {@link String} to fix + * @param width + * the size of the resulting {@link String} if the text fits or + * if cut is TRUE or -1 for a noop + * @param cut + * cut the {@link String} shorter if needed + * @param align + * align the {@link String} in this position if we have enough + * space (default is Alignment.Beginning) + * + * @return the resulting {@link String} of size size minimum + */ + static public String padString(String text, int width, boolean cut, + Alignment align) { + + if (align == null) { + align = Alignment.LEFT; + } + + align = align.undeprecate(); + + if (width >= 0) { + if (text == null) + text = ""; + + int diff = width - text.length(); + + if (diff < 0) { + if (cut) + text = text.substring(0, width); + } else if (diff > 0) { + if (diff < 2 && align != Alignment.RIGHT) + align = Alignment.LEFT; + + switch (align) { + case RIGHT: + text = new String(new char[diff]).replace('\0', ' ') + text; + break; + case CENTER: + int pad1 = (diff) / 2; + int pad2 = (diff + 1) / 2; + text = new String(new char[pad1]).replace('\0', ' ') + text + + new String(new char[pad2]).replace('\0', ' '); + break; + case LEFT: + default: + text = text + new String(new char[diff]).replace('\0', ' '); + break; + } + } + } + + return text; + } + + /** + * Justify a text into width-sized (at the maximum) lines and return all the + * lines concatenated into a single '\\n'-separated line of text. + * + * @param text + * the {@link String} to justify + * @param width + * the maximum size of the resulting lines + * + * @return a list of justified text lines concatenated into a single + * '\\n'-separated line of text + */ + static public String justifyTexts(String text, int width) { + StringBuilder builder = new StringBuilder(); + for (String line : justifyText(text, width, null)) { + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(line); + } + + return builder.toString(); + } + + /** + * Justify a text into width-sized (at the maximum) lines. + * + * @param text + * the {@link String} to justify + * @param width + * the maximum size of the resulting lines + * + * @return a list of justified text lines + */ + static public List justifyText(String text, int width) { + return justifyText(text, width, null); + } + + /** + * Justify a text into width-sized (at the maximum) lines. + * + * @param text + * the {@link String} to justify + * @param width + * the maximum size of the resulting lines + * @param align + * align the lines in this position (default is + * Alignment.Beginning) + * + * @return a list of justified text lines + */ + static public List justifyText(String text, int width, + Alignment align) { + if (align == null) { + align = Alignment.LEFT; + } + + align = align.undeprecate(); + + switch (align) { + case CENTER: + return StringJustifier.center(text, width); + case RIGHT: + return StringJustifier.right(text, width); + case JUSTIFY: + return StringJustifier.full(text, width); + case LEFT: + default: + return StringJustifier.left(text, width); + } + } + + /** + * Justify a text into width-sized (at the maximum) lines. + * + * @param text + * the {@link String} to justify + * @param width + * the maximum size of the resulting lines + * + * @return a list of justified text lines + */ + static public List justifyText(List text, int width) { + return justifyText(text, width, null); + } + + /** + * Justify a text into width-sized (at the maximum) lines. + * + * @param text + * the {@link String} to justify + * @param width + * the maximum size of the resulting lines + * @param align + * align the lines in this position (default is + * Alignment.Beginning) + * + * @return a list of justified text lines + */ + static public List justifyText(List text, int width, + Alignment align) { + List result = new ArrayList(); + + // Content <-> Bullet spacing (null = no spacing) + List> lines = new ArrayList>(); + StringBuilder previous = null; + StringBuilder tmp = new StringBuilder(); + String previousItemBulletSpacing = null; + String itemBulletSpacing = null; + for (String inputLine : text) { + boolean previousLineComplete = true; + + String current = inputLine.replace("\t", " "); + itemBulletSpacing = getItemSpacing(current); + boolean bullet = isItemLine(current); + if ((previousItemBulletSpacing == null || itemBulletSpacing + .length() <= previousItemBulletSpacing.length()) && !bullet) { + itemBulletSpacing = null; + } + + if (itemBulletSpacing != null) { + current = current.trim(); + if (!current.isEmpty() && bullet) { + current = current.substring(1); + } + current = current.trim(); + previousLineComplete = bullet; + } else { + tmp.setLength(0); + for (String word : current.split(" ")) { + if (word.isEmpty()) { + continue; + } + + if (tmp.length() > 0) { + tmp.append(' '); + } + tmp.append(word.trim()); + } + current = tmp.toString(); + + previousLineComplete = current.isEmpty() + || previousItemBulletSpacing != null + || (previous != null && isFullLine(previous)) + || isHrLine(current) || isHrLine(previous); + } + + if (previous == null) { + previous = new StringBuilder(); + } else { + if (previousLineComplete) { + lines.add(new AbstractMap.SimpleEntry( + previous.toString(), previousItemBulletSpacing)); + previous.setLength(0); + previousItemBulletSpacing = itemBulletSpacing; + } else { + previous.append(' '); + } + } + + previous.append(current); + + } + + if (previous != null) { + lines.add(new AbstractMap.SimpleEntry(previous + .toString(), previousItemBulletSpacing)); + } + + for (Entry line : lines) { + String content = line.getKey(); + String spacing = line.getValue(); + + String bullet = "- "; + if (spacing == null) { + bullet = ""; + spacing = ""; + } + + if (spacing.length() > width + 3) { + spacing = ""; + } + + for (String subline : StringUtils.justifyText(content, width + - (spacing.length() + bullet.length()), align)) { + result.add(spacing + bullet + subline); + if (!bullet.isEmpty()) { + bullet = " "; + } + } + } + + return result; + } + + /** + * Sanitise the given input to make it more Terminal-friendly by removing + * combining characters. + * + * @param input + * the input to sanitise + * @param allowUnicode + * allow Unicode or only allow ASCII Latin characters + * + * @return the sanitised {@link String} + */ + static public String sanitize(String input, boolean allowUnicode) { + return sanitize(input, allowUnicode, !allowUnicode); + } + + /** + * Sanitise the given input to make it more Terminal-friendly by removing + * combining characters. + * + * @param input + * the input to sanitise + * @param allowUnicode + * allow Unicode or only allow ASCII Latin characters + * @param removeAllAccents + * TRUE to replace all accentuated characters by their non + * accentuated counter-parts + * + * @return the sanitised {@link String} + */ + static public String sanitize(String input, boolean allowUnicode, + boolean removeAllAccents) { + + if (removeAllAccents) { + input = Normalizer.normalize(input, Form.NFKD); + if (marks != null) { + input = marks.matcher(input).replaceAll(""); + } + } + + input = Normalizer.normalize(input, Form.NFKC); + + if (!allowUnicode) { + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < input.length(); index++) { + char car = input.charAt(index); + // displayable chars in ASCII are in the range 32<->255, + // except DEL (127) + if (car >= 32 && car <= 255 && car != 127) { + builder.append(car); + } + } + input = builder.toString(); + } + + return input; + } + + /** + * Convert between the time in milliseconds to a {@link String} in a "fixed" + * way (to exchange data over the wire, for instance). + *

+ * Precise to the second. + * + * @param time + * the specified number of milliseconds since the standard base + * time known as "the epoch", namely January 1, 1970, 00:00:00 + * GMT + * + * @return the time as a {@link String} + */ + static public String fromTime(long time) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + return sdf.format(new Date(time)); + } + + /** + * Convert between the time as a {@link String} to milliseconds in a "fixed" + * way (to exchange data over the wire, for instance). + *

+ * Precise to the second. + * + * @param displayTime + * the time as a {@link String} + * + * @return the number of milliseconds since the standard base time known as + * "the epoch", namely January 1, 1970, 00:00:00 GMT, or -1 in case + * of error + * + * @throws ParseException + * in case of parse error + */ + static public long toTime(String displayTime) throws ParseException { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + return sdf.parse(displayTime).getTime(); + } + + /** + * Return a hash of the given {@link String}. + * + * @param input + * the input data + * + * @return the hash + */ + static public String getMd5Hash(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(getBytes(input)); + byte byteData[] = md.digest(); + + StringBuffer hexString = new StringBuffer(); + for (int i = 0; i < byteData.length; i++) { + String hex = Integer.toHexString(0xff & byteData[i]); + if (hex.length() == 1) + hexString.append('0'); + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + return input; + } + } + + /** + * Remove the HTML content from the given input, and un-html-ize the rest. + * + * @param html + * the HTML-encoded content + * + * @return the HTML-free equivalent content + */ + public static String unhtml(String html) { + StringBuilder builder = new StringBuilder(); + + int inTag = 0; + for (char car : html.toCharArray()) { + if (car == '<') { + inTag++; + } else if (car == '>') { + inTag--; + } else if (inTag <= 0) { + builder.append(car); + } + } + + char nbsp = ' '; // non-breakable space (a special char) + char space = ' '; + return HtmlEscape.unescapeHtml(builder.toString()).replace(nbsp, space); + } + + /** + * Escape the given {@link String} so it can be used in XML, as content. + * + * @param input + * the input {@link String} + * + * @return the escaped {@link String} + */ + public static String xmlEscape(String input) { + if (input == null) { + return ""; + } + + return HtmlEscape.escapeHtml(input, + HtmlEscapeType.HTML4_NAMED_REFERENCES_DEFAULT_TO_HEXA, + HtmlEscapeLevel.LEVEL_1_ONLY_MARKUP_SIGNIFICANT); + } + + /** + * Escape the given {@link String} so it can be used in XML, as text content + * inside double-quotes. + * + * @param input + * the input {@link String} + * + * @return the escaped {@link String} + */ + public static String xmlEscapeQuote(String input) { + if (input == null) { + return ""; + } + + return HtmlEscape.escapeHtml(input, + HtmlEscapeType.HTML4_NAMED_REFERENCES_DEFAULT_TO_HEXA, + HtmlEscapeLevel.LEVEL_1_ONLY_MARKUP_SIGNIFICANT); + } + + /** + * Zip the data and then encode it into Base64. + * + * @param data + * the data + * + * @return the Base64 zipped version + * + * @throws IOException + * in case of I/O error + */ + public static String zip64(String data) throws IOException { + try { + return zip64(getBytes(data)); + } catch (UnsupportedEncodingException e) { + // All conforming JVM are required to support UTF-8 + e.printStackTrace(); + return null; + } + } + + /** + * Zip the data and then encode it into Base64. + * + * @param data + * the data + * + * @return the Base64 zipped version + * + * @throws IOException + * in case of I/O error + */ + public static String zip64(byte[] data) throws IOException { + // 1. compress + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + try { + OutputStream out = new GZIPOutputStream(bout); + try { + out.write(data); + } finally { + out.close(); + } + } finally { + data = bout.toByteArray(); + bout.close(); + } + + // 2. base64 + InputStream in = new ByteArrayInputStream(data); + try { + in = new Base64InputStream(in, true); + return new String(IOUtils.toByteArray(in), "UTF-8"); + } finally { + in.close(); + } + } + + /** + * Unconvert from Base64 then unzip the content, which is assumed to be a + * String. + * + * @param data + * the data in Base64 format + * + * @return the raw data + * + * @throws IOException + * in case of I/O error + */ + public static String unzip64s(String data) throws IOException { + return new String(unzip64(data), "UTF-8"); + } + + /** + * Unconvert from Base64 then unzip the content. + * + * @param data + * the data in Base64 format + * + * @return the raw data + * + * @throws IOException + * in case of I/O error + */ + public static byte[] unzip64(String data) throws IOException { + InputStream in = new Base64InputStream(new ByteArrayInputStream( + getBytes(data)), false); + try { + in = new GZIPInputStream(in); + return IOUtils.toByteArray(in); + } finally { + in.close(); + } + } + + /** + * Convert the given data to Base64 format. + * + * @param data + * the data to convert + * + * @return the Base64 {@link String} representation of the data + * + * @throws IOException + * in case of I/O errors + */ + public static String base64(String data) throws IOException { + return base64(getBytes(data)); + } + + /** + * Convert the given data to Base64 format. + * + * @param data + * the data to convert + * + * @return the Base64 {@link String} representation of the data + * + * @throws IOException + * in case of I/O errors + */ + public static String base64(byte[] data) throws IOException { + Base64InputStream in = new Base64InputStream(new ByteArrayInputStream( + data), true); + try { + return new String(IOUtils.toByteArray(in), "UTF-8"); + } finally { + in.close(); + } + } + + /** + * Unconvert the given data from Base64 format back to a raw array of bytes. + * + * @param data + * the data to unconvert + * + * @return the raw data represented by the given Base64 {@link String}, + * + * @throws IOException + * in case of I/O errors + */ + public static byte[] unbase64(String data) throws IOException { + Base64InputStream in = new Base64InputStream(new ByteArrayInputStream( + getBytes(data)), false); + try { + return IOUtils.toByteArray(in); + } finally { + in.close(); + } + } + + /** + * Unonvert the given data from Base64 format back to a {@link String}. + * + * @param data + * the data to unconvert + * + * @return the {@link String} represented by the given Base64 {@link String} + * + * @throws IOException + * in case of I/O errors + */ + public static String unbase64s(String data) throws IOException { + return new String(unbase64(data), "UTF-8"); + } + + /** + * Return a display {@link String} for the given value, which can be + * suffixed with "k" or "M" depending upon the number, if it is big enough. + *

+ *

+ * Examples: + *

    + *
  • 8 765 becomes "8 k"
  • + *
  • 998 765 becomes "998 k"
  • + *
  • 12 987 364 becomes "12 M"
  • + *
  • 5 534 333 221 becomes "5 G"
  • + *
+ * + * @param value + * the value to convert + * + * @return the display value + */ + public static String formatNumber(long value) { + return formatNumber(value, 0); + } + + /** + * Return a display {@link String} for the given value, which can be + * suffixed with "k" or "M" depending upon the number, if it is big enough. + *

+ * Examples (assuming decimalPositions = 1): + *

    + *
  • 8 765 becomes "8.7 k"
  • + *
  • 998 765 becomes "998.7 k"
  • + *
  • 12 987 364 becomes "12.9 M"
  • + *
  • 5 534 333 221 becomes "5.5 G"
  • + *
+ * + * @param value + * the value to convert + * @param decimalPositions + * the number of decimal positions to keep + * + * @return the display value + */ + public static String formatNumber(long value, int decimalPositions) { + long userValue = value; + String suffix = " "; + long mult = 1; + + if (value >= 1000000000l) { + mult = 1000000000l; + userValue = value / 1000000000l; + suffix = " G"; + } else if (value >= 1000000l) { + mult = 1000000l; + userValue = value / 1000000l; + suffix = " M"; + } else if (value >= 1000l) { + mult = 1000l; + userValue = value / 1000l; + suffix = " k"; + } + + String deci = ""; + if (decimalPositions > 0) { + deci = Long.toString(value % mult); + int size = Long.toString(mult).length() - 1; + while (deci.length() < size) { + deci = "0" + deci; + } + + deci = deci.substring(0, Math.min(decimalPositions, deci.length())); + while (deci.length() < decimalPositions) { + deci += "0"; + } + + deci = "." + deci; + } + + return Long.toString(userValue) + deci + suffix; + } + + /** + * The reverse operation to {@link StringUtils#formatNumber(long)}: it will + * read a "display" number that can contain a "M" or "k" suffix and return + * the full value. + *

+ * Of course, the conversion to and from display form is lossy (example: + * 6870 to "6.5k" to 6500). + * + * @param value + * the value in display form with possible "M" and "k" suffixes, + * can be NULL + * + * @return the value as a number, or 0 if not possible to convert + */ + public static long toNumber(String value) { + return toNumber(value, 0l); + } + + /** + * The reverse operation to {@link StringUtils#formatNumber(long)}: it will + * read a "display" number that can contain a "M" or "k" suffix and return + * the full value. + *

+ * Of course, the conversion to and from display form is lossy (example: + * 6870 to "6.5k" to 6500). + * + * @param value + * the value in display form with possible "M" and "k" suffixes, + * can be NULL + * @param def + * the default value if it is not possible to convert the given + * value to a number + * + * @return the value as a number, or 0 if not possible to convert + */ + public static long toNumber(String value, long def) { + long count = def; + if (value != null) { + value = value.trim().toLowerCase(); + try { + long mult = 1; + if (value.endsWith("g")) { + value = value.substring(0, value.length() - 1).trim(); + mult = 1000000000; + } else if (value.endsWith("m")) { + value = value.substring(0, value.length() - 1).trim(); + mult = 1000000; + } else if (value.endsWith("k")) { + value = value.substring(0, value.length() - 1).trim(); + mult = 1000; + } + + long deci = 0; + if (value.contains(".")) { + String[] tab = value.split("\\."); + if (tab.length != 2) { + throw new NumberFormatException(value); + } + double decimal = Double.parseDouble("0." + + tab[tab.length - 1]); + deci = ((long) (mult * decimal)); + value = tab[0]; + } + count = mult * Long.parseLong(value) + deci; + } catch (Exception e) { + } + } + + return count; + } + + /** + * Return the bytes array representation of the given {@link String} in + * UTF-8. + * + * @param str + * the {@link String} to transform into bytes + * @return the content in bytes + */ + static public byte[] getBytes(String str) { + try { + return str.getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + // All conforming JVM must support UTF-8 + e.printStackTrace(); + return null; + } + } + + /** + * The "remove accents" pattern. + * + * @return the pattern, or NULL if a problem happens + */ + private static Pattern getMarks() { + try { + return Pattern + .compile("[\\p{InCombiningDiacriticalMarks}\\p{IsLm}\\p{IsSk}]+"); + } catch (Exception e) { + // Can fail on Android... + return null; + } + } + + // + // justify List related: + // + + /** + * Check if this line ends as a complete line (ends with a "." or similar). + *

+ * Note that we consider an empty line as full, and a line ending with + * spaces as not complete. + * + * @param line + * the line to check + * + * @return TRUE if it does + */ + static private boolean isFullLine(StringBuilder line) { + if (line.length() == 0) { + return true; + } + + char lastCar = line.charAt(line.length() - 1); + switch (lastCar) { + case '.': // points + case '?': + case '!': + + case '\'': // quotes + case '‘': + case '’': + + case '"': // double quotes + case '”': + case '“': + case '»': + case '«': + return true; + default: + return false; + } + } + + /** + * Check if this line represent an item in a list or description (i.e., + * check that the first non-space char is "-"). + * + * @param line + * the line to check + * + * @return TRUE if it is + */ + static private boolean isItemLine(String line) { + String spacing = getItemSpacing(line); + return spacing != null && !spacing.isEmpty() + && line.charAt(spacing.length()) == '-'; + } + + /** + * Return all the spaces that start this line (or Empty if none). + * + * @param line + * the line to get the starting spaces from + * + * @return the left spacing + */ + static private String getItemSpacing(String line) { + int i; + for (i = 0; i < line.length(); i++) { + if (line.charAt(i) != ' ') { + return line.substring(0, i); + } + } + + return ""; + } + + /** + * This line is an horizontal spacer line. + * + * @param line + * the line to test + * + * @return TRUE if it is + */ + static private boolean isHrLine(CharSequence line) { + int count = 0; + if (line != null) { + for (int i = 0; i < line.length(); i++) { + char car = line.charAt(i); + if (car == ' ' || car == '\t' || car == '*' || car == '-' + || car == '_' || car == '~' || car == '=' || car == '/' + || car == '\\') { + count++; + } else { + return false; + } + } + } + + return count > 2; + } + + // Deprecated functions, please do not use // + + /** + * @deprecated please use {@link StringUtils#zip64(byte[])} or + * {@link StringUtils#base64(byte[])} instead. + * + * @param data + * the data to encode + * @param zip + * TRUE to zip it before Base64 encoding it, FALSE for Base64 + * encoding only + * + * @return the encoded data + * + * @throws IOException + * in case of I/O error + */ + @Deprecated + public static String base64(String data, boolean zip) throws IOException { + return base64(getBytes(data), zip); + } + + /** + * @deprecated please use {@link StringUtils#zip64(String)} or + * {@link StringUtils#base64(String)} instead. + * + * @param data + * the data to encode + * @param zip + * TRUE to zip it before Base64 encoding it, FALSE for Base64 + * encoding only + * + * @return the encoded data + * + * @throws IOException + * in case of I/O error + */ + @Deprecated + public static String base64(byte[] data, boolean zip) throws IOException { + if (zip) { + return zip64(data); + } + + Base64InputStream b64 = new Base64InputStream(new ByteArrayInputStream( + data), true); + try { + return IOUtils.readSmallStream(b64); + } finally { + b64.close(); + } + } + + /** + * @deprecated please use {@link Base64OutputStream} and + * {@link GZIPOutputStream} instead. + * + * @param breakLines + * NOT USED ANYMORE, it is always considered FALSE now + */ + @Deprecated + public static OutputStream base64(OutputStream data, boolean zip, + boolean breakLines) throws IOException { + OutputStream out = new Base64OutputStream(data); + if (zip) { + out = new java.util.zip.GZIPOutputStream(out); + } + + return out; + } + + /** + * Unconvert the given data from Base64 format back to a raw array of bytes. + *

+ * Will automatically detect zipped data and also uncompress it before + * returning, unless ZIP is false. + * + * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped) + * + * @param data + * the data to unconvert + * @param zip + * TRUE to also uncompress the data from a GZIP format + * automatically; if set to FALSE, zipped data can be returned + * + * @return the raw data represented by the given Base64 {@link String}, + * optionally compressed with GZIP + * + * @throws IOException + * in case of I/O errors + */ + @Deprecated + public static byte[] unbase64(String data, boolean zip) throws IOException { + byte[] buffer = unbase64(data); + if (!zip) { + return buffer; + } + + try { + GZIPInputStream zipped = new GZIPInputStream( + new ByteArrayInputStream(buffer)); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + IOUtils.write(zipped, out); + return out.toByteArray(); + } finally { + out.close(); + } + } finally { + zipped.close(); + } + } catch (Exception e) { + return buffer; + } + } + + /** + * Unconvert the given data from Base64 format back to a raw array of bytes. + *

+ * Will automatically detect zipped data and also uncompress it before + * returning, unless ZIP is false. + * + * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped) + * + * @param data + * the data to unconvert + * @param zip + * TRUE to also uncompress the data from a GZIP format + * automatically; if set to FALSE, zipped data can be returned + * + * @return the raw data represented by the given Base64 {@link String}, + * optionally compressed with GZIP + * + * @throws IOException + * in case of I/O errors + */ + @Deprecated + public static InputStream unbase64(InputStream data, boolean zip) + throws IOException { + return new ByteArrayInputStream(unbase64(IOUtils.readSmallStream(data), + zip)); + } + + /** + * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped) + */ + @Deprecated + public static byte[] unbase64(byte[] data, int offset, int count, + boolean zip) throws IOException { + byte[] dataPart = Arrays.copyOfRange(data, offset, offset + count); + return unbase64(new String(dataPart, "UTF-8"), zip); + } + + /** + * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped) + */ + @Deprecated + public static String unbase64s(String data, boolean zip) throws IOException { + return new String(unbase64(data, zip), "UTF-8"); + } + + /** + * @deprecated DO NOT USE ANYMORE (bad perf, will be dropped) + */ + @Deprecated + public static String unbase64s(byte[] data, int offset, int count, + boolean zip) throws IOException { + return new String(unbase64(data, offset, count, zip), "UTF-8"); + } +} diff --git a/src/be/nikiroo/utils/TempFiles.java b/src/be/nikiroo/utils/TempFiles.java new file mode 100644 index 0000000..b54f0bc --- /dev/null +++ b/src/be/nikiroo/utils/TempFiles.java @@ -0,0 +1,187 @@ +package be.nikiroo.utils; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; + +/** + * A small utility class to generate auto-delete temporary files in a + * centralised location. + * + * @author niki + */ +public class TempFiles implements Closeable { + /** + * Root directory of this instance, owned by it, where all temporary files + * must reside. + */ + protected File root; + + /** + * Create a new {@link TempFiles} -- each instance is separate and have a + * dedicated sub-directory in a shared temporary root. + *

+ * The whole repository will be deleted on close (if you fail to call it, + * the program will try to call it on JVM termination). + * + * @param name + * the instance name (will be part of the final directory + * name) + * + * @throws IOException + * in case of I/O error + */ + public TempFiles(String name) throws IOException { + this(null, name); + } + + /** + * Create a new {@link TempFiles} -- each instance is separate and have a + * dedicated sub-directory in a given temporary root. + *

+ * The whole repository will be deleted on close (if you fail to call it, + * the program will try to call it on JVM termination). + *

+ * Be careful, this instance will own the given root directory, and + * will most probably delete all its files. + * + * @param base + * the root base directory to use for all the temporary files of + * this instance (if NULL, will be the default temporary + * directory of the OS) + * @param name + * the instance name (will be part of the final directory + * name) + * + * @throws IOException + * in case of I/O error + */ + public TempFiles(File base, String name) throws IOException { + if (base == null) { + base = File.createTempFile(".temp", ""); + } + + root = base; + + if (root.exists()) { + IOUtils.deltree(root, true); + } + + root = new File(root.getParentFile(), ".temp"); + root.mkdir(); + if (!root.exists()) { + throw new IOException("Cannot create root directory: " + root); + } + + root.deleteOnExit(); + + root = createTempFile(name); + IOUtils.deltree(root, true); + + root.mkdir(); + if (!root.exists()) { + throw new IOException("Cannot create root subdirectory: " + root); + } + } + + /** + * Create an auto-delete temporary file. + * + * @param name + * a base for the final filename (only a part of said + * filename) + * + * @return the newly created file + * + * @throws IOException + * in case of I/O errors + */ + public synchronized File createTempFile(String name) throws IOException { + name += "_"; + while (name.length() < 3) { + name += "_"; + } + + while (true) { + File tmp = File.createTempFile(name, ""); + IOUtils.deltree(tmp, true); + + File test = new File(root, tmp.getName()); + if (!test.exists()) { + test.createNewFile(); + if (!test.exists()) { + throw new IOException( + "Cannot create temporary file: " + test); + } + + test.deleteOnExit(); + return test; + } + } + } + + /** + * Create an auto-delete temporary directory. + *

+ * Note that creating 2 temporary directories with the same name will result + * in two different directories, even if the final name is the same + * (the absolute path will be different). + * + * @param name + * the actual directory name (not path) + * + * @return the newly created file + * + * @throws IOException + * in case of I/O errors, or if the name was a path instead of a + * name + */ + public synchronized File createTempDir(String name) throws IOException { + File localRoot = createTempFile(name); + IOUtils.deltree(localRoot, true); + + localRoot.mkdir(); + if (!localRoot.exists()) { + throw new IOException("Cannot create subdirectory: " + localRoot); + } + + File dir = new File(localRoot, name); + if (!dir.getName().equals(name)) { + throw new IOException( + "Cannot create temporary directory with a path, only names are allowed: " + + dir); + } + + dir.mkdir(); + dir.deleteOnExit(); + + if (!dir.exists()) { + throw new IOException("Cannot create subdirectory: " + dir); + } + + return dir; + } + + @Override + public synchronized void close() throws IOException { + File root = this.root; + this.root = null; + + if (root != null) { + IOUtils.deltree(root); + + // Since we allocate temp directories from a base point, + // try and remove that base point + root.getParentFile().delete(); // (only works if empty) + } + } + + @Override + protected void finalize() throws Throwable { + try { + close(); + } finally { + super.finalize(); + } + } +} diff --git a/src/be/nikiroo/utils/TraceHandler.java b/src/be/nikiroo/utils/TraceHandler.java new file mode 100644 index 0000000..0a09712 --- /dev/null +++ b/src/be/nikiroo/utils/TraceHandler.java @@ -0,0 +1,156 @@ +package be.nikiroo.utils; + +/** + * A handler when a trace message is sent or when a recoverable exception was + * caught by the program. + * + * @author niki + */ +public class TraceHandler { + private final boolean showErrors; + private final boolean showErrorDetails; + private final int traceLevel; + private final int maxPrintSize; + + /** + * Create a default {@link TraceHandler} that will print errors on stderr + * (without details) and no traces. + */ + public TraceHandler() { + this(true, false, false); + } + + /** + * Create a default {@link TraceHandler}. + * + * @param showErrors + * show errors on stderr + * @param showErrorDetails + * show more details when printing errors + * @param showTraces + * show level 1 traces on stderr, or no traces at all + */ + public TraceHandler(boolean showErrors, boolean showErrorDetails, + boolean showTraces) { + this(showErrors, showErrorDetails, showTraces ? 1 : 0); + } + + /** + * Create a default {@link TraceHandler}. + * + * @param showErrors + * show errors on stderr + * @param showErrorDetails + * show more details when printing errors + * @param traceLevel + * show traces of this level or lower (0 means "no traces", + * higher means more traces) + */ + public TraceHandler(boolean showErrors, boolean showErrorDetails, + int traceLevel) { + this(showErrors, showErrorDetails, traceLevel, -1); + } + + /** + * Create a default {@link TraceHandler}. + * + * @param showErrors + * show errors on stderr + * @param showErrorDetails + * show more details when printing errors + * @param traceLevel + * show traces of this level or lower (0 means "no traces", + * higher means more traces) + * @param maxPrintSize + * the maximum size at which to truncate traces data (or -1 for + * "no limit") + */ + public TraceHandler(boolean showErrors, boolean showErrorDetails, + int traceLevel, int maxPrintSize) { + this.showErrors = showErrors; + this.showErrorDetails = showErrorDetails; + this.traceLevel = Math.max(traceLevel, 0); + this.maxPrintSize = maxPrintSize; + } + + /** + * The trace level of this {@link TraceHandler}. + * + * @return the level + */ + public int getTraceLevel() { + return traceLevel; + } + + /** + * An exception happened, log it. + * + * @param e + * the exception + */ + public void error(Exception e) { + if (showErrors) { + if (showErrorDetails) { + long now = System.currentTimeMillis(); + System.err.print(StringUtils.fromTime(now) + ": "); + e.printStackTrace(); + } else { + error(e.toString()); + } + } + } + + /** + * An error happened, log it. + * + * @param message + * the error message + */ + public void error(String message) { + if (showErrors) { + long now = System.currentTimeMillis(); + System.err.println(StringUtils.fromTime(now) + ": " + message); + } + } + + /** + * A trace happened, show it. + *

+ * By default, will only be effective if {@link TraceHandler#traceLevel} is + * not 0. + *

+ * A call to this method is equivalent to a call to + * {@link TraceHandler#trace(String, int)} with a level of 1. + * + * @param message + * the trace message + */ + public void trace(String message) { + trace(message, 1); + } + + /** + * A trace happened, show it. + *

+ * By default, will only be effective if {@link TraceHandler#traceLevel} is + * not 0 and the level is lower or equal to it. + * + * @param message + * the trace message + * @param level + * the trace level + */ + public void trace(String message, int level) { + if (traceLevel > 0 && level <= traceLevel) { + long now = System.currentTimeMillis(); + System.err.print(StringUtils.fromTime(now) + ": "); + if (maxPrintSize > 0 && message.length() > maxPrintSize) { + + System.err + .println(message.substring(0, maxPrintSize) + "[...]"); + } else { + System.err.println(message); + } + } + } +} diff --git a/src/be/nikiroo/utils/Version.java b/src/be/nikiroo/utils/Version.java new file mode 100644 index 0000000..269edb6 --- /dev/null +++ b/src/be/nikiroo/utils/Version.java @@ -0,0 +1,366 @@ +package be.nikiroo.utils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * This class describe a program {@link Version}. + * + * @author niki + */ +public class Version implements Comparable { + private String version; + private int major; + private int minor; + private int patch; + private String tag; + private int tagVersion; + + /** + * Create a new, empty {@link Version}. + * + */ + public Version() { + } + + /** + * Create a new {@link Version} with the given values. + * + * @param major + * the major version + * @param minor + * the minor version + * @param patch + * the patch version + */ + public Version(int major, int minor, int patch) { + this(major, minor, patch, null, -1); + } + + /** + * Create a new {@link Version} with the given values. + * + * @param major + * the major version + * @param minor + * the minor version + * @param patch + * the patch version + * @param tag + * a tag name for this version + */ + public Version(int major, int minor, int patch, String tag) { + this(major, minor, patch, tag, -1); + } + + /** + * Create a new {@link Version} with the given values. + * + * @param major + * the major version + * @param minor + * the minor version + * @param patch + * the patch version the patch version + * @param tag + * a tag name for this version + * @param tagVersion + * the version of the tagged version + */ + public Version(int major, int minor, int patch, String tag, int tagVersion) { + if (tagVersion >= 0 && tag == null) { + throw new java.lang.IllegalArgumentException( + "A tag version cannot be used without a tag"); + } + + this.major = major; + this.minor = minor; + this.patch = patch; + this.tag = tag; + this.tagVersion = tagVersion; + + this.version = generateVersion(); + } + + /** + * Create a new {@link Version} with the given value, which must be in the + * form MAJOR.MINOR.PATCH(-TAG(TAG_VERSION)). + * + * @param version + * the version (MAJOR.MINOR.PATCH, + * MAJOR.MINOR.PATCH-TAG or + * MAJOR.MINOR.PATCH-TAGVERSIONTAG) + */ + public Version(String version) { + try { + String[] tab = version.split("\\."); + this.major = Integer.parseInt(tab[0].trim()); + this.minor = Integer.parseInt(tab[1].trim()); + if (tab[2].contains("-")) { + int posInVersion = version.indexOf('.'); + posInVersion = version.indexOf('.', posInVersion + 1); + String rest = version.substring(posInVersion + 1); + + int posInRest = rest.indexOf('-'); + this.patch = Integer.parseInt(rest.substring(0, posInRest) + .trim()); + + posInVersion = version.indexOf('-'); + this.tag = version.substring(posInVersion + 1).trim(); + this.tagVersion = -1; + + StringBuilder str = new StringBuilder(); + while (!tag.isEmpty() && tag.charAt(tag.length() - 1) >= '0' + && tag.charAt(tag.length() - 1) <= '9') { + str.insert(0, tag.charAt(tag.length() - 1)); + tag = tag.substring(0, tag.length() - 1); + } + + if (str.length() > 0) { + this.tagVersion = Integer.parseInt(str.toString()); + } + } else { + this.patch = Integer.parseInt(tab[2].trim()); + this.tag = null; + this.tagVersion = -1; + } + + this.version = generateVersion(); + } catch (Exception e) { + this.major = 0; + this.minor = 0; + this.patch = 0; + this.tag = null; + this.tagVersion = -1; + this.version = null; + } + } + + /** + * The 'major' version. + *

+ * This version should only change when API-incompatible changes are made to + * the program. + * + * @return the major version + */ + public int getMajor() { + return major; + } + + /** + * The 'minor' version. + *

+ * This version should only change when new, backwards-compatible + * functionality has been added to the program. + * + * @return the minor version + */ + public int getMinor() { + return minor; + } + + /** + * The 'patch' version. + *

+ * This version should change when backwards-compatible bugfixes have been + * added to the program. + * + * @return the patch version + */ + public int getPatch() { + return patch; + } + + /** + * A tag name for this version. + * + * @return the tag + */ + public String getTag() { + return tag; + } + + /** + * The version of the tag, or -1 for no version. + * + * @return the tag version + */ + public int getTagVersion() { + return tagVersion; + } + + /** + * Check if this {@link Version} is "empty" (i.e., the version was not + * parse-able or not given). + * + * @return TRUE if it is empty + */ + public boolean isEmpty() { + return version == null; + } + + /** + * Check if we are more recent than the given {@link Version}. + *

+ * Note that a tagged version is considered newer than a non-tagged version, + * but two tagged versions with different tags are not comparable. + *

+ * Also, an empty version is always considered older. + * + * @param o + * the other {@link Version} + * @return TRUE if this {@link Version} is more recent than the given one + */ + public boolean isNewerThan(Version o) { + if (isEmpty()) { + return false; + } else if (o.isEmpty()) { + return true; + } + + if (major > o.major) { + return true; + } + + if (major == o.major && minor > o.minor) { + return true; + } + + if (major == o.major && minor == o.minor && patch > o.patch) { + return true; + } + + // a tagged version is considered newer than a non-tagged one + if (major == o.major && minor == o.minor && patch == o.patch + && tag != null && o.tag == null) { + return true; + } + + // 2 <> tagged versions are not comparable + boolean sameTag = (tag == null && o.tag == null) + || (tag != null && tag.equals(o.tag)); + if (major == o.major && minor == o.minor && patch == o.patch && sameTag + && tagVersion > o.tagVersion) { + return true; + } + + return false; + } + + /** + * Check if we are older than the given {@link Version}. + *

+ * Note that a tagged version is considered newer than a non-tagged version, + * but two tagged versions with different tags are not comparable. + *

+ * Also, an empty version is always considered older. + * + * @param o + * the other {@link Version} + * @return TRUE if this {@link Version} is older than the given one + */ + public boolean isOlderThan(Version o) { + if (o.isEmpty()) { + return false; + } else if (isEmpty()) { + return true; + } + + // 2 <> tagged versions are not comparable + boolean sameTag = (tag == null && o.tag == null) + || (tag != null && tag.equals(o.tag)); + if (major == o.major && minor == o.minor && patch == o.patch + && !sameTag) { + return false; + } + + return !equals(o) && !isNewerThan(o); + } + + /** + * Return the version of the running program if it follows the VERSION + * convention (i.e., if it has a file called VERSION containing the version + * as a {@link String} in its binary root, and if this {@link String} + * follows the Major/Minor/Patch convention). + *

+ * If it does not, return an empty {@link Version} object. + * + * @return the {@link Version} of the program, or an empty {@link Version} + * (does not return NULL) + */ + public static Version getCurrentVersion() { + String version = null; + + InputStream in = IOUtils.openResource("VERSION"); + if (in != null) { + try { + ByteArrayOutputStream ba = new ByteArrayOutputStream(); + IOUtils.write(in, ba); + in.close(); + + version = ba.toString("UTF-8").trim(); + } catch (IOException e) { + } + } + + return new Version(version); + } + + @Override + public int compareTo(Version o) { + if (equals(o)) { + return 0; + } else if (isNewerThan(o)) { + return 1; + } else { + return -1; + } + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Version) { + Version o = (Version) obj; + if (isEmpty()) { + return o.isEmpty(); + } + + boolean sameTag = (tag == null && o.tag == null) + || (tag != null && tag.equals(o.tag)); + return o.major == major && o.minor == minor && o.patch == patch + && sameTag && o.tagVersion == tagVersion; + } + + return false; + } + + @Override + public int hashCode() { + return version == null ? 0 : version.hashCode(); + } + + /** + * Return a user-readable form of this {@link Version}. + */ + @Override + public String toString() { + return version == null ? "[unknown]" : version; + } + + /** + * Generate the clean version {@link String} from the current values. + * + * @return the clean version string + */ + private String generateVersion() { + String tagSuffix = ""; + if (tag != null) { + tagSuffix = "-" + tag + + (tagVersion >= 0 ? Integer.toString(tagVersion) : ""); + } + + return String.format("%d.%d.%d%s", major, minor, patch, tagSuffix); + } +} diff --git a/src/be/nikiroo/utils/android/ImageUtilsAndroid.java b/src/be/nikiroo/utils/android/ImageUtilsAndroid.java new file mode 100644 index 0000000..c2e269c --- /dev/null +++ b/src/be/nikiroo/utils/android/ImageUtilsAndroid.java @@ -0,0 +1,99 @@ +package be.nikiroo.utils.android; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.stream.Stream; + +import be.nikiroo.utils.Image; +import be.nikiroo.utils.ImageUtils; +import be.nikiroo.utils.StringUtils; + +/** + * This class offer some utilities based around images and uses the Android + * framework. + * + * @author niki + */ +public class ImageUtilsAndroid extends ImageUtils { + @Override + protected boolean check() { + // If we can get the class, it means we have access to it + Config c = Config.ALPHA_8; + return true; + } + + @Override + public void saveAsImage(Image img, File target, String format) + throws IOException { + FileOutputStream fos = new FileOutputStream(target); + try { + Bitmap image = fromImage(img); + + boolean ok = false; + try { + ok = image.compress( + Bitmap.CompressFormat.valueOf(format.toUpperCase()), + 90, fos); + } catch (Exception e) { + ok = false; + } + + // Some formats are not reliable + // Second chance: PNG + if (!ok && !format.equals("png")) { + ok = image.compress(Bitmap.CompressFormat.PNG, 90, fos); + } + + if (!ok) { + throw new IOException( + "Cannot find a writer for this image and format: " + + format); + } + } catch (IOException e) { + throw new IOException("Cannot write image to " + target, e); + } finally { + fos.close(); + } + } + + /** + * Convert the given {@link Image} into a {@link Bitmap} object. + * + * @param img + * the {@link Image} + * @return the {@link Image} object + * @throws IOException + * in case of IO error + */ + static public Bitmap fromImage(Image img) throws IOException { + InputStream stream = img.newInputStream(); + try { + Bitmap image = BitmapFactory.decodeStream(stream); + if (image == null) { + String extra = ""; + if (img.getSize() <= 2048) { + try { + extra = ", content: " + + new String(img.getData(), "UTF-8"); + } catch (Exception e) { + extra = ", content unavailable"; + } + } + String ssize = StringUtils.formatNumber(img.getSize()); + throw new IOException( + "Failed to convert input to image, size was: " + ssize + + extra); + } + + return image; + } finally { + stream.close(); + } + } +} diff --git a/src/be/nikiroo/utils/android/test/TestAndroid.java b/src/be/nikiroo/utils/android/test/TestAndroid.java new file mode 100644 index 0000000..2ded4e1 --- /dev/null +++ b/src/be/nikiroo/utils/android/test/TestAndroid.java @@ -0,0 +1,7 @@ +package be.nikiroo.utils.android.test; + +import be.nikiroo.utils.android.ImageUtilsAndroid; + +public class TestAndroid { + ImageUtilsAndroid a = new ImageUtilsAndroid(); +} diff --git a/src/be/nikiroo/utils/main/bridge.java b/src/be/nikiroo/utils/main/bridge.java new file mode 100644 index 0000000..1b7ab85 --- /dev/null +++ b/src/be/nikiroo/utils/main/bridge.java @@ -0,0 +1,136 @@ +package be.nikiroo.utils.main; + +import be.nikiroo.utils.TraceHandler; +import be.nikiroo.utils.serial.server.ServerBridge; + +/** + * Serialiser bridge (starts a {@link ServerBridge} and can thus intercept + * communication between a client and a server). + * + * @author niki + */ +public class bridge { + /** + * The optional options that can be passed to the program. + * + * @author niki + */ + private enum Option { + /** + * The encryption key for the input data (optional, but can also be + * empty which is different (it will then use an empty encryption + * key)). + */ + KEY, + /** + * The encryption key for the output data (optional, but can also be + * empty which is different (it will then use an empty encryption + * key)). + */ + FORWARD_KEY, + /** The trace level (1, 2, 3.. default is 1). */ + TRACE_LEVEL, + /** + * The maximum length after which to truncate data to display (the whole + * data will still be sent). + */ + MAX_DISPLAY_SIZE, + /** The help message. */ + HELP, + } + + static private String getSyntax() { + return "Syntax: (--options) (--) [NAME] [PORT] [FORWARD_HOST] [FORWARD_PORT]\n"// + + "\tNAME : the bridge name for display/debug purposes\n"// + + "\tPORT : the port to listen on\n"// + + "\tFORWARD_HOST : the host to connect to\n"// + + "\tFORWARD_PORT : the port to connect to\n"// + + "\n" // + + "\tOptions: \n" // + + "\t-- : no more options in the rest of the parameters\n" // + + "\t--help : this help message\n" // + + "\t--key : the INCOMING encryption key\n" // + + "\t--forward-key : the OUTGOING encryption key\n" // + + "\t--trace-level : the trace level (1, 2, 3... default is 1)\n" // + + "\t--max-display-size : the maximum size after which to \n"// + + "\t truncate the messages to display (the full message will still be sent)\n" // + ; + } + + /** + * Start a bridge between 2 servers. + * + * @param args + * the parameters, which can be seen by passing "--help" or just + * calling the program without parameters + */ + public static void main(String[] args) { + final TraceHandler tracer = new TraceHandler(true, false, 0); + try { + if (args.length == 0) { + tracer.error(getSyntax()); + System.exit(0); + } + + String key = null; + String fkey = null; + int traceLevel = 1; + int maxPrintSize = 0; + + int i = 0; + while (args[i].startsWith("--")) { + String arg = args[i]; + i++; + + if (arg.equals("--")) { + break; + } + + arg = arg.substring(2).toUpperCase().replace("-", "_"); + try { + Option opt = Enum.valueOf(Option.class, arg); + switch (opt) { + case HELP: + tracer.trace(getSyntax()); + System.exit(0); + break; + case FORWARD_KEY: + fkey = args[i++]; + break; + case KEY: + key = args[i++]; + break; + case MAX_DISPLAY_SIZE: + maxPrintSize = Integer.parseInt(args[i++]); + break; + case TRACE_LEVEL: + traceLevel = Integer.parseInt(args[i++]); + break; + } + } catch (Exception e) { + tracer.error(getSyntax()); + System.exit(1); + } + } + + if ((args.length - i) != 4) { + tracer.error(getSyntax()); + System.exit(2); + } + + String name = args[i++]; + int port = Integer.parseInt(args[i++]); + String fhost = args[i++]; + int fport = Integer.parseInt(args[i++]); + + ServerBridge bridge = new ServerBridge(name, port, key, fhost, + fport, fkey); + bridge.setTraceHandler(new TraceHandler(true, true, traceLevel, + maxPrintSize)); + bridge.run(); + } catch (Exception e) { + tracer.error(e); + System.exit(42); + } + } +} diff --git a/src/be/nikiroo/utils/main/img2aa.java b/src/be/nikiroo/utils/main/img2aa.java new file mode 100644 index 0000000..9cc6f0c --- /dev/null +++ b/src/be/nikiroo/utils/main/img2aa.java @@ -0,0 +1,137 @@ +package be.nikiroo.utils.main; + +import java.awt.Dimension; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.Image; +import be.nikiroo.utils.ui.ImageTextAwt; +import be.nikiroo.utils.ui.ImageTextAwt.Mode; +import be.nikiroo.utils.ui.ImageUtilsAwt; + +/** + * Image to ASCII conversion. + * + * @author niki + */ +public class img2aa { + /** + * Syntax: (--mode=MODE) (--width=WIDTH) (--height=HEIGHT) (--size=SIZE) + * (--output=OUTPUT) (--invert) (--help) + *

+ * See "--help". + * + * @param args + */ + public static void main(String[] args) { + Dimension size = null; + Mode mode = null; + boolean invert = false; + List inputs = new ArrayList(); + File output = null; + + String lastArg = ""; + try { + int height = -1; + int width = -1; + + for (String arg : args) { + lastArg = arg; + + if (arg.startsWith("--mode=")) { + mode = Mode.valueOf(arg.substring("--mode=".length())); + } else if (arg.startsWith("--width=")) { + width = Integer + .parseInt(arg.substring("--width=".length())); + } else if (arg.startsWith("--height=")) { + height = Integer.parseInt(arg.substring("--height=" + .length())); + } else if (arg.startsWith("--size=")) { + String content = arg.substring("--size=".length()).replace( + "X", "x"); + width = Integer.parseInt(content.split("x")[0]); + height = Integer.parseInt(content.split("x")[1]); + } else if (arg.startsWith("--ouput=")) { + if (!arg.equals("--output=-")) { + output = new File(arg.substring("--output=".length())); + } + } else if (arg.equals("--invert")) { + invert = true; + } else if (arg.equals("--help")) { + System.out + .println("Syntax: (--mode=MODE) (--width=WIDTH) (--height=HEIGHT) (--size=SIZE) (--output=OUTPUT) (--invert) (--help)"); + System.out.println("\t --help: will show this screen"); + System.out + .println("\t --invert: will invert the 'colours'"); + System.out + .println("\t --mode: will select the rendering mode (default: ASCII):"); + System.out + .println("\t\t ASCII: ASCI output mode, that is, characters \" .-+=o8#\""); + System.out + .println("\t\t DITHERING: Use 5 different \"colours\" which are actually" + + "\n\t\t Unicode characters \" ░▒▓█\""); + System.out + .println("\t\t DOUBLE_RESOLUTION: Use \"block\" Unicode characters up to quarter" + + "\n\t\t blocks, thus in effect doubling the resolution both in vertical" + + "\n\t\t and horizontal space." + + "\n\t\t Note that since 2 characters next to each other are square," + + "\n\t\t 4 blocks per 2 blocks for w/h resolution."); + System.out + .println("\t\t DOUBLE_DITHERING: Use characters from both DOUBLE_RESOLUTION" + + "\n\t\t and DITHERING"); + return; + } else { + inputs.add(arg); + } + } + + size = new Dimension(width, height); + if (inputs.size() == 0) { + inputs.add("-"); // by default, stdin + } + } catch (Exception e) { + System.err.println("Syntax error: \"" + lastArg + "\" is invalid"); + System.exit(1); + } + + try { + if (mode == null) { + mode = Mode.ASCII; + } + + for (String input : inputs) { + InputStream in = null; + + try { + if (input.equals("-")) { + in = System.in; + } else { + in = new FileInputStream(input); + } + BufferedImage image = ImageUtilsAwt + .fromImage(new Image(in)); + ImageTextAwt img = new ImageTextAwt(image, size, mode, + invert); + if (output == null) { + System.out.println(img.getText()); + } else { + IOUtils.writeSmallFile(output, img.getText()); + } + } finally { + if (!input.equals("-")) { + in.close(); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + System.exit(2); + } + } +} diff --git a/src/be/nikiroo/utils/main/justify.java b/src/be/nikiroo/utils/main/justify.java new file mode 100644 index 0000000..2a83389 --- /dev/null +++ b/src/be/nikiroo/utils/main/justify.java @@ -0,0 +1,53 @@ +package be.nikiroo.utils.main; + +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; + +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.StringUtils.Alignment; + +/** + * Text justification (left, right, center, justify). + * + * @author niki + */ +public class justify { + /** + * Syntax: $0 ([left|right|center|justify]) (max width) + *

+ *

    + *
  • mode: left, right, center or full justification (defaults to left)
  • + *
  • max width: the maximum width of a line, or "" for "no maximum" + * (defaults to "no maximum")
  • + *
+ * + * @param args + */ + public static void main(String[] args) { + int width = -1; + StringUtils.Alignment align = Alignment.LEFT; + + if (args.length >= 1) { + align = Alignment.valueOf(args[0].toUpperCase()); + } + if (args.length >= 2) { + width = Integer.parseInt(args[1]); + } + + Scanner scan = new Scanner(System.in); + scan.useDelimiter("\r\n|[\r\n]"); + try { + List lines = new ArrayList(); + while (scan.hasNext()) { + lines.add(scan.next()); + } + + for (String line : StringUtils.justifyText(lines, width, align)) { + System.out.println(line); + } + } finally { + scan.close(); + } + } +} diff --git a/src/be/nikiroo/utils/resources/Bundle.java b/src/be/nikiroo/utils/resources/Bundle.java new file mode 100644 index 0000000..0c57bf9 --- /dev/null +++ b/src/be/nikiroo/utils/resources/Bundle.java @@ -0,0 +1,1286 @@ +package be.nikiroo.utils.resources; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.MissingResourceException; +import java.util.PropertyResourceBundle; +import java.util.ResourceBundle; + +import be.nikiroo.utils.resources.Meta.Format; + +/** + * This class encapsulate a {@link ResourceBundle} in UTF-8. It allows to + * retrieve values associated to an enumeration, and allows some additional + * methods. + *

+ * It also sports a writable change map, and you can save back the + * {@link Bundle} to file with {@link Bundle#updateFile(String)}. + * + * @param + * the enum to use to get values out of this class + * + * @author niki + */ + +public class Bundle> { + /** The type of E. */ + protected Class type; + /** + * The {@link Enum} associated to this {@link Bundle} (all the keys used in + * this {@link Bundle} will be of this type). + */ + protected Enum keyType; + + private TransBundle descriptionBundle; + + /** R/O map */ + private Map map; + /** R/W map */ + private Map changeMap; + + /** + * Create a new {@link Bundles} of the given name. + * + * @param type + * a runtime instance of the class of E + * @param name + * the name of the {@link Bundles} + * @param descriptionBundle + * the description {@link TransBundle}, that is, a + * {@link TransBundle} dedicated to the description of the values + * of the given {@link Bundle} (can be NULL) + */ + protected Bundle(Class type, Enum name, + TransBundle descriptionBundle) { + this.type = type; + this.keyType = name; + this.descriptionBundle = descriptionBundle; + + this.map = new HashMap(); + this.changeMap = new HashMap(); + setBundle(name, Locale.getDefault(), false); + } + + /** + * Check if the setting is set into this {@link Bundle}. + * + * @param id + * the id of the setting to check + * @param includeDefaultValue + * TRUE to only return false when the setting is not set AND + * there is no default value + * + * @return TRUE if the setting is set + */ + public boolean isSet(E id, boolean includeDefaultValue) { + return isSet(id.name(), includeDefaultValue); + } + + /** + * Check if the setting is set into this {@link Bundle}. + * + * @param name + * the id of the setting to check + * @param includeDefaultValue + * TRUE to only return false when the setting is not set AND + * there is no default value + * + * @return TRUE if the setting is set + */ + protected boolean isSet(String name, boolean includeDefaultValue) { + if (getString(name, null) == null) { + if (!includeDefaultValue || getString(name, "") == null) { + return false; + } + } + + return true; + } + + /** + * Return the value associated to the given id as a {@link String}. + * + * @param id + * the id of the value to get + * + * @return the associated value, or NULL if not found (not present in the + * resource file) + */ + public String getString(E id) { + return getString(id, null); + } + + /** + * Return the value associated to the given id as a {@link String}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file + * + * @return the associated value, or NULL if not found (not present in the + * resource file) + */ + public String getString(E id, String def) { + return getString(id, def, -1); + } + + /** + * Return the value associated to the given id as a {@link String}. + *

+ * If no value is associated (or if it is empty!), take the default one if + * any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated value, or NULL if not found (not present in the + * resource file) + */ + public String getString(E id, String def, int item) { + String rep = getString(id.name(), null); + if (rep == null) { + try { + Meta meta = type.getDeclaredField(id.name()).getAnnotation( + Meta.class); + rep = meta.def(); + } catch (NoSuchFieldException e) { + } catch (SecurityException e) { + } + } + + if (rep == null || rep.isEmpty()) { + return def; + } + + if (item >= 0) { + List values = BundleHelper.parseList(rep, item); + if (values != null && item < values.size()) { + return values.get(item); + } + + return null; + } + + return rep; + } + + /** + * Set the value associated to the given id as a {@link String}. + * + * @param id + * the id of the value to set + * @param value + * the value + * + */ + public void setString(E id, String value) { + setString(id.name(), value); + } + + /** + * Set the value associated to the given id as a {@link String}. + * + * @param id + * the id of the value to set + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + */ + public void setString(E id, String value, int item) { + if (item < 0) { + setString(id.name(), value); + } else { + List values = getList(id); + setString(id.name(), BundleHelper.fromList(values, value, item)); + } + } + + /** + * Return the value associated to the given id as a {@link String} suffixed + * with the runtime value "_suffix" (that is, "_" and suffix). + *

+ * Will only accept suffixes that form an existing id. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param suffix + * the runtime suffix + * + * @return the associated value, or NULL if not found (not present in the + * resource file) + */ + public String getStringX(E id, String suffix) { + return getStringX(id, suffix, null, -1); + } + + /** + * Return the value associated to the given id as a {@link String} suffixed + * with the runtime value "_suffix" (that is, "_" and suffix). + *

+ * Will only accept suffixes that form an existing id. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param suffix + * the runtime suffix + * @param def + * the default value when it is not present in the config file + * + * @return the associated value, or NULL if not found (not present in the + * resource file) + */ + public String getStringX(E id, String suffix, String def) { + return getStringX(id, suffix, def, -1); + } + + /** + * Return the value associated to the given id as a {@link String} suffixed + * with the runtime value "_suffix" (that is, "_" and suffix). + *

+ * Will only accept suffixes that form an existing id. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param suffix + * the runtime suffix + * @param def + * the default value when it is not present in the config file + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated value, or NULL if not found (not present in the + * resource file) + */ + public String getStringX(E id, String suffix, String def, int item) { + String key = id.name() + + (suffix == null ? "" : "_" + suffix.toUpperCase()); + + try { + id = Enum.valueOf(type, key); + return getString(id, def, item); + } catch (IllegalArgumentException e) { + } + + return null; + } + + /** + * Set the value associated to the given id as a {@link String} suffixed + * with the runtime value "_suffix" (that is, "_" and suffix). + *

+ * Will only accept suffixes that form an existing id. + * + * @param id + * the id of the value to set + * @param suffix + * the runtime suffix + * @param value + * the value + */ + public void setStringX(E id, String suffix, String value) { + setStringX(id, suffix, value, -1); + } + + /** + * Set the value associated to the given id as a {@link String} suffixed + * with the runtime value "_suffix" (that is, "_" and suffix). + *

+ * Will only accept suffixes that form an existing id. + * + * @param id + * the id of the value to set + * @param suffix + * the runtime suffix + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + */ + public void setStringX(E id, String suffix, String value, int item) { + String key = id.name() + + (suffix == null ? "" : "_" + suffix.toUpperCase()); + + try { + id = Enum.valueOf(type, key); + setString(id, value, item); + } catch (IllegalArgumentException e) { + } + } + + /** + * Return the value associated to the given id as a {@link Boolean}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * + * @return the associated value + */ + public Boolean getBoolean(E id) { + return BundleHelper.parseBoolean(getString(id), -1); + } + + /** + * Return the value associated to the given id as a {@link Boolean}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a boolean value + * + * @return the associated value + */ + public boolean getBoolean(E id, boolean def) { + Boolean value = getBoolean(id); + if (value != null) { + return value; + } + + return def; + } + + /** + * Return the value associated to the given id as a {@link Boolean}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a boolean value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated value + */ + public Boolean getBoolean(E id, boolean def, int item) { + String value = getString(id); + if (value != null) { + return BundleHelper.parseBoolean(value, item); + } + + return def; + } + + /** + * Set the value associated to the given id as a {@link Boolean}. + * + * @param id + * the id of the value to set + * @param value + * the value + * + */ + public void setBoolean(E id, boolean value) { + setBoolean(id, value, -1); + } + + /** + * Set the value associated to the given id as a {@link Boolean}. + * + * @param id + * the id of the value to set + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + */ + public void setBoolean(E id, boolean value, int item) { + setString(id, BundleHelper.fromBoolean(value), item); + } + + /** + * Return the value associated to the given id as an {@link Integer}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * + * @return the associated value + */ + public Integer getInteger(E id) { + String value = getString(id); + if (value != null) { + return BundleHelper.parseInteger(value, -1); + } + + return null; + } + + /** + * Return the value associated to the given id as an int. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a int value + * + * @return the associated value + */ + public int getInteger(E id, int def) { + Integer value = getInteger(id); + if (value != null) { + return value; + } + + return def; + } + + /** + * Return the value associated to the given id as an int. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a int value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated value + */ + public Integer getInteger(E id, int def, int item) { + String value = getString(id); + if (value != null) { + return BundleHelper.parseInteger(value, item); + } + + return def; + } + + /** + * Set the value associated to the given id as a {@link Integer}. + * + * @param id + * the id of the value to set + * @param value + * the value + * + */ + public void setInteger(E id, int value) { + setInteger(id, value, -1); + } + + /** + * Set the value associated to the given id as a {@link Integer}. + * + * @param id + * the id of the value to set + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + */ + public void setInteger(E id, int value, int item) { + setString(id, BundleHelper.fromInteger(value), item); + } + + /** + * Return the value associated to the given id as a {@link Character}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * + * @return the associated value + */ + public Character getCharacter(E id) { + return BundleHelper.parseCharacter(getString(id), -1); + } + + /** + * Return the value associated to the given id as a {@link Character}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a char value + * + * @return the associated value + */ + public char getCharacter(E id, char def) { + Character value = getCharacter(id); + if (value != null) { + return value; + } + + return def; + } + + /** + * Return the value associated to the given id as a {@link Character}. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a char value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated value + */ + public Character getCharacter(E id, char def, int item) { + String value = getString(id); + if (value != null) { + return BundleHelper.parseCharacter(value, item); + } + + return def; + } + + /** + * Set the value associated to the given id as a {@link Character}. + * + * @param id + * the id of the value to set + * @param value + * the value + * + */ + public void setCharacter(E id, char value) { + setCharacter(id, value, -1); + } + + /** + * Set the value associated to the given id as a {@link Character}. + * + * @param id + * the id of the value to set + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + */ + public void setCharacter(E id, char value, int item) { + setString(id, BundleHelper.fromCharacter(value), item); + } + + /** + * Return the value associated to the given id as a colour if it is found + * and can be parsed. + *

+ * The returned value is an ARGB value. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * + * @return the associated value + */ + public Integer getColor(E id) { + return BundleHelper.parseColor(getString(id), -1); + } + + /** + * Return the value associated to the given id as a colour if it is found + * and can be parsed. + *

+ * The returned value is an ARGB value. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a char value + * + * @return the associated value + */ + public int getColor(E id, int def) { + Integer value = getColor(id); + if (value != null) { + return value; + } + + return def; + } + + /** + * Return the value associated to the given id as a colour if it is found + * and can be parsed. + *

+ * The returned value is an ARGB value. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a char value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated value + */ + public Integer getColor(E id, int def, int item) { + String value = getString(id); + if (value != null) { + return BundleHelper.parseColor(value, item); + } + + return def; + } + + /** + * Set the value associated to the given id as a colour. + *

+ * The value is a BGRA value. + * + * @param id + * the id of the value to set + * @param color + * the new colour + */ + public void setColor(E id, Integer color) { + setColor(id, color, -1); + } + + /** + * Set the value associated to the given id as a Color. + * + * @param id + * the id of the value to set + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + */ + public void setColor(E id, int value, int item) { + setString(id, BundleHelper.fromColor(value), item); + } + + /** + * Return the value associated to the given id as a list of values if it is + * found and can be parsed. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * + * @return the associated list, empty if the value is empty, NULL if it is + * not found or cannot be parsed as a list + */ + public List getList(E id) { + return BundleHelper.parseList(getString(id), -1); + } + + /** + * Return the value associated to the given id as a list of values if it is + * found and can be parsed. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a char value + * + * @return the associated list, empty if the value is empty, NULL if it is + * not found or cannot be parsed as a list + */ + public List getList(E id, List def) { + List value = getList(id); + if (value != null) { + return value; + } + + return def; + } + + /** + * Return the value associated to the given id as a list of values if it is + * found and can be parsed. + *

+ * If no value is associated, take the default one if any. + * + * @param id + * the id of the value to get + * @param def + * the default value when it is not present in the config file or + * if it is not a char value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + * @return the associated list, empty if the value is empty, NULL if it is + * not found or cannot be parsed as a list + */ + public List getList(E id, List def, int item) { + String value = getString(id); + if (value != null) { + return BundleHelper.parseList(value, item); + } + + return def; + } + + /** + * Set the value associated to the given id as a list of values. + * + * @param id + * the id of the value to set + * @param list + * the new list of values + */ + public void setList(E id, List list) { + setList(id, list, -1); + } + + /** + * Set the value associated to the given id as a {@link List}. + * + * @param id + * the id of the value to set + * @param value + * the value + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays + * + */ + public void setList(E id, List value, int item) { + setString(id, BundleHelper.fromList(value), item); + } + + /** + * Create/update the .properties file. + *

+ * Will use the most likely candidate as base if the file does not already + * exists and this resource is translatable (for instance, "en_US" will use + * "en" as a base if the resource is a translation file). + *

+ * Will update the files in {@link Bundles#getDirectory()}; it MUST + * be set. + * + * @throws IOException + * in case of IO errors + */ + public void updateFile() throws IOException { + updateFile(Bundles.getDirectory()); + } + + /** + * Create/update the .properties file. + *

+ * Will use the most likely candidate as base if the file does not already + * exists and this resource is translatable (for instance, "en_US" will use + * "en" as a base if the resource is a translation file). + * + * @param path + * the path where the .properties files are, MUST NOT be + * NULL + * + * @throws IOException + * in case of IO errors + */ + public void updateFile(String path) throws IOException { + File file = getUpdateFile(path); + + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(file), "UTF-8")); + + writeHeader(writer); + writer.write("\n"); + writer.write("\n"); + + for (Field field : type.getDeclaredFields()) { + Meta meta = field.getAnnotation(Meta.class); + if (meta != null) { + E id = Enum.valueOf(type, field.getName()); + String info = getMetaInfo(meta); + + if (info != null) { + writer.write(info); + writer.write("\n"); + } + + writeValue(writer, id); + } + } + + writer.close(); + } + + /** + * Delete the .properties file. + *

+ * Will use the most likely candidate as base if the file does not already + * exists and this resource is translatable (for instance, "en_US" will use + * "en" as a base if the resource is a translation file). + *

+ * Will delete the files in {@link Bundles#getDirectory()}; it MUST + * be set. + * + * @return TRUE if the file was deleted + */ + public boolean deleteFile() { + return deleteFile(Bundles.getDirectory()); + } + + /** + * Delete the .properties file. + *

+ * Will use the most likely candidate as base if the file does not already + * exists and this resource is translatable (for instance, "en_US" will use + * "en" as a base if the resource is a translation file). + * + * @param path + * the path where the .properties files are, MUST NOT be + * NULL + * + * @return TRUE if the file was deleted + */ + public boolean deleteFile(String path) { + File file = getUpdateFile(path); + return file.delete(); + } + + /** + * The description {@link TransBundle}, that is, a {@link TransBundle} + * dedicated to the description of the values of the given {@link Bundle} + * (can be NULL). + * + * @return the description {@link TransBundle} + */ + public TransBundle getDescriptionBundle() { + return descriptionBundle; + } + + /** + * Reload the {@link Bundle} data files. + * + * @param resetToDefault + * reset to the default configuration (do not look into the + * possible user configuration files, only take the original + * configuration) + */ + public void reload(boolean resetToDefault) { + setBundle(keyType, Locale.getDefault(), resetToDefault); + } + + /** + * Check if the internal map contains the given key. + * + * @param key + * the key to check for + * + * @return true if it does + */ + protected boolean containsKey(String key) { + return changeMap.containsKey(key) || map.containsKey(key); + } + + /** + * Get the value for the given key if it exists in the internal map, or + * def if not. + * + * @param key + * the key to check for + * @param def + * the default value when it is not present in the internal map + * + * @return the value, or def if not found + */ + protected String getString(String key, String def) { + if (changeMap.containsKey(key)) { + return changeMap.get(key); + } + + if (map.containsKey(key)) { + return map.get(key); + } + + return def; + } + + /** + * Set the value for this key, in the change map (it is kept in memory, not + * yet on disk). + * + * @param key + * the key + * @param value + * the associated value + */ + protected void setString(String key, String value) { + changeMap.put(key, value == null ? null : value.trim()); + } + + /** + * Return formated, display-able information from the {@link Meta} field + * given. Each line will always starts with a "#" character. + * + * @param meta + * the {@link Meta} field + * + * @return the information to display or NULL if none + */ + protected String getMetaInfo(Meta meta) { + String desc = meta.description(); + boolean group = meta.group(); + Meta.Format format = meta.format(); + String[] list = meta.list(); + boolean nullable = meta.nullable(); + String def = meta.def(); + boolean array = meta.array(); + + // Default, empty values -> NULL + if (desc.length() + list.length + def.length() == 0 && !group + && nullable && format == Format.STRING) { + return null; + } + + StringBuilder builder = new StringBuilder(); + for (String line : desc.split("\n")) { + builder.append("# ").append(line).append("\n"); + } + + if (group) { + builder.append("# This item is used as a group, its content is not expected to be used."); + } else { + builder.append("# (FORMAT: ").append(format) + .append(nullable ? "" : ", required"); + builder.append(") "); + + if (list.length > 0) { + builder.append("\n# ALLOWED VALUES: "); + boolean first = true; + for (String value : list) { + if (!first) { + builder.append(", "); + } + builder.append(BundleHelper.escape(value)); + first = false; + } + } + + if (array) { + builder.append("\n# (This item accepts a list of ^escaped comma-separated values)"); + } + } + + return builder.toString(); + } + + /** + * The display name used in the .properties file. + * + * @return the name + */ + protected String getBundleDisplayName() { + return keyType.toString(); + } + + /** + * Write the header found in the configuration .properties file of + * this {@link Bundles}. + * + * @param writer + * the {@link Writer} to write the header in + * + * @throws IOException + * in case of IO error + */ + protected void writeHeader(Writer writer) throws IOException { + writer.write("# " + getBundleDisplayName() + "\n"); + writer.write("#\n"); + } + + /** + * Write the given data to the config file, i.e., "MY_ID = my_curent_value" + * followed by a new line. + *

+ * Will prepend a # sign if the is is not set (see + * {@link Bundle#isSet(Enum, boolean)}). + * + * @param writer + * the {@link Writer} to write into + * @param id + * the id to write + * + * @throws IOException + * in case of IO error + */ + protected void writeValue(Writer writer, E id) throws IOException { + boolean set = isSet(id, false); + writeValue(writer, id.name(), getString(id), set); + } + + /** + * Write the given data to the config file, i.e., "MY_ID = my_curent_value" + * followed by a new line. + *

+ * Will prepend a # sign if the is is not set. + * + * @param writer + * the {@link Writer} to write into + * @param id + * the id to write + * @param value + * the id's value + * @param set + * the value is set in this {@link Bundle} + * + * @throws IOException + * in case of IO error + */ + protected void writeValue(Writer writer, String id, String value, + boolean set) throws IOException { + + if (!set) { + writer.write('#'); + } + + writer.write(id); + writer.write(" = "); + + if (value == null) { + value = ""; + } + + String[] lines = value.replaceAll("\t", "\\\\\\t").split("\n"); + for (int i = 0; i < lines.length; i++) { + writer.write(lines[i]); + if (i < lines.length - 1) { + writer.write("\\n\\"); + } + writer.write("\n"); + } + } + + /** + * Return the source file for this {@link Bundles} from the given path. + * + * @param path + * the path where the .properties files are + * + * @return the source {@link File} + */ + protected File getUpdateFile(String path) { + return new File(path, keyType.name() + ".properties"); + } + + /** + * Change the currently used bundle, and reset all changes. + * + * @param name + * the name of the bundle to load + * @param locale + * the {@link Locale} to use + * @param resetToDefault + * reset to the default configuration (do not look into the + * possible user configuration files, only take the original + * configuration) + */ + protected void setBundle(Enum name, Locale locale, boolean resetToDefault) { + changeMap.clear(); + String dir = Bundles.getDirectory(); + String bname = type.getPackage().getName() + "." + name.name(); + + boolean found = false; + if (!resetToDefault && dir != null) { + // Look into Bundles.getDirectory() for .properties files + try { + File file = getPropertyFile(dir, name.name(), locale); + if (file != null) { + Reader reader = new InputStreamReader(new FileInputStream( + file), "UTF-8"); + resetMap(new PropertyResourceBundle(reader)); + found = true; + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + if (!found) { + // Look into the package itself for resources + try { + resetMap(ResourceBundle + .getBundle(bname, locale, type.getClassLoader(), + new FixedResourceBundleControl())); + found = true; + } catch (MissingResourceException e) { + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (!found) { + // We have no bundle for this Bundle + System.err.println("No bundle found for: " + bname); + resetMap(null); + } + } + + /** + * Reset the backing map to the content of the given bundle, or with NULL + * values if bundle is NULL. + * + * @param bundle + * the bundle to copy + */ + protected void resetMap(ResourceBundle bundle) { + this.map.clear(); + for (Field field : type.getDeclaredFields()) { + try { + Meta meta = field.getAnnotation(Meta.class); + if (meta != null) { + E id = Enum.valueOf(type, field.getName()); + + String value; + if (bundle != null) { + value = bundle.getString(id.name()); + } else { + value = null; + } + + this.map.put(id.name(), value == null ? null : value.trim()); + } + } catch (MissingResourceException e) { + } + } + } + + /** + * Take a snapshot of the changes in memory in this {@link Bundle} made by + * the "set" methods ( {@link Bundle#setString(Enum, String)}...) at the + * current time. + * + * @return a snapshot to use with {@link Bundle#restoreSnapshot(Object)} + */ + public Object takeSnapshot() { + return new HashMap(changeMap); + } + + /** + * Restore a snapshot taken with {@link Bundle}, or reset the current + * changes if the snapshot is NULL. + * + * @param snap + * the snapshot or NULL + */ + @SuppressWarnings("unchecked") + public void restoreSnapshot(Object snap) { + if (snap == null) { + changeMap.clear(); + } else { + if (snap instanceof Map) { + changeMap = (Map) snap; + } else { + throw new RuntimeException( + "Restoring changes in a Bundle must be done on a changes snapshot, " + + "or NULL to discard current changes"); + } + } + } + + /** + * Return the resource file that is closer to the {@link Locale}. + * + * @param dir + * the directory to look into + * @param name + * the file base name (without .properties) + * @param locale + * the {@link Locale} + * + * @return the closest match or NULL if none + */ + private File getPropertyFile(String dir, String name, Locale locale) { + List locales = new ArrayList(); + if (locale != null) { + String country = locale.getCountry() == null ? "" : locale + .getCountry(); + String language = locale.getLanguage() == null ? "" : locale + .getLanguage(); + if (!language.isEmpty() && !country.isEmpty()) { + locales.add("_" + language + "-" + country); + } + if (!language.isEmpty()) { + locales.add("_" + language); + } + } + + locales.add(""); + + File file = null; + for (String loc : locales) { + file = new File(dir, name + loc + ".properties"); + if (file.exists()) { + break; + } + + file = null; + } + + return file; + } +} diff --git a/src/be/nikiroo/utils/resources/BundleHelper.java b/src/be/nikiroo/utils/resources/BundleHelper.java new file mode 100644 index 0000000..c6b26c7 --- /dev/null +++ b/src/be/nikiroo/utils/resources/BundleHelper.java @@ -0,0 +1,589 @@ +package be.nikiroo.utils.resources; + +import java.util.ArrayList; +import java.util.List; + +/** + * Internal class used to convert data to/from {@link String}s in the context of + * {@link Bundle}s. + * + * @author niki + */ +class BundleHelper { + /** + * Convert the given {@link String} into a {@link Boolean} if it represents + * a {@link Boolean}, or NULL if it doesn't. + *

+ * Note: null, "strange text", ""... will all be converted to NULL. + * + * @param str + * the input {@link String} + * @param item + * the item number to use for an array of values, or -1 for + * non-arrays + * + * @return the converted {@link Boolean} or NULL + */ + static public Boolean parseBoolean(String str, int item) { + str = getItem(str, item); + if (str == null) { + return null; + } + + if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("on") + || str.equalsIgnoreCase("yes")) + return true; + if (str.equalsIgnoreCase("false") || str.equalsIgnoreCase("off") + || str.equalsIgnoreCase("no")) + return false; + + return null; + } + + /** + * Return a {@link String} representation of the given {@link Boolean}. + * + * @param value + * the input value + * + * @return the raw {@link String} value that correspond to it + */ + static public String fromBoolean(boolean value) { + return Boolean.toString(value); + } + + /** + * Convert the given {@link String} into a {@link Integer} if it represents + * a {@link Integer}, or NULL if it doesn't. + *

+ * Note: null, "strange text", ""... will all be converted to NULL. + * + * @param str + * the input {@link String} + * @param item + * the item number to use for an array of values, or -1 for + * non-arrays + * + * @return the converted {@link Integer} or NULL + */ + static public Integer parseInteger(String str, int item) { + str = getItem(str, item); + if (str == null) { + return null; + } + + try { + return Integer.parseInt(str); + } catch (Exception e) { + } + + return null; + } + + /** + * Return a {@link String} representation of the given {@link Integer}. + * + * @param value + * the input value + * + * @return the raw {@link String} value that correspond to it + */ + static public String fromInteger(int value) { + return Integer.toString(value); + } + + /** + * Convert the given {@link String} into a {@link Character} if it + * represents a {@link Character}, or NULL if it doesn't. + *

+ * Note: null, "strange text", ""... will all be converted to NULL + * (remember: any {@link String} whose length is not 1 is not a + * {@link Character}). + * + * @param str + * the input {@link String} + * @param item + * the item number to use for an array of values, or -1 for + * non-arrays + * + * @return the converted {@link Character} or NULL + */ + static public Character parseCharacter(String str, int item) { + str = getItem(str, item); + if (str == null) { + return null; + } + + String s = str.trim(); + if (s.length() == 1) { + return s.charAt(0); + } + + return null; + } + + /** + * Return a {@link String} representation of the given {@link Boolean}. + * + * @param value + * the input value + * + * @return the raw {@link String} value that correspond to it + */ + static public String fromCharacter(char value) { + return Character.toString(value); + } + + /** + * Convert the given {@link String} into a colour (represented here as an + * {@link Integer}) if it represents a colour, or NULL if it doesn't. + *

+ * The returned colour value is an ARGB value. + * + * @param str + * the input {@link String} + * @param item + * the item number to use for an array of values, or -1 for + * non-arrays + * + * @return the converted colour as an {@link Integer} value or NULL + */ + static Integer parseColor(String str, int item) { + str = getItem(str, item); + if (str == null) { + return null; + } + + Integer rep = null; + + str = str.trim(); + int r = 0, g = 0, b = 0, a = -1; + if (str.startsWith("#") && (str.length() == 7 || str.length() == 9)) { + try { + r = Integer.parseInt(str.substring(1, 3), 16); + g = Integer.parseInt(str.substring(3, 5), 16); + b = Integer.parseInt(str.substring(5, 7), 16); + if (str.length() == 9) { + a = Integer.parseInt(str.substring(7, 9), 16); + } else { + a = 255; + } + + } catch (NumberFormatException e) { + // no changes + } + } + + // Try by name if still not found + if (a == -1) { + if ("black".equalsIgnoreCase(str)) { + a = 255; + r = 0; + g = 0; + b = 0; + } else if ("white".equalsIgnoreCase(str)) { + a = 255; + r = 255; + g = 255; + b = 255; + } else if ("red".equalsIgnoreCase(str)) { + a = 255; + r = 255; + g = 0; + b = 0; + } else if ("green".equalsIgnoreCase(str)) { + a = 255; + r = 0; + g = 255; + b = 0; + } else if ("blue".equalsIgnoreCase(str)) { + a = 255; + r = 0; + g = 0; + b = 255; + } else if ("grey".equalsIgnoreCase(str) + || "gray".equalsIgnoreCase(str)) { + a = 255; + r = 128; + g = 128; + b = 128; + } else if ("cyan".equalsIgnoreCase(str)) { + a = 255; + r = 0; + g = 255; + b = 255; + } else if ("magenta".equalsIgnoreCase(str)) { + a = 255; + r = 255; + g = 0; + b = 255; + } else if ("yellow".equalsIgnoreCase(str)) { + a = 255; + r = 255; + g = 255; + b = 0; + } + } + + if (a != -1) { + rep = ((a & 0xFF) << 24) // + | ((r & 0xFF) << 16) // + | ((g & 0xFF) << 8) // + | ((b & 0xFF) << 0); + } + + return rep; + } + + /** + * Return a {@link String} representation of the given colour. + *

+ * The colour value is interpreted as an ARGB value. + * + * @param color + * the ARGB colour value + * @return the raw {@link String} value that correspond to it + */ + static public String fromColor(int color) { + int a = (color >> 24) & 0xFF; + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 0) & 0xFF; + + String rs = Integer.toString(r, 16); + String gs = Integer.toString(g, 16); + String bs = Integer.toString(b, 16); + String as = ""; + if (a < 255) { + as = Integer.toString(a, 16); + } + + return "#" + rs + gs + bs + as; + } + + /** + * The size of this raw list (note than a NULL list is of size 0). + * + * @param raw + * the raw list + * + * @return its size if it is a list (NULL is an empty list), -1 if it is not + * a list + */ + static public int getListSize(String raw) { + if (raw == null) { + return 0; + } + + List list = parseList(raw, -1); + if (list == null) { + return -1; + } + + return list.size(); + } + + /** + * Return a {@link String} representation of the given list of values. + *

+ * The list of values is comma-separated and each value is surrounded by + * double-quotes; caret (^) and double-quotes (") are escaped by a caret. + * + * @param str + * the input value + * @param item + * the item number to use for an array of values, or -1 for + * non-arrays + * + * @return the raw {@link String} value that correspond to it + */ + static public List parseList(String str, int item) { + if (str == null) { + return null; + } + + if (item >= 0) { + str = getItem(str, item); + } + + List list = new ArrayList(); + try { + boolean inQuote = false; + boolean prevIsBackSlash = false; + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < str.length(); i++) { + char car = str.charAt(i); + + if (prevIsBackSlash) { + // We don't process it here + builder.append(car); + prevIsBackSlash = false; + } else { + switch (car) { + case '"': + // We don't process it here + builder.append(car); + + if (inQuote) { + list.add(unescape(builder.toString())); + builder.setLength(0); + } + + inQuote = !inQuote; + break; + case '^': + // We don't process it here + builder.append(car); + prevIsBackSlash = true; + break; + case ' ': + case '\n': + case '\r': + if (inQuote) { + builder.append(car); + } + break; + + case ',': + if (!inQuote) { + break; + } + // continue to default + default: + if (!inQuote) { + // Bad format! + return null; + } + + builder.append(car); + break; + } + } + } + + if (inQuote || prevIsBackSlash) { + // Bad format! + return null; + } + + } catch (Exception e) { + return null; + } + + return list; + } + + /** + * Return a {@link String} representation of the given list of values. + *

+ * NULL will be assimilated to an empty {@link String} if later non-null + * values exist, or just ignored if not. + *

+ * Example: + *

    + *
  • 1,NULL, 3 will become 1, + * "", 3
  • + *
  • 1,NULL, NULL will become 1
  • + *
  • NULL, NULL, NULL will become an empty list + *
  • + *
+ * + * @param list + * the input value + * + * @return the raw {@link String} value that correspond to it + */ + static public String fromList(List list) { + if (list == null) { + list = new ArrayList(); + } + + int last = list.size() - 1; + for (int i = 0; i < list.size(); i++) { + if (list.get(i) != null) { + last = i; + } + } + + StringBuilder builder = new StringBuilder(); + for (int i = 0; i <= last; i++) { + String item = list.get(i); + if (item == null) { + item = ""; + } + + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(escape(item)); + } + + return builder.toString(); + } + + /** + * Return a {@link String} representation of the given list of values. + *

+ * NULL will be assimilated to an empty {@link String} if later non-null + * values exist, or just ignored if not. + *

+ * Example: + *

    + *
  • 1,NULL, 3 will become 1, + * "", 3
  • + *
  • 1,NULL, NULL will become 1
  • + *
  • NULL, NULL, NULL will become an empty list + *
  • + *
+ * + * @param list + * the input value + * @param value + * the value to insert + * @param item + * the position to insert it at + * + * @return the raw {@link String} value that correspond to it + */ + static public String fromList(List list, String value, int item) { + if (list == null) { + list = new ArrayList(); + } + + while (item >= list.size()) { + list.add(null); + } + list.set(item, value); + + return fromList(list); + } + + /** + * Return a {@link String} representation of the given list of values. + *

+ * NULL will be assimilated to an empty {@link String} if later non-null + * values exist, or just ignored if not. + *

+ * Example: + *

    + *
  • 1,NULL, 3 will become 1, + * "", 3
  • + *
  • 1,NULL, NULL will become 1
  • + *
  • NULL, NULL, NULL will become an empty list + *
  • + *
+ * + * @param list + * the input value + * @param value + * the value to insert + * @param item + * the position to insert it at + * + * @return the raw {@link String} value that correspond to it + */ + static public String fromList(String list, String value, int item) { + return fromList(parseList(list, -1), value, item); + } + + /** + * Escape the given value for list formating (no carets, no NEWLINES...). + *

+ * You can unescape it with {@link BundleHelper#unescape(String)} + * + * @param value + * the value to escape + * + * @return an escaped value that can unquoted by the reverse operation + * {@link BundleHelper#unescape(String)} + */ + static public String escape(String value) { + return '"' + value// + .replace("^", "^^") // + .replace("\"", "^\"") // + .replace("\n", "^\n") // + .replace("\r", "^\r") // + + '"'; + } + + /** + * Unescape the given value for list formating (change ^n into NEWLINE and + * so on). + *

+ * You can escape it with {@link BundleHelper#escape(String)} + * + * @param value + * the value to escape + * + * @return an unescaped value that can reverted by the reverse operation + * {@link BundleHelper#escape(String)}, or NULL if it was badly + * formated + */ + static public String unescape(String value) { + if (value.length() < 2 || !value.startsWith("\"") + || !value.endsWith("\"")) { + // Bad format + return null; + } + + value = value.substring(1, value.length() - 1); + + boolean prevIsBackslash = false; + StringBuilder builder = new StringBuilder(); + for (char car : value.toCharArray()) { + if (prevIsBackslash) { + switch (car) { + case 'n': + case 'N': + builder.append('\n'); + break; + case 'r': + case 'R': + builder.append('\r'); + break; + default: // includes ^ and " + builder.append(car); + break; + } + prevIsBackslash = false; + } else { + if (car == '^') { + prevIsBackslash = true; + } else { + builder.append(car); + } + } + } + + if (prevIsBackslash) { + // Bad format + return null; + } + + return builder.toString(); + } + + /** + * Retrieve the specific item in the given value, assuming it is an array. + * + * @param value + * the value to look into + * @param item + * the item number to get for an array of values, or -1 for + * non-arrays (in that case, simply return the value as-is) + * + * @return the value as-is for non arrays, the item item if found, + * NULL if not + */ + static private String getItem(String value, int item) { + if (item >= 0) { + value = null; + List values = parseList(value, -1); + if (values != null && item < values.size()) { + value = values.get(item); + } + } + + return value; + } +} diff --git a/src/be/nikiroo/utils/resources/Bundles.java b/src/be/nikiroo/utils/resources/Bundles.java new file mode 100644 index 0000000..ad7b99d --- /dev/null +++ b/src/be/nikiroo/utils/resources/Bundles.java @@ -0,0 +1,40 @@ +package be.nikiroo.utils.resources; + +import java.util.ResourceBundle; + +/** + * This class help you get UTF-8 bundles for this application. + * + * @author niki + */ +public class Bundles { + /** + * The configuration directory where we try to get the .properties + * in priority, or NULL to get the information from the compiled resources. + */ + static private String confDir = null; + + /** + * Set the primary configuration directory to look for .properties + * files in. + * + * All {@link ResourceBundle}s returned by this class after that point will + * respect this new directory. + * + * @param confDir + * the new directory + */ + static public void setDirectory(String confDir) { + Bundles.confDir = confDir; + } + + /** + * Get the primary configuration directory to look for .properties + * files in. + * + * @return the directory + */ + static public String getDirectory() { + return Bundles.confDir; + } +} diff --git a/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java b/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java new file mode 100644 index 0000000..b53da9d --- /dev/null +++ b/src/be/nikiroo/utils/resources/FixedResourceBundleControl.java @@ -0,0 +1,60 @@ +package be.nikiroo.utils.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.net.URLConnection; +import java.util.Locale; +import java.util.PropertyResourceBundle; +import java.util.ResourceBundle; +import java.util.ResourceBundle.Control; + +/** + * Fixed ResourceBundle.Control class. It will use UTF-8 for the files to load. + * + * Also support an option to first check into the given path before looking into + * the resources. + * + * @author niki + * + */ +class FixedResourceBundleControl extends Control { + @Override + public ResourceBundle newBundle(String baseName, Locale locale, + String format, ClassLoader loader, boolean reload) + throws IllegalAccessException, InstantiationException, IOException { + // The below is a copy of the default implementation. + String bundleName = toBundleName(baseName, locale); + String resourceName = toResourceName(bundleName, "properties"); + + ResourceBundle bundle = null; + InputStream stream = null; + if (reload) { + URL url = loader.getResource(resourceName); + if (url != null) { + URLConnection connection = url.openConnection(); + if (connection != null) { + connection.setUseCaches(false); + stream = connection.getInputStream(); + } + } + } else { + stream = loader.getResourceAsStream(resourceName); + } + + if (stream != null) { + try { + // This line is changed to make it to read properties files + // as UTF-8. + // How can someone use an archaic encoding such as ISO 8859-1 by + // *DEFAULT* is beyond me... + bundle = new PropertyResourceBundle(new InputStreamReader( + stream, "UTF-8")); + } finally { + stream.close(); + } + } + return bundle; + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/resources/Meta.java b/src/be/nikiroo/utils/resources/Meta.java new file mode 100644 index 0000000..8ed74dc --- /dev/null +++ b/src/be/nikiroo/utils/resources/Meta.java @@ -0,0 +1,122 @@ +package be.nikiroo.utils.resources; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation used to give some information about the translation keys, so the + * translation .properties file can be created programmatically. + * + * @author niki + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Meta { + /** + * The format of an item (the values it is expected to be of). + *

+ * Note that the INI file can contain arbitrary data, but it is expected to + * be valid. + * + * @author niki + */ + public enum Format { + /** An integer value, can be negative. */ + INT, + /** true or false. */ + BOOLEAN, + /** Any text String. */ + STRING, + /** A password field. */ + PASSWORD, + /** A colour (either by name or #rrggbb or #aarrggbb). */ + COLOR, + /** A locale code (e.g., fr-BE, en-GB, es...). */ + LOCALE, + /** A path to a file. */ + FILE, + /** A path to a directory. */ + DIRECTORY, + /** A fixed list of values (see {@link Meta#list()} for the values). */ + FIXED_LIST, + /** + * A fixed list of values (see {@link Meta#list()} for the values) OR a + * custom String value (basically, a {@link Format#FIXED_LIST} with an + * option to enter a not accounted for value). + */ + COMBO_LIST, + } + + /** + * A description for this item: what it is or does, how to explain that item + * to the user including what can be used here (i.e., %s = file name, %d = + * file size...). + *

+ * For group, the first line ('\\n'-separated) will be used as a title while + * the rest will be the description. + * + * @return what it is + */ + String description() default ""; + + /** + * This item is only used as a group, not as an option. + *

+ * For instance, you could have LANGUAGE_CODE as a group for which you won't + * use the value in the program, and LANGUAGE_CODE_FR, LANGUAGE_CODE_EN + * inside for which the value must be set. + * + * @return TRUE if it is a group + */ + boolean group() default false; + + /** + * What format should/must this key be in. + * + * @return the format it is in + */ + Format format() default Format.STRING; + + /** + * The list of fixed values this item can be (either for + * {@link Format#FIXED_LIST} or {@link Format#COMBO_LIST}). + * + * @return the list of values + */ + String[] list() default {}; + + /** + * This item can be left unspecified. + * + * @return TRUE if it can + */ + boolean nullable() default true; + + /** + * The default value of this item. + * + * @return the value + */ + String def() default ""; + + /** + * This item is a comma-separated list of values instead of a single value. + *

+ * The list items are separated by a comma, each surrounded by + * double-quotes, with backslashes and double-quotes escaped by a backslash. + *

+ * Example: "un", "deux" + * + * @return TRUE if it is + */ + boolean array() default false; + + /** + * @deprecated add the info into the description, as only the description + * will be translated. + */ + @Deprecated + String info() default ""; +} diff --git a/src/be/nikiroo/utils/resources/MetaInfo.java b/src/be/nikiroo/utils/resources/MetaInfo.java new file mode 100644 index 0000000..f7598f1 --- /dev/null +++ b/src/be/nikiroo/utils/resources/MetaInfo.java @@ -0,0 +1,747 @@ +package be.nikiroo.utils.resources; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import be.nikiroo.utils.resources.Meta.Format; + +/** + * A graphical item that reflect a configuration option from the given + * {@link Bundle}. + * + * @author niki + * + * @param + * the type of {@link Bundle} to edit + */ +public class MetaInfo> implements Iterable> { + private final Bundle bundle; + private final E id; + + private Meta meta; + private List> children = new ArrayList>(); + + private String value; + private List reloadedListeners = new ArrayList(); + private List saveListeners = new ArrayList(); + + private String name; + private String description; + + private boolean dirty; + + /** + * Create a new {@link MetaInfo} from a value (without children). + *

+ * For instance, you can call + * new MetaInfo(Config.class, configBundle, Config.MY_VALUE). + * + * @param type + * the type of enum the value is + * @param bundle + * the bundle this value belongs to + * @param id + * the value itself + */ + public MetaInfo(Class type, Bundle bundle, E id) { + this.bundle = bundle; + this.id = id; + + try { + this.meta = type.getDeclaredField(id.name()).getAnnotation( + Meta.class); + } catch (NoSuchFieldException e) { + } catch (SecurityException e) { + } + + // We consider that if a description bundle is used, everything is in it + + String description = null; + if (bundle.getDescriptionBundle() != null) { + description = bundle.getDescriptionBundle().getString(id); + if (description != null && description.trim().isEmpty()) { + description = null; + } + } + if (description == null) { + description = meta.description(); + if (description == null) { + description = ""; + } + } + + String name = idToName(id, null); + + // Special rules for groups: + if (meta.group()) { + String groupName = description.split("\n")[0]; + description = description.substring(groupName.length()).trim(); + if (!groupName.isEmpty()) { + name = groupName; + } + } + + if (meta.def() != null && !meta.def().isEmpty()) { + if (!description.isEmpty()) { + description += "\n\n"; + } + description += "(Default value: " + meta.def() + ")"; + } + + this.name = name; + this.description = description; + + reload(); + } + + /** + * For normal items, this is the name of this item, deduced from its ID (or + * in other words, it is the ID but presented in a displayable form). + *

+ * For group items, this is the first line of the description if it is not + * empty (else, it is the ID in the same way as normal items). + *

+ * Never NULL. + * + * + * @return the name, never NULL + */ + public String getName() { + return name; + } + + /** + * A description for this item: what it is or does, how to explain that item + * to the user including what can be used here (i.e., %s = file name, %d = + * file size...). + *

+ * For group, the first line ('\\n'-separated) will be used as a title while + * the rest will be the description. + *

+ * If a default value is known, it will be specified here, too. + *

+ * Never NULL. + * + * @return the description, not NULL + */ + public String getDescription() { + return description; + } + + /** + * The format this item is supposed to follow + * + * @return the format + */ + public Format getFormat() { + return meta.format(); + } + + /** + * The allowed list of values that a {@link Format#FIXED_LIST} item is + * allowed to be, or a list of suggestions for {@link Format#COMBO_LIST} + * items. Also works for {@link Format#LOCALE}. + *

+ * Will always allow an empty string in addition to the rest. + * + * @return the list of values + */ + public String[] getAllowedValues() { + String[] list = meta.list(); + + String[] withEmpty = new String[list.length + 1]; + withEmpty[0] = ""; + for (int i = 0; i < list.length; i++) { + withEmpty[i + 1] = list[i]; + } + + return withEmpty; + } + + /** + * Return all the languages known by the program for this bundle. + *

+ * This only works for {@link TransBundle}, and will return an empty list if + * this is not a {@link TransBundle}. + * + * @return the known language codes + */ + public List getKnownLanguages() { + if (bundle instanceof TransBundle) { + return ((TransBundle) bundle).getKnownLanguages(); + } + + return new ArrayList(); + } + + /** + * This item is a comma-separated list of values instead of a single value. + *

+ * The list items are separated by a comma, each surrounded by + * double-quotes, with backslashes and double-quotes escaped by a backslash. + *

+ * Example: "un", "deux" + * + * @return TRUE if it is + */ + public boolean isArray() { + return meta.array(); + } + + /** + * A manual flag to specify if the data has been changed or not, which can + * be used by {@link MetaInfo#save(boolean)}. + * + * @return TRUE if it is dirty (if it has changed) + */ + public boolean isDirty() { + return dirty; + } + + /** + * A manual flag to specify that the data has been changed, which can be + * used by {@link MetaInfo#save(boolean)}. + */ + public void setDirty() { + this.dirty = true; + } + + /** + * The number of items in this item if it {@link MetaInfo#isArray()}, or -1 + * if not. + * + * @param useDefaultIfEmpty + * check the size of the default list instead if the list is + * empty + * + * @return -1 or the number of items + */ + public int getListSize(boolean useDefaultIfEmpty) { + if (!isArray()) { + return -1; + } + + return BundleHelper.getListSize(getString(-1, useDefaultIfEmpty)); + } + + /** + * This item is only used as a group, not as an option. + *

+ * For instance, you could have LANGUAGE_CODE as a group for which you won't + * use the value in the program, and LANGUAGE_CODE_FR, LANGUAGE_CODE_EN + * inside for which the value must be set. + * + * @return TRUE if it is a group + */ + public boolean isGroup() { + return meta.group(); + } + + /** + * The value stored by this item, as a {@link String}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param useDefaultIfEmpty + * use the default value instead of NULL if the setting is not + * set + * + * @return the value + */ + public String getString(int item, boolean useDefaultIfEmpty) { + if (isArray() && item >= 0) { + List values = BundleHelper.parseList(value, -1); + if (values != null && item < values.size()) { + return values.get(item); + } + + if (useDefaultIfEmpty) { + return getDefaultString(item); + } + + return null; + } + + if (value == null && useDefaultIfEmpty) { + return getDefaultString(item); + } + + return value; + } + + /** + * The default value of this item, as a {@link String}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the default value + */ + public String getDefaultString(int item) { + if (isArray() && item >= 0) { + List values = BundleHelper.parseList(meta.def(), item); + if (values != null && item < values.size()) { + return values.get(item); + } + + return null; + } + + return meta.def(); + } + + /** + * The value stored by this item, as a {@link Boolean}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param useDefaultIfEmpty + * use the default value instead of NULL if the setting is not + * set + * + * @return the value + */ + public Boolean getBoolean(int item, boolean useDefaultIfEmpty) { + return BundleHelper + .parseBoolean(getString(item, useDefaultIfEmpty), -1); + } + + /** + * The default value of this item, as a {@link Boolean}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the default value + */ + public Boolean getDefaultBoolean(int item) { + return BundleHelper.parseBoolean(getDefaultString(item), -1); + } + + /** + * The value stored by this item, as a {@link Character}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param useDefaultIfEmpty + * use the default value instead of NULL if the setting is not + * set + * + * @return the value + */ + public Character getCharacter(int item, boolean useDefaultIfEmpty) { + return BundleHelper.parseCharacter(getString(item, useDefaultIfEmpty), + -1); + } + + /** + * The default value of this item, as a {@link Character}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the default value + */ + public Character getDefaultCharacter(int item) { + return BundleHelper.parseCharacter(getDefaultString(item), -1); + } + + /** + * The value stored by this item, as an {@link Integer}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param useDefaultIfEmpty + * use the default value instead of NULL if the setting is not + * set + * + * @return the value + */ + public Integer getInteger(int item, boolean useDefaultIfEmpty) { + return BundleHelper + .parseInteger(getString(item, useDefaultIfEmpty), -1); + } + + /** + * The default value of this item, as an {@link Integer}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the default value + */ + public Integer getDefaultInteger(int item) { + return BundleHelper.parseInteger(getDefaultString(item), -1); + } + + /** + * The value stored by this item, as a colour (represented here as an + * {@link Integer}) if it represents a colour, or NULL if it doesn't. + *

+ * The returned colour value is an ARGB value. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param useDefaultIfEmpty + * use the default value instead of NULL if the setting is not + * set + * + * @return the value + */ + public Integer getColor(int item, boolean useDefaultIfEmpty) { + return BundleHelper.parseColor(getString(item, useDefaultIfEmpty), -1); + } + + /** + * The default value stored by this item, as a colour (represented here as + * an {@link Integer}) if it represents a colour, or NULL if it doesn't. + *

+ * The returned colour value is an ARGB value. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the value + */ + public Integer getDefaultColor(int item) { + return BundleHelper.parseColor(getDefaultString(item), -1); + } + + /** + * A {@link String} representation of the list of values. + *

+ * The list of values is comma-separated and each value is surrounded by + * double-quotes; backslashes and double-quotes are escaped by a backslash. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param useDefaultIfEmpty + * use the default value instead of NULL if the setting is not + * set + * + * @return the value + */ + public List getList(int item, boolean useDefaultIfEmpty) { + return BundleHelper.parseList(getString(item, useDefaultIfEmpty), -1); + } + + /** + * A {@link String} representation of the default list of values. + *

+ * The list of values is comma-separated and each value is surrounded by + * double-quotes; backslashes and double-quotes are escaped by a backslash. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the value + */ + public List getDefaultList(int item) { + return BundleHelper.parseList(getDefaultString(item), -1); + } + + /** + * The value stored by this item, as a {@link String}. + * + * @param value + * the new value + * @param item + * the item number to set for an array of values, or -1 to set + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + public void setString(String value, int item) { + if (isArray() && item >= 0) { + this.value = BundleHelper.fromList(this.value, value, item); + } else { + this.value = value; + } + } + + /** + * The value stored by this item, as a {@link Boolean}. + * + * @param value + * the new value + * @param item + * the item number to set for an array of values, or -1 to set + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + public void setBoolean(boolean value, int item) { + setString(BundleHelper.fromBoolean(value), item); + } + + /** + * The value stored by this item, as a {@link Character}. + * + * @param value + * the new value + * @param item + * the item number to set for an array of values, or -1 to set + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + public void setCharacter(char value, int item) { + setString(BundleHelper.fromCharacter(value), item); + } + + /** + * The value stored by this item, as an {@link Integer}. + * + * @param value + * the new value + * @param item + * the item number to set for an array of values, or -1 to set + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + public void setInteger(int value, int item) { + setString(BundleHelper.fromInteger(value), item); + } + + /** + * The value stored by this item, as a colour (represented here as an + * {@link Integer}) if it represents a colour, or NULL if it doesn't. + *

+ * The colour value is an ARGB value. + * + * @param value + * the value + * @param item + * the item number to set for an array of values, or -1 to set + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + public void setColor(int value, int item) { + setString(BundleHelper.fromColor(value), item); + } + + /** + * A {@link String} representation of the default list of values. + *

+ * The list of values is comma-separated and each value is surrounded by + * double-quotes; backslashes and double-quotes are escaped by a backslash. + * + * @param value + * the {@link String} representation + * @param item + * the item number to set for an array of values, or -1 to set + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + public void setList(List value, int item) { + setString(BundleHelper.fromList(value), item); + } + + /** + * Reload the value from the {@link Bundle}, so the last value that was + * saved will be used. + */ + public void reload() { + if (bundle.isSet(id, false)) { + value = bundle.getString(id); + } else { + value = null; + } + + // Copy the list so we can create new listener in a listener + for (Runnable listener : new ArrayList(reloadedListeners)) { + try { + listener.run(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + /** + * Add a listener that will be called after a reload operation. + *

+ * You could use it to refresh the UI for instance. + * + * @param listener + * the listener + */ + public void addReloadedListener(Runnable listener) { + reloadedListeners.add(listener); + } + + /** + * Save the current value to the {@link Bundle}. + *

+ * Note that listeners will be called before the dirty check and + * before saving the value. + * + * @param onlyIfDirty + * only save the data if the dirty flag is set (will reset the + * dirty flag) + */ + public void save(boolean onlyIfDirty) { + // Copy the list so we can create new listener in a listener + for (Runnable listener : new ArrayList(saveListeners)) { + try { + listener.run(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (!onlyIfDirty || isDirty()) { + bundle.setString(id, value); + } + } + + /** + * Add a listener that will be called before a save operation. + *

+ * You could use it to make some modification to the stored value before it + * is saved. + * + * @param listener + * the listener + */ + public void addSaveListener(Runnable listener) { + saveListeners.add(listener); + } + + /** + * The sub-items if any (if no sub-items, will return an empty list). + *

+ * Sub-items are declared when a {@link Meta} has an ID that starts with the + * ID of a {@link Meta#group()} {@link MetaInfo}. + *

+ * For instance: + *

    + *
  • {@link Meta} MY_PREFIX is a {@link Meta#group()}
  • + *
  • {@link Meta} MY_PREFIX_DESCRIPTION is another {@link Meta}
  • + *
  • MY_PREFIX_DESCRIPTION will be a child of MY_PREFIX
  • + *
+ * + * @return the sub-items if any + */ + public List> getChildren() { + return children; + } + + @Override + public Iterator> iterator() { + return children.iterator(); + } + + /** + * Create a list of {@link MetaInfo}, one for each of the item in the given + * {@link Bundle}. + * + * @param + * the type of {@link Bundle} to edit + * @param type + * a class instance of the item type to work on + * @param bundle + * the {@link Bundle} to sort through + * + * @return the list + */ + static public > List> getItems(Class type, + Bundle bundle) { + List> list = new ArrayList>(); + List> shadow = new ArrayList>(); + for (E id : type.getEnumConstants()) { + MetaInfo info = new MetaInfo(type, bundle, id); + list.add(info); + shadow.add(info); + } + + for (int i = 0; i < list.size(); i++) { + MetaInfo info = list.get(i); + + MetaInfo parent = findParent(info, shadow); + if (parent != null) { + list.remove(i--); + parent.children.add(info); + info.name = idToName(info.id, parent.id); + } + } + + return list; + } + + /** + * Find the longest parent of the given {@link MetaInfo}, which means: + *
    + *
  • the parent is a {@link Meta#group()}
  • + *
  • the parent Id is a substring of the Id of the given {@link MetaInfo}
  • + *
  • there is no other parent sharing a substring for this + * {@link MetaInfo} with a longer Id
  • + *
+ * + * @param + * the kind of enum + * @param info + * the info to look for a parent for + * @param candidates + * the list of potential parents + * + * @return the longest parent or NULL if no parent is found + */ + static private > MetaInfo findParent(MetaInfo info, + List> candidates) { + String id = info.id.toString(); + MetaInfo group = null; + for (MetaInfo pcandidate : candidates) { + if (pcandidate.isGroup()) { + String candidateId = pcandidate.id.toString(); + if (!id.equals(candidateId) && id.startsWith(candidateId)) { + if (group == null + || group.id.toString().length() < candidateId + .length()) { + group = pcandidate; + } + } + } + } + + return group; + } + + static private > String idToName(E id, E prefix) { + String name = id.toString(); + if (prefix != null && name.startsWith(prefix.toString())) { + name = name.substring(prefix.toString().length()); + } + + if (name.length() > 0) { + name = name.substring(0, 1).toUpperCase() + + name.substring(1).toLowerCase(); + } + + name = name.replace("_", " "); + + return name.trim(); + } +} diff --git a/src/be/nikiroo/utils/resources/TransBundle.java b/src/be/nikiroo/utils/resources/TransBundle.java new file mode 100644 index 0000000..28fa280 --- /dev/null +++ b/src/be/nikiroo/utils/resources/TransBundle.java @@ -0,0 +1,398 @@ +package be.nikiroo.utils.resources; + +import java.io.File; +import java.io.IOException; +import java.io.Writer; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * This class manages a translation-dedicated Bundle. + *

+ * Two special cases are handled for the used enum: + *

    + *
  • NULL will always will return an empty {@link String}
  • + *
  • DUMMY will return "[DUMMY]" (maybe with a suffix and/or "NOUTF")
  • + *
+ * + * @param + * the enum to use to get values out of this class + * + * @author niki + */ +public class TransBundle> extends Bundle { + private boolean utf = true; + private Locale locale; + private boolean defaultLocale = false; + + /** + * Create a translation service with the default language. + * + * @param type + * a runtime instance of the class of E + * @param name + * the name of the {@link Bundles} + */ + public TransBundle(Class type, Enum name) { + this(type, name, (Locale) null); + } + + /** + * Create a translation service for the given language (will fall back to + * the default one i not found). + * + * @param type + * a runtime instance of the class of E + * @param name + * the name of the {@link Bundles} + * @param language + * the language to use, can be NULL for default + */ + public TransBundle(Class type, Enum name, String language) { + super(type, name, null); + setLocale(language); + } + + /** + * Create a translation service for the given language (will fall back to + * the default one i not found). + * + * @param type + * a runtime instance of the class of E + * @param name + * the name of the {@link Bundles} + * @param language + * the language to use, can be NULL for default + */ + public TransBundle(Class type, Enum name, Locale language) { + super(type, name, null); + setLocale(language); + } + + /** + * Translate the given id into user text. + * + * @param stringId + * the ID to translate + * @param values + * the values to insert instead of the place holders in the + * translation + * + * @return the translated text with the given value where required or NULL + * if not found (not present in the resource file) + */ + public String getString(E stringId, Object... values) { + return getStringX(stringId, "", values); + } + + /** + * Translate the given id into user text. + * + * @param stringId + * the ID to translate + * @param values + * the values to insert instead of the place holders in the + * translation + * + * @return the translated text with the given value where required or NULL + * if not found (not present in the resource file) + */ + public String getStringNOUTF(E stringId, Object... values) { + return getStringX(stringId, "NOUTF", values); + } + + /** + * Translate the given id suffixed with the runtime value "_suffix" (that + * is, "_" and suffix) into user text. + * + * @param stringId + * the ID to translate + * @param values + * the values to insert instead of the place holders in the + * translation + * @param suffix + * the runtime suffix + * + * @return the translated text with the given value where required or NULL + * if not found (not present in the resource file) + */ + public String getStringX(E stringId, String suffix, Object... values) { + E id = stringId; + String result = ""; + + String key = id.name() + + ((suffix == null || suffix.isEmpty()) ? "" : "_" + + suffix.toUpperCase()); + + if (!isUnicode()) { + if (containsKey(key + "_NOUTF")) { + key += "_NOUTF"; + } + } + + if ("NULL".equals(id.name().toUpperCase())) { + result = ""; + } else if ("DUMMY".equals(id.name().toUpperCase())) { + result = "[" + key.toLowerCase() + "]"; + } else if (containsKey(key)) { + result = getString(key, null); + } else { + result = null; + } + + if (values != null && values.length > 0 && result != null) { + return String.format(locale, result, values); + } + + return result; + } + + /** + * Check if unicode characters should be used. + * + * @return TRUE to allow unicode + */ + public boolean isUnicode() { + return utf; + } + + /** + * Allow or disallow unicode characters in the program. + * + * @param utf + * TRUE to allow unuciode, FALSE to only allow ASCII characters + */ + public void setUnicode(boolean utf) { + this.utf = utf; + } + + /** + * Return all the languages known by the program for this bundle. + * + * @return the known language codes + */ + public List getKnownLanguages() { + return getKnownLanguages(keyType); + } + + /** + * The current language (which can be the default one, but NOT NULL). + * + * @return the language, not NULL + */ + public Locale getLocale() { + return locale; + } + + /** + * The current language (which can be the default one, but NOT NULL). + * + * @return the language, not NULL, in a display format (fr-BE, en-GB, es, + * de...) + */ + public String getLocaleString() { + String lang = locale.getLanguage(); + String country = locale.getCountry(); + if (country != null && !country.isEmpty()) { + return lang + "-" + country; + } + return lang; + } + + /** + * Initialise the translation mappings for the given language. + * + * @param language + * the language to initialise, in the form "en-GB" or "fr" for + * instance + */ + private void setLocale(String language) { + setLocale(getLocaleFor(language)); + } + + /** + * Initialise the translation mappings for the given language. + * + * @param language + * the language to initialise, or NULL for default + */ + private void setLocale(Locale language) { + if (language != null) { + defaultLocale = false; + locale = language; + } else { + defaultLocale = true; + locale = Locale.getDefault(); + } + + setBundle(keyType, locale, false); + } + + @Override + public void reload(boolean resetToDefault) { + setBundle(keyType, locale, resetToDefault); + } + + @Override + public String getString(E id) { + return getString(id, (Object[]) null); + } + + /** + * Create/update the .properties files for each supported language and for + * the default language. + *

+ * Note: this method is NOT thread-safe. + * + * @param path + * the path where the .properties files are + * + * @throws IOException + * in case of IO errors + */ + @Override + public void updateFile(String path) throws IOException { + String prev = locale.getLanguage(); + Object status = takeSnapshot(); + + // default locale + setLocale((Locale) null); + if (prev.equals(Locale.getDefault().getLanguage())) { + // restore snapshot if default locale = current locale + restoreSnapshot(status); + } + super.updateFile(path); + + for (String lang : getKnownLanguages()) { + setLocale(lang); + if (lang.equals(prev)) { + restoreSnapshot(status); + } + super.updateFile(path); + } + + setLocale(prev); + restoreSnapshot(status); + } + + @Override + protected File getUpdateFile(String path) { + String code = locale.toString(); + File file = null; + if (!defaultLocale && code.length() > 0) { + file = new File(path, keyType.name() + "_" + code + ".properties"); + } else { + // Default properties file: + file = new File(path, keyType.name() + ".properties"); + } + + return file; + } + + @Override + protected void writeHeader(Writer writer) throws IOException { + String code = locale.toString(); + String name = locale.getDisplayCountry(locale); + + if (name.length() == 0) { + name = locale.getDisplayLanguage(locale); + } + + if (name.length() == 0) { + name = "default"; + } + + if (code.length() > 0) { + name = name + " (" + code + ")"; + } + + name = (name + " " + getBundleDisplayName()).trim(); + + writer.write("# " + name + " translation file (UTF-8)\n"); + writer.write("# \n"); + writer.write("# Note that any key can be doubled with a _NOUTF suffix\n"); + writer.write("# to use when the NOUTF env variable is set to 1\n"); + writer.write("# \n"); + writer.write("# Also, the comments always refer to the key below them.\n"); + writer.write("# \n"); + } + + @Override + protected void writeValue(Writer writer, E id) throws IOException { + super.writeValue(writer, id); + + String name = id.name() + "_NOUTF"; + if (containsKey(name)) { + String value = getString(name, null); + boolean set = isSet(id, false); + writeValue(writer, name, value, set); + } + } + + /** + * Return the {@link Locale} representing the given language. + * + * @param language + * the language to initialise, in the form "en-GB" or "fr" for + * instance + * + * @return the corresponding {@link Locale} or NULL if it is not known + */ + static private Locale getLocaleFor(String language) { + Locale locale; + + if (language == null || language.trim().isEmpty()) { + return null; + } + + language = language.replaceAll("_", "-"); + String lang = language; + String country = null; + if (language.contains("-")) { + lang = language.split("-")[0]; + country = language.split("-")[1]; + } + + if (country != null) + locale = new Locale(lang, country); + else + locale = new Locale(lang); + + return locale; + } + + /** + * Return all the languages known by the program. + * + * @param name + * the enumeration on which we translate + * + * @return the known language codes + */ + static protected List getKnownLanguages(Enum name) { + List resources = new LinkedList(); + + String regex = ".*" + name.name() + "[_a-zA-Za]*\\.properties$"; + + for (String res : TransBundle_ResourceList.getResources(Pattern + .compile(regex))) { + String resource = res; + int index = resource.lastIndexOf('/'); + if (index >= 0 && index < (resource.length() - 1)) + resource = resource.substring(index + 1); + if (resource.startsWith(name.name())) { + resource = resource.substring(0, resource.length() + - ".properties".length()); + resource = resource.substring(name.name().length()); + if (resource.startsWith("_")) { + resource = resource.substring(1); + resources.add(resource); + } + } + } + + return resources; + } +} diff --git a/src/be/nikiroo/utils/resources/TransBundle_ResourceList.java b/src/be/nikiroo/utils/resources/TransBundle_ResourceList.java new file mode 100644 index 0000000..9983b8b --- /dev/null +++ b/src/be/nikiroo/utils/resources/TransBundle_ResourceList.java @@ -0,0 +1,125 @@ +package be.nikiroo.utils.resources; + +// code copied from from: +// http://forums.devx.com/showthread.php?t=153784, +// via: +// http://stackoverflow.com/questions/3923129/get-a-list-of-resources-from-classpath-directory + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +/** + * list resources available from the classpath @ * + */ +class TransBundle_ResourceList { + + /** + * for all elements of java.class.path get a Collection of resources Pattern + * pattern = Pattern.compile(".*"); gets all resources + * + * @param pattern + * the pattern to match + * @return the resources in the order they are found + */ + public static Collection getResources(final Pattern pattern) { + final ArrayList retval = new ArrayList(); + final String classPath = System.getProperty("java.class.path", "."); + final String[] classPathElements = classPath.split(System + .getProperty("path.separator")); + for (final String element : classPathElements) { + retval.addAll(getResources(element, pattern)); + } + + return retval; + } + + private static Collection getResources(final String element, + final Pattern pattern) { + final ArrayList retval = new ArrayList(); + final File file = new File(element); + if (file.isDirectory()) { + retval.addAll(getResourcesFromDirectory(file, pattern)); + } else { + retval.addAll(getResourcesFromJarFile(file, pattern)); + } + + return retval; + } + + private static Collection getResourcesFromJarFile(final File file, + final Pattern pattern) { + final ArrayList retval = new ArrayList(); + ZipFile zf; + try { + zf = new ZipFile(file); + } catch (final ZipException e) { + throw new Error(e); + } catch (final IOException e) { + throw new Error(e); + } + final Enumeration e = zf.entries(); + while (e.hasMoreElements()) { + final ZipEntry ze = e.nextElement(); + final String fileName = ze.getName(); + final boolean accept = pattern.matcher(fileName).matches(); + if (accept) { + retval.add(fileName); + } + } + try { + zf.close(); + } catch (final IOException e1) { + throw new Error(e1); + } + + return retval; + } + + private static Collection getResourcesFromDirectory( + final File directory, final Pattern pattern) { + List acc = new ArrayList(); + List dirs = new ArrayList(); + getResourcesFromDirectory(acc, dirs, directory, pattern); + + List rep = new ArrayList(); + for (String value : acc) { + if (pattern.matcher(value).matches()) { + rep.add(value); + } + } + + return rep; + } + + private static void getResourcesFromDirectory(List acc, + List dirs, final File directory, final Pattern pattern) { + final File[] fileList = directory.listFiles(); + if (fileList != null) { + for (final File file : fileList) { + if (!dirs.contains(file)) { + try { + String key = file.getCanonicalPath(); + if (!acc.contains(key)) { + if (file.isDirectory()) { + dirs.add(file); + getResourcesFromDirectory(acc, dirs, file, + pattern); + } else { + acc.add(key); + } + } + } catch (IOException e) { + } + } + } + } + } +} diff --git a/src/be/nikiroo/utils/resources/package-info.java b/src/be/nikiroo/utils/resources/package-info.java new file mode 100644 index 0000000..bda940b --- /dev/null +++ b/src/be/nikiroo/utils/resources/package-info.java @@ -0,0 +1,14 @@ +/** + * This package encloses the classes needed to use + * {@link be.nikiroo.utils.resources.Bundle}s + *

+ * Those are basically a .properties resource linked to an enumeration + * listing all the fields you can use. The classes can also be used to update + * the linked .properties files (or export them, which is useful when + * you work from a JAR file). + *

+ * All those classes expect UTF-8 content only. + * + * @author niki + */ +package be.nikiroo.utils.resources; \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/CustomSerializer.java b/src/be/nikiroo/utils/serial/CustomSerializer.java new file mode 100644 index 0000000..e58ccf2 --- /dev/null +++ b/src/be/nikiroo/utils/serial/CustomSerializer.java @@ -0,0 +1,150 @@ +package be.nikiroo.utils.serial; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import be.nikiroo.utils.streams.BufferedInputStream; +import be.nikiroo.utils.streams.ReplaceInputStream; +import be.nikiroo.utils.streams.ReplaceOutputStream; + +/** + * A {@link CustomSerializer} supports and generates values in the form: + *

    + *
  • custom^TYPE^ENCODED_VALUE
  • + *
+ *

+ * In this scheme, the values are: + *

    + *
  • custom: a fixed keyword
  • + *
  • ^: a fixed separator character (the + * ENCODED_VALUE can still use it inside its content, though
  • + *
  • TYPE: the object type of this value
  • + *
  • ENCODED_VALUE: the custom encoded value
  • + *
+ *

+ * To create a new {@link CustomSerializer}, you are expected to implement the + * abstract methods of this class. The rest should be taken care of bythe + * system. + * + * @author niki + */ +public abstract class CustomSerializer { + /** + * Generate the custom ENCODED_VALUE from this + * value. + *

+ * The value will always be of the supported type. + * + * @param out + * the {@link OutputStream} to write the value to + * @param value + * the value to serialize + * + * @throws IOException + * in case of I/O error + */ + protected abstract void toStream(OutputStream out, Object value) + throws IOException; + + /** + * Regenerate the value from the custom ENCODED_VALUE. + *

+ * The value in the {@link InputStream} in will always be of the + * supported type. + * + * @param in + * the {@link InputStream} containing the + * ENCODED_VALUE + * + * @return the regenerated object + * + * @throws IOException + * in case of I/O error + */ + protected abstract Object fromStream(InputStream in) throws IOException; + + /** + * Return the supported type name. + *

+ * It must be the name returned by {@link Object#getClass() + * #getCanonicalName()}. + * + * @return the supported class name + */ + protected abstract String getType(); + + /** + * Encode the object into the given {@link OutputStream}, i.e., generate the + * ENCODED_VALUE part. + *

+ * Use whatever scheme you wish, the system shall ensure that the content is + * correctly encoded and that you will receive the same content at decode + * time. + * + * @param out + * the builder to append to + * @param value + * the object to encode + * + * @throws IOException + * in case of I/O error + */ + public void encode(OutputStream out, Object value) throws IOException { + ReplaceOutputStream replace = new ReplaceOutputStream(out, // + new String[] { "\\", "\n" }, // + new String[] { "\\\\", "\\n" }); + + try { + SerialUtils.write(replace, "custom^"); + SerialUtils.write(replace, getType()); + SerialUtils.write(replace, "^"); + toStream(replace, value); + } finally { + replace.close(false); + } + } + + /** + * Decode the value back into the supported object type. + *

+ * We do not expect the full content here but only: + *

    + *
  • ENCODED_VALUE + *
  • + *
+ * That is, we do not expect the "custom^TYPE^" + * part. + * + * @param in + * the encoded value + * + * @return the object + * + * @throws IOException + * in case of I/O error + */ + public Object decode(InputStream in) throws IOException { + ReplaceInputStream replace = new ReplaceInputStream(in, // + new String[] { "\\\\", "\\n" }, // + new String[] { "\\", "\n" }); + + try { + return fromStream(replace); + } finally { + replace.close(false); + } + } + + public static boolean isCustom(BufferedInputStream in) throws IOException { + return in.startsWith("custom^"); + } + + public static String typeOf(String encodedValue) { + int pos1 = encodedValue.indexOf('^'); + int pos2 = encodedValue.indexOf('^', pos1 + 1); + String type = encodedValue.substring(pos1 + 1, pos2); + + return type; + } +} diff --git a/src/be/nikiroo/utils/serial/Exporter.java b/src/be/nikiroo/utils/serial/Exporter.java new file mode 100644 index 0000000..2470bde --- /dev/null +++ b/src/be/nikiroo/utils/serial/Exporter.java @@ -0,0 +1,60 @@ +package be.nikiroo.utils.serial; + +import java.io.IOException; +import java.io.NotSerializableException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +/** + * A simple class to serialise objects to {@link String}. + *

+ * This class does not support inner classes (it does support nested classes, + * though). + * + * @author niki + */ +public class Exporter { + private Map map; + private OutputStream out; + + /** + * Create a new {@link Exporter}. + * + * @param out + * export the data to this stream + */ + public Exporter(OutputStream out) { + if (out == null) { + throw new NullPointerException( + "Cannot create an be.nikiroo.utils.serials.Exporter that will export to NULL"); + } + + this.out = out; + map = new HashMap(); + } + + /** + * Serialise the given object and add it to the list. + *

+ * Important: If the operation fails (with a + * {@link NotSerializableException}), the {@link Exporter} will be corrupted + * (will contain bad, most probably not importable data). + * + * @param o + * the object to serialise + * @return this (for easier appending of multiple values) + * + * @throws NotSerializableException + * if the object cannot be serialised (in this case, the + * {@link Exporter} can contain bad, most probably not + * importable data) + * @throws IOException + * in case of I/O error + */ + public Exporter append(Object o) throws NotSerializableException, + IOException { + SerialUtils.append(out, o, map); + return this; + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/Importer.java b/src/be/nikiroo/utils/serial/Importer.java new file mode 100644 index 0000000..81814df --- /dev/null +++ b/src/be/nikiroo/utils/serial/Importer.java @@ -0,0 +1,288 @@ +package be.nikiroo.utils.serial; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.streams.Base64InputStream; +import be.nikiroo.utils.streams.BufferedInputStream; +import be.nikiroo.utils.streams.NextableInputStream; +import be.nikiroo.utils.streams.NextableInputStreamStep; + +/** + * A simple class that can accept the output of {@link Exporter} to recreate + * objects as they were sent to said exporter. + *

+ * This class requires the objects (and their potential enclosing objects) to + * have an empty constructor, and does not support inner classes (it does + * support nested classes, though). + * + * @author niki + */ +public class Importer { + private Boolean link; + private Object me; + private Importer child; + private Map map; + + private String currentFieldName; + + /** + * Create a new {@link Importer}. + */ + public Importer() { + map = new HashMap(); + map.put("NULL", null); + } + + private Importer(Map map) { + this.map = map; + } + + /** + * Read some data into this {@link Importer}: it can be the full serialised + * content, or a number of lines of it (any given line MUST be + * complete though) and accumulate it with the already present data. + * + * @param in + * the data to parse + * + * @return itself so it can be chained + * + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + * @throws IOException + * if the content cannot be read (for instance, corrupt data) + * @throws NullPointerException + * if the stream is empty + */ + public Importer read(InputStream in) throws NoSuchFieldException, + NoSuchMethodException, ClassNotFoundException, IOException, + NullPointerException { + + NextableInputStream stream = new NextableInputStream(in, + new NextableInputStreamStep('\n')); + + try { + if (in == null) { + throw new NullPointerException("InputStream is null"); + } + + boolean first = true; + while (stream.next()) { + if (stream.eof()) { + if (first) { + throw new NullPointerException( + "InputStream empty, normal termination"); + } + return this; + } + first = false; + + boolean zip = stream.startsWith("ZIP:"); + boolean b64 = stream.startsWith("B64:"); + + if (zip || b64) { + stream.skip("XXX:".length()); + + InputStream decoded = stream.open(); + if (zip) { + decoded = new GZIPInputStream(decoded); + } + decoded = new Base64InputStream(decoded, false); + + try { + read(decoded); + } finally { + decoded.close(); + } + } else { + processLine(stream); + } + } + } finally { + stream.close(false); + } + + return this; + } + + /** + * Read a single (whole) line of serialised data into this {@link Importer} + * and accumulate it with the already present data. + * + * @param in + * the line to parse + * + * @return TRUE if we are just done with one object or sub-object + * + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + * @throws IOException + * if the content cannot be read (for instance, corrupt data) + */ + private boolean processLine(BufferedInputStream in) + throws NoSuchFieldException, NoSuchMethodException, + ClassNotFoundException, IOException { + + // Defer to latest child if any + if (child != null) { + if (child.processLine(in)) { + if (currentFieldName != null) { + setField(currentFieldName, child.getValue()); + currentFieldName = null; + } + child = null; + } + + return false; + } + + // Start/Stop object + if (in.is("{")) { // START: new child if needed + if (link != null) { + child = new Importer(map); + } + in.end(); + return false; + } else if (in.is("}")) { // STOP: report self to parent + in.end(); + return true; + } + + // Custom objects + if (CustomSerializer.isCustom(in)) { + // not a field value but a direct value + me = SerialUtils.decode(in); + return false; + } + + // REF: (object) + if (in.startsWith("REF ")) { // REF: create/link self + // here, line is REF type@999:xxx + // xxx is optional + + NextableInputStream stream = new NextableInputStream(in, + new NextableInputStreamStep(':')); + try { + stream.next(); + + stream.skip("REF ".length()); + String header = IOUtils.readSmallStream(stream); + + String[] tab = header.split("@"); + if (tab.length != 2) { + throw new IOException("Bad import header line: " + header); + } + String type = tab[0]; + String ref = tab[1]; + + stream.nextAll(); + + link = map.containsKey(ref); + if (link) { + me = map.get(ref); + stream.end(); + } else { + if (stream.eof()) { + // construct + me = SerialUtils.createObject(type); + } else { + // direct value + me = SerialUtils.decode(stream); + } + map.put(ref, me); + } + } finally { + stream.close(false); + } + + return false; + } + + if (SerialUtils.isDirectValue(in)) { + // not a field value but a direct value + me = SerialUtils.decode(in); + return false; + } + + if (in.startsWith("^")) { + in.skip(1); + + NextableInputStream nameThenContent = new NextableInputStream(in, + new NextableInputStreamStep(':')); + + try { + nameThenContent.next(); + String fieldName = IOUtils.readSmallStream(nameThenContent); + + if (nameThenContent.nextAll() && !nameThenContent.eof()) { + // field value is direct or custom + Object value = null; + value = SerialUtils.decode(nameThenContent); + + // To support simple types directly: + if (me == null) { + me = value; + } else { + setField(fieldName, value); + } + } else { + // field value is compound + currentFieldName = fieldName; + } + } finally { + nameThenContent.close(false); + } + + return false; + } + + String line = IOUtils.readSmallStream(in); + throw new IOException("Line cannot be processed: <" + line + ">"); + } + + private void setField(String name, Object value) + throws NoSuchFieldException { + + try { + Field field = me.getClass().getDeclaredField(name); + + field.setAccessible(true); + field.set(me, value); + } catch (NoSuchFieldException e) { + throw new NoSuchFieldException(String.format( + "Field \"%s\" was not found in object of type \"%s\".", + name, me.getClass().getCanonicalName())); + } catch (Exception e) { + throw new NoSuchFieldException(String.format( + "Internal error when setting \"%s.%s\": %s", me.getClass() + .getCanonicalName(), name, e.getMessage())); + } + } + + /** + * Return the current deserialised value. + * + * @return the current value + */ + public Object getValue() { + return me; + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/SerialUtils.java b/src/be/nikiroo/utils/serial/SerialUtils.java new file mode 100644 index 0000000..ad3b5d4 --- /dev/null +++ b/src/be/nikiroo/utils/serial/SerialUtils.java @@ -0,0 +1,733 @@ +package be.nikiroo.utils.serial; + +import java.io.IOException; +import java.io.InputStream; +import java.io.NotSerializableException; +import java.io.OutputStream; +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UnknownFormatConversionException; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.Image; +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.streams.Base64InputStream; +import be.nikiroo.utils.streams.Base64OutputStream; +import be.nikiroo.utils.streams.BufferedInputStream; +import be.nikiroo.utils.streams.NextableInputStream; +import be.nikiroo.utils.streams.NextableInputStreamStep; + +/** + * Small class to help with serialisation. + *

+ * Note that we do not support inner classes (but we do support nested classes) + * and all objects require an empty constructor to be deserialised. + *

+ * It is possible to add support to custom types (both the encoder and the + * decoder will require the custom classes) -- see {@link CustomSerializer}. + *

+ * Default supported types are: + *

    + *
  • NULL (as a null value)
  • + *
  • String
  • + *
  • Boolean
  • + *
  • Byte
  • + *
  • Character
  • + *
  • Short
  • + *
  • Long
  • + *
  • Float
  • + *
  • Double
  • + *
  • Integer
  • + *
  • Enum (any enum whose name and value is known by the caller)
  • + *
  • java.awt.image.BufferedImage (as a {@link CustomSerializer})
  • + *
  • An array of the above (as a {@link CustomSerializer})
  • + *
  • URL
  • + *
+ * + * @author niki + */ +public class SerialUtils { + private static Map customTypes; + + static { + customTypes = new HashMap(); + + // Array types: + customTypes.put("[]", new CustomSerializer() { + @Override + protected void toStream(OutputStream out, Object value) + throws IOException { + + String type = value.getClass().getCanonicalName(); + type = type.substring(0, type.length() - 2); // remove the [] + + write(out, type); + try { + for (int i = 0; true; i++) { + Object item = Array.get(value, i); + + // encode it normally if direct value + write(out, "\r"); + if (!SerialUtils.encode(out, item)) { + try { + write(out, "B64:"); + OutputStream out64 = new Base64OutputStream( + out, true); + new Exporter(out64).append(item); + out64.flush(); + } catch (NotSerializableException e) { + throw new UnknownFormatConversionException(e + .getMessage()); + } + } + } + } catch (ArrayIndexOutOfBoundsException e) { + // Done. + } + } + + @Override + protected Object fromStream(InputStream in) throws IOException { + NextableInputStream stream = new NextableInputStream(in, + new NextableInputStreamStep('\r')); + + try { + List list = new ArrayList(); + stream.next(); + String type = IOUtils.readSmallStream(stream); + + while (stream.next()) { + Object value = new Importer().read(stream).getValue(); + list.add(value); + } + + Object array = Array.newInstance( + SerialUtils.getClass(type), list.size()); + for (int i = 0; i < list.size(); i++) { + Array.set(array, i, list.get(i)); + } + + return array; + } catch (Exception e) { + if (e instanceof IOException) { + throw (IOException) e; + } + throw new IOException(e.getMessage()); + } + } + + @Override + protected String getType() { + return "[]"; + } + }); + + // URL: + customTypes.put("java.net.URL", new CustomSerializer() { + @Override + protected void toStream(OutputStream out, Object value) + throws IOException { + String val = ""; + if (value != null) { + val = ((URL) value).toString(); + } + + out.write(StringUtils.getBytes(val)); + } + + @Override + protected Object fromStream(InputStream in) throws IOException { + String val = IOUtils.readSmallStream(in); + if (!val.isEmpty()) { + return new URL(val); + } + + return null; + } + + @Override + protected String getType() { + return "java.net.URL"; + } + }); + + // Images (this is currently the only supported image type by default) + customTypes.put("be.nikiroo.utils.Image", new CustomSerializer() { + @Override + protected void toStream(OutputStream out, Object value) + throws IOException { + Image img = (Image) value; + OutputStream encoded = new Base64OutputStream(out, true); + try { + InputStream in = img.newInputStream(); + try { + IOUtils.write(in, encoded); + } finally { + in.close(); + } + } finally { + encoded.flush(); + // Cannot close! + } + } + + @Override + protected String getType() { + return "be.nikiroo.utils.Image"; + } + + @Override + protected Object fromStream(InputStream in) throws IOException { + try { + // Cannot close it! + InputStream decoded = new Base64InputStream(in, false); + return new Image(decoded); + } catch (IOException e) { + throw new UnknownFormatConversionException(e.getMessage()); + } + } + }); + } + + /** + * Create an empty object of the given type. + * + * @param type + * the object type (its class name) + * + * @return the new object + * + * @throws ClassNotFoundException + * if the class cannot be found + * @throws NoSuchMethodException + * if the given class is not compatible with this code + */ + public static Object createObject(String type) + throws ClassNotFoundException, NoSuchMethodException { + + String desc = null; + try { + Class clazz = getClass(type); + String className = clazz.getName(); + List args = new ArrayList(); + List> classes = new ArrayList>(); + Constructor ctor = null; + if (className.contains("$")) { + for (String parentName = className.substring(0, + className.lastIndexOf('$'));; parentName = parentName + .substring(0, parentName.lastIndexOf('$'))) { + Object parent = createObject(parentName); + args.add(parent); + classes.add(parent.getClass()); + + if (!parentName.contains("$")) { + break; + } + } + + // Better error description in case there is no empty + // constructor: + desc = ""; + String end = ""; + for (Class parent = clazz; parent != null + && !parent.equals(Object.class); parent = parent + .getSuperclass()) { + if (!desc.isEmpty()) { + desc += " [:"; + end += "]"; + } + desc += parent; + } + desc += end; + // + + try { + ctor = clazz.getDeclaredConstructor(classes + .toArray(new Class[] {})); + } catch (NoSuchMethodException nsme) { + // TODO: it seems we do not always need a parameter for each + // level, so we currently try "ALL" levels or "FIRST" level + // only -> we should check the actual rule and use it + ctor = clazz.getDeclaredConstructor(classes.get(0)); + Object firstParent = args.get(0); + args.clear(); + args.add(firstParent); + } + desc = null; + } else { + ctor = clazz.getDeclaredConstructor(); + } + + ctor.setAccessible(true); + return ctor.newInstance(args.toArray()); + } catch (ClassNotFoundException e) { + throw e; + } catch (NoSuchMethodException e) { + if (desc != null) { + throw new NoSuchMethodException("Empty constructor not found: " + + desc); + } + throw e; + } catch (Exception e) { + throw new NoSuchMethodException("Cannot instantiate: " + type); + } + } + + /** + * Insert a custom serialiser that will take precedence over the default one + * or the target class. + * + * @param serializer + * the custom serialiser + */ + static public void addCustomSerializer(CustomSerializer serializer) { + customTypes.put(serializer.getType(), serializer); + } + + /** + * Serialise the given object into this {@link OutputStream}. + *

+ * Important: If the operation fails (with a + * {@link NotSerializableException}), the {@link StringBuilder} will be + * corrupted (will contain bad, most probably not importable data). + * + * @param out + * the output {@link OutputStream} to serialise to + * @param o + * the object to serialise + * @param map + * the map of already serialised objects (if the given object or + * one of its descendant is already present in it, only an ID + * will be serialised) + * + * @throws NotSerializableException + * if the object cannot be serialised (in this case, the + * {@link StringBuilder} can contain bad, most probably not + * importable data) + * @throws IOException + * in case of I/O errors + */ + static void append(OutputStream out, Object o, Map map) + throws NotSerializableException, IOException { + + Field[] fields = new Field[] {}; + String type = ""; + String id = "NULL"; + + if (o != null) { + int hash = System.identityHashCode(o); + fields = o.getClass().getDeclaredFields(); + type = o.getClass().getCanonicalName(); + if (type == null) { + // Anonymous inner classes support + type = o.getClass().getName(); + } + id = Integer.toString(hash); + if (map.containsKey(hash)) { + fields = new Field[] {}; + } else { + map.put(hash, o); + } + } + + write(out, "{\nREF "); + write(out, type); + write(out, "@"); + write(out, id); + write(out, ":"); + + if (!encode(out, o)) { // check if direct value + try { + for (Field field : fields) { + field.setAccessible(true); + + if (field.getName().startsWith("this$") + || field.isSynthetic() + || (field.getModifiers() & Modifier.STATIC) == Modifier.STATIC) { + // Do not keep this links of nested classes + // Do not keep synthetic fields + // Do not keep final fields + continue; + } + + write(out, "\n^"); + write(out, field.getName()); + write(out, ":"); + + Object value = field.get(o); + + if (!encode(out, value)) { + write(out, "\n"); + append(out, value, map); + } + } + } catch (IllegalArgumentException e) { + e.printStackTrace(); // should not happen (see + // setAccessible) + } catch (IllegalAccessException e) { + e.printStackTrace(); // should not happen (see + // setAccessible) + } + + write(out, "\n}"); + } + } + + /** + * Encode the object into the given {@link OutputStream} if possible and if + * supported. + *

+ * A supported object in this context means an object we can directly + * encode, like an Integer or a String. Custom objects and arrays are also + * considered supported, but compound objects are not supported here. + *

+ * For compound objects, you should use {@link Exporter}. + * + * @param out + * the {@link OutputStream} to append to + * @param value + * the object to encode (can be NULL, which will be encoded) + * + * @return TRUE if success, FALSE if not (the content of the + * {@link OutputStream} won't be changed in case of failure) + * + * @throws IOException + * in case of I/O error + */ + static boolean encode(OutputStream out, Object value) throws IOException { + if (value == null) { + write(out, "NULL"); + } else if (value.getClass().getSimpleName().endsWith("[]")) { + // Simple name does support [] suffix and do not return NULL for + // inner anonymous classes + customTypes.get("[]").encode(out, value); + } else if (customTypes.containsKey(value.getClass().getCanonicalName())) { + customTypes.get(value.getClass().getCanonicalName())// + .encode(out, value); + } else if (value instanceof String) { + encodeString(out, (String) value); + } else if (value instanceof Boolean) { + write(out, value); + } else if (value instanceof Byte) { + write(out, "b"); + write(out, value); + } else if (value instanceof Character) { + write(out, "c"); + encodeString(out, "" + value); + } else if (value instanceof Short) { + write(out, "s"); + write(out, value); + } else if (value instanceof Integer) { + write(out, "i"); + write(out, value); + } else if (value instanceof Long) { + write(out, "l"); + write(out, value); + } else if (value instanceof Float) { + write(out, "f"); + write(out, value); + } else if (value instanceof Double) { + write(out, "d"); + write(out, value); + } else if (value instanceof Enum) { + write(out, "E:"); + String type = value.getClass().getCanonicalName(); + write(out, type); + write(out, "."); + write(out, ((Enum) value).name()); + write(out, ";"); + } else { + return false; + } + + return true; + } + + static boolean isDirectValue(BufferedInputStream encodedValue) + throws IOException { + if (CustomSerializer.isCustom(encodedValue)) { + return false; + } + + for (String fullValue : new String[] { "NULL", "null", "true", "false" }) { + if (encodedValue.is(fullValue)) { + return true; + } + } + + for (String prefix : new String[] { "c\"", "\"", "b", "s", "i", "l", + "f", "d", "E:" }) { + if (encodedValue.startsWith(prefix)) { + return true; + } + } + + return false; + } + + /** + * Decode the data into an equivalent supported source object. + *

+ * A supported object in this context means an object we can directly + * encode, like an Integer or a String (see + * {@link SerialUtils#decode(String)}. + *

+ * Custom objects and arrays are also considered supported here, but + * compound objects are not. + *

+ * For compound objects, you should use {@link Importer}. + * + * @param encodedValue + * the encoded data, cannot be NULL + * + * @return the object (can be NULL for NULL encoded values) + * + * @throws IOException + * if the content cannot be converted + */ + static Object decode(BufferedInputStream encodedValue) throws IOException { + if (CustomSerializer.isCustom(encodedValue)) { + // custom^TYPE^ENCODED_VALUE + NextableInputStream content = new NextableInputStream(encodedValue, + new NextableInputStreamStep('^')); + try { + content.next(); + @SuppressWarnings("unused") + String custom = IOUtils.readSmallStream(content); + content.next(); + String type = IOUtils.readSmallStream(content); + content.nextAll(); + if (customTypes.containsKey(type)) { + return customTypes.get(type).decode(content); + } + content.end(); + throw new IOException("Unknown custom type: " + type); + } finally { + content.close(false); + encodedValue.end(); + } + } + + String encodedString = IOUtils.readSmallStream(encodedValue); + return decode(encodedString); + } + + /** + * Decode the data into an equivalent supported source object. + *

+ * A supported object in this context means an object we can directly + * encode, like an Integer or a String. + *

+ * For custom objects and arrays, you should use + * {@link SerialUtils#decode(InputStream)} or directly {@link Importer}. + *

+ * For compound objects, you should use {@link Importer}. + * + * @param encodedValue + * the encoded data, cannot be NULL + * + * @return the object (can be NULL for NULL encoded values) + * + * @throws IOException + * if the content cannot be converted + */ + static Object decode(String encodedValue) throws IOException { + try { + String cut = ""; + if (encodedValue.length() > 1) { + cut = encodedValue.substring(1); + } + + if (encodedValue.equals("NULL") || encodedValue.equals("null")) { + return null; + } else if (encodedValue.startsWith("\"")) { + return decodeString(encodedValue); + } else if (encodedValue.equals("true")) { + return true; + } else if (encodedValue.equals("false")) { + return false; + } else if (encodedValue.startsWith("b")) { + return Byte.parseByte(cut); + } else if (encodedValue.startsWith("c")) { + return decodeString(cut).charAt(0); + } else if (encodedValue.startsWith("s")) { + return Short.parseShort(cut); + } else if (encodedValue.startsWith("l")) { + return Long.parseLong(cut); + } else if (encodedValue.startsWith("f")) { + return Float.parseFloat(cut); + } else if (encodedValue.startsWith("d")) { + return Double.parseDouble(cut); + } else if (encodedValue.startsWith("i")) { + return Integer.parseInt(cut); + } else if (encodedValue.startsWith("E:")) { + cut = cut.substring(1); + return decodeEnum(cut); + } else { + throw new IOException("Unrecognized value: " + encodedValue); + } + } catch (Exception e) { + if (e instanceof IOException) { + throw (IOException) e; + } + throw new IOException(e.getMessage(), e); + } + } + + /** + * Write the given {@link String} into the given {@link OutputStream} in + * UTF-8. + * + * @param out + * the {@link OutputStream} + * @param data + * the data to write, cannot be NULL + * + * @throws IOException + * in case of I/O error + */ + static void write(OutputStream out, Object data) throws IOException { + out.write(StringUtils.getBytes(data.toString())); + } + + /** + * Return the corresponding class or throw an {@link Exception} if it + * cannot. + * + * @param type + * the class name to look for + * + * @return the class (will never be NULL) + * + * @throws ClassNotFoundException + * if the class cannot be found + * @throws NoSuchMethodException + * if the class cannot be created (usually because it or its + * enclosing class doesn't have an empty constructor) + */ + static private Class getClass(String type) + throws ClassNotFoundException, NoSuchMethodException { + Class clazz = null; + try { + clazz = Class.forName(type); + } catch (ClassNotFoundException e) { + int pos = type.length(); + pos = type.lastIndexOf(".", pos); + if (pos >= 0) { + String parentType = type.substring(0, pos); + String nestedType = type.substring(pos + 1); + Class javaParent = null; + try { + javaParent = getClass(parentType); + parentType = javaParent.getName(); + clazz = Class.forName(parentType + "$" + nestedType); + } catch (Exception ee) { + } + + if (javaParent == null) { + throw new NoSuchMethodException( + "Class not found: " + + type + + " (the enclosing class cannot be created: maybe it doesn't have an empty constructor?)"); + } + } + } + + if (clazz == null) { + throw new ClassNotFoundException("Class not found: " + type); + } + + return clazz; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + static private Enum decodeEnum(String escaped) { + // escaped: be.xxx.EnumType.VALUE; + int pos = escaped.lastIndexOf("."); + String type = escaped.substring(0, pos); + String name = escaped.substring(pos + 1, escaped.length() - 1); + + try { + return Enum.valueOf((Class) getClass(type), name); + } catch (Exception e) { + throw new UnknownFormatConversionException("Unknown enum: <" + type + + "> " + name); + } + } + + // aa bb -> "aa\tbb" + static void encodeString(OutputStream out, String raw) throws IOException { + // TODO: not. efficient. + out.write('\"'); + for (char car : raw.toCharArray()) { + encodeString(out, car); + } + out.write('\"'); + } + + // for encoding string, NOT to encode a char by itself! + static void encodeString(OutputStream out, char raw) throws IOException { + switch (raw) { + case '\\': + out.write('\\'); + out.write('\\'); + break; + case '\r': + out.write('\\'); + out.write('r'); + break; + case '\n': + out.write('\\'); + out.write('n'); + break; + case '"': + out.write('\\'); + out.write('\"'); + break; + default: + out.write(raw); + break; + } + } + + // "aa\tbb" -> aa bb + static String decodeString(String escaped) { + StringBuilder builder = new StringBuilder(); + + boolean escaping = false; + for (char car : escaped.toCharArray()) { + if (!escaping) { + if (car == '\\') { + escaping = true; + } else { + builder.append(car); + } + } else { + switch (car) { + case '\\': + builder.append('\\'); + break; + case 'r': + builder.append('\r'); + break; + case 'n': + builder.append('\n'); + break; + case '"': + builder.append('"'); + break; + } + escaping = false; + } + } + + return builder.substring(1, builder.length() - 1); + } +} diff --git a/src/be/nikiroo/utils/serial/server/ConnectAction.java b/src/be/nikiroo/utils/serial/server/ConnectAction.java new file mode 100644 index 0000000..6a19368 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectAction.java @@ -0,0 +1,474 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; + +import javax.net.ssl.SSLException; + +import be.nikiroo.utils.CryptUtils; +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.Version; +import be.nikiroo.utils.serial.Exporter; +import be.nikiroo.utils.serial.Importer; +import be.nikiroo.utils.streams.BufferedOutputStream; +import be.nikiroo.utils.streams.NextableInputStream; +import be.nikiroo.utils.streams.NextableInputStreamStep; +import be.nikiroo.utils.streams.ReplaceInputStream; +import be.nikiroo.utils.streams.ReplaceOutputStream; + +/** + * Base class used for the client/server basic handling. + *

+ * It represents a single action: a client is expected to only execute one + * action, while a server is expected to execute one action for each client + * action. + * + * @author niki + */ +abstract class ConnectAction { + // We separate each "packet" we send with this character and make sure it + // does not occurs in the message itself. + static private char STREAM_SEP = '\b'; + static private String[] STREAM_RAW = new String[] { "\\", "\b" }; + static private String[] STREAM_CODED = new String[] { "\\\\", "\\b" }; + + private Socket s; + private boolean server; + + private Version clientVersion; + private Version serverVersion; + + private CryptUtils crypt; + + private Object lock = new Object(); + private NextableInputStream in; + private BufferedOutputStream out; + private boolean contentToSend; + + /** + * Method that will be called when an action is performed on either the + * client or server this {@link ConnectAction} represent. + * + * @param version + * the version on the other side of the communication (client or + * server) + * + * @throws Exception + * in case of I/O error + */ + abstract protected void action(Version version) throws Exception; + + /** + * Method called when we negotiate the version with the client. + *

+ * Thus, it is only called on the server. + *

+ * Will return the actual server version by default. + * + * @param clientVersion + * the client version + * + * @return the version to send to the client + */ + abstract protected Version negotiateVersion(Version clientVersion); + + /** + * Handler called when an unexpected error occurs in the code. + * + * @param e + * the exception that occurred, SSLException usually denotes a + * crypt error + */ + abstract protected void onError(Exception e); + + /** + * Create a new {@link ConnectAction}. + * + * @param s + * the socket to bind to + * @param server + * TRUE for a server action, FALSE for a client action (will + * impact the process) + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param version + * the client-or-server version (depending upon the boolean + * parameter server) + */ + protected ConnectAction(Socket s, boolean server, String key, + Version version) { + this.s = s; + this.server = server; + if (key != null) { + crypt = new CryptUtils(key); + } + + if (version == null) { + version = new Version(); + } + + if (server) { + serverVersion = version; + } else { + clientVersion = version; + } + } + + /** + * The version of this client-or-server. + * + * @return the version + */ + public Version getVersion() { + if (server) { + return serverVersion; + } + + return clientVersion; + } + + /** + * The total amount of bytes received. + * + * @return the amount of bytes received + */ + public long getBytesReceived() { + return in.getBytesRead(); + } + + /** + * The total amount of bytes sent. + * + * @return the amount of bytes sent + */ + public long getBytesWritten() { + return out.getBytesWritten(); + } + + /** + * Actually start the process (this is synchronous). + */ + public void connect() { + try { + in = new NextableInputStream(s.getInputStream(), + new NextableInputStreamStep(STREAM_SEP)); + try { + out = new BufferedOutputStream(s.getOutputStream()); + try { + // Negotiate version + Version version; + if (server) { + String HELLO = recString(); + if (HELLO == null || !HELLO.startsWith("VERSION ")) { + throw new SSLException( + "Client used bad encryption key"); + } + version = negotiateVersion(new Version( + HELLO.substring("VERSION ".length()))); + sendString("VERSION " + version); + } else { + String HELLO = sendString("VERSION " + clientVersion); + if (HELLO == null || !HELLO.startsWith("VERSION ")) { + throw new SSLException( + "Server did not accept the encryption key"); + } + version = new Version(HELLO.substring("VERSION " + .length())); + } + + // Actual code + action(version); + } finally { + out.close(); + } + } finally { + in.close(); + } + } catch (Exception e) { + onError(e); + } finally { + try { + s.close(); + } catch (Exception e) { + onError(e); + } + } + } + + /** + * Serialise and send the given object to the counter part (and, only for + * client, return the deserialised answer -- the server will always receive + * NULL). + * + * @param data + * the data to send + * + * @return the answer (which can be NULL if no answer, or NULL for an answer + * which is NULL) if this action is a client, always NULL if it is a + * server + * + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + */ + protected Object sendObject(Object data) throws IOException, + NoSuchFieldException, NoSuchMethodException, ClassNotFoundException { + return send(out, data, false); + } + + /** + * Reserved for the server: flush the data to the client and retrieve its + * answer. + *

+ * Also used internally for the client (only do something if there is + * contentToSend). + *

+ * Will only flush the data if there is contentToSend. + * + * @return the deserialised answer (which can actually be NULL) + * + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + * @throws java.lang.NullPointerException + * if the counter part has no data to send + */ + protected Object recObject() throws IOException, NoSuchFieldException, + NoSuchMethodException, ClassNotFoundException, + java.lang.NullPointerException { + return rec(false); + } + + /** + * Send the given string to the counter part (and, only for client, return + * the answer -- the server will always receive NULL). + * + * @param line + * the data to send (we will add a line feed) + * + * @return the answer if this action is a client (without the added line + * feed), NULL if it is a server + * + * @throws IOException + * in case of I/O error + * @throws SSLException + * in case of crypt error + */ + protected String sendString(String line) throws IOException { + try { + return (String) send(out, line, true); + } catch (NoSuchFieldException e) { + // Cannot happen + e.printStackTrace(); + } catch (NoSuchMethodException e) { + // Cannot happen + e.printStackTrace(); + } catch (ClassNotFoundException e) { + // Cannot happen + e.printStackTrace(); + } + + return null; + } + + /** + * Reserved for the server (externally): flush the data to the client and + * retrieve its answer. + *

+ * Also used internally for the client (only do something if there is + * contentToSend). + *

+ * Will only flush the data if there is contentToSend. + * + * @return the answer (which can be NULL if no more content) + * + * @throws IOException + * in case of I/O error + * @throws SSLException + * in case of crypt error + */ + protected String recString() throws IOException { + try { + return (String) rec(true); + } catch (NoSuchFieldException e) { + // Cannot happen + e.printStackTrace(); + } catch (NoSuchMethodException e) { + // Cannot happen + e.printStackTrace(); + } catch (ClassNotFoundException e) { + // Cannot happen + e.printStackTrace(); + } catch (NullPointerException e) { + // Should happen + e.printStackTrace(); + } + + return null; + } + + /** + * Serialise and send the given object to the counter part (and, only for + * client, return the deserialised answer -- the server will always receive + * NULL). + * + * @param out + * the stream to write to + * @param data + * the data to write + * @param asString + * TRUE to write it as a String, FALSE to write it as an Object + * + * @return the answer (which can be NULL if no answer, or NULL for an answer + * which is NULL) if this action is a client, always NULL if it is a + * server + * + * @throws IOException + * in case of I/O error + * @throws SSLException + * in case of crypt error + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + */ + private Object send(BufferedOutputStream out, Object data, boolean asString) + throws IOException, NoSuchFieldException, NoSuchMethodException, + ClassNotFoundException, java.lang.NullPointerException { + + synchronized (lock) { + OutputStream sub; + if (crypt != null) { + sub = crypt.encrypt64(out.open()); + } else { + sub = out.open(); + } + + sub = new ReplaceOutputStream(sub, STREAM_RAW, STREAM_CODED); + try { + if (asString) { + sub.write(StringUtils.getBytes(data.toString())); + } else { + new Exporter(sub).append(data); + } + } finally { + sub.close(); + } + + out.write(STREAM_SEP); + + if (server) { + out.flush(); + return null; + } + + contentToSend = true; + try { + return rec(asString); + } catch (NullPointerException e) { + // We accept no data here for Objects + } + + return null; + } + } + + /** + * Reserved for the server: flush the data to the client and retrieve its + * answer. + *

+ * Also used internally for the client (only do something if there is + * contentToSend). + *

+ * Will only flush the data if there is contentToSend. + *

+ * Note that the behaviour is slightly different for String and Object + * reading regarding exceptions: + *

    + *
  • NULL means that the counter part has no more data to send
  • + *
  • All the exceptions except {@link IOException} are there for Object + * conversion
  • + *
+ * + * @param asString + * TRUE for String reading, FALSE for Object reading (which can + * still be a String) + * + * @return the deserialised answer (which can actually be NULL) + * + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + * @throws java.lang.NullPointerException + * for Objects only: if the counter part has no data to send + */ + @SuppressWarnings("resource") + private Object rec(boolean asString) throws IOException, + NoSuchFieldException, NoSuchMethodException, + ClassNotFoundException, java.lang.NullPointerException { + + synchronized (lock) { + if (server || contentToSend) { + if (contentToSend) { + out.flush(); + contentToSend = false; + } + + if (in.next() && !in.eof()) { + InputStream read = new ReplaceInputStream(in.open(), + STREAM_CODED, STREAM_RAW); + try { + if (crypt != null) { + read = crypt.decrypt64(read); + } + + if (asString) { + return IOUtils.readSmallStream(read); + } + + return new Importer().read(read).getValue(); + } finally { + read.close(); + } + } + + if (!asString) { + throw new NullPointerException(); + } + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionClient.java b/src/be/nikiroo/utils/serial/server/ConnectActionClient.java new file mode 100644 index 0000000..cb6bef3 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectActionClient.java @@ -0,0 +1,166 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.Version; + +/** + * Base class used for the client basic handling. + *

+ * It represents a single action: a client is expected to only execute one + * action. + * + * @author niki + */ +abstract class ConnectActionClient { + /** + * The underlying {@link ConnectAction}. + *

+ * Cannot be NULL. + */ + protected ConnectAction action; + + /** + * Create a new {@link ConnectActionClient}, using the current version of + * the program. + * + * @param host + * the host to bind to + * @param port + * the port to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the host is not known + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ConnectActionClient(String host, int port, String key) + throws IOException { + this(host, port, key, Version.getCurrentVersion()); + } + + /** + * Create a new {@link ConnectActionClient}. + * + * @param host + * the host to bind to + * @param port + * the port to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param clientVersion + * the client version + * + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the host is not known + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ConnectActionClient(String host, int port, String key, + Version clientVersion) throws IOException { + this(new Socket(host, port), key, clientVersion); + } + + /** + * Create a new {@link ConnectActionClient}, using the current version of + * the program. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + */ + public ConnectActionClient(Socket s, String key) { + this(s, key, Version.getCurrentVersion()); + } + + /** + * Create a new {@link ConnectActionClient}. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param clientVersion + * the client version + */ + public ConnectActionClient(Socket s, String key, Version clientVersion) { + action = new ConnectAction(s, false, key, clientVersion) { + @Override + protected void action(Version serverVersion) throws Exception { + ConnectActionClient.this.action(serverVersion); + } + + @Override + protected void onError(Exception e) { + ConnectActionClient.this.onError(e); + } + + @Override + protected Version negotiateVersion(Version clientVersion) { + new Exception("Should never be called on a client") + .printStackTrace(); + return null; + } + }; + } + + /** + * Actually start the process and call the action (synchronous). + */ + public void connect() { + action.connect(); + } + + /** + * Actually start the process and call the action (asynchronous). + */ + public void connectAsync() { + new Thread(new Runnable() { + @Override + public void run() { + connect(); + } + }).start(); + } + + /** + * Method that will be called when an action is performed on the client. + * + * @param serverVersion + * the version of the server connected to this client + * + * @throws Exception + * in case of I/O error + */ + @SuppressWarnings("unused") + public void action(Version serverVersion) throws Exception { + } + + /** + * Handler called when an unexpected error occurs in the code. + *

+ * Will just ignore the error by default. + * + * @param e + * the exception that occurred + */ + protected void onError(@SuppressWarnings("unused") Exception e) { + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java b/src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java new file mode 100644 index 0000000..9385645 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectActionClientObject.java @@ -0,0 +1,175 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.Version; + +/** + * Class used for the client basic handling. + *

+ * It represents a single action: a client is expected to only execute one + * action. + * + * @author niki + */ +public class ConnectActionClientObject extends ConnectActionClient { + /** + * Create a new {@link ConnectActionClientObject} . + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + */ + public ConnectActionClientObject(Socket s, String key) { + super(s, key); + } + + /** + * Create a new {@link ConnectActionClientObject} . + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param clientVersion + * the version of the client + */ + public ConnectActionClientObject(Socket s, String key, Version clientVersion) { + super(s, key, clientVersion); + } + + /** + * Create a new {@link ConnectActionClientObject}. + * + * @param host + * the host to bind to + * @param port + * the port to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ConnectActionClientObject(String host, int port, String key) + throws IOException { + super(host, port, key); + } + + /** + * Create a new {@link ConnectActionClientObject}. + * + * @param host + * the host to bind to + * @param port + * the port to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param clientVersion + * the version of the client + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ConnectActionClientObject(String host, int port, String key, + Version clientVersion) throws IOException { + super(host, port, key, clientVersion); + } + + /** + * Serialise and send the given object to the server (and return the + * deserialised answer). + * + * @param data + * the data to send + * + * @return the answer, which can be NULL + * + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + */ + public Object send(Object data) throws IOException, NoSuchFieldException, + NoSuchMethodException, ClassNotFoundException { + return action.sendObject(data); + } + + // Deprecated // + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ConnectActionClientObject(String host, int port, boolean ssl) + throws IOException { + this(host, port, ssl ? "" : null); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ConnectActionClientObject(String host, int port, boolean ssl, + Version version) throws IOException { + this(host, port, ssl ? "" : null, version); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @SuppressWarnings("unused") + @Deprecated + public ConnectActionClientObject(Socket s, boolean ssl) throws IOException { + this(s, ssl ? "" : null); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @SuppressWarnings("unused") + @Deprecated + public ConnectActionClientObject(Socket s, boolean ssl, Version version) + throws IOException { + this(s, ssl ? "" : null, version); + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionClientString.java b/src/be/nikiroo/utils/serial/server/ConnectActionClientString.java new file mode 100644 index 0000000..3005cee --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectActionClientString.java @@ -0,0 +1,165 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.Version; + +/** + * Class used for the client basic handling. + *

+ * It represents a single action: a client is expected to only execute one + * action. + * + * @author niki + */ +public class ConnectActionClientString extends ConnectActionClient { + /** + * Create a new {@link ConnectActionClientString}. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + */ + public ConnectActionClientString(Socket s, String key) { + super(s, key); + } + + /** + * Create a new {@link ConnectActionClientString}. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param clientVersion + * the version of this client + */ + public ConnectActionClientString(Socket s, String key, Version clientVersion) { + super(s, key, clientVersion); + } + + /** + * Create a new {@link ConnectActionClientString}. + * + * @param host + * the host to bind to + * @param port + * the port to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ConnectActionClientString(String host, int port, String key) + throws IOException { + super(host, port, key); + } + + /** + * Create a new {@link ConnectActionClientString}. + * + * @param host + * the host to bind to + * @param port + * the port to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param clientVersion + * the version of this client + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ConnectActionClientString(String host, int port, String key, + Version clientVersion) throws IOException { + super(host, port, key, clientVersion); + } + + /** + * Send the given object to the server (and return the answer). + * + * @param data + * the data to send + * + * @return the answer, which can be NULL + * + * @throws IOException + * in case of I/O error + */ + public String send(String data) throws IOException { + return action.sendString(data); + } + + // Deprecated // + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ConnectActionClientString(String host, int port, boolean ssl) + throws IOException { + this(host, port, ssl ? "" : null); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ConnectActionClientString(String host, int port, boolean ssl, + Version version) throws IOException { + this(host, port, ssl ? "" : null, version); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @SuppressWarnings("unused") + @Deprecated + public ConnectActionClientString(Socket s, boolean ssl) throws IOException { + this(s, ssl ? "" : null); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @SuppressWarnings("unused") + @Deprecated + public ConnectActionClientString(Socket s, boolean ssl, Version version) + throws IOException { + this(s, ssl ? "" : null, version); + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionServer.java b/src/be/nikiroo/utils/serial/server/ConnectActionServer.java new file mode 100644 index 0000000..350d3fe --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectActionServer.java @@ -0,0 +1,171 @@ +package be.nikiroo.utils.serial.server; + +import java.net.Socket; + +import be.nikiroo.utils.Version; + +/** + * Base class used for the server basic handling. + *

+ * It represents a single action: a server is expected to execute one action for + * each client action. + * + * @author niki + */ +abstract class ConnectActionServer { + private boolean closing; + + /** + * The underlying {@link ConnectAction}. + *

+ * Cannot be NULL. + */ + protected ConnectAction action; + + /** + * Create a new {@link ConnectActionServer}, using the current version. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + */ + public ConnectActionServer(Socket s, String key) { + this(s, key, Version.getCurrentVersion()); + } + + /** + * Create a new {@link ConnectActionServer}. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param serverVersion + * the version of this server,that will be sent to the client + */ + public ConnectActionServer(Socket s, String key, Version serverVersion) { + action = new ConnectAction(s, true, key, serverVersion) { + @Override + protected void action(Version clientVersion) throws Exception { + ConnectActionServer.this.action(clientVersion); + } + + @Override + protected void onError(Exception e) { + ConnectActionServer.this.onError(e); + } + + @Override + protected Version negotiateVersion(Version clientVersion) { + return ConnectActionServer.this.negotiateVersion(clientVersion); + } + }; + } + + /** + * Actually start the process and call the action (synchronous). + */ + public void connect() { + action.connect(); + } + + /** + * Actually start the process and call the action (asynchronous). + */ + public void connectAsync() { + new Thread(new Runnable() { + @Override + public void run() { + connect(); + } + }).start(); + } + + /** + * Stop the client/server connection on behalf of the server (usually, the + * client connects then is allowed to send as many requests as it wants; in + * some cases, though, the server may wish to forcefully close the + * connection and can do via this value, when it is set to TRUE). + *

+ * Example of usage: the client failed an authentication check, cut the + * connection here and now. + * + * @return TRUE when it is + */ + public boolean isClosing() { + return closing; + } + + /** + * Can be called to stop the client/server connection on behalf of the + * server (usually, the client connects then is allowed to send as many + * requests as it wants; in some cases, though, the server may wish to + * forcefully close the connection and can do so by calling this method). + *

+ * Example of usage: the client failed an authentication check, cut the + * connection here and now. + */ + public void close() { + closing = true; + } + + /** + * The total amount of bytes received. + * + * @return the amount of bytes received + */ + public long getBytesReceived() { + return action.getBytesReceived(); + } + + /** + * The total amount of bytes sent. + * + * @return the amount of bytes sent + */ + public long getBytesSent() { + return action.getBytesWritten(); + } + + /** + * Method that will be called when an action is performed on the server. + * + * @param clientVersion + * the version of the client connected to this server + * + * @throws Exception + * in case of I/O error + */ + @SuppressWarnings("unused") + public void action(Version clientVersion) throws Exception { + } + + /** + * Handler called when an unexpected error occurs in the code. + *

+ * Will just ignore the error by default. + * + * @param e + * the exception that occurred + */ + protected void onError(@SuppressWarnings("unused") Exception e) { + } + + /** + * Method called when we negotiate the version with the client. + *

+ * Will return the actual server version by default. + * + * @param clientVersion + * the client version + * + * @return the version to send to the client + */ + protected Version negotiateVersion( + @SuppressWarnings("unused") Version clientVersion) { + return action.getVersion(); + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java b/src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java new file mode 100644 index 0000000..07d9867 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectActionServerObject.java @@ -0,0 +1,72 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; + +/** + * Class used for the server basic handling. + *

+ * It represents a single action: a server is expected to execute one action for + * each client action. + * + * @author niki + */ +public class ConnectActionServerObject extends ConnectActionServer { + /** + * Create a new {@link ConnectActionServerObject} as the server version. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + */ + public ConnectActionServerObject(Socket s, String key) { + super(s, key); + } + + /** + * Serialise and send the given object to the client. + * + * @param data + * the data to send + * + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + */ + public void send(Object data) throws IOException, NoSuchFieldException, + NoSuchMethodException, ClassNotFoundException { + action.sendObject(data); + } + + /** + * (Flush the data to the client if needed and) retrieve its answer. + * + * @return the deserialised answer (which can actually be NULL) + * + * @throws IOException + * in case of I/O error + * @throws NoSuchFieldException + * if the serialised data contains information about a field + * which does actually not exist in the class we know of + * @throws NoSuchMethodException + * if a class described in the serialised data cannot be created + * because it is not compatible with this code + * @throws ClassNotFoundException + * if a class described in the serialised data cannot be found + * @throws java.lang.NullPointerException + * if the counter part has no data to send + */ + public Object rec() throws NoSuchFieldException, NoSuchMethodException, + ClassNotFoundException, IOException, java.lang.NullPointerException { + return action.recObject(); + } +} diff --git a/src/be/nikiroo/utils/serial/server/ConnectActionServerString.java b/src/be/nikiroo/utils/serial/server/ConnectActionServerString.java new file mode 100644 index 0000000..8d113c1 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ConnectActionServerString.java @@ -0,0 +1,52 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; + +/** + * Class used for the server basic handling. + *

+ * It represents a single action: a server is expected to execute one action for + * each client action. + * + * @author niki + */ +public class ConnectActionServerString extends ConnectActionServer { + /** + * Create a new {@link ConnectActionServerString} as the server version. + * + * @param s + * the socket to bind to + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + */ + public ConnectActionServerString(Socket s, String key) { + super(s, key); + } + + /** + * Serialise and send the given object to the client. + * + * @param data + * the data to send + * + * @throws IOException + * in case of I/O error + */ + public void send(String data) throws IOException { + action.sendString(data); + } + + /** + * (Flush the data to the client if needed and) retrieve its answer. + * + * @return the answer if it is available, or NULL if not + * + * @throws IOException + * in case of I/O error + */ + public String rec() throws IOException { + return action.recString(); + } +} diff --git a/src/be/nikiroo/utils/serial/server/Server.java b/src/be/nikiroo/utils/serial/server/Server.java new file mode 100644 index 0000000..0470159 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/Server.java @@ -0,0 +1,419 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.TraceHandler; + +/** + * This class implements a simple server that can listen for connections and + * send/receive objects. + *

+ * Note: this {@link Server} has to be discarded after use (cannot be started + * twice). + * + * @author niki + */ +abstract class Server implements Runnable { + protected final String key; + protected long id = 0; + + private final String name; + private final Object lock = new Object(); + private final Object counterLock = new Object(); + + private ServerSocket ss; + private int port; + + private boolean started; + private boolean exiting = false; + private int counter; + + private long bytesReceived; + private long bytesSent; + + private TraceHandler tracer = new TraceHandler(); + + /** + * Create a new {@link ConnectActionServer} to handle a request. + * + * @param s + * the socket to service + * + * @return the action + */ + abstract ConnectActionServer createConnectActionServer(Socket s); + + /** + * Create a new server that will start listening on the network when + * {@link Server#start()} is called. + * + * @param port + * the port to listen on, or 0 to assign any unallocated port + * found (which can later on be queried via + * {@link Server#getPort()} + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public Server(int port, String key) throws IOException { + this((String) null, port, key); + } + + /** + * Create a new server that will start listening on the network when + * {@link Server#start()} is called. + *

+ * All the communications will happen in plain text. + * + * @param name + * the server name (only used for debug info and traces) + * @param port + * the port to listen on + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public Server(String name, int port) throws IOException { + this(name, port, null); + } + + /** + * Create a new server that will start listening on the network when + * {@link Server#start()} is called. + * + * @param name + * the server name (only used for debug info and traces) + * @param port + * the port to listen on + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public Server(String name, int port, String key) throws IOException { + this.name = name; + this.port = port; + this.key = key; + this.ss = new ServerSocket(port); + + if (this.port == 0) { + this.port = this.ss.getLocalPort(); + } + } + + /** + * The traces handler for this {@link Server}. + * + * @return the traces handler + */ + public TraceHandler getTraceHandler() { + return tracer; + } + + /** + * The traces handler for this {@link Server}. + * + * @param tracer + * the new traces handler + */ + public void setTraceHandler(TraceHandler tracer) { + if (tracer == null) { + tracer = new TraceHandler(false, false, false); + } + + this.tracer = tracer; + } + + /** + * The name of this {@link Server} if any. + *

+ * Used for traces and debug purposes only. + * + * @return the name or NULL + */ + public String getName() { + return name; + } + + /** + * Return the assigned port. + * + * @return the assigned port + */ + public int getPort() { + return port; + } + + /** + * The total amount of bytes received. + * + * @return the amount of bytes received + */ + public long getBytesReceived() { + return bytesReceived; + } + + /** + * The total amount of bytes sent. + * + * @return the amount of bytes sent + */ + public long getBytesSent() { + return bytesSent; + } + + /** + * Start the server (listen on the network for new connections). + *

+ * Can only be called once. + *

+ * This call is asynchronous, and will just start a new {@link Thread} on + * itself (see {@link Server#run()}). + */ + public void start() { + new Thread(this).start(); + } + + /** + * Start the server (listen on the network for new connections). + *

+ * Can only be called once. + *

+ * You may call it via {@link Server#start()} for an asynchronous call, too. + */ + @Override + public void run() { + ServerSocket ss = null; + boolean alreadyStarted = false; + synchronized (lock) { + ss = this.ss; + if (!started && ss != null) { + started = true; + } else { + alreadyStarted = started; + } + } + + if (alreadyStarted) { + tracer.error(name + ": cannot start server on port " + port + + ", it is already started"); + return; + } + + if (ss == null) { + tracer.error(name + ": cannot start server on port " + port + + ", it has already been used"); + return; + } + + try { + tracer.trace(name + ": server starting on port " + port + " (" + + (key != null ? "encrypted" : "plain text") + ")"); + + while (started && !exiting) { + count(1); + final Socket s = ss.accept(); + new Thread(new Runnable() { + @Override + public void run() { + ConnectActionServer action = null; + try { + action = createConnectActionServer(s); + action.connect(); + } finally { + count(-1); + if (action != null) { + bytesReceived += action.getBytesReceived(); + bytesSent += action.getBytesSent(); + } + } + } + }).start(); + } + + // Will be covered by @link{Server#stop(long)} for timeouts + while (counter > 0) { + Thread.sleep(10); + } + } catch (Exception e) { + if (counter > 0) { + onError(e); + } + } finally { + try { + ss.close(); + } catch (Exception e) { + onError(e); + } + + this.ss = null; + + started = false; + exiting = false; + counter = 0; + + tracer.trace(name + ": client terminated on port " + port); + } + } + + /** + * Will stop the server, synchronously and without a timeout. + */ + public void stop() { + tracer.trace(name + ": stopping server"); + stop(0, true); + } + + /** + * Stop the server. + * + * @param timeout + * the maximum timeout to wait for existing actions to complete, + * or 0 for "no timeout" + * @param wait + * wait for the server to be stopped before returning + * (synchronous) or not (asynchronous) + */ + public void stop(final long timeout, final boolean wait) { + if (wait) { + stop(timeout); + } else { + new Thread(new Runnable() { + @Override + public void run() { + stop(timeout); + } + }).start(); + } + } + + /** + * Stop the server (synchronous). + * + * @param timeout + * the maximum timeout to wait for existing actions to complete, + * or 0 for "no timeout" + */ + private void stop(long timeout) { + tracer.trace(name + ": server stopping on port " + port); + synchronized (lock) { + if (started && !exiting) { + exiting = true; + + try { + getConnectionToMe().connect(); + long time = 0; + while (ss != null && timeout > 0 && timeout > time) { + Thread.sleep(10); + time += 10; + } + } catch (Exception e) { + if (ss != null) { + counter = 0; // will stop the main thread + onError(e); + } + } + } + } + + // return only when stopped + while (started || exiting) { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + } + } + } + + /** + * Return a connection to this server (used by the Exit code to send an exit + * message). + * + * @return the connection + * + * @throws UnknownHostException + * the host should always be NULL (localhost) + * @throws IOException + * in case of I/O error + */ + abstract protected ConnectActionClient getConnectionToMe() + throws UnknownHostException, IOException; + + /** + * Change the number of currently serviced actions. + * + * @param change + * the number to increase or decrease + * + * @return the current number after this operation + */ + private int count(int change) { + synchronized (counterLock) { + counter += change; + return counter; + } + } + + /** + * This method will be called on errors. + *

+ * By default, it will only call the trace handler (so you may want to call + * super {@link Server#onError} if you override it). + * + * @param e + * the error + */ + protected void onError(Exception e) { + tracer.error(e); + } + + /** + * Return the next ID to use. + * + * @return the next ID + */ + protected synchronized long getNextId() { + return id++; + } + + /** + * Method called when + * {@link ServerObject#onRequest(ConnectActionServerObject, Object, long)} + * has successfully finished. + *

+ * Can be used to know how much data was transmitted. + * + * @param id + * the ID used to identify the request + * @param bytesReceived + * the bytes received during the request + * @param bytesSent + * the bytes sent during the request + */ + @SuppressWarnings("unused") + protected void onRequestDone(long id, long bytesReceived, long bytesSent) { + } +} diff --git a/src/be/nikiroo/utils/serial/server/ServerBridge.java b/src/be/nikiroo/utils/serial/server/ServerBridge.java new file mode 100644 index 0000000..0b734c6 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ServerBridge.java @@ -0,0 +1,292 @@ +package be.nikiroo.utils.serial.server; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Array; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.TraceHandler; +import be.nikiroo.utils.Version; +import be.nikiroo.utils.serial.Importer; + +/** + * This class implements a simple server that can bridge two other + * {@link Server}s. + *

+ * It can, of course, inspect the data that goes through it (by default, it + * prints traces of the data). + *

+ * Note: this {@link ServerBridge} has to be discarded after use (cannot be + * started twice). + * + * @author niki + */ +public class ServerBridge extends Server { + private final String forwardToHost; + private final int forwardToPort; + private final String forwardToKey; + + /** + * Create a new server that will start listening on the network when + * {@link ServerBridge#start()} is called. + * + * @param port + * the port to listen on, or 0 to assign any unallocated port + * found (which can later on be queried via + * {@link ServerBridge#getPort()} + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param forwardToHost + * the host server to forward the calls to + * @param forwardToPort + * the host port to forward the calls to + * @param forwardToKey + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ServerBridge(int port, String key, String forwardToHost, + int forwardToPort, String forwardToKey) throws IOException { + super(port, key); + this.forwardToHost = forwardToHost; + this.forwardToPort = forwardToPort; + this.forwardToKey = forwardToKey; + } + + /** + * Create a new server that will start listening on the network when + * {@link ServerBridge#start()} is called. + * + * @param name + * the server name (only used for debug info and traces) + * @param port + * the port to listen on + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * @param forwardToHost + * the host server to forward the calls to + * @param forwardToPort + * the host port to forward the calls to + * @param forwardToKey + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) use an SSL connection + * for the forward server or not + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ServerBridge(String name, int port, String key, + String forwardToHost, int forwardToPort, String forwardToKey) + throws IOException { + super(name, port, key); + this.forwardToHost = forwardToHost; + this.forwardToPort = forwardToPort; + this.forwardToKey = forwardToKey; + } + + /** + * The traces handler for this {@link Server}. + *

+ * The trace levels are handled as follow: + *

    + *
  • 1: it will only print basic IN/OUT messages with length
  • + *
  • 2: it will try to interpret it as an object (SLOW) and print the + * object class if possible
  • + *
  • 3: it will try to print the {@link Object#toString()} value, or the + * data if it is not an object
  • + *
  • 4: it will also print the unzipped serialised value if it is an + * object
  • + *
+ * + * @param tracer + * the new traces handler + */ + @Override + public void setTraceHandler(TraceHandler tracer) { + super.setTraceHandler(tracer); + } + + @Override + protected ConnectActionServer createConnectActionServer(Socket s) { + // Bad impl, not up to date (should work, but not efficient) + return new ConnectActionServerString(s, key) { + @Override + public void action(Version clientVersion) throws Exception { + onClientContact(clientVersion); + final ConnectActionServerString bridge = this; + + try { + new ConnectActionClientString(forwardToHost, forwardToPort, + forwardToKey) { + @Override + public void action(Version serverVersion) + throws Exception { + onServerContact(serverVersion); + + for (String fromClient = bridge.rec(); fromClient != null; fromClient = bridge + .rec()) { + onRec(fromClient); + String fromServer = send(fromClient); + onSend(fromServer); + bridge.send(fromServer); + } + + getTraceHandler().trace("=== DONE", 1); + getTraceHandler().trace("", 1); + } + + @Override + protected void onError(Exception e) { + ServerBridge.this.onError(e); + } + }.connect(); + } catch (Exception e) { + ServerBridge.this.onError(e); + } + } + }; + } + + /** + * This is the method that is called each time a client contact us. + */ + protected void onClientContact(Version clientVersion) { + getTraceHandler().trace(">>> CLIENT " + clientVersion); + } + + /** + * This is the method that is called each time a client contact us. + */ + protected void onServerContact(Version serverVersion) { + getTraceHandler().trace("<<< SERVER " + serverVersion); + getTraceHandler().trace(""); + } + + /** + * This is the method that is called each time a client contact us. + * + * @param data + * the data sent by the client + */ + protected void onRec(String data) { + trace(">>> CLIENT", data); + } + + /** + * This is the method that is called each time the forwarded server contact + * us. + * + * @param data + * the data sent by the client + */ + protected void onSend(String data) { + trace("<<< SERVER", data); + } + + @Override + protected ConnectActionClient getConnectionToMe() + throws UnknownHostException, IOException { + return new ConnectActionClientString(new Socket((String) null, + getPort()), key); + } + + @Override + public void run() { + getTraceHandler().trace( + getName() + ": will forward to " + forwardToHost + ":" + + forwardToPort + " (" + + (forwardToKey != null ? "encrypted" : "plain text") + + ")"); + super.run(); + } + + /** + * Trace the data with the given prefix. + * + * @param prefix + * the prefix (client, server, version...) + * @param data + * the data to trace + */ + private void trace(String prefix, String data) { + int size = data == null ? 0 : data.length(); + String ssize = StringUtils.formatNumber(size) + "bytes"; + + getTraceHandler().trace(prefix + ": " + ssize, 1); + + if (getTraceHandler().getTraceLevel() >= 2) { + try { + while (data.startsWith("ZIP:") || data.startsWith("B64:")) { + if (data.startsWith("ZIP:")) { + data = StringUtils.unzip64s(data.substring(4)); + } else if (data.startsWith("B64:")) { + data = StringUtils.unzip64s(data.substring(4)); + } + } + + InputStream stream = new ByteArrayInputStream( + StringUtils.getBytes(data)); + try { + Object obj = new Importer().read(stream).getValue(); + if (obj == null) { + getTraceHandler().trace("NULL", 2); + getTraceHandler().trace("NULL", 3); + getTraceHandler().trace("NULL", 4); + } else { + if (obj.getClass().isArray()) { + getTraceHandler().trace( + "(" + obj.getClass() + ") with " + + Array.getLength(obj) + + "element(s)", 3); + } else { + getTraceHandler().trace("(" + obj.getClass() + ")", + 2); + } + getTraceHandler().trace("" + obj.toString(), 3); + getTraceHandler().trace(data, 4); + } + } finally { + stream.close(); + } + } catch (NoSuchMethodException e) { + getTraceHandler().trace("(not an object)", 2); + getTraceHandler().trace(data, 3); + getTraceHandler().trace("", 4); + } catch (NoSuchFieldException e) { + getTraceHandler().trace( + "(incompatible: " + e.getMessage() + ")", 2); + getTraceHandler().trace(data, 3); + getTraceHandler().trace("", 4); + } catch (ClassNotFoundException e) { + getTraceHandler().trace( + "(unknown object: " + e.getMessage() + ")", 2); + getTraceHandler().trace(data, 3); + getTraceHandler().trace("", 4); + } catch (Exception e) { + getTraceHandler().trace( + "(decode error: " + e.getMessage() + ")", 2); + getTraceHandler().trace(data, 3); + getTraceHandler().trace("", 4); + } + + getTraceHandler().trace("", 2); + } + } +} diff --git a/src/be/nikiroo/utils/serial/server/ServerObject.java b/src/be/nikiroo/utils/serial/server/ServerObject.java new file mode 100644 index 0000000..a6a5dd1 --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ServerObject.java @@ -0,0 +1,180 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.Version; + +/** + * This class implements a simple server that can listen for connections and + * send/receive objects. + *

+ * Note: this {@link ServerObject} has to be discarded after use (cannot be + * started twice). + * + * @author niki + */ +abstract public class ServerObject extends Server { + /** + * Create a new server that will start listening on the network when + * {@link ServerObject#start()} is called. + * + * @param port + * the port to listen on, or 0 to assign any unallocated port + * found (which can later on be queried via + * {@link ServerObject#getPort()} + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ServerObject(int port, String key) throws IOException { + super(port, key); + } + + /** + * Create a new server that will start listening on the network when + * {@link ServerObject#start()} is called. + * + * @param name + * the server name (only used for debug info and traces) + * @param port + * the port to listen on + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ServerObject(String name, int port, String key) throws IOException { + super(name, port, key); + } + + @Override + protected ConnectActionServer createConnectActionServer(Socket s) { + return new ConnectActionServerObject(s, key) { + @Override + public void action(Version clientVersion) throws Exception { + long id = getNextId(); + try { + for (Object data = rec(); true; data = rec()) { + Object rep = null; + try { + rep = onRequest(this, clientVersion, data, id); + if (isClosing()) { + return; + } + } catch (Exception e) { + onError(e); + } + + send(rep); + } + } catch (NullPointerException e) { + // Client has no data any more, we quit + onRequestDone(id, getBytesReceived(), getBytesSent()); + } + } + + @Override + protected void onError(Exception e) { + ServerObject.this.onError(e); + } + }; + } + + @Override + protected ConnectActionClient getConnectionToMe() + throws UnknownHostException, IOException { + return new ConnectActionClientObject(new Socket((String) null, + getPort()), key); + } + + /** + * This is the method that is called on each client request. + *

+ * You are expected to react to it and return an answer (which can be NULL). + * + * @param action + * the client action + * @param data + * the data sent by the client (which can be NULL) + * @param id + * an ID to identify this request (will also be re-used for + * {@link ServerObject#onRequestDone(long, long, long)}. + * + * @return the answer to return to the client (which can be NULL) + * + * @throws Exception + * in case of an exception, the error will only be logged + */ + protected Object onRequest(ConnectActionServerObject action, + Version clientVersion, Object data, + @SuppressWarnings("unused") long id) throws Exception { + // TODO: change to abstract when deprecated method is removed + // Default implementation for compat + return onRequest(action, clientVersion, data); + } + + // Deprecated // + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ServerObject(int port, boolean ssl) throws IOException { + this(port, ssl ? "" : null); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ServerObject(String name, int port, boolean ssl) throws IOException { + this(name, port, ssl ? "" : null); + } + + /** + * Will be called if the correct version is not overrided. + * + * @deprecated use the version with the id. + * + * @param action + * the client action + * @param data + * the data sent by the client + * + * @return the answer to return to the client + * + * @throws Exception + * in case of an exception, the error will only be logged + */ + @Deprecated + @SuppressWarnings("unused") + protected Object onRequest(ConnectActionServerObject action, + Version version, Object data) throws Exception { + return null; + } +} diff --git a/src/be/nikiroo/utils/serial/server/ServerString.java b/src/be/nikiroo/utils/serial/server/ServerString.java new file mode 100644 index 0000000..3c982fd --- /dev/null +++ b/src/be/nikiroo/utils/serial/server/ServerString.java @@ -0,0 +1,183 @@ +package be.nikiroo.utils.serial.server; + +import java.io.IOException; +import java.net.Socket; +import java.net.UnknownHostException; + +import be.nikiroo.utils.Version; + +/** + * This class implements a simple server that can listen for connections and + * send/receive Strings. + *

+ * Note: this {@link ServerString} has to be discarded after use (cannot be + * started twice). + * + * @author niki + */ +abstract public class ServerString extends Server { + /** + * Create a new server that will start listening on the network when + * {@link ServerString#start()} is called. + * + * @param port + * the port to listen on, or 0 to assign any unallocated port + * found (which can later on be queried via + * {@link ServerString#getPort()} + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ServerString(int port, String key) throws IOException { + super(port, key); + } + + /** + * Create a new server that will start listening on the network when + * {@link ServerString#start()} is called. + * + * @param name + * the server name (only used for debug info and traces) + * @param port + * the port to listen on + * @param key + * an optional key to encrypt all the communications (if NULL, + * everything will be sent in clear text) + * + * @throws IOException + * in case of I/O error + * @throws UnknownHostException + * if the IP address of the host could not be determined + * @throws IllegalArgumentException + * if the port parameter is outside the specified range of valid + * port values, which is between 0 and 65535, inclusive + */ + public ServerString(String name, int port, String key) throws IOException { + super(name, port, key); + } + + @Override + protected ConnectActionServer createConnectActionServer(Socket s) { + return new ConnectActionServerString(s, key) { + @Override + public void action(Version clientVersion) throws Exception { + long id = getNextId(); + for (String data = rec(); data != null; data = rec()) { + String rep = null; + try { + rep = onRequest(this, clientVersion, data, id); + if (isClosing()) { + return; + } + } catch (Exception e) { + onError(e); + } + + if (rep == null) { + rep = ""; + } + send(rep); + } + + onRequestDone(id, getBytesReceived(), getBytesSent()); + } + + @Override + protected void onError(Exception e) { + ServerString.this.onError(e); + } + }; + } + + @Override + protected ConnectActionClient getConnectionToMe() + throws UnknownHostException, IOException { + return new ConnectActionClientString(new Socket((String) null, + getPort()), key); + } + + /** + * This is the method that is called on each client request. + *

+ * You are expected to react to it and return an answer (NULL will be + * converted to an empty {@link String}). + * + * @param action + * the client action + * @param clientVersion + * the client version + * @param data + * the data sent by the client + * @param id + * an ID to identify this request (will also be re-used for + * {@link ServerObject#onRequestDone(long, long, long)}. + * + * @return the answer to return to the client + * + * @throws Exception + * in case of an exception, the error will only be logged + */ + protected String onRequest(ConnectActionServerString action, + Version clientVersion, String data, + @SuppressWarnings("unused") long id) throws Exception { + // TODO: change to abstract when deprecated method is removed + // Default implementation for compat + return onRequest(action, clientVersion, data); + } + + // Deprecated // + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ServerString(int port, boolean ssl) throws IOException { + this(port, ssl ? "" : null); + } + + /** + * @deprecated SSL support has been replaced by key-based encryption. + *

+ * Please use the version with key encryption (this deprecated + * version uses an empty key when ssl is TRUE and no + * key (NULL) when ssl is FALSE). + */ + @Deprecated + public ServerString(String name, int port, boolean ssl) throws IOException { + this(name, port, ssl ? "" : null); + } + + /** + * Will be called if the correct version is not overrided. + * + * @deprecated use the version with the id. + * + * @param action + * the client action + * @param data + * the data sent by the client + * + * @return the answer to return to the client + * + * @throws Exception + * in case of an exception, the error will only be logged + */ + @Deprecated + @SuppressWarnings("unused") + protected String onRequest(ConnectActionServerString action, + Version version, String data) throws Exception { + return null; + } +} diff --git a/src/be/nikiroo/utils/streams/Base64.java b/src/be/nikiroo/utils/streams/Base64.java new file mode 100644 index 0000000..d54794b --- /dev/null +++ b/src/be/nikiroo/utils/streams/Base64.java @@ -0,0 +1,752 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * 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. + */ + +/* + * Changes (@author niki): + * - default charset -> UTF-8 + */ + +package be.nikiroo.utils.streams; + +import java.io.UnsupportedEncodingException; + +import be.nikiroo.utils.StringUtils; + +/** + * Utilities for encoding and decoding the Base64 representation of + * binary data. See RFCs 2045 and 3548. + */ +class Base64 { + /** + * Default values for encoder/decoder flags. + */ + public static final int DEFAULT = 0; + + /** + * Encoder flag bit to omit the padding '=' characters at the end + * of the output (if any). + */ + public static final int NO_PADDING = 1; + + /** + * Encoder flag bit to omit all line terminators (i.e., the output + * will be on one long line). + */ + public static final int NO_WRAP = 2; + + /** + * Encoder flag bit to indicate lines should be terminated with a + * CRLF pair instead of just an LF. Has no effect if {@code + * NO_WRAP} is specified as well. + */ + public static final int CRLF = 4; + + /** + * Encoder/decoder flag bit to indicate using the "URL and + * filename safe" variant of Base64 (see RFC 3548 section 4) where + * {@code -} and {@code _} are used in place of {@code +} and + * {@code /}. + */ + public static final int URL_SAFE = 8; + + /** + * Flag to pass to {@link Base64OutputStream} to indicate that it + * should not close the output stream it is wrapping when it + * itself is closed. + */ + public static final int NO_CLOSE = 16; + + // -------------------------------------------------------- + // shared code + // -------------------------------------------------------- + + /* package */ static abstract class Coder { + public byte[] output; + public int op; + + /** + * Encode/decode another block of input data. this.output is + * provided by the caller, and must be big enough to hold all + * the coded data. On exit, this.opwill be set to the length + * of the coded data. + * + * @param finish true if this is the final call to process for + * this object. Will finalize the coder state and + * include any final bytes in the output. + * + * @return true if the input so far is good; false if some + * error has been detected in the input stream.. + */ + public abstract boolean process(byte[] input, int offset, int len, boolean finish); + + /** + * @return the maximum number of bytes a call to process() + * could produce for the given number of input bytes. This may + * be an overestimate. + */ + public abstract int maxOutputSize(int len); + } + + // -------------------------------------------------------- + // decoding + // -------------------------------------------------------- + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param str the input String to decode, which is converted to + * bytes using the default charset + * @param flags controls certain features of the decoded output. + * Pass {@code DEFAULT} to decode standard Base64. + * + * @throws IllegalArgumentException if the input contains + * incorrect padding + */ + public static byte[] decode(String str, int flags) { + return decode(StringUtils.getBytes(str), flags); + } + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param input the input array to decode + * @param flags controls certain features of the decoded output. + * Pass {@code DEFAULT} to decode standard Base64. + * + * @throws IllegalArgumentException if the input contains + * incorrect padding + */ + public static byte[] decode(byte[] input, int flags) { + return decode(input, 0, input.length, flags); + } + + /** + * Decode the Base64-encoded data in input and return the data in + * a new byte array. + * + *

The padding '=' characters at the end are considered optional, but + * if any are present, there must be the correct number of them. + * + * @param input the data to decode + * @param offset the position within the input array at which to start + * @param len the number of bytes of input to decode + * @param flags controls certain features of the decoded output. + * Pass {@code DEFAULT} to decode standard Base64. + * + * @throws IllegalArgumentException if the input contains + * incorrect padding + */ + public static byte[] decode(byte[] input, int offset, int len, int flags) { + // Allocate space for the most data the input could represent. + // (It could contain less if it contains whitespace, etc.) + Decoder decoder = new Decoder(flags, new byte[len*3/4]); + + if (!decoder.process(input, offset, len, true)) { + throw new IllegalArgumentException("bad base-64"); + } + + // Maybe we got lucky and allocated exactly enough output space. + if (decoder.op == decoder.output.length) { + return decoder.output; + } + + // Need to shorten the array, so allocate a new one of the + // right size and copy. + byte[] temp = new byte[decoder.op]; + System.arraycopy(decoder.output, 0, temp, 0, decoder.op); + return temp; + } + + /* package */ static class Decoder extends Coder { + /** + * Lookup table for turning bytes into their position in the + * Base64 alphabet. + */ + private static final int DECODE[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + /** + * Decode lookup table for the "web safe" variant (RFC 3548 + * sec. 4) where - and _ replace + and /. + */ + private static final int DECODE_WEBSAFE[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -2, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + }; + + /** Non-data values in the DECODE arrays. */ + private static final int SKIP = -1; + private static final int EQUALS = -2; + + /** + * States 0-3 are reading through the next input tuple. + * State 4 is having read one '=' and expecting exactly + * one more. + * State 5 is expecting no more data or padding characters + * in the input. + * State 6 is the error state; an error has been detected + * in the input and no future input can "fix" it. + */ + private int state; // state number (0 to 6) + private int value; + + final private int[] alphabet; + + public Decoder(int flags, byte[] output) { + this.output = output; + + alphabet = ((flags & URL_SAFE) == 0) ? DECODE : DECODE_WEBSAFE; + state = 0; + value = 0; + } + + /** + * @return an overestimate for the number of bytes {@code + * len} bytes could decode to. + */ + @Override + public int maxOutputSize(int len) { + return len * 3/4 + 10; + } + + /** + * Decode another block of input data. + * + * @return true if the state machine is still healthy. false if + * bad base-64 data has been detected in the input stream. + */ + @Override + public boolean process(byte[] input, int offset, int len, boolean finish) { + if (this.state == 6) return false; + + int p = offset; + len += offset; + + // Using local variables makes the decoder about 12% + // faster than if we manipulate the member variables in + // the loop. (Even alphabet makes a measurable + // difference, which is somewhat surprising to me since + // the member variable is final.) + int state = this.state; + int value = this.value; + int op = 0; + final byte[] output = this.output; + final int[] alphabet = this.alphabet; + + while (p < len) { + // Try the fast path: we're starting a new tuple and the + // next four bytes of the input stream are all data + // bytes. This corresponds to going through states + // 0-1-2-3-0. We expect to use this method for most of + // the data. + // + // If any of the next four bytes of input are non-data + // (whitespace, etc.), value will end up negative. (All + // the non-data values in decode are small negative + // numbers, so shifting any of them up and or'ing them + // together will result in a value with its top bit set.) + // + // You can remove this whole block and the output should + // be the same, just slower. + if (state == 0) { + while (p+4 <= len && + (value = ((alphabet[input[p] & 0xff] << 18) | + (alphabet[input[p+1] & 0xff] << 12) | + (alphabet[input[p+2] & 0xff] << 6) | + (alphabet[input[p+3] & 0xff]))) >= 0) { + output[op+2] = (byte) value; + output[op+1] = (byte) (value >> 8); + output[op] = (byte) (value >> 16); + op += 3; + p += 4; + } + if (p >= len) break; + } + + // The fast path isn't available -- either we've read a + // partial tuple, or the next four input bytes aren't all + // data, or whatever. Fall back to the slower state + // machine implementation. + + int d = alphabet[input[p++] & 0xff]; + + switch (state) { + case 0: + if (d >= 0) { + value = d; + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 1: + if (d >= 0) { + value = (value << 6) | d; + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 2: + if (d >= 0) { + value = (value << 6) | d; + ++state; + } else if (d == EQUALS) { + // Emit the last (partial) output tuple; + // expect exactly one more padding character. + output[op++] = (byte) (value >> 4); + state = 4; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 3: + if (d >= 0) { + // Emit the output triple and return to state 0. + value = (value << 6) | d; + output[op+2] = (byte) value; + output[op+1] = (byte) (value >> 8); + output[op] = (byte) (value >> 16); + op += 3; + state = 0; + } else if (d == EQUALS) { + // Emit the last (partial) output tuple; + // expect no further data or padding characters. + output[op+1] = (byte) (value >> 2); + output[op] = (byte) (value >> 10); + op += 2; + state = 5; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 4: + if (d == EQUALS) { + ++state; + } else if (d != SKIP) { + this.state = 6; + return false; + } + break; + + case 5: + if (d != SKIP) { + this.state = 6; + return false; + } + break; + } + } + + if (!finish) { + // We're out of input, but a future call could provide + // more. + this.state = state; + this.value = value; + this.op = op; + return true; + } + + // Done reading input. Now figure out where we are left in + // the state machine and finish up. + + switch (state) { + case 0: + // Output length is a multiple of three. Fine. + break; + case 1: + // Read one extra input byte, which isn't enough to + // make another output byte. Illegal. + this.state = 6; + return false; + case 2: + // Read two extra input bytes, enough to emit 1 more + // output byte. Fine. + output[op++] = (byte) (value >> 4); + break; + case 3: + // Read three extra input bytes, enough to emit 2 more + // output bytes. Fine. + output[op++] = (byte) (value >> 10); + output[op++] = (byte) (value >> 2); + break; + case 4: + // Read one padding '=' when we expected 2. Illegal. + this.state = 6; + return false; + case 5: + // Read all the padding '='s we expected and no more. + // Fine. + break; + } + + this.state = state; + this.op = op; + return true; + } + } + + // -------------------------------------------------------- + // encoding + // -------------------------------------------------------- + + /** + * Base64-encode the given data and return a newly allocated + * String with the result. + * + * @param input the data to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + */ + public static String encodeToString(byte[] input, int flags) { + try { + return new String(encode(input, flags), "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // US-ASCII is guaranteed to be available. + throw new AssertionError(e); + } + } + + /** + * Base64-encode the given data and return a newly allocated + * String with the result. + * + * @param input the data to encode + * @param offset the position within the input array at which to + * start + * @param len the number of bytes of input to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + */ + public static String encodeToString(byte[] input, int offset, int len, int flags) { + try { + return new String(encode(input, offset, len, flags), "US-ASCII"); + } catch (UnsupportedEncodingException e) { + // US-ASCII is guaranteed to be available. + throw new AssertionError(e); + } + } + + /** + * Base64-encode the given data and return a newly allocated + * byte[] with the result. + * + * @param input the data to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + */ + public static byte[] encode(byte[] input, int flags) { + return encode(input, 0, input.length, flags); + } + + /** + * Base64-encode the given data and return a newly allocated + * byte[] with the result. + * + * @param input the data to encode + * @param offset the position within the input array at which to + * start + * @param len the number of bytes of input to encode + * @param flags controls certain features of the encoded output. + * Passing {@code DEFAULT} results in output that + * adheres to RFC 2045. + */ + public static byte[] encode(byte[] input, int offset, int len, int flags) { + Encoder encoder = new Encoder(flags, null); + + // Compute the exact length of the array we will produce. + int output_len = len / 3 * 4; + + // Account for the tail of the data and the padding bytes, if any. + if (encoder.do_padding) { + if (len % 3 > 0) { + output_len += 4; + } + } else { + switch (len % 3) { + case 0: break; + case 1: output_len += 2; break; + case 2: output_len += 3; break; + } + } + + // Account for the newlines, if any. + if (encoder.do_newline && len > 0) { + output_len += (((len-1) / (3 * Encoder.LINE_GROUPS)) + 1) * + (encoder.do_cr ? 2 : 1); + } + + encoder.output = new byte[output_len]; + encoder.process(input, offset, len, true); + + assert encoder.op == output_len; + + return encoder.output; + } + + /* package */ static class Encoder extends Coder { + /** + * Emit a new line every this many output tuples. Corresponds to + * a 76-character line length (the maximum allowable according to + * RFC 2045). + */ + public static final int LINE_GROUPS = 19; + + /** + * Lookup table for turning Base64 alphabet positions (6 bits) + * into output bytes. + */ + private static final byte ENCODE[] = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/', + }; + + /** + * Lookup table for turning Base64 alphabet positions (6 bits) + * into output bytes. + */ + private static final byte ENCODE_WEBSAFE[] = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', + }; + + final private byte[] tail; + /* package */ int tailLen; + private int count; + + final public boolean do_padding; + final public boolean do_newline; + final public boolean do_cr; + final private byte[] alphabet; + + public Encoder(int flags, byte[] output) { + this.output = output; + + do_padding = (flags & NO_PADDING) == 0; + do_newline = (flags & NO_WRAP) == 0; + do_cr = (flags & CRLF) != 0; + alphabet = ((flags & URL_SAFE) == 0) ? ENCODE : ENCODE_WEBSAFE; + + tail = new byte[2]; + tailLen = 0; + + count = do_newline ? LINE_GROUPS : -1; + } + + /** + * @return an overestimate for the number of bytes {@code + * len} bytes could encode to. + */ + @Override + public int maxOutputSize(int len) { + return len * 8/5 + 10; + } + + @Override + public boolean process(byte[] input, int offset, int len, boolean finish) { + // Using local variables makes the encoder about 9% faster. + final byte[] alphabet = this.alphabet; + final byte[] output = this.output; + int op = 0; + int count = this.count; + + int p = offset; + len += offset; + int v = -1; + + // First we need to concatenate the tail of the previous call + // with any input bytes available now and see if we can empty + // the tail. + + switch (tailLen) { + case 0: + // There was no tail. + break; + + case 1: + if (p+2 <= len) { + // A 1-byte tail with at least 2 bytes of + // input available now. + v = ((tail[0] & 0xff) << 16) | + ((input[p++] & 0xff) << 8) | + (input[p++] & 0xff); + tailLen = 0; + } + break; + + case 2: + if (p+1 <= len) { + // A 2-byte tail with at least 1 byte of input. + v = ((tail[0] & 0xff) << 16) | + ((tail[1] & 0xff) << 8) | + (input[p++] & 0xff); + tailLen = 0; + } + break; + } + + if (v != -1) { + output[op++] = alphabet[(v >> 18) & 0x3f]; + output[op++] = alphabet[(v >> 12) & 0x3f]; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (--count == 0) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + count = LINE_GROUPS; + } + } + + // At this point either there is no tail, or there are fewer + // than 3 bytes of input available. + + // The main loop, turning 3 input bytes into 4 output bytes on + // each iteration. + while (p+3 <= len) { + v = ((input[p] & 0xff) << 16) | + ((input[p+1] & 0xff) << 8) | + (input[p+2] & 0xff); + output[op] = alphabet[(v >> 18) & 0x3f]; + output[op+1] = alphabet[(v >> 12) & 0x3f]; + output[op+2] = alphabet[(v >> 6) & 0x3f]; + output[op+3] = alphabet[v & 0x3f]; + p += 3; + op += 4; + if (--count == 0) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + count = LINE_GROUPS; + } + } + + if (finish) { + // Finish up the tail of the input. Note that we need to + // consume any bytes in tail before any bytes + // remaining in input; there should be at most two bytes + // total. + + if (p-tailLen == len-1) { + int t = 0; + v = ((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 4; + tailLen -= t; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (do_padding) { + output[op++] = '='; + output[op++] = '='; + } + if (do_newline) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + } + } else if (p-tailLen == len-2) { + int t = 0; + v = (((tailLen > 1 ? tail[t++] : input[p++]) & 0xff) << 10) | + (((tailLen > 0 ? tail[t++] : input[p++]) & 0xff) << 2); + tailLen -= t; + output[op++] = alphabet[(v >> 12) & 0x3f]; + output[op++] = alphabet[(v >> 6) & 0x3f]; + output[op++] = alphabet[v & 0x3f]; + if (do_padding) { + output[op++] = '='; + } + if (do_newline) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + } + } else if (do_newline && op > 0 && count != LINE_GROUPS) { + if (do_cr) output[op++] = '\r'; + output[op++] = '\n'; + } + + assert tailLen == 0; + assert p == len; + } else { + // Save the leftovers in tail to be consumed on the next + // call to encodeInternal. + + if (p == len-1) { + tail[tailLen++] = input[p]; + } else if (p == len-2) { + tail[tailLen++] = input[p]; + tail[tailLen++] = input[p+1]; + } + } + + this.op = op; + this.count = count; + + return true; + } + } + + private Base64() { } // don't instantiate +} diff --git a/src/be/nikiroo/utils/streams/Base64InputStream.java b/src/be/nikiroo/utils/streams/Base64InputStream.java new file mode 100644 index 0000000..a3afaef --- /dev/null +++ b/src/be/nikiroo/utils/streams/Base64InputStream.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * 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 be.nikiroo.utils.streams; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * An InputStream that does Base64 decoding on the data read through + * it. + */ +public class Base64InputStream extends FilterInputStream { + private final Base64.Coder coder; + + private static byte[] EMPTY = new byte[0]; + + private static final int BUFFER_SIZE = 2048; + private boolean eof; + private byte[] inputBuffer; + private int outputStart; + private int outputEnd; + + /** + * An InputStream that performs Base64 decoding on the data read + * from the wrapped stream. + * + * @param in the InputStream to read the source data from + */ + public Base64InputStream(InputStream in) { + this(in, false); + } + + /** + * Performs Base64 encoding or decoding on the data read from the + * wrapped InputStream. + * + * @param in the InputStream to read the source data from + * @param flags bit flags for controlling the decoder; see the + * constants in {@link Base64} + * @param encode true to encode, false to decode + * + * @hide + */ + public Base64InputStream(InputStream in, boolean encode) { + super(in); + eof = false; + inputBuffer = new byte[BUFFER_SIZE]; + if (encode) { + coder = new Base64.Encoder(Base64.NO_WRAP, null); + } else { + coder = new Base64.Decoder(Base64.NO_WRAP, null); + } + coder.output = new byte[coder.maxOutputSize(BUFFER_SIZE)]; + outputStart = 0; + outputEnd = 0; + } + + @Override + public boolean markSupported() { + return false; + } + + @SuppressWarnings("sync-override") + @Override + public void mark(int readlimit) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("sync-override") + @Override + public void reset() { + throw new UnsupportedOperationException(); + } + + @Override + public void close() throws IOException { + in.close(); + inputBuffer = null; + } + + @Override + public int available() { + return outputEnd - outputStart; + } + + @Override + public long skip(long n) throws IOException { + if (outputStart >= outputEnd) { + refill(); + } + if (outputStart >= outputEnd) { + return 0; + } + long bytes = Math.min(n, outputEnd-outputStart); + outputStart += bytes; + return bytes; + } + + @Override + public int read() throws IOException { + if (outputStart >= outputEnd) { + refill(); + } + if (outputStart >= outputEnd) { + return -1; + } + + return coder.output[outputStart++] & 0xff; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (outputStart >= outputEnd) { + refill(); + } + if (outputStart >= outputEnd) { + return -1; + } + int bytes = Math.min(len, outputEnd-outputStart); + System.arraycopy(coder.output, outputStart, b, off, bytes); + outputStart += bytes; + return bytes; + } + + /** + * Read data from the input stream into inputBuffer, then + * decode/encode it into the empty coder.output, and reset the + * outputStart and outputEnd pointers. + */ + private void refill() throws IOException { + if (eof) return; + int bytesRead = in.read(inputBuffer); + boolean success; + if (bytesRead == -1) { + eof = true; + success = coder.process(EMPTY, 0, 0, true); + } else { + success = coder.process(inputBuffer, 0, bytesRead, false); + } + if (!success) { + throw new IOException("bad base-64"); + } + outputEnd = coder.op; + outputStart = 0; + } +} diff --git a/src/be/nikiroo/utils/streams/Base64OutputStream.java b/src/be/nikiroo/utils/streams/Base64OutputStream.java new file mode 100644 index 0000000..ab4e457 --- /dev/null +++ b/src/be/nikiroo/utils/streams/Base64OutputStream.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * 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 be.nikiroo.utils.streams; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * An OutputStream that does Base64 encoding on the data written to + * it, writing the resulting data to another OutputStream. + */ +public class Base64OutputStream extends FilterOutputStream { + private final Base64.Coder coder; + private final int flags; + + private byte[] buffer = null; + private int bpos = 0; + + private static byte[] EMPTY = new byte[0]; + + /** + * Performs Base64 encoding on the data written to the stream, + * writing the encoded data to another OutputStream. + * + * @param out the OutputStream to write the encoded data to + */ + public Base64OutputStream(OutputStream out) { + this(out, true); + } + + /** + * Performs Base64 encoding or decoding on the data written to the + * stream, writing the encoded/decoded data to another + * OutputStream. + * + * @param out the OutputStream to write the encoded data to + * @param encode true to encode, false to decode + * + * @hide + */ + public Base64OutputStream(OutputStream out, boolean encode) { + super(out); + this.flags = Base64.NO_WRAP; + if (encode) { + coder = new Base64.Encoder(flags, null); + } else { + coder = new Base64.Decoder(flags, null); + } + } + + @Override + public void write(int b) throws IOException { + // To avoid invoking the encoder/decoder routines for single + // bytes, we buffer up calls to write(int) in an internal + // byte array to transform them into writes of decently-sized + // arrays. + + if (buffer == null) { + buffer = new byte[1024]; + } + if (bpos >= buffer.length) { + // internal buffer full; write it out. + internalWrite(buffer, 0, bpos, false); + bpos = 0; + } + buffer[bpos++] = (byte) b; + } + + /** + * Flush any buffered data from calls to write(int). Needed + * before doing a write(byte[], int, int) or a close(). + */ + private void flushBuffer() throws IOException { + if (bpos > 0) { + internalWrite(buffer, 0, bpos, false); + bpos = 0; + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (len <= 0) return; + flushBuffer(); + internalWrite(b, off, len, false); + } + + @Override + public void close() throws IOException { + IOException thrown = null; + try { + flushBuffer(); + internalWrite(EMPTY, 0, 0, true); + } catch (IOException e) { + thrown = e; + } + + try { + if ((flags & Base64.NO_CLOSE) == 0) { + out.close(); + } else { + out.flush(); + } + } catch (IOException e) { + if (thrown != null) { + thrown = e; + } + } + + if (thrown != null) { + throw thrown; + } + } + + /** + * Write the given bytes to the encoder/decoder. + * + * @param finish true if this is the last batch of input, to cause + * encoder/decoder state to be finalized. + */ + private void internalWrite(byte[] b, int off, int len, boolean finish) throws IOException { + coder.output = embiggen(coder.output, coder.maxOutputSize(len)); + if (!coder.process(b, off, len, finish)) { + throw new IOException("bad base-64"); + } + out.write(coder.output, 0, coder.op); + } + + /** + * If b.length is at least len, return b. Otherwise return a new + * byte array of length len. + */ + private byte[] embiggen(byte[] b, int len) { + if (b == null || b.length < len) { + return new byte[len]; + } + return b; + } +} diff --git a/src/be/nikiroo/utils/streams/BufferedInputStream.java b/src/be/nikiroo/utils/streams/BufferedInputStream.java new file mode 100644 index 0000000..683fa55 --- /dev/null +++ b/src/be/nikiroo/utils/streams/BufferedInputStream.java @@ -0,0 +1,522 @@ +package be.nikiroo.utils.streams; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; + +import be.nikiroo.utils.StringUtils; + +/** + * A simple {@link InputStream} that is buffered with a bytes array. + *

+ * It is mostly intended to be used as a base class to create new + * {@link InputStream}s with special operation modes, and to give some default + * methods. + * + * @author niki + */ +public class BufferedInputStream extends InputStream { + /** + * The size of the internal buffer (can be different if you pass your own + * buffer, of course). + *

+ * A second buffer of twice the size can sometimes be created as needed for + * the {@link BufferedInputStream#startsWith(byte[])} search operation. + */ + static private final int BUFFER_SIZE = 4096; + + /** The current position in the buffer. */ + protected int start; + /** The index of the last usable position of the buffer. */ + protected int stop; + /** The buffer itself. */ + protected byte[] buffer; + /** An End-Of-File (or {@link InputStream}, here) marker. */ + protected boolean eof; + + private boolean closed; + private InputStream in; + private int openCounter; + + // special use, prefetched next buffer + private byte[] buffer2; + private int pos2; + private int len2; + private byte[] originalBuffer; + + private long bytesRead; + + /** + * Create a new {@link BufferedInputStream} that wraps the given + * {@link InputStream}. + * + * @param in + * the {@link InputStream} to wrap + */ + public BufferedInputStream(InputStream in) { + this.in = in; + + this.buffer = new byte[BUFFER_SIZE]; + this.originalBuffer = this.buffer; + this.start = 0; + this.stop = 0; + } + + /** + * Create a new {@link BufferedInputStream} that wraps the given bytes array + * as a data source. + * + * @param in + * the array to wrap, cannot be NULL + */ + public BufferedInputStream(byte[] in) { + this(in, 0, in.length); + } + + /** + * Create a new {@link BufferedInputStream} that wraps the given bytes array + * as a data source. + * + * @param in + * the array to wrap, cannot be NULL + * @param offset + * the offset to start the reading at + * @param length + * the number of bytes to take into account in the array, + * starting from the offset + * + * @throws NullPointerException + * if the array is NULL + * @throws IndexOutOfBoundsException + * if the offset and length do not correspond to the given array + */ + public BufferedInputStream(byte[] in, int offset, int length) { + if (in == null) { + throw new NullPointerException(); + } else if (offset < 0 || length < 0 || length > in.length - offset) { + throw new IndexOutOfBoundsException(); + } + + this.in = null; + + this.buffer = in; + this.originalBuffer = this.buffer; + this.start = offset; + this.stop = length; + } + + /** + * The internal buffer size (can be useful to know for search methods). + * + * @return the size of the internal buffer, in bytes. + */ + public int getInternalBufferSize() { + return originalBuffer.length; + } + + /** + * Return this very same {@link BufferedInputStream}, but keep a counter of + * how many streams were open this way. When calling + * {@link BufferedInputStream#close()}, decrease this counter if it is not + * already zero instead of actually closing the stream. + *

+ * You are now responsible for it — you must close it. + *

+ * This method allows you to use a wrapping stream around this one and still + * close the wrapping stream. + * + * @return the same stream, but you are now responsible for closing it + * + * @throws IOException + * in case of I/O error or if the stream is closed + */ + public synchronized InputStream open() throws IOException { + checkClose(); + openCounter++; + return this; + } + + /** + * Check if the current content (until eof) is equal to the given search + * term. + *

+ * Note: the search term size must be smaller or equal the internal + * buffer size. + * + * @param search + * the term to search for + * + * @return TRUE if the content that will be read starts with it + * + * @throws IOException + * in case of I/O error or if the size of the search term is + * greater than the internal buffer + */ + public boolean is(String search) throws IOException { + return is(StringUtils.getBytes(search)); + } + + /** + * Check if the current content (until eof) is equal to the given search + * term. + *

+ * Note: the search term size must be smaller or equal the internal + * buffer size. + * + * @param search + * the term to search for + * + * @return TRUE if the content that will be read starts with it + * + * @throws IOException + * in case of I/O error or if the size of the search term is + * greater than the internal buffer + */ + public boolean is(byte[] search) throws IOException { + if (startsWith(search)) { + return (stop - start) == search.length; + } + + return false; + } + + /** + * Check if the current content (what will be read next) starts with the + * given search term. + *

+ * Note: the search term size must be smaller or equal the internal + * buffer size. + * + * @param search + * the term to search for + * + * @return TRUE if the content that will be read starts with it + * + * @throws IOException + * in case of I/O error or if the size of the search term is + * greater than the internal buffer + */ + public boolean startsWith(String search) throws IOException { + return startsWith(StringUtils.getBytes(search)); + } + + /** + * Check if the current content (what will be read next) starts with the + * given search term. + *

+ * An empty string will always return true (unless the stream is closed, + * which would throw an {@link IOException}). + *

+ * Note: the search term size must be smaller or equal the internal + * buffer size. + * + * @param search + * the term to search for + * + * @return TRUE if the content that will be read starts with it + * + * @throws IOException + * in case of I/O error or if the size of the search term is + * greater than the internal buffer + */ + public boolean startsWith(byte[] search) throws IOException { + if (search.length > originalBuffer.length) { + throw new IOException( + "This stream does not support searching for more than " + + buffer.length + " bytes"); + } + + checkClose(); + + if (available() < search.length) { + preRead(); + } + + if (available() >= search.length) { + // Easy path + return StreamUtils.startsWith(search, buffer, start, stop); + } else if (in != null && !eof) { + // Harder path + if (buffer2 == null && buffer.length == originalBuffer.length) { + buffer2 = Arrays.copyOf(buffer, buffer.length * 2); + + pos2 = buffer.length; + len2 = read(in, buffer2, pos2, buffer.length); + if (len2 > 0) { + bytesRead += len2; + } + + // Note: here, len/len2 = INDEX of last good byte + len2 += pos2; + } + + return StreamUtils.startsWith(search, buffer2, pos2, len2); + } + + return false; + } + + /** + * The number of bytes read from the under-laying {@link InputStream}. + * + * @return the number of bytes + */ + public long getBytesRead() { + return bytesRead; + } + + /** + * Check if this stream is spent (no more data to read or to + * process). + * + * @return TRUE if it is + * + * @throws IOException + * in case of I/O error + */ + public boolean eof() throws IOException { + if (closed) { + return true; + } + + preRead(); + return !hasMoreData(); + } + + /** + * Read the whole {@link InputStream} until the end and return the number of + * bytes read. + * + * @return the number of bytes read + * + * @throws IOException + * in case of I/O error + */ + public long end() throws IOException { + long skipped = 0; + while (hasMoreData()) { + skipped += skip(buffer.length); + } + + return skipped; + } + + @Override + public int read() throws IOException { + checkClose(); + + preRead(); + if (eof) { + return -1; + } + + return buffer[start++]; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte[] b, int boff, int blen) throws IOException { + checkClose(); + + if (b == null) { + throw new NullPointerException(); + } else if (boff < 0 || blen < 0 || blen > b.length - boff) { + throw new IndexOutOfBoundsException(); + } else if (blen == 0) { + return 0; + } + + int done = 0; + while (hasMoreData() && done < blen) { + preRead(); + if (hasMoreData()) { + int now = Math.min(blen - done, stop - start); + if (now > 0) { + System.arraycopy(buffer, start, b, boff + done, now); + start += now; + done += now; + } + } + } + + return done > 0 ? done : -1; + } + + @Override + public long skip(long n) throws IOException { + if (n <= 0) { + return 0; + } + + long skipped = 0; + while (hasMoreData() && n > 0) { + preRead(); + + long inBuffer = Math.min(n, available()); + start += inBuffer; + n -= inBuffer; + skipped += inBuffer; + } + + return skipped; + } + + @Override + public int available() { + if (closed) { + return 0; + } + + return Math.max(0, stop - start); + } + + /** + * Closes this stream and releases any system resources associated with the + * stream. + *

+ * Including the under-laying {@link InputStream}. + *

+ * Note: if you called the {@link BufferedInputStream#open()} method + * prior to this one, it will just decrease the internal count of how many + * open streams it held and do nothing else. The stream will actually be + * closed when you have called {@link BufferedInputStream#close()} once more + * than {@link BufferedInputStream#open()}. + * + * @exception IOException + * in case of I/O error + */ + @Override + public synchronized void close() throws IOException { + close(true); + } + + /** + * Closes this stream and releases any system resources associated with the + * stream. + *

+ * Including the under-laying {@link InputStream} if + * incudingSubStream is true. + *

+ * You can call this method multiple times, it will not cause an + * {@link IOException} for subsequent calls. + *

+ * Note: if you called the {@link BufferedInputStream#open()} method + * prior to this one, it will just decrease the internal count of how many + * open streams it held and do nothing else. The stream will actually be + * closed when you have called {@link BufferedInputStream#close()} once more + * than {@link BufferedInputStream#open()}. + * + * @param includingSubStream + * also close the under-laying stream + * + * @exception IOException + * in case of I/O error + */ + public synchronized void close(boolean includingSubStream) + throws IOException { + if (!closed) { + if (openCounter > 0) { + openCounter--; + } else { + closed = true; + if (includingSubStream && in != null) { + in.close(); + } + } + } + } + + /** + * Check if we still have some data in the buffer and, if not, fetch some. + * + * @return TRUE if we fetched some data, FALSE if there are still some in + * the buffer + * + * @throws IOException + * in case of I/O error + */ + protected boolean preRead() throws IOException { + boolean hasRead = false; + if (in != null && !eof && start >= stop) { + start = 0; + if (buffer2 != null) { + buffer = buffer2; + start = pos2; + stop = len2; + + buffer2 = null; + pos2 = 0; + len2 = 0; + } else { + buffer = originalBuffer; + + stop = read(in, buffer, 0, buffer.length); + if (stop > 0) { + bytesRead += stop; + } + } + + hasRead = true; + } + + if (start >= stop) { + eof = true; + } + + return hasRead; + } + + /** + * Read the under-laying stream into the local buffer. + * + * @param in + * the under-laying {@link InputStream} + * @param buffer + * the buffer we use in this {@link BufferedInputStream} + * @param off + * the offset + * @param len + * the length in bytes + * + * @return the number of bytes read + * + * @throws IOException + * in case of I/O error + */ + protected int read(InputStream in, byte[] buffer, int off, int len) + throws IOException { + return in.read(buffer, off, len); + } + + /** + * We have more data available in the buffer or we can, maybe, fetch + * more. + * + * @return TRUE if it is the case, FALSE if not + */ + protected boolean hasMoreData() { + if (closed) { + return false; + } + + return (start < stop) || !eof; + } + + /** + * Check that the stream was not closed, and throw an {@link IOException} if + * it was. + * + * @throws IOException + * if it was closed + */ + protected void checkClose() throws IOException { + if (closed) { + throw new IOException( + "This BufferedInputStream was closed, you cannot use it anymore."); + } + } +} diff --git a/src/be/nikiroo/utils/streams/BufferedOutputStream.java b/src/be/nikiroo/utils/streams/BufferedOutputStream.java new file mode 100644 index 0000000..1442534 --- /dev/null +++ b/src/be/nikiroo/utils/streams/BufferedOutputStream.java @@ -0,0 +1,260 @@ +package be.nikiroo.utils.streams; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * A simple {@link OutputStream} that is buffered with a bytes array. + *

+ * It is mostly intended to be used as a base class to create new + * {@link OutputStream}s with special operation modes, and to give some default + * methods. + * + * @author niki + */ +public class BufferedOutputStream extends OutputStream { + /** The current position in the buffer. */ + protected int start; + /** The index of the last usable position of the buffer. */ + protected int stop; + /** The buffer itself. */ + protected byte[] buffer; + /** An End-Of-File (or buffer, here) marker. */ + protected boolean eof; + /** The actual under-laying stream. */ + protected OutputStream out; + /** The number of bytes written to the under-laying stream. */ + protected long bytesWritten; + /** + * Can bypass the flush process for big writes (will directly write to the + * under-laying buffer if the array to write is > the internal buffer + * size). + *

+ * By default, this is true. + */ + protected boolean bypassFlush = true; + + private boolean closed; + private int openCounter; + private byte[] b1; + + /** + * Create a new {@link BufferedInputStream} that wraps the given + * {@link InputStream}. + * + * @param out + * the {@link OutputStream} to wrap + */ + public BufferedOutputStream(OutputStream out) { + this.out = out; + + this.buffer = new byte[4096]; + this.b1 = new byte[1]; + this.start = 0; + this.stop = 0; + } + + @Override + public void write(int b) throws IOException { + b1[0] = (byte) b; + write(b1, 0, 1); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] source, int sourceOffset, int sourceLength) + throws IOException { + + checkClose(); + + if (source == null) { + throw new NullPointerException(); + } else if ((sourceOffset < 0) || (sourceOffset > source.length) + || (sourceLength < 0) + || ((sourceOffset + sourceLength) > source.length) + || ((sourceOffset + sourceLength) < 0)) { + throw new IndexOutOfBoundsException(); + } else if (sourceLength == 0) { + return; + } + + if (bypassFlush && sourceLength >= buffer.length) { + /* + * If the request length exceeds the size of the output buffer, + * flush the output buffer and then write the data directly. In this + * way buffered streams will cascade harmlessly. + */ + flush(false); + out.write(source, sourceOffset, sourceLength); + bytesWritten += (sourceLength - sourceOffset); + return; + } + + int done = 0; + while (done < sourceLength) { + if (available() <= 0) { + flush(false); + } + + int now = Math.min(sourceLength - done, available()); + System.arraycopy(source, sourceOffset + done, buffer, stop, now); + stop += now; + done += now; + } + } + + /** + * The available space in the buffer. + * + * @return the space in bytes + */ + private int available() { + if (closed) { + return 0; + } + + return Math.max(0, buffer.length - stop - 1); + } + + /** + * The number of bytes written to the under-laying {@link OutputStream}. + * + * @return the number of bytes + */ + public long getBytesWritten() { + return bytesWritten; + } + + /** + * Return this very same {@link BufferedInputStream}, but keep a counter of + * how many streams were open this way. When calling + * {@link BufferedInputStream#close()}, decrease this counter if it is not + * already zero instead of actually closing the stream. + *

+ * You are now responsible for it — you must close it. + *

+ * This method allows you to use a wrapping stream around this one and still + * close the wrapping stream. + * + * @return the same stream, but you are now responsible for closing it + * + * @throws IOException + * in case of I/O error or if the stream is closed + */ + public synchronized OutputStream open() throws IOException { + checkClose(); + openCounter++; + return this; + } + + /** + * Check that the stream was not closed, and throw an {@link IOException} if + * it was. + * + * @throws IOException + * if it was closed + */ + protected void checkClose() throws IOException { + if (closed) { + throw new IOException( + "This BufferedInputStream was closed, you cannot use it anymore."); + } + } + + @Override + public void flush() throws IOException { + flush(true); + } + + /** + * Flush the {@link BufferedOutputStream}, write the current buffered data + * to (and optionally also flush) the under-laying stream. + *

+ * If {@link BufferedOutputStream#bypassFlush} is false, all writes to the + * under-laying stream are done in this method. + *

+ * This can be used if you want to write some data in the under-laying + * stream yourself (in that case, flush this {@link BufferedOutputStream} + * with or without flushing the under-laying stream, then you can write to + * the under-laying stream). + * + * @param includingSubStream + * also flush the under-laying stream + * @throws IOException + * in case of I/O error + */ + public void flush(boolean includingSubStream) throws IOException { + if (stop > start) { + out.write(buffer, start, stop - start); + bytesWritten += (stop - start); + } + start = 0; + stop = 0; + + if (includingSubStream) { + out.flush(); + } + } + + /** + * Closes this stream and releases any system resources associated with the + * stream. + *

+ * Including the under-laying {@link InputStream}. + *

+ * Note: if you called the {@link BufferedInputStream#open()} method + * prior to this one, it will just decrease the internal count of how many + * open streams it held and do nothing else. The stream will actually be + * closed when you have called {@link BufferedInputStream#close()} once more + * than {@link BufferedInputStream#open()}. + * + * @exception IOException + * in case of I/O error + */ + @Override + public synchronized void close() throws IOException { + close(true); + } + + /** + * Closes this stream and releases any system resources associated with the + * stream. + *

+ * Including the under-laying {@link InputStream} if + * incudingSubStream is true. + *

+ * You can call this method multiple times, it will not cause an + * {@link IOException} for subsequent calls. + *

+ * Note: if you called the {@link BufferedInputStream#open()} method + * prior to this one, it will just decrease the internal count of how many + * open streams it held and do nothing else. The stream will actually be + * closed when you have called {@link BufferedInputStream#close()} once more + * than {@link BufferedInputStream#open()}. + * + * @param includingSubStream + * also close the under-laying stream + * + * @exception IOException + * in case of I/O error + */ + public synchronized void close(boolean includingSubStream) + throws IOException { + if (!closed) { + if (openCounter > 0) { + openCounter--; + } else { + closed = true; + flush(includingSubStream); + if (includingSubStream && out != null) { + out.close(); + } + } + } + } +} diff --git a/src/be/nikiroo/utils/streams/MarkableFileInputStream.java b/src/be/nikiroo/utils/streams/MarkableFileInputStream.java new file mode 100644 index 0000000..7622b24 --- /dev/null +++ b/src/be/nikiroo/utils/streams/MarkableFileInputStream.java @@ -0,0 +1,66 @@ +package be.nikiroo.utils.streams; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; + +/** + * This is a markable (and thus reset-able) stream that you can create from a + * FileInputStream. + * + * @author niki + */ +public class MarkableFileInputStream extends FilterInputStream { + private FileChannel channel; + private long mark = 0; + + /** + * Create a new {@link MarkableFileInputStream} from this file. + * + * @param file + * the {@link File} to wrap + * + * @throws FileNotFoundException + * if the {@link File} cannot be found + */ + public MarkableFileInputStream(File file) throws FileNotFoundException { + this(new FileInputStream(file)); + } + + /** + * Create a new {@link MarkableFileInputStream} from this stream. + * + * @param in + * the original {@link FileInputStream} to wrap + */ + public MarkableFileInputStream(FileInputStream in) { + super(in); + channel = in.getChannel(); + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public synchronized void mark(int readlimit) { + try { + mark = channel.position(); + } catch (IOException ex) { + ex.printStackTrace(); + mark = -1; + } + } + + @Override + public synchronized void reset() throws IOException { + if (mark < 0) { + throw new IOException("mark position not valid"); + } + channel.position(mark); + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/streams/NextableInputStream.java b/src/be/nikiroo/utils/streams/NextableInputStream.java new file mode 100644 index 0000000..dcab472 --- /dev/null +++ b/src/be/nikiroo/utils/streams/NextableInputStream.java @@ -0,0 +1,279 @@ +package be.nikiroo.utils.streams; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.util.Arrays; + +/** + * This {@link InputStream} can be separated into sub-streams (you can process + * it as a normal {@link InputStream} but, when it is spent, you can call + * {@link NextableInputStream#next()} on it to unlock new data). + *

+ * The separation in sub-streams is done via {@link NextableInputStreamStep}. + * + * @author niki + */ +public class NextableInputStream extends BufferedInputStream { + private NextableInputStreamStep step; + private boolean started; + private boolean stopped; + + /** + * Create a new {@link NextableInputStream} that wraps the given + * {@link InputStream}. + * + * @param in + * the {@link InputStream} to wrap + * @param step + * how to separate it into sub-streams (can be NULL, but in that + * case it will behave as a normal {@link InputStream}) + */ + public NextableInputStream(InputStream in, NextableInputStreamStep step) { + super(in); + this.step = step; + } + + /** + * Create a new {@link NextableInputStream} that wraps the given bytes array + * as a data source. + * + * @param in + * the array to wrap, cannot be NULL + * @param step + * how to separate it into sub-streams (can be NULL, but in that + * case it will behave as a normal {@link InputStream}) + */ + public NextableInputStream(byte[] in, NextableInputStreamStep step) { + this(in, step, 0, in.length); + } + + /** + * Create a new {@link NextableInputStream} that wraps the given bytes array + * as a data source. + * + * @param in + * the array to wrap, cannot be NULL + * @param step + * how to separate it into sub-streams (can be NULL, but in that + * case it will behave as a normal {@link InputStream}) + * @param offset + * the offset to start the reading at + * @param length + * the number of bytes to take into account in the array, + * starting from the offset + * + * @throws NullPointerException + * if the array is NULL + * @throws IndexOutOfBoundsException + * if the offset and length do not correspond to the given array + */ + public NextableInputStream(byte[] in, NextableInputStreamStep step, + int offset, int length) { + super(in, offset, length); + this.step = step; + checkBuffer(true); + } + + /** + * Unblock the processing of the next sub-stream. + *

+ * It can only be called when the "current" stream is spent (i.e., you must + * first process the stream until it is spent). + *

+ * {@link IOException}s can happen when we have no data available in the + * buffer; in that case, we fetch more data to know if we can have a next + * sub-stream or not. + *

+ * This is can be a blocking call when data need to be fetched. + * + * @return TRUE if we unblocked the next sub-stream, FALSE if not (i.e., + * FALSE when there are no more sub-streams to fetch) + * + * @throws IOException + * in case of I/O error or if the stream is closed + */ + public boolean next() throws IOException { + return next(false); + } + + /** + * Unblock the next sub-stream as would have done + * {@link NextableInputStream#next()}, but disable the sub-stream systems. + *

+ * That is, the next stream, if any, will be the last one and will not be + * subject to the {@link NextableInputStreamStep}. + *

+ * This is can be a blocking call when data need to be fetched. + * + * @return TRUE if we unblocked the next sub-stream, FALSE if not + * + * @throws IOException + * in case of I/O error or if the stream is closed + */ + public boolean nextAll() throws IOException { + return next(true); + } + + /** + * Check if this stream is totally spent (no more data to read or to + * process). + *

+ * Note: when the stream is divided into sub-streams, each sub-stream will + * report its own eof when spent. + * + * @return TRUE if it is + * + * @throws IOException + * in case of I/O error + */ + @Override + public boolean eof() throws IOException { + return super.eof(); + } + + /** + * Check if we still have some data in the buffer and, if not, fetch some. + * + * @return TRUE if we fetched some data, FALSE if there are still some in + * the buffer + * + * @throws IOException + * in case of I/O error + */ + @Override + protected boolean preRead() throws IOException { + if (!stopped) { + boolean bufferChanged = super.preRead(); + checkBuffer(bufferChanged); + return bufferChanged; + } + + if (start >= stop) { + eof = true; + } + + return false; + } + + @Override + protected boolean hasMoreData() { + return started && super.hasMoreData(); + } + + /** + * Check that the buffer didn't overshot to the next item, and fix + * {@link NextableInputStream#stop} if needed. + *

+ * If {@link NextableInputStream#stop} is fixed, + * {@link NextableInputStream#eof} and {@link NextableInputStream#stopped} + * are set to TRUE. + * + * @param newBuffer + * we changed the buffer, we need to clear some information in + * the {@link NextableInputStreamStep} + */ + private void checkBuffer(boolean newBuffer) { + if (step != null && stop >= 0) { + if (newBuffer) { + step.clearBuffer(); + } + + int stopAt = step.stop(buffer, start, stop, eof); + if (stopAt >= 0) { + stop = stopAt; + eof = true; + stopped = true; + } + } + } + + /** + * The implementation of {@link NextableInputStream#next()} and + * {@link NextableInputStream#nextAll()}. + *

+ * This is can be a blocking call when data need to be fetched. + * + * @param all + * TRUE for {@link NextableInputStream#nextAll()}, FALSE for + * {@link NextableInputStream#next()} + * + * @return TRUE if we unblocked the next sub-stream, FALSE if not (i.e., + * FALSE when there are no more sub-streams to fetch) + * + * @throws IOException + * in case of I/O error or if the stream is closed + */ + private boolean next(boolean all) throws IOException { + checkClose(); + + if (!started) { + // First call before being allowed to read + started = true; + + if (all) { + step = null; + } + + return true; + } + + // If started, must be stopped and no more data to continue + // i.e., sub-stream must be spent + if (!stopped || hasMoreData()) { + return false; + } + + if (step != null) { + stop = step.getResumeLen(); + start += step.getResumeSkip(); + eof = step.getResumeEof(); + stopped = false; + + if (all) { + step = null; + } + + checkBuffer(false); + + return true; + } + + return false; + + // // consider that if EOF, there is no next + // if (start >= stop) { + // // Make sure, block if necessary + // preRead(); + // + // return hasMoreData(); + // } + // + // return true; + } + + /** + * Display a DEBUG {@link String} representation of this object. + *

+ * Do not use for release code. + */ + @Override + public String toString() { + String data = ""; + if (stop > 0) { + try { + data = new String(Arrays.copyOfRange(buffer, 0, stop), "UTF-8"); + } catch (UnsupportedEncodingException e) { + } + if (data.length() > 200) { + data = data.substring(0, 197) + "..."; + } + } + String rep = String.format( + "Nextable %s: %d -> %d [eof: %s] [more data: %s]: %s", + (stopped ? "stopped" : "running"), start, stop, "" + eof, "" + + hasMoreData(), data); + + return rep; + } +} diff --git a/src/be/nikiroo/utils/streams/NextableInputStreamStep.java b/src/be/nikiroo/utils/streams/NextableInputStreamStep.java new file mode 100644 index 0000000..fda998d --- /dev/null +++ b/src/be/nikiroo/utils/streams/NextableInputStreamStep.java @@ -0,0 +1,112 @@ +package be.nikiroo.utils.streams; + +import java.io.InputStream; + +/** + * Divide an {@link InputStream} into sub-streams. + * + * @author niki + */ +public class NextableInputStreamStep { + private int stopAt; + private int last = -1; + private int resumeLen; + private int resumeSkip; + private boolean resumeEof; + + /** + * Create a new divider that will separate the sub-streams each time it sees + * this byte. + *

+ * Note that the byte will be bypassed by the {@link InputStream} as far as + * the consumers will be aware. + * + * @param byt + * the byte at which to separate two sub-streams + */ + public NextableInputStreamStep(int byt) { + stopAt = byt; + } + + /** + * Check if we need to stop the {@link InputStream} reading at some point in + * the current buffer. + *

+ * If we do, return the index at which to stop; if not, return -1. + *

+ * This method will not return the same index a second time (unless + * we cleared the buffer). + * + * @param buffer + * the buffer to check + * @param pos + * the current position of what was read in the buffer + * @param len + * the maximum index to use in the buffer (anything above that is + * not to be used) + * @param eof + * the current state of the under-laying stream + * + * @return the index at which to stop, or -1 + */ + public int stop(byte[] buffer, int pos, int len, boolean eof) { + for (int i = pos; i < len; i++) { + if (buffer[i] == stopAt) { + if (i > this.last) { + // we skip the sep + this.resumeSkip = 1; + + this.resumeLen = len; + this.resumeEof = eof; + this.last = i; + return i; + } + } + } + + return -1; + } + + /** + * Get the maximum index to use in the buffer used in + * {@link NextableInputStreamStep#stop(byte[], int, int, boolean)} at resume + * time. + * + * @return the index + */ + public int getResumeLen() { + return resumeLen; + } + + /** + * Get the number of bytes to skip at resume time. + * + * @return the number of bytes to skip + */ + public int getResumeSkip() { + return resumeSkip; + } + + /** + * Get the under-laying stream state at resume time. + * + * @return the EOF state + */ + public boolean getResumeEof() { + return resumeEof; + } + + /** + * Clear the information we may have kept about the current buffer + *

+ * You should call this method each time you change the content of the + * buffer used in + * {@link NextableInputStreamStep#stop(byte[], int, int, boolean)}. + */ + public void clearBuffer() { + this.last = -1; + this.resumeSkip = 0; + this.resumeLen = 0; + this.resumeEof = false; + } +} diff --git a/src/be/nikiroo/utils/streams/ReplaceInputStream.java b/src/be/nikiroo/utils/streams/ReplaceInputStream.java new file mode 100644 index 0000000..1cc5139 --- /dev/null +++ b/src/be/nikiroo/utils/streams/ReplaceInputStream.java @@ -0,0 +1,162 @@ +package be.nikiroo.utils.streams; + +import java.io.IOException; +import java.io.InputStream; + +import be.nikiroo.utils.StringUtils; + +/** + * This {@link InputStream} will change some of its content by replacing it with + * something else. + * + * @author niki + */ +public class ReplaceInputStream extends BufferedInputStream { + /** + * The minimum size of the internal buffer (could be more if at least one of + * the 'FROM' bytes arrays is > 2048 bytes — in that case the + * buffer will be twice the largest size of the 'FROM' bytes arrays). + *

+ * This is a different buffer than the one from the inherited class. + */ + static private final int MIN_BUFFER_SIZE = 4096; + + private byte[][] froms; + private byte[][] tos; + private int maxFromSize; + private int maxToSize; + + private byte[] source; + private int spos; + private int slen; + + /** + * Create a {@link ReplaceInputStream} that will replace from with + * to. + * + * @param in + * the under-laying {@link InputStream} + * @param from + * the {@link String} to replace + * @param to + * the {@link String} to replace with + */ + public ReplaceInputStream(InputStream in, String from, String to) { + this(in, StringUtils.getBytes(from), StringUtils.getBytes(to)); + } + + /** + * Create a {@link ReplaceInputStream} that will replace from with + * to. + * + * @param in + * the under-laying {@link InputStream} + * @param from + * the value to replace + * @param to + * the value to replace with + */ + public ReplaceInputStream(InputStream in, byte[] from, byte[] to) { + this(in, new byte[][] { from }, new byte[][] { to }); + } + + /** + * Create a {@link ReplaceInputStream} that will replace all froms + * with tos. + *

+ * Note that they will be replaced in order, and that for each from + * a to must correspond. + * + * @param in + * the under-laying {@link InputStream} + * @param froms + * the values to replace + * @param tos + * the values to replace with + */ + public ReplaceInputStream(InputStream in, String[] froms, String[] tos) { + this(in, StreamUtils.getBytes(froms), StreamUtils.getBytes(tos)); + } + + /** + * Create a {@link ReplaceInputStream} that will replace all froms + * with tos. + *

+ * Note that they will be replaced in order, and that for each from + * a to must correspond. + * + * @param in + * the under-laying {@link InputStream} + * @param froms + * the values to replace + * @param tos + * the values to replace with + */ + public ReplaceInputStream(InputStream in, byte[][] froms, byte[][] tos) { + super(in); + + if (froms.length != tos.length) { + throw new IllegalArgumentException( + "For replacing, each FROM must have a corresponding TO"); + } + + this.froms = froms; + this.tos = tos; + + maxFromSize = 0; + for (int i = 0; i < froms.length; i++) { + maxFromSize = Math.max(maxFromSize, froms[i].length); + } + + maxToSize = 0; + for (int i = 0; i < tos.length; i++) { + maxToSize = Math.max(maxToSize, tos[i].length); + } + + // We need at least maxFromSize so we can iterate and replace + source = new byte[Math.max(2 * maxFromSize, MIN_BUFFER_SIZE)]; + spos = 0; + slen = 0; + } + + @Override + protected int read(InputStream in, byte[] buffer, int off, int len) + throws IOException { + if (len < maxToSize || source.length < maxToSize * 2) { + throw new IOException( + "An underlaying buffer is too small for these replace values"); + } + + // We need at least one byte of data to process + if (available() < Math.max(maxFromSize, 1) && !eof) { + spos = 0; + slen = in.read(source); + } + + // Note: very simple, not efficient implementation; sorry. + int count = 0; + while (spos < slen && count < len - maxToSize) { + boolean replaced = false; + for (int i = 0; i < froms.length; i++) { + if (froms[i] != null && froms[i].length > 0 + && StreamUtils.startsWith(froms[i], source, spos, slen)) { + if (tos[i] != null && tos[i].length > 0) { + System.arraycopy(tos[i], 0, buffer, off + spos, + tos[i].length); + count += tos[i].length; + } + + spos += froms[i].length; + replaced = true; + break; + } + } + + if (!replaced) { + buffer[off + count++] = source[spos++]; + } + } + + return count; + } +} diff --git a/src/be/nikiroo/utils/streams/ReplaceOutputStream.java b/src/be/nikiroo/utils/streams/ReplaceOutputStream.java new file mode 100644 index 0000000..c6679cc --- /dev/null +++ b/src/be/nikiroo/utils/streams/ReplaceOutputStream.java @@ -0,0 +1,148 @@ +package be.nikiroo.utils.streams; + +import java.io.IOException; +import java.io.OutputStream; + +import be.nikiroo.utils.StringUtils; + +/** + * This {@link OutputStream} will change some of its content by replacing it + * with something else. + * + * @author niki + */ +public class ReplaceOutputStream extends BufferedOutputStream { + private byte[][] froms; + private byte[][] tos; + + /** + * Create a {@link ReplaceOutputStream} that will replace from with + * to. + * + * @param out + * the under-laying {@link OutputStream} + * @param from + * the {@link String} to replace + * @param to + * the {@link String} to replace with + */ + public ReplaceOutputStream(OutputStream out, String from, String to) { + this(out, StringUtils.getBytes(from), StringUtils.getBytes(to)); + } + + /** + * Create a {@link ReplaceOutputStream} that will replace from with + * to. + * + * @param out + * the under-laying {@link OutputStream} + * @param from + * the value to replace + * @param to + * the value to replace with + */ + public ReplaceOutputStream(OutputStream out, byte[] from, byte[] to) { + this(out, new byte[][] { from }, new byte[][] { to }); + } + + /** + * Create a {@link ReplaceOutputStream} that will replace all froms + * with tos. + *

+ * Note that they will be replaced in order, and that for each from + * a to must correspond. + * + * @param out + * the under-laying {@link OutputStream} + * @param froms + * the values to replace + * @param tos + * the values to replace with + */ + public ReplaceOutputStream(OutputStream out, String[] froms, String[] tos) { + this(out, StreamUtils.getBytes(froms), StreamUtils.getBytes(tos)); + } + + /** + * Create a {@link ReplaceOutputStream} that will replace all froms + * with tos. + *

+ * Note that they will be replaced in order, and that for each from + * a to must correspond. + * + * @param out + * the under-laying {@link OutputStream} + * @param froms + * the values to replace + * @param tos + * the values to replace with + */ + public ReplaceOutputStream(OutputStream out, byte[][] froms, byte[][] tos) { + super(out); + bypassFlush = false; + + if (froms.length != tos.length) { + throw new IllegalArgumentException( + "For replacing, each FROM must have a corresponding TO"); + } + + this.froms = froms; + this.tos = tos; + } + + /** + * Flush the {@link BufferedOutputStream}, write the current buffered data + * to (and optionally also flush) the under-laying stream. + *

+ * If {@link BufferedOutputStream#bypassFlush} is false, all writes to the + * under-laying stream are done in this method. + *

+ * This can be used if you want to write some data in the under-laying + * stream yourself (in that case, flush this {@link BufferedOutputStream} + * with or without flushing the under-laying stream, then you can write to + * the under-laying stream). + *

+ * But be careful! If a replacement could be done with the end o the + * currently buffered data and the start of the data to come, we obviously + * will not be able to do it. + * + * @param includingSubStream + * also flush the under-laying stream + * @throws IOException + * in case of I/O error + */ + @Override + public void flush(boolean includingSubStream) throws IOException { + // Note: very simple, not efficient implementation; sorry. + while (start < stop) { + boolean replaced = false; + for (int i = 0; i < froms.length; i++) { + if (froms[i] != null + && froms[i].length > 0 + && StreamUtils + .startsWith(froms[i], buffer, start, stop)) { + if (tos[i] != null && tos[i].length > 0) { + out.write(tos[i]); + bytesWritten += tos[i].length; + } + + start += froms[i].length; + replaced = true; + break; + } + } + + if (!replaced) { + out.write(buffer[start++]); + bytesWritten++; + } + } + + start = 0; + stop = 0; + + if (includingSubStream) { + out.flush(); + } + } +} diff --git a/src/be/nikiroo/utils/streams/StreamUtils.java b/src/be/nikiroo/utils/streams/StreamUtils.java new file mode 100644 index 0000000..dc75090 --- /dev/null +++ b/src/be/nikiroo/utils/streams/StreamUtils.java @@ -0,0 +1,69 @@ +package be.nikiroo.utils.streams; + +import be.nikiroo.utils.StringUtils; + +/** + * Some non-public utilities used in the stream classes. + * + * @author niki + */ +class StreamUtils { + /** + * Check if the buffer starts with the given search term (given as an array, + * a start position and an end position). + *

+ * Note: the parameter stop is the index of the last + * position, not the length. + *

+ * Note: the search term size must be smaller or equal the internal + * buffer size. + * + * @param search + * the term to search for + * @param buffer + * the buffer to look into + * @param start + * the offset at which to start the search + * @param stop + * the maximum index of the data to check (this is not a + * length, but an index) + * + * @return TRUE if the search content is present at the given location and + * does not exceed the len index + */ + static public boolean startsWith(byte[] search, byte[] buffer, int start, + int stop) { + + // Check if there even is enough space for it + if (search.length > (stop - start)) { + return false; + } + + boolean same = true; + for (int i = 0; i < search.length; i++) { + if (search[i] != buffer[start + i]) { + same = false; + break; + } + } + + return same; + } + + /** + * Return the bytes array representation of the given {@link String} in + * UTF-8. + * + * @param strs + * the {@link String}s to transform into bytes + * @return the content in bytes + */ + static public byte[][] getBytes(String[] strs) { + byte[][] bytes = new byte[strs.length][]; + for (int i = 0; i < strs.length; i++) { + bytes[i] = StringUtils.getBytes(strs[i]); + } + + return bytes; + } +} diff --git a/src/be/nikiroo/utils/test/TestCase.java b/src/be/nikiroo/utils/test/TestCase.java new file mode 100644 index 0000000..fe7b9af --- /dev/null +++ b/src/be/nikiroo/utils/test/TestCase.java @@ -0,0 +1,535 @@ +package be.nikiroo.utils.test; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import be.nikiroo.utils.IOUtils; + +/** + * A {@link TestCase} that can be run with {@link TestLauncher}. + * + * @author niki + */ +abstract public class TestCase { + /** + * The type of {@link Exception} used to signal a failed assertion or a + * force-fail. + * + * @author niki + */ + class AssertException extends Exception { + private static final long serialVersionUID = 1L; + + public AssertException(String reason, Exception source) { + super(reason, source); + } + + public AssertException(String reason) { + super(reason); + } + } + + private String name; + + /** + * Create a new {@link TestCase}. + * + * @param name + * the test name + */ + public TestCase(String name) { + this.name = name; + } + + /** + * This constructor can be used if you require a no-param constructor. In + * this case, you are allowed to set the name manually via + * {@link TestCase#setName}. + */ + protected TestCase() { + this("no name"); + } + + /** + * Setup the test (called before the test is run). + * + * @throws Exception + * in case of error + */ + public void setUp() throws Exception { + } + + /** + * Tear-down the test (called when the test has been ran). + * + * @throws Exception + * in case of error + */ + public void tearDown() throws Exception { + } + + /** + * The test name. + * + * @return the name + */ + public String getName() { + return name; + } + + /** + * The test name. + * + * @param name + * the new name (internal use only) + * + * @return this (so we can chain and so we can initialize it in a member + * variable if this is an anonymous inner class) + */ + protected TestCase setName(String name) { + this.name = name; + return this; + } + + /** + * Actually do the test. + * + * @throws Exception + * in case of error + */ + abstract public void test() throws Exception; + + /** + * Force a failure. + * + * @throws AssertException + * every time + */ + public void fail() throws AssertException { + fail(null); + } + + /** + * Force a failure. + * + * @param reason + * the failure reason + * + * @throws AssertException + * every time + */ + public void fail(String reason) throws AssertException { + fail(reason, null); + } + + /** + * Force a failure. + * + * @param reason + * the failure reason + * @param e + * the exception that caused the failure (can be NULL) + * + * @throws AssertException + * every time + */ + public void fail(String reason, Exception e) throws AssertException { + throw new AssertException("Failed!" + // + reason != null ? "\n" + reason : "", e); + } + + /** + * Check that 2 {@link Object}s are equals. + * + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(Object expected, Object actual) + throws AssertException { + assertEquals(null, expected, actual); + } + + /** + * Check that 2 {@link Object}s are equals. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, Object expected, Object actual) + throws AssertException { + if ((expected == null && actual != null) + || (expected != null && !expected.equals(actual))) { + if (errorMessage == null) { + throw new AssertException(generateAssertMessage(expected, + actual)); + } + + throw new AssertException(errorMessage, new AssertException( + generateAssertMessage(expected, actual))); + } + } + + /** + * Check that 2 longs are equals. + * + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(long expected, long actual) throws AssertException { + assertEquals(Long.valueOf(expected), Long.valueOf(actual)); + } + + /** + * Check that 2 longs are equals. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, long expected, long actual) + throws AssertException { + assertEquals(errorMessage, Long.valueOf(expected), Long.valueOf(actual)); + } + + /** + * Check that 2 booleans are equals. + * + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(boolean expected, boolean actual) + throws AssertException { + assertEquals(Boolean.valueOf(expected), Boolean.valueOf(actual)); + } + + /** + * Check that 2 booleans are equals. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, boolean expected, + boolean actual) throws AssertException { + assertEquals(errorMessage, Boolean.valueOf(expected), + Boolean.valueOf(actual)); + } + + /** + * Check that 2 doubles are equals. + * + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(double expected, double actual) + throws AssertException { + assertEquals(Double.valueOf(expected), Double.valueOf(actual)); + } + + /** + * Check that 2 doubles are equals. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, double expected, double actual) + throws AssertException { + assertEquals(errorMessage, Double.valueOf(expected), + Double.valueOf(actual)); + } + + /** + * Check that 2 {@link List}s are equals. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(List expected, List actual) + throws AssertException { + assertEquals("Assertion failed", expected, actual); + } + + /** + * Check that 2 {@link List}s are equals. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, List expected, + List actual) throws AssertException { + + if (expected.size() != actual.size()) { + assertEquals(errorMessage + ": not same number of items", + list(expected), list(actual)); + } + + int size = expected.size(); + for (int i = 0; i < size; i++) { + assertEquals(errorMessage + ": item " + i + + " (0-based) is not correct", expected.get(i), + actual.get(i)); + } + } + + /** + * Check that 2 {@link File}s are equals, by doing a line-by-line + * comparison. + * + * @param expected + * the expected value + * @param actual + * the actual value + * @param errorMessage + * the error message to display if they differ + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(File expected, File actual) throws AssertException { + assertEquals(generateAssertMessage(expected, actual), expected, actual); + } + + /** + * Check that 2 {@link File}s are equals, by doing a line-by-line + * comparison. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, File expected, File actual) + throws AssertException { + assertEquals(errorMessage, expected, actual, null); + } + + /** + * Check that 2 {@link File}s are equals, by doing a line-by-line + * comparison. + * + * @param errorMessage + * the error message to display if they differ + * @param expected + * the expected value + * @param actual + * the actual value + * @param skipCompare + * skip the lines starting with some values for the given files + * (relative path from base directory in recursive mode) + * + * @throws AssertException + * in case they differ + */ + public void assertEquals(String errorMessage, File expected, File actual, + Map> skipCompare) throws AssertException { + assertEquals(errorMessage, expected, actual, skipCompare, null); + } + + private void assertEquals(String errorMessage, File expected, File actual, + Map> skipCompare, String removeFromName) + throws AssertException { + + if (expected.isDirectory() || actual.isDirectory()) { + assertEquals(errorMessage + ": type mismatch: expected a " + + (expected.isDirectory() ? "directory" : "file") + + ", received a " + + (actual.isDirectory() ? "directory" : "file"), + expected.isDirectory(), actual.isDirectory()); + + List expectedFiles = Arrays.asList(expected.list()); + Collections.sort(expectedFiles); + List actualFiles = Arrays.asList(actual.list()); + Collections.sort(actualFiles); + + assertEquals(errorMessage, expectedFiles, actualFiles); + for (int i = 0; i < actualFiles.size(); i++) { + File expectedFile = new File(expected, expectedFiles.get(i)); + File actualFile = new File(actual, actualFiles.get(i)); + + assertEquals(errorMessage, expectedFile, actualFile, + skipCompare, expected.getAbsolutePath()); + } + } else { + try { + List expectedLines = Arrays.asList(IOUtils + .readSmallFile(expected).split("\n")); + List resultLines = Arrays.asList(IOUtils.readSmallFile( + actual).split("\n")); + + String name = expected.getAbsolutePath(); + if (removeFromName != null && name.startsWith(removeFromName)) { + name = expected.getName() + + name.substring(removeFromName.length()); + } + + assertEquals(errorMessage + ": " + name + + ": the number of lines is not the same", + expectedLines.size(), resultLines.size()); + + for (int j = 0; j < expectedLines.size(); j++) { + String expectedLine = expectedLines.get(j); + String resultLine = resultLines.get(j); + + boolean skip = false; + if (skipCompare != null) { + for (Entry> skipThose : skipCompare + .entrySet()) { + for (String skipStart : skipThose.getValue()) { + if (name.endsWith(skipThose.getKey()) + && expectedLine.startsWith(skipStart) + && resultLine.startsWith(skipStart)) { + skip = true; + } + } + } + } + + if (skip) { + continue; + } + + assertEquals(errorMessage + ": line " + (j + 1) + + " is not the same in file " + name, expectedLine, + resultLine); + } + } catch (Exception e) { + throw new AssertException(errorMessage, e); + } + } + } + + /** + * Check that given {@link Object} is not NULL. + * + * @param errorMessage + * the error message to display if it is NULL + * @param actual + * the actual value + * + * @throws AssertException + * in case they differ + */ + public void assertNotNull(String errorMessage, Object actual) + throws AssertException { + if (actual == null) { + String defaultReason = String.format("" // + + "Assertion failed!%n" // + + "Object should not have been NULL"); + + if (errorMessage == null) { + throw new AssertException(defaultReason); + } + + throw new AssertException(errorMessage, new AssertException( + defaultReason)); + } + } + + /** + * Generate the default assert message for 2 different values that were + * supposed to be equals. + * + * @param expected + * the expected value + * @param actual + * the actual value + * + * @return the message + */ + public static String generateAssertMessage(Object expected, Object actual) { + return String.format("" // + + "Assertion failed!%n" // + + "Expected value: [%s]%n" // + + "Actual value: [%s]", expected, actual); + } + + private static String list(List items) { + StringBuilder builder = new StringBuilder(); + for (Object item : items) { + if (builder.length() == 0) { + builder.append(items.size() + " item(s): "); + } else { + builder.append(", "); + } + + builder.append("" + item); + + if (builder.length() > 60) { + builder.setLength(57); + builder.append("..."); + break; + } + } + + return builder.toString(); + } +} diff --git a/src/be/nikiroo/utils/test/TestLauncher.java b/src/be/nikiroo/utils/test/TestLauncher.java new file mode 100644 index 0000000..895b565 --- /dev/null +++ b/src/be/nikiroo/utils/test/TestLauncher.java @@ -0,0 +1,434 @@ +package be.nikiroo.utils.test; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; + +/** + * A {@link TestLauncher} starts a series of {@link TestCase}s and displays the + * result to the user. + * + * @author niki + */ +public class TestLauncher { + /** + * {@link Exception} happening during the setup process. + * + * @author niki + */ + private class SetupException extends Exception { + private static final long serialVersionUID = 1L; + + public SetupException(Throwable e) { + super(e); + } + } + + /** + * {@link Exception} happening during the tear-down process. + * + * @author niki + */ + private class TearDownException extends Exception { + private static final long serialVersionUID = 1L; + + public TearDownException(Throwable e) { + super(e); + } + } + + private List series; + private List tests; + private TestLauncher parent; + + private int columns; + private String okString; + private String koString; + private String name; + private boolean cont; + + protected int executed; + protected int total; + + private int currentSeries = 0; + private boolean details = false; + + /** + * Create a new {@link TestLauncher} with default parameters. + * + * @param name + * the test suite name + * @param args + * the arguments to configure the number of columns and the ok/ko + * {@link String}s + */ + public TestLauncher(String name, String[] args) { + this.name = name; + + int cols = 80; + if (args != null && args.length >= 1) { + try { + cols = Integer.parseInt(args[0]); + } catch (NumberFormatException e) { + System.err.println("Test configuration: given number " + + "of columns is not parseable: " + args[0]); + } + } + + setColumns(cols); + + String okString = "[ ok ]"; + String koString = "[ !! ]"; + if (args != null && args.length >= 3) { + okString = args[1]; + koString = args[2]; + } + + setOkString(okString); + setKoString(koString); + + series = new ArrayList(); + tests = new ArrayList(); + cont = true; + } + + /** + * Display the details of the errors + * + * @return TRUE to display them, false to simply mark the test as failed + */ + public boolean isDetails() { + if (parent != null) { + return parent.isDetails(); + } + + return details; + } + + /** + * Display the details of the errors + * + * @param details + * TRUE to display them, false to simply mark the test as failed + */ + public void setDetails(boolean details) { + if (parent != null) { + parent.setDetails(details); + } + + this.details = details; + } + + /** + * Called before actually starting the tests themselves. + * + * @throws Exception + * in case of error + */ + protected void start() throws Exception { + } + + /** + * Called when the tests are passed (or failed to do so). + * + * @throws Exception + * in case of error + */ + protected void stop() throws Exception { + } + + protected void addTest(TestCase test) { + tests.add(test); + } + + protected void addSeries(TestLauncher series) { + this.series.add(series); + series.parent = this; + } + + /** + * Launch the series of {@link TestCase}s and the {@link TestCase}s. + * + * @return the number of errors + */ + public int launch() { + return launch(0); + } + + /** + * Launch the series of {@link TestCase}s and the {@link TestCase}s. + * + * @param depth + * the level at which is the launcher (0 = main launcher) + * + * @return the number of errors + */ + public int launch(int depth) { + int errors = 0; + executed = 0; + total = tests.size(); + + print(depth); + + try { + start(); + + errors += launchTests(depth); + if (tests.size() > 0 && depth == 0) { + System.out.println(""); + } + + currentSeries = 0; + for (TestLauncher serie : series) { + errors += serie.launch(depth + 1); + executed += serie.executed; + total += serie.total; + currentSeries++; + } + } catch (Exception e) { + print(depth, "__start"); + print(depth, e); + } finally { + try { + stop(); + } catch (Exception e) { + print(depth, "__stop"); + print(depth, e); + } + } + + print(depth, executed, errors, total); + + return errors; + } + + /** + * Launch the {@link TestCase}s. + * + * @param depth + * the level at which is the launcher (0 = main launcher) + * + * @return the number of errors + */ + protected int launchTests(int depth) { + int errors = 0; + for (TestCase test : tests) { + print(depth, test.getName()); + + Throwable ex = null; + try { + try { + test.setUp(); + } catch (Throwable e) { + throw new SetupException(e); + } + test.test(); + try { + test.tearDown(); + } catch (Throwable e) { + throw new TearDownException(e); + } + } catch (Throwable e) { + ex = e; + } + + if (ex != null) { + errors++; + } + + print(depth, ex); + + executed++; + + if (ex != null && !cont) { + break; + } + } + + return errors; + } + + /** + * Specify a custom number of columns to use for the display of messages. + * + * @param columns + * the number of columns + */ + public void setColumns(int columns) { + this.columns = columns; + } + + /** + * Continue to run the tests when an error is detected. + * + * @param cont + * yes or no + */ + public void setContinueAfterFail(boolean cont) { + this.cont = cont; + } + + /** + * Set a custom "[ ok ]" {@link String} when a test passed. + * + * @param okString + * the {@link String} to display at the end of a success + */ + public void setOkString(String okString) { + this.okString = okString; + } + + /** + * Set a custom "[ !! ]" {@link String} when a test failed. + * + * @param koString + * the {@link String} to display at the end of a failure + */ + public void setKoString(String koString) { + this.koString = koString; + } + + /** + * Print the test suite header. + * + * @param depth + * the level at which is the launcher (0 = main launcher) + */ + protected void print(int depth) { + if (depth == 0) { + System.out.println("[ Test suite: " + name + " ]"); + System.out.println(""); + } else { + System.out.println(prefix(depth, false) + name + ":"); + } + } + + /** + * Print the name of the {@link TestCase} we will start immediately after. + * + * @param depth + * the level at which is the launcher (0 = main launcher) + * @param name + * the {@link TestCase} name + */ + protected void print(int depth, String name) { + name = prefix(depth, false) + + (name == null ? "" : name).replace("\t", " "); + + StringBuilder dots = new StringBuilder(); + while ((name.length() + dots.length()) < columns - 11) { + dots.append('.'); + } + + System.out.print(name + dots.toString()); + } + + /** + * Print the result of the {@link TestCase} we just ran. + * + * @param depth + * the level at which is the launcher (0 = main launcher) + * @param error + * the {@link Exception} it ran into if any + */ + private void print(int depth, Throwable error) { + if (error != null) { + System.out.println(" " + koString); + if (isDetails()) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + error.printStackTrace(pw); + String lines = sw.toString(); + for (String line : lines.split("\n")) { + System.out.println(prefix(depth, false) + "\t\t" + line); + } + } + } else { + System.out.println(" " + okString); + } + } + + /** + * Print the total result for this test suite. + * + * @param depth + * the level at which is the launcher (0 = main launcher) + * @param executed + * the number of tests actually ran + * @param errors + * the number of errors encountered + * @param total + * the total number of tests in the suite + */ + private void print(int depth, int executed, int errors, int total) { + int ok = executed - errors; + int pc = (int) ((100.0 * ok) / executed); + if (pc == 0 && ok > 0) { + pc = 1; + } + int pcTotal = (int) ((100.0 * ok) / total); + if (pcTotal == 0 && ok > 0) { + pcTotal = 1; + } + + String resume = "Tests passed: " + ok + "/" + executed + " (" + pc + + "%) on a total of " + total + " (" + pcTotal + "% total)"; + if (depth == 0) { + System.out.println(resume); + } else { + String arrow = "┗▶ "; + System.out.println(prefix(depth, currentSeries == 0) + arrow + + resume); + System.out.println(prefix(depth, currentSeries == 0)); + } + } + + private int last = -1; + + /** + * Return the prefix to print before the current line. + * + * @param depth + * the current depth + * @param first + * this line is the first of its tabulation level + * + * @return the prefix + */ + private String prefix(int depth, boolean first) { + String space = tabs(depth - 1); + + String line = ""; + if (depth > 0) { + if (depth > 1) { + if (depth != last && first) { + line = "╻"; // first line + } else { + line = "┃"; // continuation + } + } + + space += line + tabs(1); + } + + last = depth; + return space; + } + + /** + * Return the given number of space-converted tabs in a {@link String}. + * + * @param depth + * the number of tabs to return + * + * @return the string + */ + private String tabs(int depth) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < depth; i++) { + builder.append(" "); + } + return builder.toString(); + } +} diff --git a/src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java b/src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java new file mode 100644 index 0000000..c715585 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/BufferedInputStreamTest.java @@ -0,0 +1,115 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.streams.BufferedInputStream; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class BufferedInputStreamTest extends TestLauncher { + public BufferedInputStreamTest(String[] args) { + super("BufferedInputStream test", args); + + addTest(new TestCase("Simple InputStream reading") { + @Override + public void test() throws Exception { + byte[] expected = new byte[] { 42, 12, 0, 127 }; + BufferedInputStream in = new BufferedInputStream( + new ByteArrayInputStream(expected)); + checkArrays(this, "FIRST", in, expected); + } + }); + + addTest(new TestCase("Simple byte array reading") { + @Override + public void test() throws Exception { + byte[] expected = new byte[] { 42, 12, 0, 127 }; + BufferedInputStream in = new BufferedInputStream(expected); + checkArrays(this, "FIRST", in, expected); + } + }); + + addTest(new TestCase("Byte array is(byte[])") { + @Override + public void test() throws Exception { + byte[] expected = new byte[] { 42, 12, 0, 127 }; + BufferedInputStream in = new BufferedInputStream(expected); + assertEquals( + "The array should be considered identical to its source", + true, in.is(expected)); + assertEquals( + "The array should be considered different to that one", + false, in.is(new byte[] { 42, 12, 0, 121 })); + in.close(); + } + }); + + addTest(new TestCase("InputStream is(byte[])") { + @Override + public void test() throws Exception { + byte[] expected = new byte[] { 42, 12, 0, 127 }; + BufferedInputStream in = new BufferedInputStream( + new ByteArrayInputStream(expected)); + assertEquals( + "The array should be considered identical to its source", + true, in.is(expected)); + assertEquals( + "The array should be considered different to that one", + false, in.is(new byte[] { 42, 12, 0, 121 })); + in.close(); + } + }); + + addTest(new TestCase("Byte array is(String)") { + @Override + public void test() throws Exception { + String expected = "Testy"; + BufferedInputStream in = new BufferedInputStream( + expected.getBytes("UTF-8")); + assertEquals( + "The array should be considered identical to its source", + true, in.is(expected)); + assertEquals( + "The array should be considered different to that one", + false, in.is("Autre")); + assertEquals( + "The array should be considered different to that one", + false, in.is("Test")); + in.close(); + } + }); + + addTest(new TestCase("InputStream is(String)") { + @Override + public void test() throws Exception { + String expected = "Testy"; + BufferedInputStream in = new BufferedInputStream( + new ByteArrayInputStream(expected.getBytes("UTF-8"))); + assertEquals( + "The array should be considered identical to its source", + true, in.is(expected)); + assertEquals( + "The array should be considered different to that one", + false, in.is("Autre")); + assertEquals( + "The array should be considered different to that one", + false, in.is("Testy.")); + in.close(); + } + }); + } + + static void checkArrays(TestCase test, String prefix, InputStream in, + byte[] expected) throws Exception { + byte[] actual = IOUtils.toByteArray(in); + test.assertEquals("The " + prefix + + " resulting array has not the correct number of items", + expected.length, actual.length); + for (int i = 0; i < actual.length; i++) { + test.assertEquals(prefix + ": item " + i + + " (0-based) is not the same", expected[i], actual[i]); + } + } +} diff --git a/src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java b/src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java new file mode 100644 index 0000000..5646e61 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/BufferedOutputStreamTest.java @@ -0,0 +1,136 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +import be.nikiroo.utils.streams.BufferedOutputStream; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class BufferedOutputStreamTest extends TestLauncher { + public BufferedOutputStreamTest(String[] args) { + super("BufferedOutputStream test", args); + + addTest(new TestCase("Single write") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + BufferedOutputStream out = new BufferedOutputStream(bout); + + byte[] data = new byte[] { 42, 12, 0, 127 }; + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, data); + } + }); + + addTest(new TestCase("Single write of 5000 bytes") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + BufferedOutputStream out = new BufferedOutputStream(bout); + + byte[] data = new byte[5000]; + for (int i = 0; i < data.length; i++) { + data[i] = (byte) (i % 255); + } + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, data); + } + }); + + addTest(new TestCase("Multiple writes") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + BufferedOutputStream out = new BufferedOutputStream(bout); + + byte[] data1 = new byte[] { 42, 12, 0, 127 }; + byte[] data2 = new byte[] { 15, 55 }; + byte[] data3 = new byte[] {}; + + byte[] dataAll = new byte[] { 42, 12, 0, 127, 15, 55 }; + + out.write(data1); + out.write(data2); + out.write(data3); + out.close(); + + checkArrays(this, "FIRST", bout, dataAll); + } + }); + + addTest(new TestCase("Multiple writes for a 5000 bytes total") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + BufferedOutputStream out = new BufferedOutputStream(bout); + + byte[] data = new byte[] { 42, 12, 0, 127, 51, 2, 32, 66, 7, 87 }; + + List bytes = new ArrayList(); + + // write 400 * 10 + 1000 bytes = 5000 + for (int i = 0; i < 400; i++) { + for (int j = 0; j < data.length; j++) { + bytes.add(data[j]); + } + out.write(data); + } + + for (int i = 0; i < 1000; i++) { + for (int j = 0; j < data.length; j++) { + bytes.add(data[j]); + } + out.write(data); + } + + out.close(); + + byte[] abytes = new byte[bytes.size()]; + for (int i = 0; i < bytes.size(); i++) { + abytes[i] = bytes.get(i); + } + + checkArrays(this, "FIRST", bout, abytes); + } + }); + } + + static void checkArrays(TestCase test, String prefix, + ByteArrayOutputStream bout, byte[] expected) throws Exception { + byte[] actual = bout.toByteArray(); + + if (false) { + System.out.print("\nExpected data: [ "); + for (int i = 0; i < expected.length; i++) { + if (i > 0) + System.out.print(", "); + System.out.print(expected[i]); + } + System.out.println(" ]"); + + System.out.print("Actual data : [ "); + for (int i = 0; i < actual.length; i++) { + if (i > 0) + System.out.print(", "); + System.out.print(actual[i]); + } + System.out.println(" ]"); + } + + test.assertEquals("The " + prefix + + " resulting array has not the correct number of items", + expected.length, actual.length); + for (int i = 0; i < actual.length; i++) { + test.assertEquals(prefix + ": item " + i + + " (0-based) is not the same", expected[i], actual[i]); + } + } +} diff --git a/src/be/nikiroo/utils/test_code/BundleTest.java b/src/be/nikiroo/utils/test_code/BundleTest.java new file mode 100644 index 0000000..2e25eb0 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/BundleTest.java @@ -0,0 +1,249 @@ +package be.nikiroo.utils.test_code; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.resources.Bundle; +import be.nikiroo.utils.resources.Bundles; +import be.nikiroo.utils.resources.Meta; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class BundleTest extends TestLauncher { + private File tmp; + private B b = new B(); + + public BundleTest(String[] args) { + this("Bundle test", args); + } + + protected BundleTest(String name, String[] args) { + super(name, args); + + for (TestCase test : getSimpleTests()) { + addTest(test); + } + + addSeries(new TestLauncher("After saving/reloading the resources", args) { + { + for (TestCase test : getSimpleTests()) { + addTest(test); + } + } + + @Override + protected void start() throws Exception { + tmp = File.createTempFile("nikiroo-utils", ".test"); + tmp.delete(); + tmp.mkdir(); + b.updateFile(tmp.getAbsolutePath()); + Bundles.setDirectory(tmp.getAbsolutePath()); + b.reload(false); + } + + @Override + protected void stop() { + IOUtils.deltree(tmp); + } + }); + + addSeries(new TestLauncher("Read/Write support", args) { + { + addTest(new TestCase("Reload") { + @Override + public void test() throws Exception { + String def = b.getString(E.ONE); + String val = "Something"; + + b.setString(E.ONE, val); + b.updateFile(); + b.reload(true); + + assertEquals("We should have reset the bundle", def, + b.getString(E.ONE)); + + b.reload(false); + + assertEquals("We should have reloaded the same files", + val, b.getString(E.ONE)); + + // reset values for next tests + b.reload(true); + b.updateFile(); + } + }); + + addTest(new TestCase("Set/Get") { + @Override + public void test() throws Exception { + String val = "Newp"; + b.setString(E.ONE, val); + String setGet = b.getString(E.ONE); + + assertEquals(val, setGet); + + // reset values for next tests + b.restoreSnapshot(null); + } + }); + + addTest(new TestCase("Snapshots") { + @Override + public void test() throws Exception { + String val = "Newp"; + String def = b.getString(E.ONE); + + b.setString(E.ONE, val); + Object snap = b.takeSnapshot(); + + b.restoreSnapshot(null); + assertEquals( + "restoreChanges(null) should clear the changes", + def, b.getString(E.ONE)); + b.restoreSnapshot(snap); + assertEquals( + "restoreChanges(snapshot) should restore the changes", + val, b.getString(E.ONE)); + + // reset values for next tests + b.restoreSnapshot(null); + } + }); + + addTest(new TestCase("updateFile with changes") { + @Override + public void test() throws Exception { + String val = "Go to disk! (UTF-8 test: 日本語)"; + + String def = b.getString(E.ONE); + b.setString(E.ONE, val); + b.updateFile(tmp.getAbsolutePath()); + b.reload(false); + + assertEquals(val, b.getString(E.ONE)); + + // reset values for next tests + b.setString(E.ONE, def); + b.updateFile(tmp.getAbsolutePath()); + b.reload(false); + } + }); + } + + @Override + protected void start() throws Exception { + tmp = File.createTempFile("nikiroo-utils", ".test"); + tmp.delete(); + tmp.mkdir(); + b.updateFile(tmp.getAbsolutePath()); + Bundles.setDirectory(tmp.getAbsolutePath()); + b.reload(false); + } + + @Override + protected void stop() { + IOUtils.deltree(tmp); + } + }); + } + + private List getSimpleTests() { + String pre = ""; + + List list = new ArrayList(); + + list.add(new TestCase(pre + "getString simple") { + @Override + public void test() throws Exception { + assertEquals("un", b.getString(E.ONE)); + } + }); + + list.add(new TestCase(pre + "getStringX with null suffix") { + @Override + public void test() throws Exception { + assertEquals("un", b.getStringX(E.ONE, null)); + } + }); + + list.add(new TestCase(pre + "getStringX with empty suffix") { + @Override + public void test() throws Exception { + assertEquals(null, b.getStringX(E.ONE, "")); + } + }); + + list.add(new TestCase(pre + "getStringX with existing suffix") { + @Override + public void test() throws Exception { + assertEquals("un + suffix", b.getStringX(E.ONE, "suffix")); + } + }); + + list.add(new TestCase(pre + "getStringX with not existing suffix") { + @Override + public void test() throws Exception { + assertEquals(null, b.getStringX(E.ONE, "fake")); + } + }); + + list.add(new TestCase(pre + "getString with UTF-8 content") { + @Override + public void test() throws Exception { + assertEquals("日本語 Nihongo", b.getString(E.JAPANESE)); + } + }); + + return list; + } + + /** + * {@link Bundle}. + * + * @author niki + */ + private class B extends Bundle { + protected B() { + super(E.class, N.bundle_test, null); + } + + @Override + // ...and make it public + public Object takeSnapshot() { + return super.takeSnapshot(); + } + + @Override + // ...and make it public + public void restoreSnapshot(Object snap) { + super.restoreSnapshot(snap); + } + } + + /** + * Key enum for the {@link Bundle}. + * + * @author niki + */ + private enum E { + @Meta + ONE, // + @Meta + ONE_SUFFIX, // + @Meta + TWO, // + @Meta + JAPANESE + } + + /** + * Name enum for the {@link Bundle}. + * + * @author niki + */ + private enum N { + bundle_test + } +} diff --git a/src/be/nikiroo/utils/test_code/CryptUtilsTest.java b/src/be/nikiroo/utils/test_code/CryptUtilsTest.java new file mode 100644 index 0000000..0c53461 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/CryptUtilsTest.java @@ -0,0 +1,155 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; + +import be.nikiroo.utils.CryptUtils; +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class CryptUtilsTest extends TestLauncher { + private String key; + private CryptUtils crypt; + + public CryptUtilsTest(String[] args) { + super("CryptUtils test", args); + + String longKey = "some long string with more than 128 bits (=32 bytes) of data"; + + addSeries(new CryptUtilsTest(args, "Manual input wuth NULL key", null, + 1)); + addSeries(new CryptUtilsTest(args, "Streams with NULL key", null, true)); + + addSeries(new CryptUtilsTest(args, "Manual input with emptykey", "", 1)); + addSeries(new CryptUtilsTest(args, "Streams with empty key", "", true)); + + addSeries(new CryptUtilsTest(args, "Manual input with long key", + longKey, 1)); + addSeries(new CryptUtilsTest(args, "Streams with long key", longKey, + true)); + } + + @Override + protected void addTest(final TestCase test) { + super.addTest(new TestCase(test.getName()) { + @Override + public void test() throws Exception { + test.test(); + } + + @Override + public void setUp() throws Exception { + crypt = new CryptUtils(key); + test.setUp(); + } + + @Override + public void tearDown() throws Exception { + test.tearDown(); + crypt = null; + } + }); + } + + private CryptUtilsTest(String[] args, String title, String key, + @SuppressWarnings("unused") int dummy) { + super(title, args); + this.key = key; + + final String longData = "Le premier jour, Le Grand Barbu dans le cloud fit la lumière, et il vit que c'était bien. Ou quelque chose comme ça. Je préfère la Science-Fiction en général, je trouve ça plus sain :/"; + + addTest(new TestCase("Short") { + @Override + public void test() throws Exception { + String orig = "data"; + byte[] encrypted = crypt.encrypt(orig); + String decrypted = crypt.decrypts(encrypted); + + assertEquals(orig, decrypted); + } + }); + + addTest(new TestCase("Short, base64") { + @Override + public void test() throws Exception { + String orig = "data"; + String encrypted = crypt.encrypt64(orig); + String decrypted = crypt.decrypt64s(encrypted); + + assertEquals(orig, decrypted); + } + }); + + addTest(new TestCase("Empty") { + @Override + public void test() throws Exception { + String orig = ""; + byte[] encrypted = crypt.encrypt(orig); + String decrypted = crypt.decrypts(encrypted); + + assertEquals(orig, decrypted); + } + }); + + addTest(new TestCase("Empty, base64") { + @Override + public void test() throws Exception { + String orig = ""; + String encrypted = crypt.encrypt64(orig); + String decrypted = crypt.decrypt64s(encrypted); + + assertEquals(orig, decrypted); + } + }); + + addTest(new TestCase("Long") { + @Override + public void test() throws Exception { + String orig = longData; + byte[] encrypted = crypt.encrypt(orig); + String decrypted = crypt.decrypts(encrypted); + + assertEquals(orig, decrypted); + } + }); + + addTest(new TestCase("Long, base64") { + @Override + public void test() throws Exception { + String orig = longData; + String encrypted = crypt.encrypt64(orig); + String decrypted = crypt.decrypt64s(encrypted); + + assertEquals(orig, decrypted); + } + }); + } + + private CryptUtilsTest(String[] args, String title, String key, + @SuppressWarnings("unused") boolean dummy) { + super(title, args); + this.key = key; + + addTest(new TestCase("Simple test") { + @Override + public void test() throws Exception { + InputStream in = new ByteArrayInputStream(new byte[] { 42, 127, + 12 }); + crypt.encrypt(in); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + IOUtils.write(in, out); + byte[] result = out.toByteArray(); + + assertEquals( + "We wrote 3 bytes, we expected 3 bytes back but got: " + + result.length, result.length, result.length); + + assertEquals(42, result[0]); + assertEquals(127, result[1]); + assertEquals(12, result[2]); + } + }); + } +} diff --git a/src/be/nikiroo/utils/test_code/IOUtilsTest.java b/src/be/nikiroo/utils/test_code/IOUtilsTest.java new file mode 100644 index 0000000..9f22896 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/IOUtilsTest.java @@ -0,0 +1,24 @@ +package be.nikiroo.utils.test_code; + +import java.io.InputStream; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class IOUtilsTest extends TestLauncher { + public IOUtilsTest(String[] args) { + super("IOUtils test", args); + + addTest(new TestCase("openResource") { + @Override + public void test() throws Exception { + InputStream in = IOUtils.openResource("VERSION"); + assertNotNull( + "The VERSION file is supposed to be present in the binaries", + in); + in.close(); + } + }); + } +} diff --git a/src/be/nikiroo/utils/test_code/NextableInputStreamTest.java b/src/be/nikiroo/utils/test_code/NextableInputStreamTest.java new file mode 100644 index 0000000..463a123 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/NextableInputStreamTest.java @@ -0,0 +1,345 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.streams.NextableInputStream; +import be.nikiroo.utils.streams.NextableInputStreamStep; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +public class NextableInputStreamTest extends TestLauncher { + public NextableInputStreamTest(String[] args) { + super("NextableInputStream test", args); + + addTest(new TestCase("Simple byte array reading") { + @Override + public void test() throws Exception { + byte[] expected = new byte[] { 42, 12, 0, 127 }; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(expected), null); + checkNext(this, "READ", in, expected); + } + }); + + addTest(new TestCase("Stop at 12") { + @Override + public void test() throws Exception { + byte[] expected = new byte[] { 42, 12, 0, 127 }; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(expected), + new NextableInputStreamStep(12)); + + checkNext(this, "FIRST", in, new byte[] { 42 }); + } + }); + + addTest(new TestCase("Stop at 12, resume, stop again, resume") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data), + new NextableInputStreamStep(12)); + + checkNext(this, "FIRST", in, new byte[] { 42 }); + checkNext(this, "SECOND", in, new byte[] { 0, 127 }); + checkNext(this, "THIRD", in, new byte[] { 51, 11 }); + } + }); + + addTest(new TestCase("Encapsulation") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 4, 127, 12, 5 }; + NextableInputStream in4 = new NextableInputStream( + new ByteArrayInputStream(data), + new NextableInputStreamStep(4)); + NextableInputStream subIn12 = new NextableInputStream(in4, + new NextableInputStreamStep(12)); + + in4.next(); + checkNext(this, "SUB FIRST", subIn12, new byte[] { 42 }); + checkNext(this, "SUB SECOND", subIn12, new byte[] { 0 }); + + assertEquals("The subIn still has some data", false, + subIn12.next()); + + checkNext(this, "MAIN LAST", in4, new byte[] { 127, 12, 5 }); + } + }); + + addTest(new TestCase("UTF-8 text lines test") { + @Override + public void test() throws Exception { + String ln1 = "Ligne première"; + String ln2 = "Ligne la deuxième du nom"; + byte[] data = (ln1 + "\n" + ln2).getBytes("UTF-8"); + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data), + new NextableInputStreamStep('\n')); + + checkNext(this, "FIRST", in, ln1.getBytes("UTF-8")); + checkNext(this, "SECOND", in, ln2.getBytes("UTF-8")); + } + }); + + addTest(new TestCase("nextAll()") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data), + new NextableInputStreamStep(12)); + + checkNext(this, "FIRST", in, new byte[] { 42 }); + checkNextAll(this, "REST", in, new byte[] { 0, 127, 12, 51, 11, + 12 }); + assertEquals("The stream still has some data", false, in.next()); + } + }); + + addTest(new TestCase("getBytesRead()") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data), + new NextableInputStreamStep(12)); + + in.nextAll(); + IOUtils.toByteArray(in); + + assertEquals("The number of bytes read is not correct", + data.length, in.getBytesRead()); + } + }); + + addTest(new TestCase("bytes array input") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream(data, + new NextableInputStreamStep(12)); + + checkNext(this, "FIRST", in, new byte[] { 42 }); + checkNext(this, "SECOND", in, new byte[] { 0, 127 }); + checkNext(this, "THIRD", in, new byte[] { 51, 11 }); + } + }); + + addTest(new TestCase("Skip data") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream(data, null); + in.next(); + + byte[] rest = new byte[] { 12, 51, 11, 12 }; + + in.skip(4); + assertEquals("STARTS_WITH OK_1", true, in.startsWith(rest)); + assertEquals("STARTS_WITH KO_1", false, + in.startsWith(new byte[] { 0 })); + assertEquals("STARTS_WITH KO_2", false, in.startsWith(data)); + assertEquals("STARTS_WITH KO_3", false, + in.startsWith(new byte[] { 1, 2, 3 })); + assertEquals("STARTS_WITH OK_2", true, in.startsWith(rest)); + assertEquals("READ REST", IOUtils.readSmallStream(in), + new String(rest)); + in.close(); + } + }); + + addTest(new TestCase("Starts with") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream(data, null); + in.next(); + + // yes + assertEquals("It actually starts with that", true, + in.startsWith(new byte[] { 42 })); + assertEquals("It actually starts with that", true, + in.startsWith(new byte[] { 42, 12 })); + assertEquals("It actually is the same array", true, + in.startsWith(data)); + + // no + assertEquals("It actually does not start with that", false, + in.startsWith(new byte[] { 12 })); + assertEquals( + "It actually does not start with that", + false, + in.startsWith(new byte[] { 42, 12, 0, 127, 12, 51, 11, + 11 })); + + // too big + try { + in.startsWith(new byte[] { 42, 12, 0, 127, 12, 51, 11, 12, + 0 }); + fail("Searching a prefix bigger than the array should throw an IOException"); + } catch (IOException e) { + } + + in.close(); + } + }); + + addTest(new TestCase("Starts with strings") { + @Override + public void test() throws Exception { + String text = "Fanfan et Toto vont à la mer"; + byte[] data = text.getBytes("UTF-8"); + NextableInputStream in = new NextableInputStream(data, null); + in.next(); + + // yes + assertEquals("It actually starts with that", true, + in.startsWith("F")); + assertEquals("It actually starts with that", true, + in.startsWith("Fanfan et")); + assertEquals("It actually is the same text", true, + in.startsWith(text)); + + // no + assertEquals("It actually does not start with that", false, + in.startsWith("Toto")); + assertEquals("It actually does not start with that", false, + in.startsWith("Fanfan et Toto vont à la mee")); + + // too big + try { + in.startsWith("Fanfan et Toto vont à la mer."); + fail("Searching a prefix bigger than the array should throw an IOException"); + } catch (IOException e) { + } + + in.close(); + } + }); + + addTest(new TestCase("Starts With strings + steps") { + @Override + public void test() throws Exception { + String data = "{\nREF: fanfan\n}"; + NextableInputStream in = new NextableInputStream( + data.getBytes("UTF-8"), new NextableInputStreamStep( + '\n')); + in.next(); + + assertEquals("STARTS_WITH OK", true, in.startsWith("{")); + in.skip(1); + assertEquals("STARTS_WITH WHEN SPENT", false, + in.startsWith("{")); + + checkNext(this, "PARTIAL CONTENT", in, + "REF: fanfan".getBytes("UTF-8")); + } + }); + + addTest(new TestCase("InputStream is(String)") { + @Override + public void test() throws Exception { + String data = "{\nREF: fanfan\n}"; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data.getBytes("UTF-8")), + new NextableInputStreamStep('\n')); + + in.next(); + assertEquals("Item 1 OK", true, in.is("{")); + assertEquals("Item 1 KO_1", false, in.is("|")); + assertEquals("Item 1 KO_2", false, in.is("{}")); + in.skip(1); + in.next(); + assertEquals("Item 2 OK", true, in.is("REF: fanfan")); + assertEquals("Item 2 KO", false, in.is("REF: fanfan.")); + IOUtils.readSmallStream(in); + in.next(); + assertEquals("Item 3 OK", true, in.is("}")); + + in.close(); + } + }); + + addTest(new TestCase("Bytes NextAll test") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127, 12, 51, 11, 12 }; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data), + new NextableInputStreamStep(12)); + + checkNext(this, "FIRST", in, new byte[] { 42 }); + checkNextAll(this, "SECOND", in, new byte[] { 0, 127, 12, 51, + 11, 12 }); + } + }); + + addTest(new TestCase("String NextAll test") { + @Override + public void test() throws Exception { + String d1 = "^java.lang.String"; + String d2 = "\"http://example.com/query.html\""; + String data = d1 + ":" + d2; + NextableInputStream in = new NextableInputStream( + new ByteArrayInputStream(data.getBytes("UTF-8")), + new NextableInputStreamStep(':')); + + checkNext(this, "FIRST", in, d1.getBytes("UTF-8")); + checkNextAll(this, "SECOND", in, d2.getBytes("UTF-8")); + } + }); + + addTest(new TestCase("NextAll in Next test") { + @Override + public void test() throws Exception { + String line1 = "première ligne"; + String d1 = "^java.lang.String"; + String d2 = "\"http://example.com/query.html\""; + String line3 = "end of lines"; + String data = line1 + "\n" + d1 + ":" + d2 + "\n" + line3; + + NextableInputStream inL = new NextableInputStream( + new ByteArrayInputStream(data.getBytes("UTF-8")), + new NextableInputStreamStep('\n')); + + checkNext(this, "Line 1", inL, line1.getBytes("UTF-8")); + inL.next(); + + NextableInputStream in = new NextableInputStream(inL, + new NextableInputStreamStep(':')); + + checkNext(this, "Line 2 FIRST", in, d1.getBytes("UTF-8")); + checkNextAll(this, "Line 2 SECOND", in, d2.getBytes("UTF-8")); + } + }); + } + + static void checkNext(TestCase test, String prefix, NextableInputStream in, + byte[] expected) throws Exception { + test.assertEquals("Cannot get " + prefix + " entry", true, in.next()); + checkArrays(test, prefix, in, expected); + } + + static void checkNextAll(TestCase test, String prefix, + NextableInputStream in, byte[] expected) throws Exception { + test.assertEquals("Cannot get " + prefix + " entries", true, + in.nextAll()); + checkArrays(test, prefix, in, expected); + } + + static void checkArrays(TestCase test, String prefix, + NextableInputStream in, byte[] expected) throws Exception { + byte[] actual = IOUtils.toByteArray(in); + test.assertEquals("The " + prefix + + " resulting array has not the correct number of items", + expected.length, actual.length); + for (int i = 0; i < actual.length; i++) { + test.assertEquals("Item " + i + " (0-based) is not the same", + expected[i], actual[i]); + } + } +} diff --git a/src/be/nikiroo/utils/test_code/ProgressTest.java b/src/be/nikiroo/utils/test_code/ProgressTest.java new file mode 100644 index 0000000..22e36cb --- /dev/null +++ b/src/be/nikiroo/utils/test_code/ProgressTest.java @@ -0,0 +1,319 @@ +package be.nikiroo.utils.test_code; + +import be.nikiroo.utils.Progress; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class ProgressTest extends TestLauncher { + public ProgressTest(String[] args) { + super("Progress reporting", args); + + addSeries(new TestLauncher("Simple progress", args) { + { + addTest(new TestCase("Relative values and direct values") { + @Override + public void test() throws Exception { + Progress p = new Progress(); + assertEquals(0, p.getProgress()); + assertEquals(0, p.getRelativeProgress()); + p.setProgress(33); + assertEquals(33, p.getProgress()); + assertEquals(0.33, p.getRelativeProgress()); + p.setMax(3); + p.setProgress(1); + assertEquals(1, p.getProgress()); + assertEquals( + generateAssertMessage("0.33..", + p.getRelativeProgress()), true, + p.getRelativeProgress() >= 0.332); + assertEquals( + generateAssertMessage("0.33..", + p.getRelativeProgress()), true, + p.getRelativeProgress() <= 0.334); + } + }); + + addTest(new TestCase("Listeners at first level") { + int pg; + + @Override + public void test() throws Exception { + Progress p = new Progress(); + p.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + pg = progress.getProgress(); + } + }); + + p.setProgress(42); + assertEquals(42, pg); + p.setProgress(0); + assertEquals(0, pg); + } + }); + } + }); + + addSeries(new TestLauncher("Progress with children", args) { + { + addTest(new TestCase("One child") { + @Override + public void test() throws Exception { + Progress p = new Progress(); + Progress child = new Progress(); + + p.addProgress(child, 100); + + child.setProgress(42); + assertEquals(42, p.getProgress()); + } + }); + + addTest(new TestCase("Multiple children") { + @Override + public void test() throws Exception { + Progress p = new Progress(); + Progress child1 = new Progress(); + Progress child2 = new Progress(); + Progress child3 = new Progress(); + + p.addProgress(child1, 20); + p.addProgress(child2, 60); + p.addProgress(child3, 20); + + child1.setProgress(50); + assertEquals(10, p.getProgress()); + child2.setProgress(100); + assertEquals(70, p.getProgress()); + child3.setProgress(100); + assertEquals(90, p.getProgress()); + child1.setProgress(100); + assertEquals(100, p.getProgress()); + } + }); + + addTest(new TestCase("Listeners with children") { + int pg; + + @Override + public void test() throws Exception { + final Progress p = new Progress(); + Progress child1 = new Progress(); + Progress child2 = new Progress(); + p.addProgress(child1, 50); + p.addProgress(child2, 50); + + p.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + pg = p.getProgress(); + } + }); + + child1.setProgress(50); + assertEquals(25, pg); + child2.setProgress(100); + assertEquals(75, pg); + child1.setProgress(100); + assertEquals(100, pg); + } + }); + + addTest(new TestCase("Listeners with children, not 1-100") { + int pg; + + @Override + public void test() throws Exception { + final Progress p = new Progress(); + p.setMax(1000); + + Progress child1 = new Progress(); + child1.setMax(2); + + Progress child2 = new Progress(); + p.addProgress(child1, 500); + p.addProgress(child2, 500); + + p.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + pg = p.getProgress(); + } + }); + + child1.setProgress(1); + assertEquals(250, pg); + child2.setProgress(100); + assertEquals(750, pg); + child1.setProgress(2); + assertEquals(1000, pg); + } + }); + + addTest(new TestCase( + "Listeners with children, not 1-100, local progress") { + int pg; + + @Override + public void test() throws Exception { + final Progress p = new Progress(); + p.setMax(1000); + + Progress child1 = new Progress(); + child1.setMax(2); + + Progress child2 = new Progress(); + p.addProgress(child1, 400); + p.addProgress(child2, 400); + // 200 = local progress + + p.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + pg = p.getProgress(); + } + }); + + child1.setProgress(1); + assertEquals(200, pg); + child2.setProgress(100); + assertEquals(600, pg); + p.setProgress(100); + assertEquals(700, pg); + child1.setProgress(2); + assertEquals(900, pg); + p.setProgress(200); + assertEquals(1000, pg); + } + }); + + addTest(new TestCase("Listeners with 5+ children, 4+ depth") { + int pg; + + @Override + public void test() throws Exception { + final Progress p = new Progress(); + Progress child1 = new Progress(); + Progress child2 = new Progress(); + p.addProgress(child1, 50); + p.addProgress(child2, 50); + Progress child11 = new Progress(); + child1.addProgress(child11, 100); + Progress child111 = new Progress(); + child11.addProgress(child111, 100); + Progress child1111 = new Progress(); + child111.addProgress(child1111, 20); + Progress child1112 = new Progress(); + child111.addProgress(child1112, 20); + Progress child1113 = new Progress(); + child111.addProgress(child1113, 20); + Progress child1114 = new Progress(); + child111.addProgress(child1114, 20); + Progress child1115 = new Progress(); + child111.addProgress(child1115, 20); + + p.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + pg = p.getProgress(); + } + }); + + child1111.setProgress(100); + child1112.setProgress(50); + child1113.setProgress(25); + child1114.setProgress(25); + child1115.setProgress(50); + assertEquals(25, pg); + child2.setProgress(100); + assertEquals(75, pg); + child1111.setProgress(100); + child1112.setProgress(100); + child1113.setProgress(100); + child1114.setProgress(100); + child1115.setProgress(100); + assertEquals(100, pg); + } + }); + + addTest(new TestCase("Listeners with children, multi-thread") { + int pg; + boolean decrease; + Object lock1 = new Object(); + Object lock2 = new Object(); + int currentStep1; + int currentStep2; + + @Override + public void test() throws Exception { + final Progress p = new Progress(0, 200); + + final Progress child1 = new Progress(); + final Progress child2 = new Progress(); + p.addProgress(child1, 100); + p.addProgress(child2, 100); + + p.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + int now = p.getProgress(); + if (now < pg) { + decrease = true; + } + pg = now; + } + }); + + // Run 200 concurrent threads, 2 at a time allowed to + // make progress (each on a different child) + for (int i = 0; i <= 100; i++) { + final int step = i; + new Thread(new Runnable() { + @Override + public void run() { + synchronized (lock1) { + if (step > currentStep1) { + currentStep1 = step; + child1.setProgress(step); + } + } + } + }).start(); + + new Thread(new Runnable() { + @Override + public void run() { + synchronized (lock2) { + if (step > currentStep2) { + currentStep2 = step; + child2.setProgress(step); + } + } + } + }).start(); + } + + int i; + int timeout = 20; // in 1/10th of seconds + for (i = 0; i < timeout + && (currentStep1 + currentStep2) < 200; i++) { + Thread.sleep(100); + } + + assertEquals("The test froze at step " + currentStep1 + + " + " + currentStep2, true, i < timeout); + assertEquals( + "There should not have any decresing steps", + decrease, false); + assertEquals("The progress should have reached 200", + 200, p.getProgress()); + assertEquals( + "The progress should have reached completion", + true, p.isDone()); + } + }); + } + }); + } +} diff --git a/src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java b/src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java new file mode 100644 index 0000000..e6e2112 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/ReplaceInputStreamTest.java @@ -0,0 +1,106 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import be.nikiroo.utils.IOUtils; +import be.nikiroo.utils.streams.ReplaceInputStream; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class ReplaceInputStreamTest extends TestLauncher { + public ReplaceInputStreamTest(String[] args) { + super("ReplaceInputStream test", args); + + addTest(new TestCase("Empty replace") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127 }; + ReplaceInputStream in = new ReplaceInputStream( + new ByteArrayInputStream(data), new byte[0], + new byte[0]); + + checkArrays(this, "FIRST", in, data); + } + }); + + addTest(new TestCase("Simple replace") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127 }; + ReplaceInputStream in = new ReplaceInputStream( + new ByteArrayInputStream(data), new byte[] { 0 }, + new byte[] { 10 }); + + checkArrays(this, "FIRST", in, new byte[] { 42, 12, 10, 127 }); + } + }); + + addTest(new TestCase("3/4 replace") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127 }; + ReplaceInputStream in = new ReplaceInputStream( + new ByteArrayInputStream(data), + new byte[] { 12, 0, 127 }, new byte[] { 10, 10, 10 }); + + checkArrays(this, "FIRST", in, new byte[] { 42, 10, 10, 10 }); + } + }); + + addTest(new TestCase("Lnger replace") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127 }; + ReplaceInputStream in = new ReplaceInputStream( + new ByteArrayInputStream(data), new byte[] { 0 }, + new byte[] { 10, 10, 10 }); + + checkArrays(this, "FIRST", in, new byte[] { 42, 12, 10, 10, 10, + 127 }); + } + }); + + addTest(new TestCase("Shorter replace") { + @Override + public void test() throws Exception { + byte[] data = new byte[] { 42, 12, 0, 127 }; + ReplaceInputStream in = new ReplaceInputStream( + new ByteArrayInputStream(data), + new byte[] { 42, 12, 0 }, new byte[] { 10 }); + + checkArrays(this, "FIRST", in, new byte[] { 10, 127 }); + } + }); + + addTest(new TestCase("String replace") { + @Override + public void test() throws Exception { + byte[] data = "I like red".getBytes("UTF-8"); + ReplaceInputStream in = new ReplaceInputStream( + new ByteArrayInputStream(data), + "red".getBytes("UTF-8"), "blue".getBytes("UTF-8")); + + checkArrays(this, "FIRST", in, "I like blue".getBytes("UTF-8")); + + data = "I like blue".getBytes("UTF-8"); + in = new ReplaceInputStream(new ByteArrayInputStream(data), + "blue".getBytes("UTF-8"), "red".getBytes("UTF-8")); + + checkArrays(this, "FIRST", in, "I like red".getBytes("UTF-8")); + } + }); + } + + static void checkArrays(TestCase test, String prefix, InputStream in, + byte[] expected) throws Exception { + byte[] actual = IOUtils.toByteArray(in); + test.assertEquals("The " + prefix + + " resulting array has not the correct number of items", + expected.length, actual.length); + for (int i = 0; i < actual.length; i++) { + test.assertEquals("Item " + i + " (0-based) is not the same", + expected[i], actual[i]); + } + } +} diff --git a/src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java b/src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java new file mode 100644 index 0000000..1db3397 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/ReplaceOutputStreamTest.java @@ -0,0 +1,168 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayOutputStream; + +import be.nikiroo.utils.streams.ReplaceOutputStream; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class ReplaceOutputStreamTest extends TestLauncher { + public ReplaceOutputStreamTest(String[] args) { + super("ReplaceOutputStream test", args); + + addTest(new TestCase("Single write, empty bytes replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, + new byte[0], new byte[0]); + + byte[] data = new byte[] { 42, 12, 0, 127 }; + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, data); + } + }); + + addTest(new TestCase("Multiple writes, empty Strings replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, "", ""); + + byte[] data1 = new byte[] { 42, 12, 0, 127 }; + byte[] data2 = new byte[] { 15, 55 }; + byte[] data3 = new byte[] {}; + + byte[] dataAll = new byte[] { 42, 12, 0, 127, 15, 55 }; + + out.write(data1); + out.write(data2); + out.write(data3); + out.close(); + + checkArrays(this, "FIRST", bout, dataAll); + } + }); + + addTest(new TestCase("Single write, bytes replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, + new byte[] { 12 }, new byte[] { 55 }); + + byte[] data = new byte[] { 42, 12, 0, 127 }; + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, new byte[] { 42, 55, 0, 127 }); + } + }); + + addTest(new TestCase("Multiple writes, Strings replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, "(-)", + "(.)"); + + byte[] data1 = "un mot ".getBytes("UTF-8"); + byte[] data2 = "(-) of twee ".getBytes("UTF-8"); + byte[] data3 = "(-) makes the difference".getBytes("UTF-8"); + + out.write(data1); + out.write(data2); + out.write(data3); + out.close(); + + checkArrays(this, "FIRST", bout, + "un mot (.) of twee (.) makes the difference" + .getBytes("UTF-8")); + } + }); + + addTest(new TestCase("Single write, longer bytes replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, + new byte[] { 12 }, new byte[] { 55, 55, 66 }); + + byte[] data = new byte[] { 42, 12, 0, 127 }; + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, new byte[] { 42, 55, 55, 66, + 0, 127 }); + } + }); + + addTest(new TestCase("Single write, shorter bytes replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, + new byte[] { 12, 0 }, new byte[] { 55 }); + + byte[] data = new byte[] { 42, 12, 0, 127 }; + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, new byte[] { 42, 55, 127 }); + } + }); + + addTest(new TestCase("Single write, remove bytes replaces") { + @Override + public void test() throws Exception { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + ReplaceOutputStream out = new ReplaceOutputStream(bout, + new byte[] { 12 }, new byte[] {}); + + byte[] data = new byte[] { 42, 12, 0, 127 }; + + out.write(data); + out.close(); + + checkArrays(this, "FIRST", bout, new byte[] { 42, 0, 127 }); + } + }); + } + + static void checkArrays(TestCase test, String prefix, + ByteArrayOutputStream bout, byte[] expected) throws Exception { + byte[] actual = bout.toByteArray(); + + if (false) { + System.out.print("\nExpected data: [ "); + for (int i = 0; i < expected.length; i++) { + if (i > 0) + System.out.print(", "); + System.out.print(expected[i]); + } + System.out.println(" ]"); + + System.out.print("Actual data : [ "); + for (int i = 0; i < actual.length; i++) { + if (i > 0) + System.out.print(", "); + System.out.print(actual[i]); + } + System.out.println(" ]"); + } + + test.assertEquals("The " + prefix + + " resulting array has not the correct number of items", + expected.length, actual.length); + for (int i = 0; i < actual.length; i++) { + test.assertEquals(prefix + ": item " + i + + " (0-based) is not the same", expected[i], actual[i]); + } + } +} diff --git a/src/be/nikiroo/utils/test_code/SerialServerTest.java b/src/be/nikiroo/utils/test_code/SerialServerTest.java new file mode 100644 index 0000000..c10a158 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/SerialServerTest.java @@ -0,0 +1,637 @@ +package be.nikiroo.utils.test_code; + +import java.net.URL; + +import be.nikiroo.utils.Version; +import be.nikiroo.utils.serial.server.ConnectActionClientObject; +import be.nikiroo.utils.serial.server.ConnectActionClientString; +import be.nikiroo.utils.serial.server.ConnectActionServerObject; +import be.nikiroo.utils.serial.server.ConnectActionServerString; +import be.nikiroo.utils.serial.server.ServerBridge; +import be.nikiroo.utils.serial.server.ServerObject; +import be.nikiroo.utils.serial.server.ServerString; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class SerialServerTest extends TestLauncher { + public SerialServerTest(String[] args) { + super("SerialServer test", args); + + for (String key : new String[] { null, + "some super secret encryption key" }) { + for (boolean bridge : new Boolean[] { false, true }) { + final String skey = (key != null ? "(encrypted)" + : "(plain text)"); + final String sbridge = (bridge ? " with bridge" : ""); + + addSeries(new SerialServerTest(args, key, skey, bridge, + sbridge, "ServerString")); + + addSeries(new SerialServerTest(args, key, skey, bridge, + sbridge, new Object() { + @Override + public String toString() { + return "ServerObject"; + } + })); + } + } + } + + private SerialServerTest(final String[] args, final String key, + final String skey, final boolean bridge, final String sbridge, + final String title) { + + super(title + " " + skey + sbridge, args); + + addTest(new TestCase("Simple connection " + skey) { + @Override + public void test() throws Exception { + final String[] rec = new String[1]; + + ServerString server = new ServerString(this.getName(), 0, key) { + @Override + protected String onRequest( + ConnectActionServerString action, Version version, + String data, long id) throws Exception { + return null; + } + + @Override + protected void onError(Exception e) { + } + }; + + int port = server.getPort(); + assertEquals("A port should have been assigned", true, port > 0); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + + port = br.getPort(); + assertEquals( + "A port should have been assigned to the bridge", + true, port > 0); + + br.start(); + } + + try { + try { + new ConnectActionClientString(null, port, key) { + @Override + public void action(Version version) + throws Exception { + rec[0] = "ok"; + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + assertNotNull("The client action was not run", rec[0]); + assertEquals("ok", rec[0]); + } + }); + + addTest(new TestCase("Simple exchange " + skey) { + final String[] sent = new String[1]; + final String[] recd = new String[1]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerString server = new ServerString(this.getName(), 0, key) { + @Override + protected String onRequest( + ConnectActionServerString action, Version version, + String data, long id) throws Exception { + sent[0] = data; + return "pong"; + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientString(null, port, key) { + @Override + public void action(Version version) + throws Exception { + recd[0] = send("ping"); + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + assertEquals("ping", sent[0]); + assertEquals("pong", recd[0]); + } + }); + + addTest(new TestCase("Multiple exchanges " + skey) { + final String[] sent = new String[3]; + final String[] recd = new String[3]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerString server = new ServerString(this.getName(), 0, key) { + @Override + protected String onRequest( + ConnectActionServerString action, Version version, + String data, long id) throws Exception { + sent[0] = data; + action.send("pong"); + sent[1] = action.rec(); + return "pong2"; + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientString(null, port, key) { + @Override + public void action(Version version) + throws Exception { + recd[0] = send("ping"); + recd[1] = send("ping2"); + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + assertEquals("ping", sent[0]); + assertEquals("pong", recd[0]); + assertEquals("ping2", sent[1]); + assertEquals("pong2", recd[1]); + } + }); + + addTest(new TestCase("Multiple call from client " + skey) { + final String[] sent = new String[3]; + final String[] recd = new String[3]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerString server = new ServerString(this.getName(), 0, key) { + @Override + protected String onRequest( + ConnectActionServerString action, Version version, + String data, long id) throws Exception { + sent[Integer.parseInt(data)] = data; + return "" + (Integer.parseInt(data) * 2); + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientString(null, port, key) { + @Override + public void action(Version version) + throws Exception { + for (int i = 0; i < 3; i++) { + recd[i] = send("" + i); + } + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + assertEquals("0", sent[0]); + assertEquals("0", recd[0]); + assertEquals("1", sent[1]); + assertEquals("2", recd[1]); + assertEquals("2", sent[2]); + assertEquals("4", recd[2]); + } + }); + } + + private SerialServerTest(final String[] args, final String key, + final String skey, final boolean bridge, final String sbridge, + final Object title) { + + super(title + " " + skey + sbridge, args); + + addTest(new TestCase("Simple connection " + skey) { + @Override + public void test() throws Exception { + final Object[] rec = new Object[1]; + + ServerObject server = new ServerObject(this.getName(), 0, key) { + @Override + protected Object onRequest( + ConnectActionServerObject action, Version version, + Object data, long id) throws Exception { + return null; + } + + @Override + protected void onError(Exception e) { + } + }; + + int port = server.getPort(); + assertEquals("A port should have been assigned", true, port > 0); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientObject(null, port, key) { + @Override + public void action(Version version) + throws Exception { + rec[0] = true; + } + + @Override + protected void onError(Exception e) { + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + assertNotNull("The client action was not run", rec[0]); + assertEquals(true, (boolean) ((Boolean) rec[0])); + } + }); + + addTest(new TestCase("Simple exchange " + skey) { + final Object[] sent = new Object[1]; + final Object[] recd = new Object[1]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerObject server = new ServerObject(this.getName(), 0, key) { + @Override + protected Object onRequest( + ConnectActionServerObject action, Version version, + Object data, long id) throws Exception { + sent[0] = data; + return "pong"; + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientObject(null, port, key) { + @Override + public void action(Version version) + throws Exception { + recd[0] = send("ping"); + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + assertEquals("ping", sent[0]); + assertEquals("pong", recd[0]); + } + }); + + addTest(new TestCase("Multiple exchanges " + skey) { + final Object[] sent = new Object[3]; + final Object[] recd = new Object[3]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerObject server = new ServerObject(this.getName(), 0, key) { + @Override + protected Object onRequest( + ConnectActionServerObject action, Version version, + Object data, long id) throws Exception { + sent[0] = data; + action.send("pong"); + sent[1] = action.rec(); + return "pong2"; + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientObject(null, port, key) { + @Override + public void action(Version version) + throws Exception { + recd[0] = send("ping"); + recd[1] = send("ping2"); + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + assertEquals("ping", sent[0]); + assertEquals("pong", recd[0]); + assertEquals("ping2", sent[1]); + assertEquals("pong2", recd[1]); + } + }); + + addTest(new TestCase("Object array of URLs " + skey) { + final Object[] sent = new Object[1]; + final Object[] recd = new Object[1]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerObject server = new ServerObject(this.getName(), 0, key) { + @Override + protected Object onRequest( + ConnectActionServerObject action, Version version, + Object data, long id) throws Exception { + sent[0] = data; + return new Object[] { "ACK" }; + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientObject(null, port, key) { + @Override + public void action(Version version) + throws Exception { + recd[0] = send(new Object[] { + "key", + new URL( + "https://example.com/from_client"), + "https://example.com/from_client" }); + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + Object[] sento = (Object[]) (sent[0]); + Object[] recdo = (Object[]) (recd[0]); + + assertEquals("key", sento[0]); + assertEquals("https://example.com/from_client", + ((URL) sento[1]).toString()); + assertEquals("https://example.com/from_client", sento[2]); + assertEquals("ACK", recdo[0]); + } + }); + + addTest(new TestCase("Multiple call from client " + skey) { + final Object[] sent = new Object[3]; + final Object[] recd = new Object[3]; + final Exception[] err = new Exception[1]; + + @Override + public void test() throws Exception { + ServerObject server = new ServerObject(this.getName(), 0, key) { + @Override + protected Object onRequest( + ConnectActionServerObject action, Version version, + Object data, long id) throws Exception { + sent[(Integer) data] = data; + return ((Integer) data) * 2; + } + + @Override + protected void onError(Exception e) { + err[0] = e; + } + }; + + int port = server.getPort(); + + server.start(); + + ServerBridge br = null; + if (bridge) { + br = new ServerBridge(0, key, "", port, key); + br.setTraceHandler(null); + port = br.getPort(); + br.start(); + } + + try { + try { + new ConnectActionClientObject(null, port, key) { + @Override + public void action(Version version) + throws Exception { + for (int i = 0; i < 3; i++) { + recd[i] = send(i); + } + } + }.connect(); + } finally { + server.stop(); + } + } finally { + if (br != null) { + br.stop(); + } + } + + if (err[0] != null) { + fail("An exception was thrown: " + err[0].getMessage(), + err[0]); + } + + assertEquals(0, sent[0]); + assertEquals(0, recd[0]); + assertEquals(1, sent[1]); + assertEquals(2, recd[1]); + assertEquals(2, sent[2]); + assertEquals(4, recd[2]); + } + }); + } +} diff --git a/src/be/nikiroo/utils/test_code/SerialTest.java b/src/be/nikiroo/utils/test_code/SerialTest.java new file mode 100644 index 0000000..bf08f5c --- /dev/null +++ b/src/be/nikiroo/utils/test_code/SerialTest.java @@ -0,0 +1,281 @@ +package be.nikiroo.utils.test_code; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.NotSerializableException; +import java.net.URL; +import java.util.Arrays; + +import be.nikiroo.utils.serial.Exporter; +import be.nikiroo.utils.serial.Importer; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class SerialTest extends TestLauncher { + /** + * Required for Import/Export of objects. + */ + public SerialTest() { + this(null); + } + + private void encodeRecodeTest(TestCase test, Object data) throws Exception { + byte[] encoded = toBytes(data, true); + Object redata = fromBytes(toBytes(data, false)); + byte[] reencoded = toBytes(redata, true); + + // We suppose text mode + if (encoded.length < 256 && reencoded.length < 256) { + test.assertEquals("Different data after encode/decode/encode", + new String(encoded, "UTF-8"), + new String(reencoded, "UTF-8")); + } else { + test.assertEquals("Different data after encode/decode/encode", + true, Arrays.equals(encoded, reencoded)); + } + } + + // try to remove pointer addresses + private byte[] toBytes(Object data, boolean clearRefs) + throws NotSerializableException, IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new Exporter(out).append(data); + out.flush(); + + if (clearRefs) { + String tmp = new String(out.toByteArray(), "UTF-8"); + tmp = tmp.replaceAll("@[0-9]*", "@REF"); + return tmp.getBytes("UTF-8"); + } + + return out.toByteArray(); + } + + private Object fromBytes(byte[] data) throws NoSuchFieldException, + NoSuchMethodException, ClassNotFoundException, + NullPointerException, IOException { + + InputStream in = new ByteArrayInputStream(data); + try { + return new Importer().read(in).getValue(); + } finally { + in.close(); + } + } + + public SerialTest(String[] args) { + super("Serial test", args); + + addTest(new TestCase("Simple class Import/Export") { + @Override + public void test() throws Exception { + Data data = new Data(42); + encodeRecodeTest(this, data); + } + }); + + addTest(new TestCase() { + @SuppressWarnings("unused") + private TestCase me = setName("Anonymous inner class"); + + @Override + public void test() throws Exception { + Data data = new Data() { + @SuppressWarnings("unused") + int value = 42; + }; + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase() { + @SuppressWarnings("unused") + private TestCase me = setName("Array of anonymous inner classes"); + + @Override + public void test() throws Exception { + Data[] data = new Data[] { new Data() { + @SuppressWarnings("unused") + int value = 42; + } }; + + byte[] encoded = toBytes(data, false); + Object redata = fromBytes(encoded); + + // Comparing the 2 arrays won't be useful, because the @REFs + // will be ZIP-encoded; so we parse and re-encode each object + + byte[] encoded1 = toBytes(data[0], true); + byte[] reencoded1 = toBytes(((Object[]) redata)[0], true); + + assertEquals("Different data after encode/decode/encode", true, + Arrays.equals(encoded1, reencoded1)); + } + }); + addTest(new TestCase("URL Import/Export") { + @Override + public void test() throws Exception { + URL data = new URL("https://fanfan.be/"); + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("URL-String Import/Export") { + @Override + public void test() throws Exception { + String data = new URL("https://fanfan.be/").toString(); + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("URL/URL-String arrays Import/Export") { + @Override + public void test() throws Exception { + final String url = "https://fanfan.be/"; + Object[] data = new Object[] { new URL(url), url }; + + byte[] encoded = toBytes(data, false); + Object redata = fromBytes(encoded); + + // Comparing the 2 arrays won't be useful, because the @REFs + // will be ZIP-encoded; so we parse and re-encode each object + + byte[] encoded1 = toBytes(data[0], true); + byte[] reencoded1 = toBytes(((Object[]) redata)[0], true); + byte[] encoded2 = toBytes(data[1], true); + byte[] reencoded2 = toBytes(((Object[]) redata)[1], true); + + assertEquals("Different data 1 after encode/decode/encode", + true, Arrays.equals(encoded1, reencoded1)); + assertEquals("Different data 2 after encode/decode/encode", + true, Arrays.equals(encoded2, reencoded2)); + } + }); + addTest(new TestCase("Import/Export with nested objects") { + @Override + public void test() throws Exception { + Data data = new DataObject(new Data(21)); + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("Import/Export String in object") { + @Override + public void test() throws Exception { + Data data = new DataString("fanfan"); + encodeRecodeTest(this, data); + data = new DataString("http://example.com/query.html"); + encodeRecodeTest(this, data); + data = new DataString("Test|Ché|http://|\"\\\"Pouch\\"); + encodeRecodeTest(this, data); + data = new DataString("Test|Ché\\n|\nhttp://|\"\\\"Pouch\\"); + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("Import/Export with nested objects forming a loop") { + @Override + public void test() throws Exception { + DataLoop data = new DataLoop("looping"); + data.next = new DataLoop("level 2"); + data.next.next = data; + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("Array in Object Import/Export") { + @Override + public void test() throws Exception { + Object data = new DataArray();// new String[] { "un", "deux" }; + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("Array Import/Export") { + @Override + public void test() throws Exception { + Object data = new String[] { "un", "deux" }; + encodeRecodeTest(this, data); + } + }); + addTest(new TestCase("Enum Import/Export") { + @Override + public void test() throws Exception { + Object data = EnumToSend.FANFAN; + encodeRecodeTest(this, data); + } + }); + } + + class DataArray { + public String[] data = new String[] { "un", "deux" }; + } + + class Data { + private int value; + + private Data() { + } + + public Data(int value) { + this.value = value; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Data) { + Data other = (Data) obj; + return other.value == this.value; + } + + return false; + } + + @Override + public int hashCode() { + return new Integer(value).hashCode(); + } + } + + @SuppressWarnings("unused") + class DataObject extends Data { + private Data data; + + @SuppressWarnings("synthetic-access") + private DataObject() { + } + + @SuppressWarnings("synthetic-access") + public DataObject(Data data) { + this.data = data; + } + } + + @SuppressWarnings("unused") + class DataString extends Data { + private String data; + + @SuppressWarnings("synthetic-access") + private DataString() { + } + + @SuppressWarnings("synthetic-access") + public DataString(String data) { + this.data = data; + } + } + + @SuppressWarnings("unused") + class DataLoop extends Data { + public DataLoop next; + private String value; + + @SuppressWarnings("synthetic-access") + private DataLoop() { + } + + @SuppressWarnings("synthetic-access") + public DataLoop(String value) { + this.value = value; + } + } + + enum EnumToSend { + FANFAN, TULIPE, + } +} diff --git a/src/be/nikiroo/utils/test_code/StringUtilsTest.java b/src/be/nikiroo/utils/test_code/StringUtilsTest.java new file mode 100644 index 0000000..a441195 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/StringUtilsTest.java @@ -0,0 +1,304 @@ +package be.nikiroo.utils.test_code; + +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.StringUtils.Alignment; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class StringUtilsTest extends TestLauncher { + public StringUtilsTest(String[] args) { + super("StringUtils test", args); + + addTest(new TestCase("Time serialisation") { + @Override + public void test() throws Exception { + for (long fullTime : new Long[] { 0l, 123456l, 123456000l, + new Date().getTime() }) { + // precise to the second, no more + long time = (fullTime / 1000) * 1000; + + String displayTime = StringUtils.fromTime(time); + assertNotNull("The stringified time for " + time + + " should not be null", displayTime); + assertEquals("The stringified time for " + time + + " should not be empty", false, displayTime.trim() + .isEmpty()); + + assertEquals("The time " + time + + " should be loop-convertable", time, + StringUtils.toTime(displayTime)); + + assertEquals("The time " + displayTime + + " should be loop-convertable", displayTime, + StringUtils.fromTime(StringUtils + .toTime(displayTime))); + } + } + }); + + addTest(new TestCase("MD5") { + @Override + public void test() throws Exception { + String mess = "The String we got is not what 'md5sum' said it should heve been"; + assertEquals(mess, "34ded48fcff4221d644be9a37e1cb1d9", + StringUtils.getMd5Hash("fanfan la tulipe")); + assertEquals(mess, "7691b0cb74ed0f94b4d8cd858abe1165", + StringUtils.getMd5Hash("je te do-o-o-o-o-o-nne")); + } + }); + + addTest(new TestCase("Padding") { + @Override + public void test() throws Exception { + for (String data : new String[] { "fanfan", "la tulipe", + "1234567890", "12345678901234567890", "1", "" }) { + String result = StringUtils.padString(data, -1); + assertEquals("A size of -1 is expected to produce a noop", + true, data.equals(result)); + for (int size : new Integer[] { 0, 1, 5, 10, 40 }) { + result = StringUtils.padString(data, size); + assertEquals( + "Padding a String at a certain size should give a String of the given size", + size, result.length()); + assertEquals( + "Padding a String should not change the content", + true, data.trim().startsWith(result.trim())); + + result = StringUtils.padString(data, size, false, null); + assertEquals( + "Padding a String without cutting should not shorten the String", + true, data.length() <= result.length()); + assertEquals( + "Padding a String without cutting should keep the whole content", + true, data.trim().equals(result.trim())); + + result = StringUtils.padString(data, size, false, + Alignment.RIGHT); + if (size > data.length()) { + assertEquals( + "Padding a String to the end should work as expected", + true, result.endsWith(data)); + } + + result = StringUtils.padString(data, size, false, + Alignment.JUSTIFY); + if (size > data.length()) { + String unspacedData = data.trim(); + String unspacedResult = result.trim(); + for (int i = 0; i < size; i++) { + unspacedData = unspacedData.replace(" ", " "); + unspacedResult = unspacedResult.replace(" ", + " "); + } + + assertEquals( + "Justified text trimmed with all spaces collapsed " + + "sould be identical to original text " + + "trimmed with all spaces collapsed", + unspacedData, unspacedResult); + } + + result = StringUtils.padString(data, size, false, + Alignment.CENTER); + if (size > data.length()) { + int before = 0; + for (int i = 0; i < result.length() + && result.charAt(i) == ' '; i++) { + before++; + } + + int after = 0; + for (int i = result.length() - 1; i >= 0 + && result.charAt(i) == ' '; i--) { + after++; + } + + if (result.trim().isEmpty()) { + after = before / 2; + if (before > (2 * after)) { + before = after + 1; + } else { + before = after; + } + } + + assertEquals( + "Padding a String on center should work as expected", + result.length(), before + data.length() + + after); + assertEquals( + "Padding a String on center should not uncenter the content", + true, Math.abs(before - after) <= 1); + } + } + } + } + }); + + addTest(new TestCase("Justifying") { + @Override + public void test() throws Exception { + Map>>> source = new HashMap>>>(); + addValue(source, Alignment.LEFT, "testy", -1, "testy"); + addValue(source, Alignment.RIGHT, "testy", -1, "testy"); + addValue(source, Alignment.CENTER, "testy", -1, "testy"); + addValue(source, Alignment.JUSTIFY, "testy", -1, "testy"); + addValue(source, Alignment.LEFT, "testy", 5, "testy"); + addValue(source, Alignment.LEFT, "testy", 3, "te-", "sty"); + addValue(source, Alignment.LEFT, + "Un petit texte qui se mettra sur plusieurs lignes", + 10, "Un petit", "texte qui", "se mettra", "sur", + "plusieurs", "lignes"); + addValue(source, Alignment.LEFT, + "Un petit texte qui se mettra sur plusieurs lignes", 7, + "Un", "petit", "texte", "qui se", "mettra", "sur", + "plusie-", "urs", "lignes"); + addValue(source, Alignment.RIGHT, + "Un petit texte qui se mettra sur plusieurs lignes", 7, + " Un", " petit", " texte", " qui se", " mettra", + " sur", "plusie-", " urs", " lignes"); + addValue(source, Alignment.CENTER, + "Un petit texte qui se mettra sur plusieurs lignes", 7, + " Un ", " petit ", " texte ", "qui se ", "mettra ", + " sur ", "plusie-", " urs ", "lignes "); + addValue(source, Alignment.JUSTIFY, + "Un petit texte qui se mettra sur plusieurs lignes", 7, + "Un pet-", "it tex-", "te qui", "se met-", "tra sur", + "plusie-", "urs li-", "gnes"); + addValue(source, Alignment.JUSTIFY, + "Un petit texte qui se mettra sur plusieurs lignes", + 14, "Un petit", "texte qui se", + "mettra sur", "plusieurs lig-", "nes"); + addValue(source, Alignment.JUSTIFY, "le dash-test", 9, + "le dash-", "test"); + + for (String data : source.keySet()) { + for (int size : source.get(data).keySet()) { + Alignment align = source.get(data).get(size).getKey(); + List values = source.get(data).get(size) + .getValue(); + + List result = StringUtils.justifyText(data, + size, align); + + // System.out.println("[" + data + " (" + size + ")" + + // "] -> ["); + // for (int i = 0; i < result.size(); i++) { + // String resultLine = result.get(i); + // System.out.println(i + ": " + resultLine); + // } + // System.out.println("]"); + + assertEquals(values, result); + } + } + } + }); + + addTest(new TestCase("unhtml") { + @Override + public void test() throws Exception { + Map data = new HashMap(); + data.put("aa", "aa"); + data.put("test with spaces ", "test with spaces "); + data.put("link", "link"); + data.put("Digimon", "Digimon"); + data.put("", ""); + data.put(" ", " "); + + for (Entry entry : data.entrySet()) { + String result = StringUtils.unhtml(entry.getKey()); + assertEquals("Result is not what we expected", + entry.getValue(), result); + } + } + }); + + addTest(new TestCase("zip64") { + @Override + public void test() throws Exception { + String orig = "test"; + String zipped = StringUtils.zip64(orig); + String unzipped = StringUtils.unzip64s(zipped); + assertEquals(orig, unzipped); + } + }); + + addTest(new TestCase("format/toNumber simple") { + @Override + public void test() throws Exception { + assertEquals(263l, StringUtils.toNumber("263")); + assertEquals(21200l, StringUtils.toNumber("21200")); + assertEquals(0l, StringUtils.toNumber("0")); + assertEquals("263", StringUtils.formatNumber(263l)); + assertEquals("21 k", StringUtils.formatNumber(21000l)); + assertEquals("0", StringUtils.formatNumber(0l)); + } + }); + + addTest(new TestCase("format/toNumber not 000") { + @Override + public void test() throws Exception { + assertEquals(263200l, StringUtils.toNumber("263.2 k")); + assertEquals(42000l, StringUtils.toNumber("42.0 k")); + assertEquals(12000000l, StringUtils.toNumber("12 M")); + assertEquals(2000000000l, StringUtils.toNumber("2 G")); + assertEquals("263 k", StringUtils.formatNumber(263012l)); + assertEquals("42 k", StringUtils.formatNumber(42012l)); + assertEquals("12 M", StringUtils.formatNumber(12012121l)); + assertEquals("7 G", StringUtils.formatNumber(7364635928l)); + } + }); + + addTest(new TestCase("format/toNumber decimals") { + @Override + public void test() throws Exception { + assertEquals(263200l, StringUtils.toNumber("263.2 k")); + assertEquals(1200l, StringUtils.toNumber("1.2 k")); + assertEquals(42700000l, StringUtils.toNumber("42.7 M")); + assertEquals(1220l, StringUtils.toNumber("1.22 k")); + assertEquals(1432l, StringUtils.toNumber("1.432 k")); + assertEquals(6938l, StringUtils.toNumber("6.938 k")); + assertEquals("1.3 k", StringUtils.formatNumber(1300l, 1)); + assertEquals("263.2020 k", StringUtils.formatNumber(263202l, 4)); + assertEquals("1.26 k", StringUtils.formatNumber(1267l, 2)); + assertEquals("42.7 M", StringUtils.formatNumber(42712121l, 1)); + assertEquals("5.09 G", StringUtils.formatNumber(5094837485l, 2)); + } + }); + } + + static private void addValue( + Map>>> source, + final Alignment align, String input, int size, + final String... result) { + if (!source.containsKey(input)) { + source.put(input, + new HashMap>>()); + } + + source.get(input).put(size, new Entry>() { + @Override + public Alignment getKey() { + return align; + } + + @Override + public List getValue() { + return Arrays.asList(result); + } + + @Override + public List setValue(List value) { + return null; + } + }); + } +} diff --git a/src/be/nikiroo/utils/test_code/TempFilesTest.java b/src/be/nikiroo/utils/test_code/TempFilesTest.java new file mode 100644 index 0000000..dad4cac --- /dev/null +++ b/src/be/nikiroo/utils/test_code/TempFilesTest.java @@ -0,0 +1,109 @@ +package be.nikiroo.utils.test_code; + +import java.io.File; +import java.io.IOException; + +import be.nikiroo.utils.TempFiles; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class TempFilesTest extends TestLauncher { + public TempFilesTest(String[] args) { + super("TempFiles", args); + + addTest(new TestCase("Name is correct") { + @Override + public void test() throws Exception { + RootTempFiles files = new RootTempFiles("testy"); + try { + assertEquals("The root was not created", true, files + .getRoot().exists()); + assertEquals( + "The root is not prefixed with the expected name", + true, files.getRoot().getName().startsWith("testy")); + + } finally { + files.close(); + } + } + }); + + addTest(new TestCase("Clean after itself no use") { + @Override + public void test() throws Exception { + RootTempFiles files = new RootTempFiles("testy2"); + try { + assertEquals("The root was not created", true, files + .getRoot().exists()); + } finally { + files.close(); + assertEquals("The root was not deleted", false, files + .getRoot().exists()); + } + } + }); + + addTest(new TestCase("Clean after itself after usage") { + @Override + public void test() throws Exception { + RootTempFiles files = new RootTempFiles("testy3"); + try { + assertEquals("The root was not created", true, files + .getRoot().exists()); + files.createTempFile("test"); + } finally { + files.close(); + assertEquals("The root was not deleted", false, files + .getRoot().exists()); + assertEquals("The main root in /tmp was not deleted", + false, files.getRoot().getParentFile().exists()); + } + } + }); + + addTest(new TestCase("Temporary directories") { + @Override + public void test() throws Exception { + RootTempFiles files = new RootTempFiles("testy4"); + File file = null; + try { + File dir = files.createTempDir("test"); + file = new File(dir, "fanfan"); + file.createNewFile(); + assertEquals( + "Cannot create a file in a temporary directory", + true, file.exists()); + } finally { + files.close(); + if (file != null) { + assertEquals( + "A file created in a temporary directory should be deleted on close", + false, file.exists()); + } + assertEquals("The root was not deleted", false, files + .getRoot().exists()); + } + } + }); + } + + private class RootTempFiles extends TempFiles { + private File root = null; + + public RootTempFiles(String name) throws IOException { + super(name); + } + + public File getRoot() { + if (root != null) + return root; + return super.root; + } + + @Override + public synchronized void close() throws IOException { + root = super.root; + super.close(); + } + } +} diff --git a/src/be/nikiroo/utils/test_code/Test.java b/src/be/nikiroo/utils/test_code/Test.java new file mode 100644 index 0000000..8d99cba --- /dev/null +++ b/src/be/nikiroo/utils/test_code/Test.java @@ -0,0 +1,68 @@ +package be.nikiroo.utils.test_code; + +import be.nikiroo.utils.Cache; +import be.nikiroo.utils.CacheMemory; +import be.nikiroo.utils.Downloader; +import be.nikiroo.utils.Proxy; +import be.nikiroo.utils.main.bridge; +import be.nikiroo.utils.main.img2aa; +import be.nikiroo.utils.main.justify; +import be.nikiroo.utils.test.TestLauncher; +import be.nikiroo.utils.ui.UIUtils; + +/** + * Tests for nikiroo-utils. + * + * @author niki + */ +public class Test extends TestLauncher { + /** + * Start the tests. + * + * @param args + * the arguments (which are passed as-is to the other test + * classes) + */ + public Test(String[] args) { + super("Nikiroo-utils", args); + + // setDetails(true); + + addSeries(new ProgressTest(args)); + addSeries(new BundleTest(args)); + addSeries(new IOUtilsTest(args)); + addSeries(new VersionTest(args)); + addSeries(new SerialTest(args)); + addSeries(new SerialServerTest(args)); + addSeries(new StringUtilsTest(args)); + addSeries(new TempFilesTest(args)); + addSeries(new CryptUtilsTest(args)); + addSeries(new BufferedInputStreamTest(args)); + addSeries(new NextableInputStreamTest(args)); + addSeries(new ReplaceInputStreamTest(args)); + addSeries(new BufferedOutputStreamTest(args)); + addSeries(new ReplaceOutputStreamTest(args)); + + // TODO: test cache and downloader + Cache cache = null; + CacheMemory memcache = null; + Downloader downloader = null; + + // To include the sources: + img2aa siu; + justify ssu; + bridge aa; + Proxy proxy; + UIUtils uiUtils; + } + + /** + * Main entry point of the program. + * + * @param args + * the arguments passed to the {@link TestLauncher}s. + */ + static public void main(String[] args) { + System.exit(new Test(args).launch()); + } +} diff --git a/src/be/nikiroo/utils/test_code/VersionTest.java b/src/be/nikiroo/utils/test_code/VersionTest.java new file mode 100644 index 0000000..2d84476 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/VersionTest.java @@ -0,0 +1,140 @@ +package be.nikiroo.utils.test_code; + +import be.nikiroo.utils.Version; +import be.nikiroo.utils.test.TestCase; +import be.nikiroo.utils.test.TestLauncher; + +class VersionTest extends TestLauncher { + public VersionTest(String[] args) { + super("Version test", args); + + addTest(new TestCase("String <-> int") { + @Override + public void test() throws Exception { + assertEquals("Cannot parse version 1.2.3 from int to String", + "1.2.3", new Version(1, 2, 3).toString()); + assertEquals( + "Cannot parse major version \"1.2.3\" from String to int", + 1, new Version("1.2.3").getMajor()); + assertEquals( + "Cannot parse minor version \"1.2.3\" from String to int", + 2, new Version("1.2.3").getMinor()); + assertEquals( + "Cannot parse patch version \"1.2.3\" from String to int", + 3, new Version("1.2.3").getPatch()); + } + }); + + addTest(new TestCase("Bad input") { + @Override + public void test() throws Exception { + assertEquals( + "Bad input should return an empty version", + true, + new Version( + "Doors 98 SE Special Deluxe Edition Pro++ Not-Home") + .isEmpty()); + + assertEquals( + "Bad input should return [unknown]", + "[unknown]", + new Version( + "Doors 98 SE Special Deluxe Edition Pro++ Not-Home") + .toString()); + } + }); + + addTest(new TestCase("Read current version") { + @Override + public void test() throws Exception { + assertNotNull("The version should not be NULL (in any case!)", + Version.getCurrentVersion()); + assertEquals("The current version should not be empty", false, + Version.getCurrentVersion().isEmpty()); + } + }); + + addTest(new TestCase("Tag version") { + @Override + public void test() throws Exception { + Version version = new Version("1.0.0-debian0"); + assertEquals("debian", version.getTag()); + assertEquals(0, version.getTagVersion()); + version = new Version("1.0.0-debian.0"); + assertEquals("debian.", version.getTag()); + assertEquals(0, version.getTagVersion()); + version = new Version("1.0.0-debian-0"); + assertEquals("debian-", version.getTag()); + assertEquals(0, version.getTagVersion()); + version = new Version("1.0.0-debian-12"); + assertEquals("debian-", version.getTag()); + assertEquals(12, version.getTagVersion()); + + // tag with no tag version + version = new Version("1.0.0-dev"); + assertEquals(1, version.getMajor()); + assertEquals(0, version.getMinor()); + assertEquals(0, version.getPatch()); + assertEquals("dev", version.getTag()); + assertEquals(-1, version.getTagVersion()); + } + }); + + addTest(new TestCase("Comparing versions") { + @Override + public void test() throws Exception { + assertEquals(true, + new Version(1, 1, 1).isNewerThan(new Version(1, 1, 0))); + assertEquals(true, + new Version(2, 0, 0).isNewerThan(new Version(1, 1, 1))); + assertEquals(true, + new Version(10, 7, 8).isNewerThan(new Version(9, 9, 9))); + assertEquals(true, + new Version(0, 0, 0).isOlderThan(new Version(0, 0, 1))); + assertEquals(1, + new Version(1, 1, 1).compareTo(new Version(0, 1, 1))); + assertEquals(-1, + new Version(0, 0, 1).compareTo(new Version(0, 1, 1))); + assertEquals(0, + new Version(0, 0, 1).compareTo(new Version(0, 0, 1))); + assertEquals(true, + new Version(0, 0, 1).equals(new Version(0, 0, 1))); + assertEquals(false, + new Version(0, 2, 1).equals(new Version(0, 0, 1))); + + assertEquals(true, + new Version(1, 0, 1, "my.tag.", 2).equals(new Version( + 1, 0, 1, "my.tag.", 2))); + assertEquals(false, + new Version(1, 0, 1, "my.tag.", 2).equals(new Version( + 1, 0, 0, "my.tag.", 2))); + assertEquals(false, + new Version(1, 0, 1, "my.tag.", 2).equals(new Version( + 1, 0, 1, "not-my.tag.", 2))); + } + }); + + addTest(new TestCase("toString") { + @Override + public void test() throws Exception { + // Check leading 0s: + Version version = new Version("01.002.4"); + assertEquals("Leading 0s not working", "1.2.4", + version.toString()); + + // Check spacing + version = new Version("1 . 2.4 "); + assertEquals("Additional spaces not working", "1.2.4", + version.toString()); + + String[] tests = new String[] { "1.0.0", "1.2.3", "1.0.0-dev", + "1.1.2-niki0" }; + for (String test : tests) { + version = new Version(test); + assertEquals("toString and back conversion failed", test, + version.toString()); + } + } + }); + } +} diff --git a/src/be/nikiroo/utils/test_code/bundle_test.properties b/src/be/nikiroo/utils/test_code/bundle_test.properties new file mode 100644 index 0000000..5222c59 --- /dev/null +++ b/src/be/nikiroo/utils/test_code/bundle_test.properties @@ -0,0 +1,3 @@ +ONE = un +ONE_SUFFIX = un + suffix +JAPANESE = 日本語 Nihongo \ No newline at end of file diff --git a/src/be/nikiroo/utils/ui/ConfigEditor.java b/src/be/nikiroo/utils/ui/ConfigEditor.java new file mode 100644 index 0000000..c687c98 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigEditor.java @@ -0,0 +1,165 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; + +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.resources.Bundle; +import be.nikiroo.utils.resources.MetaInfo; + +/** + * A configuration panel for a {@link Bundle}. + *

+ * All the items in the given {@link Bundle} will be displayed in editable + * controls, with options to Save, Reset and/or Reset to the application default + * values. + * + * @author niki + * + * @param + * the type of {@link Bundle} to edit + */ +public class ConfigEditor> extends JPanel { + private static final long serialVersionUID = 1L; + private List> items; + + /** + * Create a new {@link ConfigEditor} for this {@link Bundle}. + * + * @param type + * a class instance of the item type to work on + * @param bundle + * the {@link Bundle} to sort through + * @param title + * the title to display before the options + */ + public ConfigEditor(Class type, final Bundle bundle, String title) { + this.setLayout(new BorderLayout()); + + JPanel main = new JPanel(); + main.setLayout(new BoxLayout(main, BoxLayout.PAGE_AXIS)); + main.setBorder(new EmptyBorder(5, 5, 5, 5)); + + main.add(new JLabel(title)); + + items = new ArrayList>(); + List> groupedItems = MetaInfo.getItems(type, bundle); + for (MetaInfo item : groupedItems) { + // will init this.items + addItem(main, item, 0); + } + + JPanel buttons = new JPanel(); + buttons.setLayout(new BoxLayout(buttons, BoxLayout.PAGE_AXIS)); + buttons.setBorder(new EmptyBorder(5, 5, 5, 5)); + + buttons.add(createButton("Reset", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + for (MetaInfo item : items) { + item.reload(); + } + } + })); + + buttons.add(createButton("Default", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + Object snap = bundle.takeSnapshot(); + bundle.reload(true); + for (MetaInfo item : items) { + item.reload(); + } + bundle.reload(false); + bundle.restoreSnapshot(snap); + } + })); + + buttons.add(createButton("Save", new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + for (MetaInfo item : items) { + item.save(true); + } + + try { + bundle.updateFile(); + } catch (IOException e1) { + e1.printStackTrace(); + } + } + })); + + JScrollPane scroll = new JScrollPane(main); + scroll.getVerticalScrollBar().setUnitIncrement(16); + + this.add(scroll, BorderLayout.CENTER); + this.add(buttons, BorderLayout.SOUTH); + } + + private void addItem(JPanel main, MetaInfo item, int nhgap) { + if (item.isGroup()) { + JPanel bpane = new JPanel(new BorderLayout()); + bpane.setBorder(new TitledBorder(item.getName())); + JPanel pane = new JPanel(); + pane.setBorder(new EmptyBorder(5, 5, 5, 5)); + pane.setLayout(new BoxLayout(pane, BoxLayout.Y_AXIS)); + + String info = item.getDescription(); + info = StringUtils.justifyTexts(info, 100); + if (!info.isEmpty()) { + info = info + "\n"; + JTextArea text = new JTextArea(info); + text.setWrapStyleWord(true); + text.setOpaque(false); + text.setForeground(new Color(100, 100, 180)); + text.setEditable(false); + pane.add(text); + } + + for (MetaInfo subitem : item) { + addItem(pane, subitem, nhgap + 11); + } + bpane.add(pane, BorderLayout.CENTER); + main.add(bpane); + } else { + items.add(item); + main.add(ConfigItem.createItem(item, nhgap)); + } + } + + /** + * Add an action button for this action. + * + * @param title + * the action title + * @param listener + * the action + */ + private JComponent createButton(String title, ActionListener listener) { + JButton button = new JButton(title); + button.addActionListener(listener); + + JPanel panel = new JPanel(); + panel.setLayout(new BorderLayout()); + panel.setBorder(new EmptyBorder(2, 10, 2, 10)); + panel.add(button, BorderLayout.CENTER); + + return panel; + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItem.java b/src/be/nikiroo/utils/ui/ConfigItem.java new file mode 100644 index 0000000..1f69886 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItem.java @@ -0,0 +1,764 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import be.nikiroo.utils.Image; +import be.nikiroo.utils.StringUtils; +import be.nikiroo.utils.StringUtils.Alignment; +import be.nikiroo.utils.resources.Bundle; +import be.nikiroo.utils.resources.MetaInfo; + +/** + * A graphical item that reflect a configuration option from the given + * {@link Bundle}. + *

+ * This graphical item can be edited, and the result will be saved back into the + * linked {@link MetaInfo}; you still have to save the {@link MetaInfo} should + * you wish to, of course. + * + * @author niki + * + * @param + * the type of {@link Bundle} to edit + */ +public abstract class ConfigItem> extends JPanel { + private static final long serialVersionUID = 1L; + + private static int minimumHeight = -1; + + /** A small 16x16 "?" blue in PNG, base64 encoded. */ + private static String img64info = // + "" + + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI" + + "WXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wURFRg6IrtcdgAAATdJREFUOMvtkj8sQ1EUxr9z/71G" + + "m1RDogYxq7WDDYMYTSajSG4n6YRYzSaSLibWbiaDIGwdiLIYDFKDNJEgKu969xi8UNHy7H7LPcN3" + + "v/Odcy+hG9oOIeIcBCJS9MAvlZtOMtHxsrFrJHGqe0RVGnHAHpcIbPlng8BS3HmKBJYzabGUzcrJ" + + "XK+ckIrqANYR2JEv2nYDEVck0WKGfHzyq82Go+btxoX3XAcAIqTj8wPqOH6mtMeM4bGCLhyfhTMA" + + "qlLhKHqujCfaweCAmV0p50dPzsNpEKpK01V/n55HIvTnfDC2odKlfeYadZN/T+AqDACUsnkhqaU1" + + "LRIVuX1x7ciuSWQxVIrunONrfq3dI6oh+T94Z8453vEem/HTqT8ZpFJ0qDXtGkPbAGAMeSRngQCA" + + "eUvgn195AwlZWyvjtQdhAAAAAElFTkSuQmCC"; + + /** A small 16x16 "+" image with colours */ + private static String img64add = // + "" + + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI" + + "WXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wUeES0QBFvvnAAAAB1pVFh0Q29tbWVudAAAAAAAQ3Jl" + + "YXRlZCB3aXRoIEdJTVBkLmUHAAACH0lEQVQ4y42Tz0sVURTHP+fMmC7CQMpH1EjgIimCsEVBEIg/" + + "qIbcBAW2Uai1m/oH2rlJXLQpeRJt2gQhTO0iTTKC1I2JBf5gKCJCRPvhPOed22LmvV70Fn7hwr3c" + + "+z3ne+73HCFHEClxaASRHgduA91AW369BkwDI3Foy0GkEofmACQnSxyaCyItAkMClMzYdeCAJgVP" + + "tJJrPA7tVoUjNZlngXMAiRmXClfoK/Tjq09x7T6LW+8RxOVJ5+LQzgSRojm5WCEDlMrQVbjIQNtN" + + "rh0d5FTzaTLBmWKgM4h0Ig4NzWseohYCJUuqx123Sx0MBpF2+MAdyWUnlqX4lf4bIDHjR+rwJJPR" + + "qNCgCjDsA10lM/oKIRcO9lByCYklnG/pqQa4euQ6J5tPoKI0yD6ef33Ku40Z80R7CSJNWyZxT+Ki" + + "2ytGP911hyZxQaRp1RtPPPYKD4+sGJwPrDUp7Q9Xxnj9fYrUUnaszEAwQHfrZQAerT/g7cYMiuCp" + + "z8LmLI0qBqz6wLQn2v5he57FrXkAtlPH2ZZOuskCzG2+4dnnx3iSuSgCKqLAlAIjmXPiVIRsgYjU" + + "usrfO0Gq7cA9jUNbBsZrmiQnac1e6n3FeBzakpf39OSBG9IPHAZwzlFoagVg5edHXn57wZed9dpA" + + "C3FoYRDpf8M0AQwKwu9yubxjeA7Y72ENqlp3mOqMcwcwDPQCx8gGchV4BYzGoS1V3gL8AVA5C5/0" + + "oRFoAAAAAElFTkSuQmCC"; + + /** A small 32x32 "-" image with colours */ + private static String img64remove = // + "" + + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBI" + + "WXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wUeESw5X/JGsQAAAB1pVFh0Q29tbWVudAAAAAAAQ3Jl" + + "YXRlZCB3aXRoIEdJTVBkLmUHAAACKUlEQVQ4y5WTO2iTYRSG3+//v/+SJrG5SSABh1JQBHFJNUNR" + + "YodCLoMoTkK0YKhQtBmsl01wKVZRBwcrgosg3SwFW9Cippe0VmlpB6uYqYIaNSZtbv/lOKRx0iR9" + + "4YOzvOc8vOd8wLbG4nYGAKP9tshKr3Pq0zFXORt0UzbopvUeZ2ml1/niUcIWAYBzwwqr+xgAjCSt" + + "wpXjWzx105Ha+1XsMgT8U6IJfPAacyfO50OXJi3VwbtbxMbidtZ3tiClbzi/eAuCmxgai4AfNvNn" + + "KJn3X5xWKgwA0lHHYud3MdDUXMcmIOMx0oGJXJCN9tuiJ98p4//DbtTk2cFKhB/OSBcMgQHVMkir" + + "AqwJBhGYrIIkCQc2eJK3aewI9Crko2FIh0K1Jo0mcwmV6XFUlmfRXhK7eXuRKaRVIYdiUGKnW8Kn" + + "0ia0t6/hKHJVqCcLzncQgLhtIvBfbWbZZahq+cl96AuvQLre2Mw59NUlkCwjZ6USL0uYgSj26B/X" + + "oK+vtkYgMAhMRF4x5oWlPdod0UQtfUFo7YEBBKz59BEGAAtRx1xHVgzu5AYyHmMmMJHrZolhhU3t" + + "05XJe7s2PJuCq9k1MgKyNjOXiBf8kWW5JDy4XKHBl2ql6+pvX8ZjzDOqrcWsFQAAE/T3H3z2GG/6" + + "zhT8sfdKeehWkUQAeJ7WcH23xTz1uPBwf1hclA3mBZjPojFOIOSsVPpmN1OznfpA+Gn+2kCHqg/d" + + "LhIA/AFU5d0V6gTjtQAAAABJRU5ErkJggg=="; + + /** The original value before current changes. */ + private Object orig; + private List origs = new ArrayList(); + private List dirtyBits; + + /** The fields (one for non-array, a list for arrays). */ + private JComponent field; + private List fields = new ArrayList(); + + /** The fields to panel map to get the actual item added to 'main'. */ + private Map itemFields = new HashMap(); + + /** The main panel with all the fields in it. */ + private JPanel main; + + /** The {@link MetaInfo} linked to the field. */ + protected MetaInfo info; + + /** + * Create a new {@link ConfigItem} for the given {@link MetaInfo}. + * + * @param nhgap + * negative horisontal gap in pixel to use for the label, i.e., + * the step lock sized labels will start smaller by that amount + * (the use case would be to align controls that start at a + * different horisontal position) + */ + public void init(int nhgap) { + if (info.isArray()) { + this.setLayout(new BorderLayout()); + add(label(nhgap), BorderLayout.WEST); + + main = new JPanel(); + + main.setLayout(new BoxLayout(main, BoxLayout.Y_AXIS)); + int size = info.getListSize(false); + for (int i = 0; i < size; i++) { + addItem(i); + } + main.revalidate(); + main.repaint(); + + final JButton add = new JButton(); + setImage(add, img64add, "+"); + + add.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + addItem(fields.size()); + main.revalidate(); + main.repaint(); + } + }); + + JPanel tmp = new JPanel(new BorderLayout()); + tmp.add(add, BorderLayout.WEST); + + JPanel mainPlus = new JPanel(new BorderLayout()); + mainPlus.add(main, BorderLayout.CENTER); + mainPlus.add(tmp, BorderLayout.SOUTH); + + add(mainPlus, BorderLayout.CENTER); + } else { + this.setLayout(new BorderLayout()); + add(label(nhgap), BorderLayout.WEST); + + JComponent field = createField(-1); + add(field, BorderLayout.CENTER); + } + } + + private void addItem(final int item) { + JPanel minusPanel = new JPanel(new BorderLayout()); + itemFields.put(item, minusPanel); + + JComponent field = createField(item); + + final JButton remove = new JButton(); + setImage(remove, img64remove, "-"); + + remove.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + removeItem(item); + } + }); + + minusPanel.add(field, BorderLayout.CENTER); + minusPanel.add(remove, BorderLayout.EAST); + + main.add(minusPanel); + main.revalidate(); + main.repaint(); + } + + private void removeItem(int item) { + int last = itemFields.size() - 1; + + for (int i = item; i <= last; i++) { + Object value = null; + if (i < last) { + value = getFromField(i + 1); + } + setToField(value, i); + setToInfo(value, i); + setDirtyItem(i); + } + + main.remove(itemFields.remove(last)); + main.revalidate(); + main.repaint(); + } + + /** + * Prepare a new {@link ConfigItem} instance, linked to the given + * {@link MetaInfo}. + * + * @param info + * the info + * @param autoDirtyHandling + * TRUE to automatically manage the setDirty/Save operations, + * FALSE if you want to do it yourself via + * {@link ConfigItem#setDirtyItem(int)} + */ + protected ConfigItem(MetaInfo info, boolean autoDirtyHandling) { + this.info = info; + if (!autoDirtyHandling) { + dirtyBits = new ArrayList(); + } + } + + /** + * Create an empty graphical component to be used later by + * {@link ConfigItem#createField(int)}. + *

+ * Note that {@link ConfigItem#reload(int)} will be called after it was + * created by {@link ConfigItem#createField(int)}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the graphical component + */ + abstract protected JComponent createEmptyField(int item); + + /** + * Get the information from the {@link MetaInfo} in the subclass preferred + * format. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the information in the subclass preferred format + */ + abstract protected Object getFromInfo(int item); + + /** + * Set the value to the {@link MetaInfo}. + * + * @param value + * the value in the subclass preferred format + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + abstract protected void setToInfo(Object value, int item); + + /** + * The value present in the given item's related field in the subclass + * preferred format. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the value present in the given item's related field in the + * subclass preferred format + */ + abstract protected Object getFromField(int item); + + /** + * Set the value (in the subclass preferred format) into the field. + * + * @param value + * the value in the subclass preferred format + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + abstract protected void setToField(Object value, int item); + + /** + * Create a new field for the given graphical component at the given index + * (note that the component is usually created by + * {@link ConfigItem#createEmptyField(int)}). + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param field + * the graphical component + */ + private void setField(int item, JComponent field) { + if (item < 0) { + this.field = field; + return; + } + + for (int i = fields.size(); i <= item; i++) { + fields.add(null); + } + + fields.set(item, field); + } + + /** + * Retrieve the associated graphical component that was created with + * {@link ConfigItem#createEmptyField(int)}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the graphical component + */ + protected JComponent getField(int item) { + if (item < 0) { + return field; + } + + if (item < fields.size()) { + return fields.get(item); + } + + return null; + } + + /** + * The original value (before any changes to the {@link MetaInfo}) for this + * item. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the original value + */ + private Object getOrig(int item) { + if (item < 0) { + return orig; + } + + if (item < origs.size()) { + return origs.get(item); + } + + return null; + } + + /** + * The original value (before any changes to the {@link MetaInfo}) for this + * item. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * @param value + * the new original value + */ + private void setOrig(Object value, int item) { + if (item < 0) { + orig = value; + } else { + while (item >= origs.size()) { + origs.add(null); + } + + origs.set(item, value); + } + } + + /** + * Manually specify that the given item is "dirty" and thus should be saved + * when asked. + *

+ * Has no effect if the class is using automatic dirty handling (see + * {@link ConfigItem#ConfigItem(MetaInfo, boolean)}). + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + protected void setDirtyItem(int item) { + if (dirtyBits != null) { + dirtyBits.add(item); + } + } + + /** + * Check if the value changed since the last load/save into the linked + * {@link MetaInfo}. + *

+ * Note that we consider NULL and an Empty {@link String} to be equals. + * + * @param value + * the value to test + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return TRUE if it has + */ + protected boolean hasValueChanged(Object value, int item) { + // We consider "" and NULL to be equals + Object orig = getOrig(item); + if (orig == null) { + orig = ""; + } + return !orig.equals(value == null ? "" : value); + } + + /** + * Reload the values to what they currently are in the {@link MetaInfo}. + */ + private void reload() { + if (info.isArray()) { + while (!itemFields.isEmpty()) { + main.remove(itemFields.remove(itemFields.size() - 1)); + } + main.revalidate(); + main.repaint(); + for (int item = 0; item < info.getListSize(false); item++) { + reload(item); + } + } else { + reload(-1); + } + } + + /** + * Reload the values to what they currently are in the {@link MetaInfo}. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + */ + private void reload(int item) { + if (item >= 0 && !itemFields.containsKey(item)) { + addItem(item); + } + + Object value = getFromInfo(item); + setToField(value, item); + setOrig(value == null ? "" : value, item); + } + + /** + * If the item has been modified, set the {@link MetaInfo} to dirty then + * modify it to, reflect the changes so it can be saved later. + *

+ * This method does not call {@link MetaInfo#save(boolean)}. + */ + private void save() { + if (info.isArray()) { + boolean dirty = itemFields.size() != info.getListSize(false); + for (int item = 0; item < itemFields.size(); item++) { + if (getDirtyBit(item)) { + dirty = true; + } + } + + if (dirty) { + info.setDirty(); + info.setString(null, -1); + + for (int item = 0; item < itemFields.size(); item++) { + Object value = null; + if (getField(item) != null) { + value = getFromField(item); + if ("".equals(value)) { + value = null; + } + } + + setToInfo(value, item); + setOrig(value, item); + } + } + } else { + if (getDirtyBit(-1)) { + Object value = getFromField(-1); + + info.setDirty(); + setToInfo(value, -1); + setOrig(value, -1); + } + } + } + + /** + * Check if the item is dirty, and clear the dirty bit if set. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return TRUE if it was dirty, FALSE if not + */ + private boolean getDirtyBit(int item) { + if (dirtyBits != null) { + return dirtyBits.remove((Integer) item); + } + + Object value = null; + if (getField(item) != null) { + value = getFromField(item); + } + + return hasValueChanged(value, item); + } + + /** + * Create a new field for the given item. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return the newly created field + */ + protected JComponent createField(final int item) { + JComponent field = createEmptyField(item); + setField(item, field); + reload(item); + + info.addReloadedListener(new Runnable() { + @Override + public void run() { + reload(); + } + }); + info.addSaveListener(new Runnable() { + @Override + public void run() { + save(); + } + }); + + int height = Math + .max(getMinimumHeight(), field.getMinimumSize().height); + field.setPreferredSize(new Dimension(200, height)); + + return field; + } + + /** + * Create a label which width is constrained in lock steps. + * + * @param nhgap + * negative horisontal gap in pixel to use for the label, i.e., + * the step lock sized labels will start smaller by that amount + * (the use case would be to align controls that start at a + * different horisontal position) + * + * @return the label + */ + protected JComponent label(int nhgap) { + final JLabel label = new JLabel(info.getName()); + + Dimension ps = label.getPreferredSize(); + if (ps == null) { + ps = label.getSize(); + } + + ps.height = Math.max(ps.height, getMinimumHeight()); + + int w = ps.width; + int step = 150; + for (int i = 2 * step - nhgap; i < 10 * step; i += step) { + if (w < i) { + w = i; + break; + } + } + + final Runnable showInfo = new Runnable() { + @Override + public void run() { + StringBuilder builder = new StringBuilder(); + String text = (info.getDescription().replace("\\n", "\n")) + .trim(); + for (String line : StringUtils.justifyText(text, 80, + Alignment.LEFT)) { + if (builder.length() > 0) { + builder.append("\n"); + } + builder.append(line); + } + text = builder.toString(); + JOptionPane.showMessageDialog(ConfigItem.this, text, + info.getName(), JOptionPane.INFORMATION_MESSAGE); + } + }; + + JLabel help = new JLabel(""); + help.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setImage(help, img64info, "?"); + + help.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + showInfo.run(); + } + }); + + JPanel pane2 = new JPanel(new BorderLayout()); + pane2.add(help, BorderLayout.WEST); + pane2.add(new JLabel(" "), BorderLayout.CENTER); + + JPanel contentPane = new JPanel(new BorderLayout()); + contentPane.add(label, BorderLayout.WEST); + contentPane.add(pane2, BorderLayout.CENTER); + + ps.width = w + 30; // 30 for the (?) sign + contentPane.setSize(ps); + contentPane.setPreferredSize(ps); + + JPanel pane = new JPanel(new BorderLayout()); + pane.add(contentPane, BorderLayout.NORTH); + + return pane; + } + + /** + * Create a new {@link ConfigItem} for the given {@link MetaInfo}. + * + * @param + * the type of {@link Bundle} to edit + * + * @param info + * the {@link MetaInfo} + * @param nhgap + * negative horisontal gap in pixel to use for the label, i.e., + * the step lock sized labels will start smaller by that amount + * (the use case would be to align controls that start at a + * different horisontal position) + * + * @return the new {@link ConfigItem} + */ + static public > ConfigItem createItem( + MetaInfo info, int nhgap) { + + ConfigItem configItem; + switch (info.getFormat()) { + case BOOLEAN: + configItem = new ConfigItemBoolean(info); + break; + case COLOR: + configItem = new ConfigItemColor(info); + break; + case FILE: + configItem = new ConfigItemBrowse(info, false); + break; + case DIRECTORY: + configItem = new ConfigItemBrowse(info, true); + break; + case COMBO_LIST: + configItem = new ConfigItemCombobox(info, true); + break; + case FIXED_LIST: + configItem = new ConfigItemCombobox(info, false); + break; + case INT: + configItem = new ConfigItemInteger(info); + break; + case PASSWORD: + configItem = new ConfigItemPassword(info); + break; + case LOCALE: + configItem = new ConfigItemLocale(info); + break; + case STRING: + default: + configItem = new ConfigItemString(info); + break; + } + + configItem.init(nhgap); + return configItem; + } + + /** + * Set an image to the given {@link JButton}, with a fallback text if it + * fails. + * + * @param button + * the button to set + * @param image64 + * the image in BASE64 (should be PNG or similar) + * @param fallbackText + * text to use in case the image cannot be created + */ + static protected void setImage(JLabel button, String image64, + String fallbackText) { + try { + Image img = new Image(image64); + try { + BufferedImage bImg = ImageUtilsAwt.fromImage(img); + button.setIcon(new ImageIcon(bImg)); + } finally { + img.close(); + } + } catch (IOException e) { + // This is an hard-coded image, should not happen + button.setText(fallbackText); + } + } + + /** + * Set an image to the given {@link JButton}, with a fallback text if it + * fails. + * + * @param button + * the button to set + * @param image64 + * the image in BASE64 (should be PNG or similar) + * @param fallbackText + * text to use in case the image cannot be created + */ + static protected void setImage(JButton button, String image64, + String fallbackText) { + try { + Image img = new Image(image64); + try { + BufferedImage bImg = ImageUtilsAwt.fromImage(img); + button.setIcon(new ImageIcon(bImg)); + } finally { + img.close(); + } + } catch (IOException e) { + // This is an hard-coded image, should not happen + button.setText(fallbackText); + } + } + + static private int getMinimumHeight() { + if (minimumHeight < 0) { + minimumHeight = new JTextField("Test").getMinimumSize().height; + } + + return minimumHeight; + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemBoolean.java b/src/be/nikiroo/utils/ui/ConfigItemBoolean.java new file mode 100644 index 0000000..255ec13 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemBoolean.java @@ -0,0 +1,66 @@ +package be.nikiroo.utils.ui; + +import javax.swing.JCheckBox; +import javax.swing.JComponent; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemBoolean> extends ConfigItem { + private static final long serialVersionUID = 1L; + + /** + * Create a new {@link ConfigItemBoolean} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + */ + public ConfigItemBoolean(MetaInfo info) { + super(info, true); + } + + @Override + protected Object getFromField(int item) { + JCheckBox field = (JCheckBox) getField(item); + if (field != null) { + return field.isSelected(); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + return info.getBoolean(item, true); + } + + @Override + protected void setToField(Object value, int item) { + JCheckBox field = (JCheckBox) getField(item); + if (field != null) { + // Should not happen if config enum is correct + // (but this is not enforced) + if (value == null) { + value = false; + } + + field.setSelected((Boolean) value); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setBoolean((Boolean) value, item); + } + + @Override + protected JComponent createEmptyField(int item) { + // Should not happen! + if (getFromInfo(item) == null) { + System.err + .println("No default value given for BOOLEAN parameter \"" + + info.getName() + "\", we consider it is FALSE"); + } + + return new JCheckBox(); + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemBrowse.java b/src/be/nikiroo/utils/ui/ConfigItemBrowse.java new file mode 100644 index 0000000..6c8af99 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemBrowse.java @@ -0,0 +1,116 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemBrowse> extends ConfigItem { + private static final long serialVersionUID = 1L; + + private boolean dir; + private Map fields = new HashMap(); + + /** + * Create a new {@link ConfigItemBrowse} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + * @param dir + * TRUE for directory browsing, FALSE for file browsing + */ + public ConfigItemBrowse(MetaInfo info, boolean dir) { + super(info, false); + this.dir = dir; + } + + @Override + protected Object getFromField(int item) { + JTextField field = fields.get(getField(item)); + if (field != null) { + return new File(field.getText()); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + String path = info.getString(item, false); + if (path != null && !path.isEmpty()) { + return new File(path); + } + + return null; + } + + @Override + protected void setToField(Object value, int item) { + JTextField field = fields.get(getField(item)); + if (field != null) { + field.setText(value == null ? "" : ((File) value).getPath()); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setString(((File) value).getPath(), item); + } + + @Override + protected JComponent createEmptyField(final int item) { + final JPanel pane = new JPanel(new BorderLayout()); + final JTextField field = new JTextField(); + field.addKeyListener(new KeyAdapter() { + @Override + public void keyTyped(KeyEvent e) { + File file = null; + if (!field.getText().isEmpty()) { + file = new File(field.getText()); + } + + if (hasValueChanged(file, item)) { + setDirtyItem(item); + } + } + }); + + final JButton browseButton = new JButton("..."); + browseButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + JFileChooser chooser = new JFileChooser(); + chooser.setCurrentDirectory((File) getFromInfo(item)); + chooser.setFileSelectionMode(dir ? JFileChooser.DIRECTORIES_ONLY + : JFileChooser.FILES_ONLY); + if (chooser.showOpenDialog(ConfigItemBrowse.this) == JFileChooser.APPROVE_OPTION) { + File file = chooser.getSelectedFile(); + if (file != null) { + setToField(file, item); + if (hasValueChanged(file, item)) { + setDirtyItem(item); + } + } + } + } + }); + + pane.add(browseButton, BorderLayout.WEST); + pane.add(field, BorderLayout.CENTER); + + fields.put(pane, field); + return pane; + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemColor.java b/src/be/nikiroo/utils/ui/ConfigItemColor.java new file mode 100644 index 0000000..500efff --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemColor.java @@ -0,0 +1,168 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; + +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JColorChooser; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemColor> extends ConfigItem { + private static final long serialVersionUID = 1L; + + private Map fields = new HashMap(); + private Map panels = new HashMap(); + + /** + * Create a new {@link ConfigItemColor} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + */ + public ConfigItemColor(MetaInfo info) { + super(info, true); + } + + @Override + protected Object getFromField(int item) { + JTextField field = fields.get(getField(item)); + if (field != null) { + return field.getText(); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + return info.getString(item, true); + } + + @Override + protected void setToField(Object value, int item) { + JTextField field = fields.get(getField(item)); + if (field != null) { + field.setText(value == null ? "" : value.toString()); + } + + JButton colorWheel = panels.get(getField(item)); + if (colorWheel != null) { + colorWheel.setIcon(getIcon(17, getFromInfoColor(item))); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setString((String) value, item); + } + + /** + * Get the colour currently present in the linked info for the given item. + * + * @param item + * the item number to get for an array of values, or -1 to get + * the whole value (has no effect if {@link MetaInfo#isArray()} + * is FALSE) + * + * @return a colour + */ + private int getFromInfoColor(int item) { + Integer color = info.getColor(item, true); + if (color == null) { + return new Color(255, 255, 255, 255).getRGB(); + } + + return color; + } + + @Override + protected JComponent createEmptyField(final int item) { + final JPanel pane = new JPanel(new BorderLayout()); + final JTextField field = new JTextField(); + + final JButton colorWheel = new JButton(); + colorWheel.setIcon(getIcon(17, getFromInfoColor(item))); + colorWheel.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + int icol = getFromInfoColor(item); + Color initialColor = new Color(icol, true); + Color newColor = JColorChooser.showDialog(ConfigItemColor.this, + info.getName(), initialColor); + if (newColor != null) { + info.setColor(newColor.getRGB(), item); + field.setText(info.getString(item, false)); + colorWheel.setIcon(getIcon(17, info.getColor(item, true))); + } + } + }); + + field.addKeyListener(new KeyAdapter() { + @Override + public void keyTyped(KeyEvent e) { + info.setString(field.getText() + e.getKeyChar(), item); + int color = getFromInfoColor(item); + colorWheel.setIcon(getIcon(17, color)); + } + }); + + pane.add(colorWheel, BorderLayout.WEST); + pane.add(field, BorderLayout.CENTER); + + fields.put(pane, field); + panels.put(pane, colorWheel); + return pane; + } + + /** + * Return an {@link Icon} to use as a colour badge for the colour field + * controls. + * + * @param size + * the size of the badge + * @param color + * the colour of the badge, which can be NULL (will return + * transparent white) + * + * @return the badge + */ + static private Icon getIcon(int size, Integer color) { + // Allow null values + if (color == null) { + color = new Color(255, 255, 255, 255).getRGB(); + } + + Color c = new Color(color, true); + int avg = (c.getRed() + c.getGreen() + c.getBlue()) / 3; + Color border = (avg >= 128 ? Color.BLACK : Color.WHITE); + + BufferedImage img = new BufferedImage(size, size, + BufferedImage.TYPE_4BYTE_ABGR); + + Graphics2D g = img.createGraphics(); + try { + g.setColor(c); + g.fillRect(0, 0, img.getWidth(), img.getHeight()); + g.setColor(border); + g.drawRect(0, 0, img.getWidth() - 1, img.getHeight() - 1); + } finally { + g.dispose(); + } + + return new ImageIcon(img); + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemCombobox.java b/src/be/nikiroo/utils/ui/ConfigItemCombobox.java new file mode 100644 index 0000000..b77e0a8 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemCombobox.java @@ -0,0 +1,68 @@ +package be.nikiroo.utils.ui; + +import javax.swing.JComboBox; +import javax.swing.JComponent; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemCombobox> extends ConfigItem { + private static final long serialVersionUID = 1L; + + private boolean editable; + private String[] allowedValues; + + /** + * Create a new {@link ConfigItemCombobox} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + * @param editable + * allows the user to type in another value not in the list + */ + public ConfigItemCombobox(MetaInfo info, boolean editable) { + super(info, true); + this.editable = editable; + this.allowedValues = info.getAllowedValues(); + } + + @Override + protected Object getFromField(int item) { + // rawtypes for Java 1.6 (and 1.7 ?) support + @SuppressWarnings("rawtypes") + JComboBox field = (JComboBox) getField(item); + if (field != null) { + return field.getSelectedItem(); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + return info.getString(item, false); + } + + @Override + protected void setToField(Object value, int item) { + // rawtypes for Java 1.6 (and 1.7 ?) support + @SuppressWarnings("rawtypes") + JComboBox field = (JComboBox) getField(item); + if (field != null) { + field.setSelectedItem(value); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setString((String) value, item); + } + + // rawtypes for Java 1.6 (and 1.7 ?) support + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + protected JComponent createEmptyField(int item) { + JComboBox field = new JComboBox(allowedValues); + field.setEditable(editable); + return field; + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemInteger.java b/src/be/nikiroo/utils/ui/ConfigItemInteger.java new file mode 100644 index 0000000..9b838a5 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemInteger.java @@ -0,0 +1,53 @@ +package be.nikiroo.utils.ui; + +import javax.swing.JComponent; +import javax.swing.JSpinner; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemInteger> extends ConfigItem { + private static final long serialVersionUID = 1L; + + /** + * Create a new {@link ConfigItemInteger} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + */ + public ConfigItemInteger(MetaInfo info) { + super(info, true); + } + + @Override + protected Object getFromField(int item) { + JSpinner field = (JSpinner) getField(item); + if (field != null) { + return field.getValue(); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + return info.getInteger(item, true); + } + + @Override + protected void setToField(Object value, int item) { + JSpinner field = (JSpinner) getField(item); + if (field != null) { + field.setValue(value == null ? 0 : (Integer) value); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setInteger((Integer) value, item); + } + + @Override + protected JComponent createEmptyField(int item) { + return new JSpinner(); + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemLocale.java b/src/be/nikiroo/utils/ui/ConfigItemLocale.java new file mode 100644 index 0000000..eef8da0 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemLocale.java @@ -0,0 +1,62 @@ +package be.nikiroo.utils.ui; + +import java.awt.Component; +import java.util.Locale; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JList; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemLocale> extends ConfigItemCombobox { + private static final long serialVersionUID = 1L; + + /** + * Create a new {@link ConfigItemLocale} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + */ + public ConfigItemLocale(MetaInfo info) { + super(info, true); + } + + // rawtypes for Java 1.6 (and 1.7 ?) support + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Override + protected JComponent createEmptyField(int item) { + JComboBox field = (JComboBox) super.createEmptyField(item); + field.setRenderer(new DefaultListCellRenderer() { + private static final long serialVersionUID = 1L; + + @Override + public Component getListCellRendererComponent(JList list, + Object value, int index, boolean isSelected, + boolean cellHasFocus) { + + String svalue = value == null ? "" : value.toString(); + String[] tab = svalue.split("-"); + Locale locale = null; + if (tab.length == 1) { + locale = new Locale(tab[0]); + } else if (tab.length == 2) { + locale = new Locale(tab[0], tab[1]); + } else if (tab.length == 3) { + locale = new Locale(tab[0], tab[1], tab[2]); + } + + String displayValue = svalue; + if (locale != null) { + displayValue = locale.getDisplayName(); + } + + return super.getListCellRendererComponent(list, displayValue, + index, isSelected, cellHasFocus); + } + }); + + return field; + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemPassword.java b/src/be/nikiroo/utils/ui/ConfigItemPassword.java new file mode 100644 index 0000000..348b78f --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemPassword.java @@ -0,0 +1,109 @@ +package be.nikiroo.utils.ui; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.HashMap; +import java.util.Map; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JPasswordField; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemPassword> extends ConfigItem { + private static final long serialVersionUID = 1L; + /** A small 16x16 pass-protecet icon in PNG, base64 encoded. */ + private static String img64passProtected = // + "" + + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAnNCSVQICFXsRgQAAAD5SURBVCjP" + + "ndG9LoNxGIbxHxJTG9U0IsJAdCSNqZEa9BR87BaHYfW5ESYkmjQh4giwIU00MWFwAPWRSmpgaf6G" + + "6ts36eZ+xuu+lvuhlTGjOFHAsXldWVDRa82WhE9pZFxrtmBeUY87+yqCH3UzMh4E1VYhp2ZVVfi7" + + "C0PuBc9G2v6KoOlIQUoyhovyLb+uZla/TbsRHnOgJkfSi4YpbDiXjuwJDS+SlASLYC9mw5KgxJlg" + + "CWJ4OyqckvKkIWswwmXrmPbl0QBkHcbsHRv6Fbz6MNnesWMnpMw51vRmphuXo7FujHf+cCt4NGza" + + "lbp3l5b1xR/1rWrYf/MLWpplWwswQpMAAAAASUVORK5CYII="; + + /** A small 16x16 pass-unprotecet icon in PNG, base64 encoded. */ + private static String img64passUnprotected = // + "" + + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAA" + + "CxMAAAsTAQCanBgAAAAHdElNRQfjBR8MIilwhCwdAAABK0lEQVQoz5XQv0uUAQCH8c/7qod4nect" + + "gop3BIKDFBIiRyiKtATmcEiBDW7+Ae5ODt5gW0SLigouKTg6SJvkjw4Co8mcNeWgc+o839dBBXPz" + + "+Y7PM33r3NCpWcWKM1lfHapJq0B4G/TbEDoyZlyHQxuGtdw6eSMC33yyJxa79MW+wIj8TdDrxJSS" + + "+N5KppQNEchrkrMosmzRT0/0eGdSaFrob6DXloSqgu9mNWlUNqPPpmYNJkg5UvEMResystYVpbwW" + + "qWpjVWwcfNQqLS1rAXwQOw4N4SWoqZeUVFMGuzgg65/IqIw5a3LarZnDcxd+ScMrkcikhB8+m1eU" + + "MODUua67q967EttR0KHFoCVX/nhxp1N4o/rfUTueekC332KRM9veqnuoAwQyHs81DiddylUvrecA" + + "AAAASUVORK5CYII="; + + private Map fields = new HashMap(); + + /** + * Create a new {@link ConfigItemPassword} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + */ + public ConfigItemPassword(MetaInfo info) { + super(info, true); + } + + @Override + protected Object getFromField(int item) { + JPasswordField field = fields.get(getField(item)); + if (field != null) { + return new String(field.getPassword()); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + return info.getString(item, false); + } + + @Override + protected void setToField(Object value, int item) { + JPasswordField field = fields.get(getField(item)); + if (field != null) { + field.setText(value == null ? "" : value.toString()); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setString((String) value, item); + } + + @Override + protected JComponent createEmptyField(int item) { + JPanel pane = new JPanel(new BorderLayout()); + final JPasswordField field = new JPasswordField(); + field.setEchoChar('*'); + + final JButton show = new JButton(); + final Boolean[] visible = new Boolean[] { false }; + setImage(show, img64passProtected, "/"); + show.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + visible[0] = !visible[0]; + if (visible[0]) { + field.setEchoChar((char) 0); + setImage(show, img64passUnprotected, "o"); + } else { + field.setEchoChar('*'); + setImage(show, img64passProtected, "/"); + } + } + }); + + pane.add(field, BorderLayout.CENTER); + pane.add(show, BorderLayout.EAST); + + fields.put(pane, field); + return pane; + } +} diff --git a/src/be/nikiroo/utils/ui/ConfigItemString.java b/src/be/nikiroo/utils/ui/ConfigItemString.java new file mode 100644 index 0000000..99a8cc3 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ConfigItemString.java @@ -0,0 +1,53 @@ +package be.nikiroo.utils.ui; + +import javax.swing.JComponent; +import javax.swing.JTextField; + +import be.nikiroo.utils.resources.MetaInfo; + +class ConfigItemString> extends ConfigItem { + private static final long serialVersionUID = 1L; + + /** + * Create a new {@link ConfigItemString} for the given {@link MetaInfo}. + * + * @param info + * the {@link MetaInfo} + */ + public ConfigItemString(MetaInfo info) { + super(info, true); + } + + @Override + protected Object getFromField(int item) { + JTextField field = (JTextField) getField(item); + if (field != null) { + return field.getText(); + } + + return null; + } + + @Override + protected Object getFromInfo(int item) { + return info.getString(item, false); + } + + @Override + protected void setToField(Object value, int item) { + JTextField field = (JTextField) getField(item); + if (field != null) { + field.setText(value == null ? "" : value.toString()); + } + } + + @Override + protected void setToInfo(Object value, int item) { + info.setString((String) value, item); + } + + @Override + protected JComponent createEmptyField(int item) { + return new JTextField(); + } +} diff --git a/src/be/nikiroo/utils/ui/ImageTextAwt.java b/src/be/nikiroo/utils/ui/ImageTextAwt.java new file mode 100644 index 0000000..4c0c824 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ImageTextAwt.java @@ -0,0 +1,512 @@ +package be.nikiroo.utils.ui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.awt.image.ImageObserver; + +/** + * This class converts an {@link Image} into a textual representation that can + * be displayed to the user in a TUI. + * + * @author niki + */ +public class ImageTextAwt { + private Image image; + private Dimension size; + private String text; + private boolean ready; + private Mode mode; + private boolean invert; + + /** + * The rendering modes supported by this {@link ImageTextAwt} to convert + * {@link Image}s into text. + * + * @author niki + * + */ + public enum Mode { + /** + * Use 5 different "colours" which are actually Unicode + * {@link Character}s representing + *

    + *
  • space (blank)
  • + *
  • low shade (░)
  • + *
  • medium shade (▒)
  • + *
  • high shade (▓)
  • + *
  • full block (█)
  • + *
+ */ + DITHERING, + /** + * Use "block" Unicode {@link Character}s up to quarter blocks, thus in + * effect doubling the resolution both in vertical and horizontal space. + * Note that since 2 {@link Character}s next to each other are square, + * we will use 4 blocks per 2 blocks for w/h resolution. + */ + DOUBLE_RESOLUTION, + /** + * Use {@link Character}s from both {@link Mode#DOUBLE_RESOLUTION} and + * {@link Mode#DITHERING}. + */ + DOUBLE_DITHERING, + /** + * Only use ASCII {@link Character}s. + */ + ASCII, + } + + /** + * Create a new {@link ImageTextAwt} with the given parameters. Defaults to + * {@link Mode#DOUBLE_DITHERING} and no colour inversion. + * + * @param image + * the source {@link Image} + * @param size + * the final text size to target + */ + public ImageTextAwt(Image image, Dimension size) { + this(image, size, Mode.DOUBLE_DITHERING, false); + } + + /** + * Create a new {@link ImageTextAwt} with the given parameters. + * + * @param image + * the source {@link Image} + * @param size + * the final text size to target + * @param mode + * the mode of conversion + * @param invert + * TRUE to invert colours rendering + */ + public ImageTextAwt(Image image, Dimension size, Mode mode, boolean invert) { + setImage(image); + setSize(size); + setMode(mode); + setColorInvert(invert); + } + + /** + * Change the source {@link Image}. + * + * @param image + * the new {@link Image} + */ + public void setImage(Image image) { + this.text = null; + this.ready = false; + this.image = image; + } + + /** + * Change the target size of this {@link ImageTextAwt}. + * + * @param size + * the new size + */ + public void setSize(Dimension size) { + this.text = null; + this.ready = false; + this.size = size; + } + + /** + * Change the image-to-text mode. + * + * @param mode + * the new {@link Mode} + */ + public void setMode(Mode mode) { + this.mode = mode; + this.text = null; + this.ready = false; + } + + /** + * Set the colour-invert mode. + * + * @param invert + * TRUE to inverse the colours + */ + public void setColorInvert(boolean invert) { + this.invert = invert; + this.text = null; + this.ready = false; + } + + /** + * Check if the colours are inverted. + * + * @return TRUE if the colours are inverted + */ + public boolean isColorInvert() { + return invert; + } + + /** + * Return the textual representation of the included {@link Image}. + * + * @return the {@link String} representation + */ + public String getText() { + if (text == null) { + if (image == null || size == null || size.width == 0 + || size.height == 0) { + return ""; + } + + int mult = 1; + if (mode == Mode.DOUBLE_RESOLUTION || mode == Mode.DOUBLE_DITHERING) { + mult = 2; + } + + Dimension srcSize = getSize(image); + srcSize = new Dimension(srcSize.width * 2, srcSize.height); + int x = 0; + int y = 0; + + int w = size.width * mult; + int h = size.height * mult; + + // Default = original ratio or original size if none + if (w < 0 || h < 0) { + if (w < 0 && h < 0) { + w = srcSize.width * mult; + h = srcSize.height * mult; + } else { + double ratioSrc = (double) srcSize.width + / (double) srcSize.height; + if (w < 0) { + w = (int) Math.round(h * ratioSrc); + } else { + h = (int) Math.round(w / ratioSrc); + } + } + } + + // Fail safe: we consider this to be too much + if (w > 1000 || h > 1000) { + return "[IMAGE TOO BIG]"; + } + + BufferedImage buff = new BufferedImage(w, h, + BufferedImage.TYPE_INT_ARGB); + + Graphics gfx = buff.getGraphics(); + + double ratioAsked = (double) (w) / (double) (h); + double ratioSrc = (double) srcSize.height / (double) srcSize.width; + double ratio = ratioAsked * ratioSrc; + if (srcSize.width < srcSize.height) { + h = (int) Math.round(ratio * h); + y = (buff.getHeight() - h) / 2; + } else { + w = (int) Math.round(w / ratio); + x = (buff.getWidth() - w) / 2; + } + + if (gfx.drawImage(image, x, y, w, h, new ImageObserver() { + @Override + public boolean imageUpdate(Image img, int infoflags, int x, + int y, int width, int height) { + ImageTextAwt.this.ready = true; + return true; + } + })) { + ready = true; + } + + while (!ready) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + } + } + + gfx.dispose(); + + StringBuilder builder = new StringBuilder(); + + for (int row = 0; row + (mult - 1) < buff.getHeight(); row += mult) { + if (row > 0) { + builder.append('\n'); + } + + for (int col = 0; col + (mult - 1) < buff.getWidth(); col += mult) { + if (mult == 1) { + char car = ' '; + float brightness = getBrightness(buff.getRGB(col, row)); + if (mode == Mode.DITHERING) + car = getDitheringChar(brightness, " ░▒▓█"); + if (mode == Mode.ASCII) + car = getDitheringChar(brightness, " .-+=o8#"); + + builder.append(car); + } else if (mult == 2) { + builder.append(getBlockChar( // + buff.getRGB(col, row),// + buff.getRGB(col + 1, row),// + buff.getRGB(col, row + 1),// + buff.getRGB(col + 1, row + 1),// + mode == Mode.DOUBLE_DITHERING// + )); + } + } + } + + text = builder.toString(); + } + + return text; + } + + @Override + public String toString() { + return getText(); + } + + /** + * Return the size of the given {@link Image}. + * + * @param img + * the image to measure + * + * @return the size + */ + static private Dimension getSize(Image img) { + Dimension size = null; + while (size == null) { + int w = img.getWidth(null); + int h = img.getHeight(null); + if (w > -1 && h > -1) { + size = new Dimension(w, h); + } else { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + } + } + } + + return size; + } + + /** + * Return the {@link Character} corresponding to the given brightness level + * from the evenly-separated given {@link Character}s. + * + * @param brightness + * the brightness level + * @param cars + * the {@link Character}s to choose from, from less bright to + * most bright; MUST contain at least one + * {@link Character} + * + * @return the {@link Character} to use + */ + private char getDitheringChar(float brightness, String cars) { + int index = Math.round(brightness * (cars.length() - 1)); + return cars.charAt(index); + } + + /** + * Return the {@link Character} corresponding to the 4 given colours in + * {@link Mode#DOUBLE_RESOLUTION} or {@link Mode#DOUBLE_DITHERING} mode. + * + * @param upperleft + * the upper left colour + * @param upperright + * the upper right colour + * @param lowerleft + * the lower left colour + * @param lowerright + * the lower right colour + * @param dithering + * TRUE to use {@link Mode#DOUBLE_DITHERING}, FALSE for + * {@link Mode#DOUBLE_RESOLUTION} + * + * @return the {@link Character} to use + */ + private char getBlockChar(int upperleft, int upperright, int lowerleft, + int lowerright, boolean dithering) { + int choice = 0; + + if (getBrightness(upperleft) > 0.5f) { + choice += 1; + } + if (getBrightness(upperright) > 0.5f) { + choice += 2; + } + if (getBrightness(lowerleft) > 0.5f) { + choice += 4; + } + if (getBrightness(lowerright) > 0.5f) { + choice += 8; + } + + switch (choice) { + case 0: + return ' '; + case 1: + return '▘'; + case 2: + return '▝'; + case 3: + return '▀'; + case 4: + return '▖'; + case 5: + return '▌'; + case 6: + return '▞'; + case 7: + return '▛'; + case 8: + return '▗'; + case 9: + return '▚'; + case 10: + return '▐'; + case 11: + return '▜'; + case 12: + return '▄'; + case 13: + return '▙'; + case 14: + return '▟'; + case 15: + if (dithering) { + float avg = 0; + avg += getBrightness(upperleft); + avg += getBrightness(upperright); + avg += getBrightness(lowerleft); + avg += getBrightness(lowerright); + avg /= 4; + + // Since all the quarters are > 0.5, avg is between 0.5 and 1.0 + // So, expand the range of the value + avg = (avg - 0.5f) * 2; + + // Do not use the " " char, as it would make a + // "all quarters > 0.5" pixel go black + return getDitheringChar(avg, "░▒▓█"); + } + + return '█'; + } + + return ' '; + } + + /** + * Temporary array used so not to create a lot of new ones. + */ + private float[] tmp = new float[4]; + + /** + * Return the brightness value to use from the given ARGB colour. + * + * @param argb + * the argb colour + * + * @return the brightness to sue for computations + */ + private float getBrightness(int argb) { + if (invert) { + return 1 - rgb2hsb(argb, tmp)[2]; + } + + return rgb2hsb(argb, tmp)[2]; + } + + /** + * Convert the given ARGB colour in HSL/HSB, either into the supplied array + * or into a new one if array is NULL. + * + *

+ * ARGB pixels are given in 0xAARRGGBB format, while the returned array will + * contain Hue, Saturation, Lightness/Brightness, Alpha, in this order. H, + * S, L and A are all ranging from 0 to 1 (indeed, H is in 1/360th). + *

+ * pixel + * + * @param argb + * the ARGB colour pixel to convert + * @param array + * the array to convert into or NULL to create a new one + * + * @return the array containing the HSL/HSB converted colour + */ + static float[] rgb2hsb(int argb, float[] array) { + int a, r, g, b; + a = ((argb & 0xff000000) >> 24); + r = ((argb & 0x00ff0000) >> 16); + g = ((argb & 0x0000ff00) >> 8); + b = ((argb & 0x000000ff)); + + if (array == null) { + array = new float[4]; + } + + Color.RGBtoHSB(r, g, b, array); + + array[3] = a; + + return array; + + // // other implementation: + // + // float a, r, g, b; + // a = ((argb & 0xff000000) >> 24) / 255.0f; + // r = ((argb & 0x00ff0000) >> 16) / 255.0f; + // g = ((argb & 0x0000ff00) >> 8) / 255.0f; + // b = ((argb & 0x000000ff)) / 255.0f; + // + // float rgbMin, rgbMax; + // rgbMin = Math.min(r, Math.min(g, b)); + // rgbMax = Math.max(r, Math.max(g, b)); + // + // float l; + // l = (rgbMin + rgbMax) / 2; + // + // float s; + // if (rgbMin == rgbMax) { + // s = 0; + // } else { + // if (l <= 0.5) { + // s = (rgbMax - rgbMin) / (rgbMax + rgbMin); + // } else { + // s = (rgbMax - rgbMin) / (2.0f - rgbMax - rgbMin); + // } + // } + // + // float h; + // if (r > g && r > b) { + // h = (g - b) / (rgbMax - rgbMin); + // } else if (g > b) { + // h = 2.0f + (b - r) / (rgbMax - rgbMin); + // } else { + // h = 4.0f + (r - g) / (rgbMax - rgbMin); + // } + // h /= 6; // from 0 to 1 + // + // return new float[] { h, s, l, a }; + // + // // // natural mode: + // // + // // int aa = (int) Math.round(100 * a); + // // int hh = (int) (360 * h); + // // if (hh < 0) + // // hh += 360; + // // int ss = (int) Math.round(100 * s); + // // int ll = (int) Math.round(100 * l); + // // + // // return new int[] { hh, ss, ll, aa }; + } +} diff --git a/src/be/nikiroo/utils/ui/ImageUtilsAwt.java b/src/be/nikiroo/utils/ui/ImageUtilsAwt.java new file mode 100644 index 0000000..4cf12c0 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ImageUtilsAwt.java @@ -0,0 +1,180 @@ +package be.nikiroo.utils.ui; + +import java.awt.geom.AffineTransform; +import java.awt.image.AffineTransformOp; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import javax.imageio.ImageIO; + +import be.nikiroo.utils.Image; +import be.nikiroo.utils.ImageUtils; +import be.nikiroo.utils.StringUtils; + +/** + * This class offer some utilities based around images and uses java.awt. + * + * @author niki + */ +public class ImageUtilsAwt extends ImageUtils { + @Override + protected boolean check() { + // Will not work if ImageIO is not available + ImageIO.getCacheDirectory(); + return true; + } + + @Override + public void saveAsImage(Image img, File target, String format) + throws IOException { + try { + BufferedImage image = fromImage(img); + + boolean ok = false; + try { + + ok = ImageIO.write(image, format, target); + } catch (IOException e) { + ok = false; + } + + // Some formats are not reliable + // Second chance: PNG + if (!ok && !format.equals("png")) { + try { + ok = ImageIO.write(image, "png", target); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IOException("Undocumented exception occured, " + + "converting to IOException", e); + } + } + + if (!ok) { + throw new IOException( + "Cannot find a writer for this image and format: " + + format); + } + } catch (IOException e) { + throw new IOException("Cannot write image to " + target, e); + } + } + + /** + * Convert the given {@link Image} into a {@link BufferedImage} object, + * respecting the EXIF transformations if any. + * + * @param img + * the {@link Image} + * + * @return the {@link Image} object + * + * @throws IOException + * in case of IO error + */ + public static BufferedImage fromImage(Image img) throws IOException { + InputStream in = img.newInputStream(); + BufferedImage image; + try { + int orientation; + try { + orientation = getExifTransorm(in); + } catch (Exception e) { + // no EXIF transform, ok + orientation = -1; + } + + in.reset(); + + try { + image = ImageIO.read(in); + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IOException("Undocumented exception occured, " + + "converting to IOException", e); + } + + if (image == null) { + String extra = ""; + if (img.getSize() <= 2048) { + try { + extra = ", content: " + + new String(img.getData(), "UTF-8"); + } catch (Exception e) { + extra = ", content unavailable"; + } + } + String ssize = StringUtils.formatNumber(img.getSize()); + throw new IOException( + "Failed to convert input to image, size was: " + ssize + + extra); + } + + // Note: this code has been found on Internet; + // thank you anonymous coder. + int width = image.getWidth(); + int height = image.getHeight(); + AffineTransform affineTransform = new AffineTransform(); + + switch (orientation) { + case 1: + affineTransform = null; + break; + case 2: // Flip X + affineTransform.scale(-1.0, 1.0); + affineTransform.translate(-width, 0); + break; + case 3: // PI rotation + affineTransform.translate(width, height); + affineTransform.rotate(Math.PI); + break; + case 4: // Flip Y + affineTransform.scale(1.0, -1.0); + affineTransform.translate(0, -height); + break; + case 5: // - PI/2 and Flip X + affineTransform.rotate(-Math.PI / 2); + affineTransform.scale(-1.0, 1.0); + break; + case 6: // -PI/2 and -width + affineTransform.translate(height, 0); + affineTransform.rotate(Math.PI / 2); + break; + case 7: // PI/2 and Flip + affineTransform.scale(-1.0, 1.0); + affineTransform.translate(-height, 0); + affineTransform.translate(0, width); + affineTransform.rotate(3 * Math.PI / 2); + break; + case 8: // PI / 2 + affineTransform.translate(0, width); + affineTransform.rotate(3 * Math.PI / 2); + break; + default: + affineTransform = null; + break; + } + + if (affineTransform != null) { + AffineTransformOp affineTransformOp = new AffineTransformOp( + affineTransform, AffineTransformOp.TYPE_BILINEAR); + + BufferedImage transformedImage = new BufferedImage(width, + height, image.getType()); + transformedImage = affineTransformOp.filter(image, + transformedImage); + + image = transformedImage; + } + // + } finally { + in.close(); + } + + return image; + } +} diff --git a/src/be/nikiroo/utils/ui/ProgressBar.java b/src/be/nikiroo/utils/ui/ProgressBar.java new file mode 100644 index 0000000..219cde9 --- /dev/null +++ b/src/be/nikiroo/utils/ui/ProgressBar.java @@ -0,0 +1,183 @@ +package be.nikiroo.utils.ui; + +import java.awt.GridLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.SwingUtilities; + +import be.nikiroo.utils.Progress; + +/** + * A graphical control to show the progress of a {@link Progress}. + *

+ * This control is NOT thread-safe. + * + * @author niki + */ +public class ProgressBar extends JPanel { + private static final long serialVersionUID = 1L; + + private Map bars; + private List actionListeners; + private List updateListeners; + private Progress pg; + private Object lock = new Object(); + + public ProgressBar() { + bars = new HashMap(); + actionListeners = new ArrayList(); + updateListeners = new ArrayList(); + } + + public void setProgress(final Progress pg) { + this.pg = pg; + + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if (pg != null) { + final JProgressBar bar = new JProgressBar(); + bar.setStringPainted(true); + + bars.clear(); + bars.put(pg, bar); + + bar.setMinimum(pg.getMin()); + bar.setMaximum(pg.getMax()); + bar.setValue(pg.getProgress()); + bar.setString(pg.getName()); + + pg.addProgressListener(new Progress.ProgressListener() { + @Override + public void progress(Progress progress, String name) { + final Progress.ProgressListener l = this; + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + Map newBars = new HashMap(); + newBars.put(pg, bar); + + bar.setMinimum(pg.getMin()); + bar.setMaximum(pg.getMax()); + bar.setValue(pg.getProgress()); + bar.setString(pg.getName()); + + synchronized (lock) { + for (Progress pgChild : getChildrenAsOrderedList(pg)) { + JProgressBar barChild = bars + .get(pgChild); + if (barChild == null) { + barChild = new JProgressBar(); + barChild.setStringPainted(true); + } + + newBars.put(pgChild, barChild); + + barChild.setMinimum(pgChild.getMin()); + barChild.setMaximum(pgChild.getMax()); + barChild.setValue(pgChild.getProgress()); + barChild.setString(pgChild.getName()); + } + + if (ProgressBar.this.pg == null) { + bars.clear(); + } else { + bars = newBars; + } + } + + if (ProgressBar.this.pg != null) { + if (pg.isDone()) { + pg.removeProgressListener(l); + for (ActionListener listener : actionListeners) { + listener.actionPerformed(new ActionEvent( + ProgressBar.this, 0, + "done")); + } + } + + update(); + } + } + }); + } + }); + } + + update(); + } + }); + } + + public void addActionListener(ActionListener l) { + actionListeners.add(l); + } + + public void clearActionListeners() { + actionListeners.clear(); + } + + public void addUpdateListener(ActionListener l) { + updateListeners.add(l); + } + + public void clearUpdateListeners() { + updateListeners.clear(); + } + + public int getProgress() { + if (pg == null) { + return 0; + } + + return pg.getProgress(); + } + + // only named ones + private List getChildrenAsOrderedList(Progress pg) { + List children = new ArrayList(); + + synchronized (lock) { + for (Progress child : pg.getChildren()) { + if (child.getName() != null && !child.getName().isEmpty()) { + children.add(child); + } + children.addAll(getChildrenAsOrderedList(child)); + } + } + + return children; + } + + private void update() { + synchronized (lock) { + invalidate(); + removeAll(); + + if (pg != null) { + setLayout(new GridLayout(bars.size(), 1)); + add(bars.get(pg), 0); + for (Progress child : getChildrenAsOrderedList(pg)) { + JProgressBar jbar = bars.get(child); + if (jbar != null) { + add(jbar); + } + } + } + + validate(); + repaint(); + } + + for (ActionListener listener : updateListeners) { + listener.actionPerformed(new ActionEvent(this, 0, "update")); + } + } +} diff --git a/src/be/nikiroo/utils/ui/UIUtils.java b/src/be/nikiroo/utils/ui/UIUtils.java new file mode 100644 index 0000000..24cbf64 --- /dev/null +++ b/src/be/nikiroo/utils/ui/UIUtils.java @@ -0,0 +1,118 @@ +package be.nikiroo.utils.ui; + +import java.awt.Color; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Paint; +import java.awt.RadialGradientPaint; +import java.awt.RenderingHints; + +import javax.swing.UIManager; +import javax.swing.UnsupportedLookAndFeelException; + +/** + * Some Java Swing utilities. + * + * @author niki + */ +public class UIUtils { + /** + * Set a fake "native look & feel" for the application if possible + * (check for the one currently in use, then try GTK). + *

+ * Must be called prior to any GUI work. + */ + static public void setLookAndFeel() { + // native look & feel + try { + String noLF = "javax.swing.plaf.metal.MetalLookAndFeel"; + String lf = UIManager.getSystemLookAndFeelClassName(); + if (lf.equals(noLF)) + lf = "com.sun.java.swing.plaf.gtk.GTKLookAndFeel"; + UIManager.setLookAndFeel(lf); + } catch (InstantiationException e) { + } catch (ClassNotFoundException e) { + } catch (UnsupportedLookAndFeelException e) { + } catch (IllegalAccessException e) { + } + } + + /** + * Draw a 3D-looking ellipse at the given location, if the given + * {@link Graphics} object is compatible (with {@link Graphics2D}); draw a + * simple ellipse if not. + * + * @param g + * the {@link Graphics} to draw on + * @param color + * the base colour + * @param x + * the X coordinate + * @param y + * the Y coordinate + * @param width + * the width radius + * @param height + * the height radius + */ + static public void drawEllipse3D(Graphics g, Color color, int x, int y, + int width, int height) { + if (g instanceof Graphics2D) { + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + + // Retains the previous state + Paint oldPaint = g2.getPaint(); + + // Base shape + g2.setColor(color); + g2.fillOval(x, y, width, height); + + // Compute dark/bright colours + Paint p = null; + Color dark = color.darker(); + Color bright = color.brighter(); + Color darkEnd = new Color(dark.getRed(), dark.getGreen(), + dark.getBlue(), 0); + Color darkPartial = new Color(dark.getRed(), dark.getGreen(), + dark.getBlue(), 64); + Color brightEnd = new Color(bright.getRed(), bright.getGreen(), + bright.getBlue(), 0); + + // Adds shadows at the bottom left + p = new GradientPaint(0, height, dark, width, 0, darkEnd); + g2.setPaint(p); + g2.fillOval(x, y, width, height); + + // Adds highlights at the top right + p = new GradientPaint(width, 0, bright, 0, height, brightEnd); + g2.setPaint(p); + g2.fillOval(x, y, width, height); + + // Darken the edges + p = new RadialGradientPaint(x + width / 2f, y + height / 2f, + Math.min(width / 2f, height / 2f), new float[] { 0f, 1f }, + new Color[] { darkEnd, darkPartial }, + RadialGradientPaint.CycleMethod.NO_CYCLE); + g2.setPaint(p); + g2.fillOval(x, y, width, height); + + // Adds inner highlight at the top right + p = new RadialGradientPaint(x + 3f * width / 4f, y + height / 4f, + Math.min(width / 4f, height / 4f), + new float[] { 0.0f, 0.8f }, + new Color[] { bright, brightEnd }, + RadialGradientPaint.CycleMethod.NO_CYCLE); + g2.setPaint(p); + g2.fillOval(x * 2, y, width, height); + + // Reset original paint + g2.setPaint(oldPaint); + } else { + g.setColor(color); + g.fillOval(x, y, width, height); + } + } +} diff --git a/src/be/nikiroo/utils/ui/WrapLayout.java b/src/be/nikiroo/utils/ui/WrapLayout.java new file mode 100644 index 0000000..7f34d79 --- /dev/null +++ b/src/be/nikiroo/utils/ui/WrapLayout.java @@ -0,0 +1,205 @@ +package be.nikiroo.utils.ui; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Insets; + +import javax.swing.JScrollPane; +import javax.swing.SwingUtilities; + +/** + * FlowLayout subclass that fully supports wrapping of components. + * + * @author https://tips4java.wordpress.com/2008/11/06/wrap-layout/ + */ +public class WrapLayout extends FlowLayout { + private static final long serialVersionUID = 1L; + + /** + * Constructs a new WrapLayout with a left alignment and a + * default 5-unit horizontal and vertical gap. + */ + public WrapLayout() { + super(); + } + + /** + * Constructs a new FlowLayout with the specified alignment and + * a default 5-unit horizontal and vertical gap. The value of the alignment + * argument must be one of WrapLayout, WrapLayout, + * or WrapLayout. + * + * @param align + * the alignment value + */ + public WrapLayout(int align) { + super(align); + } + + /** + * Creates a new flow layout manager with the indicated alignment and the + * indicated horizontal and vertical gaps. + *

+ * The value of the alignment argument must be one of + * WrapLayout, WrapLayout, or + * WrapLayout. + * + * @param align + * the alignment value + * @param hgap + * the horizontal gap between components + * @param vgap + * the vertical gap between components + */ + public WrapLayout(int align, int hgap, int vgap) { + super(align, hgap, vgap); + } + + /** + * Returns the preferred dimensions for this layout given the visible + * components in the specified target container. + * + * @param target + * the component which needs to be laid out + * @return the preferred dimensions to lay out the subcomponents of the + * specified container + */ + @Override + public Dimension preferredLayoutSize(Container target) { + return layoutSize(target, true); + } + + /** + * Returns the minimum dimensions needed to layout the visible + * components contained in the specified target container. + * + * @param target + * the component which needs to be laid out + * @return the minimum dimensions to lay out the subcomponents of the + * specified container + */ + @Override + public Dimension minimumLayoutSize(Container target) { + Dimension minimum = layoutSize(target, false); + minimum.width -= (getHgap() + 1); + return minimum; + } + + /** + * Returns the minimum or preferred dimension needed to layout the target + * container. + * + * @param target + * target to get layout size for + * @param preferred + * should preferred size be calculated + * @return the dimension to layout the target container + */ + private Dimension layoutSize(Container target, boolean preferred) { + synchronized (target.getTreeLock()) { + // Each row must fit with the width allocated to the containter. + // When the container width = 0, the preferred width of the + // container + // has not yet been calculated so lets ask for the maximum. + + int targetWidth = target.getSize().width; + Container container = target; + + while (container.getSize().width == 0 + && container.getParent() != null) { + container = container.getParent(); + } + + targetWidth = container.getSize().width; + + if (targetWidth == 0) + targetWidth = Integer.MAX_VALUE; + + int hgap = getHgap(); + int vgap = getVgap(); + Insets insets = target.getInsets(); + int horizontalInsetsAndGap = insets.left + insets.right + + (hgap * 2); + int maxWidth = targetWidth - horizontalInsetsAndGap; + + // Fit components into the allowed width + + Dimension dim = new Dimension(0, 0); + int rowWidth = 0; + int rowHeight = 0; + + int nmembers = target.getComponentCount(); + + for (int i = 0; i < nmembers; i++) { + Component m = target.getComponent(i); + + if (m.isVisible()) { + Dimension d = preferred ? m.getPreferredSize() : m + .getMinimumSize(); + + // Can't add the component to current row. Start a new + // row. + + if (rowWidth + d.width > maxWidth) { + addRow(dim, rowWidth, rowHeight); + rowWidth = 0; + rowHeight = 0; + } + + // Add a horizontal gap for all components after the + // first + + if (rowWidth != 0) { + rowWidth += hgap; + } + + rowWidth += d.width; + rowHeight = Math.max(rowHeight, d.height); + } + } + + addRow(dim, rowWidth, rowHeight); + + dim.width += horizontalInsetsAndGap; + dim.height += insets.top + insets.bottom + vgap * 2; + + // When using a scroll pane or the DecoratedLookAndFeel we need + // to + // make sure the preferred size is less than the size of the + // target containter so shrinking the container size works + // correctly. Removing the horizontal gap is an easy way to do + // this. + + Container scrollPane = SwingUtilities.getAncestorOfClass( + JScrollPane.class, target); + + if (scrollPane != null && target.isValid()) { + dim.width -= (hgap + 1); + } + + return dim; + } + } + + /* + * A new row has been completed. Use the dimensions of this row to update + * the preferred size for the container. + * + * @param dim update the width and height when appropriate + * + * @param rowWidth the width of the row to add + * + * @param rowHeight the height of the row to add + */ + private void addRow(Dimension dim, int rowWidth, int rowHeight) { + dim.width = Math.max(dim.width, rowWidth); + + if (dim.height > 0) { + dim.height += getVgap(); + } + + dim.height += rowHeight; + } +} \ No newline at end of file diff --git a/src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java b/src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java new file mode 100644 index 0000000..b416cbc --- /dev/null +++ b/src/be/nikiroo/utils/ui/test/ProgressBarManualTest.java @@ -0,0 +1,82 @@ +package be.nikiroo.utils.ui.test; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; + +import javax.swing.JButton; +import javax.swing.JFrame; + +import be.nikiroo.utils.Progress; +import be.nikiroo.utils.ui.ProgressBar; + +public class ProgressBarManualTest extends JFrame { + private static final long serialVersionUID = 1L; + private int i = 0; + + public ProgressBarManualTest() { + final ProgressBar bar = new ProgressBar(); + final Progress pg = new Progress("name"); + final Progress pg2 = new Progress("second level", 0, 2); + final Progress pg3 = new Progress("third level"); + + setLayout(new BorderLayout()); + this.add(bar, BorderLayout.SOUTH); + + final JButton b = new JButton("Set pg to 10%"); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + switch (i) { + case 0: + pg.setProgress(10); + pg2.setProgress(0); + b.setText("Set pg to 20%"); + break; + case 1: + pg.setProgress(20); + b.setText("Add pg2 (0-2)"); + break; + case 2: + pg.addProgress(pg2, 80); + pg2.setProgress(0); + b.setText("Add pg3 (0-100)"); + break; + case 3: + pg2.addProgress(pg3, 2); + pg3.setProgress(0); + b.setText("Set pg3 to 10%"); + break; + case 4: + pg3.setProgress(10); + b.setText("Set pg3 to 20%"); + break; + case 5: + pg3.setProgress(20); + b.setText("Set pg3 to 60%"); + break; + case 6: + pg3.setProgress(60); + b.setText("Set pg3 to 100%"); + break; + case 7: + pg3.setProgress(100); + b.setText("[done]"); + break; + } + + i++; + } + }); + this.add(b, BorderLayout.CENTER); + + setSize(800, 600); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + + bar.setProgress(pg); + } + + public static void main(String[] args) { + new ProgressBarManualTest().setVisible(true); + } +} diff --git a/src/be/nikiroo/utils/ui/test/TestUI.java b/src/be/nikiroo/utils/ui/test/TestUI.java new file mode 100644 index 0000000..c260295 --- /dev/null +++ b/src/be/nikiroo/utils/ui/test/TestUI.java @@ -0,0 +1,8 @@ +package be.nikiroo.utils.ui.test; + +public class TestUI { + // TODO: make a GUI tester + public TestUI() { + ProgressBarManualTest a = new ProgressBarManualTest(); + } +}