fix Base64 but breaks compat
[nikiroo-utils.git] / src / be / nikiroo / utils / serial / SerialUtils.java
CommitLineData
db31c358
NR
1package be.nikiroo.utils.serial;
2
08f80ac5 3import java.io.ByteArrayInputStream;
e570f7eb 4import java.io.IOException;
08f80ac5 5import java.io.InputStream;
db31c358 6import java.io.NotSerializableException;
08f80ac5
NR
7import java.io.OutputStream;
8import java.io.UnsupportedEncodingException;
ce0974c4 9import java.lang.reflect.Array;
8c8da42a 10import java.lang.reflect.Constructor;
db31c358 11import java.lang.reflect.Field;
e570f7eb 12import java.lang.reflect.Modifier;
f4053377 13import java.net.URL;
72648e75 14import java.util.ArrayList;
db31c358 15import java.util.HashMap;
72648e75 16import java.util.List;
db31c358 17import java.util.Map;
ce0974c4 18import java.util.UnknownFormatConversionException;
db31c358 19
08f80ac5 20import be.nikiroo.utils.IOUtils;
80500544 21import be.nikiroo.utils.Image;
08f80ac5 22import be.nikiroo.utils.StringUtils;
a6a73de3
NR
23import be.nikiroo.utils.streams.Base64InputStream;
24import be.nikiroo.utils.streams.Base64OutputStream;
08f80ac5
NR
25import be.nikiroo.utils.streams.NextableInputStream;
26import be.nikiroo.utils.streams.NextableInputStreamStep;
e570f7eb 27
db31c358 28/**
8c8da42a 29 * Small class to help with serialisation.
db31c358
NR
30 * <p>
31 * Note that we do not support inner classes (but we do support nested classes)
32 * and all objects require an empty constructor to be deserialised.
5bc55b51
NR
33 * <p>
34 * It is possible to add support to custom types (both the encoder and the
35 * decoder will require the custom classes) -- see {@link CustomSerializer}.
36 * <p>
37 * Default supported types are:
38 * <ul>
39 * <li>NULL (as a null value)</li>
40 * <li>String</li>
41 * <li>Boolean</li>
42 * <li>Byte</li>
43 * <li>Character</li>
44 * <li>Short</li>
45 * <li>Long</li>
46 * <li>Float</li>
47 * <li>Double</li>
48 * <li>Integer</li>
49 * <li>Enum (any enum whose name and value is known by the caller)</li>
50 * <li>java.awt.image.BufferedImage (as a {@link CustomSerializer})</li>
51 * <li>An array of the above (as a {@link CustomSerializer})</li>
f4053377 52 * <li>URL</li>
5bc55b51 53 * </ul>
db31c358
NR
54 *
55 * @author niki
56 */
aad14586 57public class SerialUtils {
db31c358
NR
58 private static Map<String, CustomSerializer> customTypes;
59
60 static {
61 customTypes = new HashMap<String, CustomSerializer>();
ce0974c4
NR
62
63 // Array types:
64 customTypes.put("[]", new CustomSerializer() {
65 @Override
08f80ac5
NR
66 protected void toStream(OutputStream out, Object value)
67 throws IOException {
68
69 // TODO: we use \n to separate, and b64 to un-\n
70 // -- but we could use \\n ?
ce0974c4
NR
71 String type = value.getClass().getCanonicalName();
72 type = type.substring(0, type.length() - 2); // remove the []
73
08f80ac5 74 write(out, type);
ce0974c4
NR
75 try {
76 for (int i = 0; true; i++) {
77 Object item = Array.get(value, i);
08f80ac5 78
ce0974c4 79 // encode it normally if direct value
08f80ac5
NR
80 write(out, "\r");
81 if (!SerialUtils.encode(out, item)) {
ce0974c4 82 try {
08f80ac5
NR
83 // TODO: bad escaping?
84 write(out, "B64:");
a6a73de3
NR
85 OutputStream out64 = new Base64OutputStream(
86 out, true);
87 new Exporter(out64).append(item);
ce0974c4
NR
88 } catch (NotSerializableException e) {
89 throw new UnknownFormatConversionException(e
90 .getMessage());
91 }
92 }
ce0974c4
NR
93 }
94 } catch (ArrayIndexOutOfBoundsException e) {
95 // Done.
96 }
ce0974c4
NR
97 }
98
99 @Override
08f80ac5
NR
100 protected Object fromStream(InputStream in) throws IOException {
101 NextableInputStream stream = new NextableInputStream(in,
102 new NextableInputStreamStep('\r'));
ce0974c4
NR
103
104 try {
08f80ac5
NR
105 List<Object> list = new ArrayList<Object>();
106 stream.next();
107 String type = IOUtils.readSmallStream(stream);
108
109 while (stream.next()) {
110 Object value = new Importer().read(stream).getValue();
111 list.add(value);
112 }
113
ce0974c4 114 Object array = Array.newInstance(
08f80ac5
NR
115 SerialUtils.getClass(type), list.size());
116 for (int i = 0; i < list.size(); i++) {
117 Array.set(array, i, list.get(i));
ce0974c4
NR
118 }
119
120 return array;
121 } catch (Exception e) {
452f38c8
NR
122 if (e instanceof IOException) {
123 throw (IOException) e;
124 }
125 throw new IOException(e.getMessage());
ce0974c4
NR
126 }
127 }
08f80ac5
NR
128
129 @Override
130 protected String getType() {
131 return "[]";
132 }
ce0974c4 133 });
e570f7eb 134
f4053377
NR
135 // URL:
136 customTypes.put("java.net.URL", new CustomSerializer() {
137 @Override
08f80ac5
NR
138 protected void toStream(OutputStream out, Object value)
139 throws IOException {
140 String val = "";
f4053377 141 if (value != null) {
08f80ac5 142 val = ((URL) value).toString();
f4053377 143 }
08f80ac5
NR
144
145 out.write(val.getBytes("UTF-8"));
f4053377
NR
146 }
147
148 @Override
08f80ac5
NR
149 protected Object fromStream(InputStream in) throws IOException {
150 String val = IOUtils.readSmallStream(in);
151 if (!val.isEmpty()) {
152 return new URL(val);
f4053377 153 }
08f80ac5 154
f4053377
NR
155 return null;
156 }
157
158 @Override
159 protected String getType() {
160 return "java.net.URL";
161 }
162 });
163
e570f7eb 164 // Images (this is currently the only supported image type by default)
80500544 165 customTypes.put("be.nikiroo.utils.Image", new CustomSerializer() {
e570f7eb 166 @Override
08f80ac5
NR
167 protected void toStream(OutputStream out, Object value)
168 throws IOException {
169 Image img = (Image) value;
a6a73de3 170 OutputStream encoded = new Base64OutputStream(out, true);
08f80ac5
NR
171 try {
172 InputStream in = img.newInputStream();
173 try {
174 IOUtils.write(in, encoded);
175 } finally {
176 in.close();
177 }
178 } finally {
179 encoded.flush();
180 // Cannot close!
181 }
e570f7eb
NR
182 }
183
184 @Override
185 protected String getType() {
80500544 186 return "be.nikiroo.utils.Image";
e570f7eb
NR
187 }
188
189 @Override
08f80ac5 190 protected Object fromStream(InputStream in) throws IOException {
e570f7eb 191 try {
08f80ac5 192 // Cannot close it!
a6a73de3 193 InputStream decoded = new Base64InputStream(in, false);
08f80ac5 194 return new Image(decoded);
e570f7eb
NR
195 } catch (IOException e) {
196 throw new UnknownFormatConversionException(e.getMessage());
197 }
198 }
199 });
db31c358 200 }
8c8da42a 201
aad14586
NR
202 /**
203 * Create an empty object of the given type.
204 *
205 * @param type
206 * the object type (its class name)
207 *
208 * @return the new object
209 *
8c8da42a
NR
210 * @throws ClassNotFoundException
211 * if the class cannot be found
212 * @throws NoSuchMethodException
213 * if the given class is not compatible with this code
aad14586 214 */
8c8da42a
NR
215 public static Object createObject(String type)
216 throws ClassNotFoundException, NoSuchMethodException {
aad14586 217
72648e75 218 String desc = null;
aad14586
NR
219 try {
220 Class<?> clazz = getClass(type);
aad14586 221 String className = clazz.getName();
72648e75
NR
222 List<Object> args = new ArrayList<Object>();
223 List<Class<?>> classes = new ArrayList<Class<?>>();
aad14586
NR
224 Constructor<?> ctor = null;
225 if (className.contains("$")) {
72648e75
NR
226 for (String parentName = className.substring(0,
227 className.lastIndexOf('$'));; parentName = parentName
949445ee 228 .substring(0, parentName.lastIndexOf('$'))) {
72648e75
NR
229 Object parent = createObject(parentName);
230 args.add(parent);
231 classes.add(parent.getClass());
949445ee 232
72648e75
NR
233 if (!parentName.contains("$")) {
234 break;
235 }
236 }
237
238 // Better error description in case there is no empty
239 // constructor:
240 desc = "";
241 String end = "";
242 for (Class<?> parent = clazz; parent != null
243 && !parent.equals(Object.class); parent = parent
949445ee 244 .getSuperclass()) {
72648e75
NR
245 if (!desc.isEmpty()) {
246 desc += " [:";
247 end += "]";
248 }
249 desc += parent;
250 }
251 desc += end;
252 //
253
cd26ee07
NR
254 try {
255 ctor = clazz.getDeclaredConstructor(classes
256 .toArray(new Class[] {}));
257 } catch (NoSuchMethodException nsme) {
08f80ac5 258 // TODO: it seems we do not always need a parameter for each
cd26ee07
NR
259 // level, so we currently try "ALL" levels or "FIRST" level
260 // only -> we should check the actual rule and use it
261 ctor = clazz.getDeclaredConstructor(classes.get(0));
262 Object firstParent = args.get(0);
263 args.clear();
264 args.add(firstParent);
265 }
72648e75 266 desc = null;
aad14586 267 } else {
aad14586
NR
268 ctor = clazz.getDeclaredConstructor();
269 }
270
271 ctor.setAccessible(true);
72648e75 272 return ctor.newInstance(args.toArray());
8c8da42a
NR
273 } catch (ClassNotFoundException e) {
274 throw e;
aad14586 275 } catch (NoSuchMethodException e) {
72648e75 276 if (desc != null) {
949445ee
NR
277 throw new NoSuchMethodException("Empty constructor not found: "
278 + desc);
72648e75 279 }
8c8da42a
NR
280 throw e;
281 } catch (Exception e) {
282 throw new NoSuchMethodException("Cannot instantiate: " + type);
aad14586 283 }
aad14586 284 }
db31c358 285
8c8da42a
NR
286 /**
287 * Insert a custom serialiser that will take precedence over the default one
288 * or the target class.
289 *
290 * @param serializer
291 * the custom serialiser
292 */
db31c358
NR
293 static public void addCustomSerializer(CustomSerializer serializer) {
294 customTypes.put(serializer.getType(), serializer);
295 }
296
8c8da42a 297 /**
08f80ac5 298 * Serialise the given object into this {@link OutputStream}.
8c8da42a
NR
299 * <p>
300 * <b>Important: </b>If the operation fails (with a
301 * {@link NotSerializableException}), the {@link StringBuilder} will be
302 * corrupted (will contain bad, most probably not importable data).
303 *
08f80ac5
NR
304 * @param out
305 * the output {@link OutputStream} to serialise to
8c8da42a
NR
306 * @param o
307 * the object to serialise
308 * @param map
309 * the map of already serialised objects (if the given object or
310 * one of its descendant is already present in it, only an ID
311 * will be serialised)
312 *
313 * @throws NotSerializableException
314 * if the object cannot be serialised (in this case, the
315 * {@link StringBuilder} can contain bad, most probably not
316 * importable data)
08f80ac5
NR
317 * @throws IOException
318 * in case of I/O errors
8c8da42a 319 */
08f80ac5
NR
320 static void append(OutputStream out, Object o, Map<Integer, Object> map)
321 throws NotSerializableException, IOException {
db31c358
NR
322
323 Field[] fields = new Field[] {};
324 String type = "";
325 String id = "NULL";
326
327 if (o != null) {
328 int hash = System.identityHashCode(o);
329 fields = o.getClass().getDeclaredFields();
330 type = o.getClass().getCanonicalName();
331 if (type == null) {
72648e75
NR
332 // Anonymous inner classes support
333 type = o.getClass().getName();
db31c358
NR
334 }
335 id = Integer.toString(hash);
336 if (map.containsKey(hash)) {
337 fields = new Field[] {};
338 } else {
339 map.put(hash, o);
340 }
341 }
342
08f80ac5
NR
343 write(out, "{\nREF ");
344 write(out, type);
345 write(out, "@");
346 write(out, id);
347 write(out, ":");
348
349 if (!encode(out, o)) { // check if direct value
ce0974c4
NR
350 try {
351 for (Field field : fields) {
352 field.setAccessible(true);
db31c358 353
e570f7eb
NR
354 if (field.getName().startsWith("this$")
355 || field.isSynthetic()
356 || (field.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
ce0974c4 357 // Do not keep this links of nested classes
e570f7eb
NR
358 // Do not keep synthetic fields
359 // Do not keep final fields
ce0974c4
NR
360 continue;
361 }
db31c358 362
08f80ac5
NR
363 write(out, "\n");
364 write(out, field.getName());
365 write(out, ":");
db31c358 366
08f80ac5 367 Object value = field.get(o);
db31c358 368
08f80ac5
NR
369 if (!encode(out, value)) {
370 write(out, "\n");
371 append(out, value, map);
ce0974c4 372 }
db31c358 373 }
ce0974c4
NR
374 } catch (IllegalArgumentException e) {
375 e.printStackTrace(); // should not happen (see
376 // setAccessible)
377 } catch (IllegalAccessException e) {
378 e.printStackTrace(); // should not happen (see
379 // setAccessible)
db31c358 380 }
08f80ac5
NR
381
382 write(out, "\n}");
db31c358 383 }
db31c358
NR
384 }
385
5bc55b51 386 /**
08f80ac5
NR
387 * Encode the object into the given {@link OutputStream} if possible and if
388 * supported.
949445ee
NR
389 * <p>
390 * A supported object in this context means an object we can directly
391 * encode, like an Integer or a String. Custom objects and arrays are also
392 * considered supported, but <b>compound objects are not supported here</b>.
393 * <p>
394 * For compound objects, you should use {@link Exporter}.
5bc55b51 395 *
08f80ac5
NR
396 * @param out
397 * the {@link OutputStream} to append to
5bc55b51
NR
398 * @param value
399 * the object to encode (can be NULL, which will be encoded)
400 *
08f80ac5
NR
401 * @return TRUE if success, FALSE if not (the content of the
402 * {@link OutputStream} won't be changed in case of failure)
403 *
404 * @throws IOException
405 * in case of I/O error
5bc55b51 406 */
08f80ac5 407 static boolean encode(OutputStream out, Object value) throws IOException {
db31c358 408 if (value == null) {
08f80ac5 409 write(out, "NULL");
72648e75
NR
410 } else if (value.getClass().getSimpleName().endsWith("[]")) {
411 // Simple name does support [] suffix and do not return NULL for
412 // inner anonymous classes
08f80ac5 413 customTypes.get("[]").encode(out, value);
db31c358 414 } else if (customTypes.containsKey(value.getClass().getCanonicalName())) {
08f80ac5
NR
415 customTypes.get(value.getClass().getCanonicalName())//
416 .encode(out, value);
db31c358 417 } else if (value instanceof String) {
08f80ac5 418 encodeString(out, (String) value);
db31c358 419 } else if (value instanceof Boolean) {
08f80ac5 420 write(out, value);
db31c358 421 } else if (value instanceof Byte) {
08f80ac5
NR
422 write(out, value);
423 write(out, "b");
db31c358 424 } else if (value instanceof Character) {
08f80ac5
NR
425 encodeString(out, "" + value);
426 write(out, "c");
db31c358 427 } else if (value instanceof Short) {
08f80ac5
NR
428 write(out, value);
429 write(out, "s");
db31c358 430 } else if (value instanceof Integer) {
08f80ac5 431 write(out, value);
db31c358 432 } else if (value instanceof Long) {
08f80ac5
NR
433 write(out, value);
434 write(out, "L");
db31c358 435 } else if (value instanceof Float) {
08f80ac5
NR
436 write(out, value);
437 write(out, "F");
db31c358 438 } else if (value instanceof Double) {
08f80ac5
NR
439 write(out, value);
440 write(out, "d");
e570f7eb
NR
441 } else if (value instanceof Enum) {
442 String type = value.getClass().getCanonicalName();
08f80ac5
NR
443 write(out, type);
444 write(out, ".");
445 write(out, ((Enum<?>) value).name());
446 write(out, ";");
db31c358
NR
447 } else {
448 return false;
449 }
450
451 return true;
452 }
453
5bc55b51 454 /**
949445ee
NR
455 * Decode the data into an equivalent supported source object.
456 * <p>
457 * A supported object in this context means an object we can directly
458 * encode, like an Integer or a String. Custom objects and arrays are also
459 * considered supported, but <b>compound objects are not supported here</b>.
460 * <p>
461 * For compound objects, you should use {@link Importer}.
5bc55b51
NR
462 *
463 * @param encodedValue
464 * the encoded data, cannot be NULL
465 *
466 * @return the object (can be NULL for NULL encoded values)
467 *
452f38c8 468 * @throws IOException
5bc55b51
NR
469 * if the content cannot be converted
470 */
452f38c8 471 static Object decode(String encodedValue) throws IOException {
5bc55b51 472 try {
452f38c8
NR
473 String cut = "";
474 if (encodedValue.length() > 1) {
475 cut = encodedValue.substring(0, encodedValue.length() - 1);
476 }
477
5bc55b51
NR
478 if (CustomSerializer.isCustom(encodedValue)) {
479 // custom:TYPE_NAME:"content is String-encoded"
480 String type = CustomSerializer.typeOf(encodedValue);
481 if (customTypes.containsKey(type)) {
08f80ac5
NR
482 // TODO: we should start with a stream
483 InputStream streamEncodedValue = new ByteArrayInputStream(
484 encodedValue.getBytes("UTF-8"));
485 try {
486 return customTypes.get(type).decode(streamEncodedValue);
487 } finally {
488 streamEncodedValue.close();
489 }
5bc55b51 490 }
452f38c8 491 throw new IOException("Unknown custom type: " + type);
5bc55b51
NR
492 } else if (encodedValue.equals("NULL")
493 || encodedValue.equals("null")) {
494 return null;
495 } else if (encodedValue.endsWith("\"")) {
496 return decodeString(encodedValue);
497 } else if (encodedValue.equals("true")) {
498 return true;
499 } else if (encodedValue.equals("false")) {
500 return false;
501 } else if (encodedValue.endsWith("b")) {
502 return Byte.parseByte(cut);
503 } else if (encodedValue.endsWith("c")) {
504 return decodeString(cut).charAt(0);
505 } else if (encodedValue.endsWith("s")) {
506 return Short.parseShort(cut);
507 } else if (encodedValue.endsWith("L")) {
508 return Long.parseLong(cut);
509 } else if (encodedValue.endsWith("F")) {
510 return Float.parseFloat(cut);
511 } else if (encodedValue.endsWith("d")) {
512 return Double.parseDouble(cut);
513 } else if (encodedValue.endsWith(";")) {
514 return decodeEnum(encodedValue);
515 } else {
516 return Integer.parseInt(encodedValue);
db31c358 517 }
5bc55b51 518 } catch (Exception e) {
452f38c8
NR
519 if (e instanceof IOException) {
520 throw (IOException) e;
5bc55b51 521 }
08f80ac5
NR
522 throw new IOException(e.getMessage(), e);
523 }
524 }
525
526 /**
527 * Write the given {@link String} into the given {@link OutputStream} in
528 * UTF-8.
529 *
530 * @param out
531 * the {@link OutputStream}
532 * @param data
533 * the data to write, cannot be NULL
534 *
535 * @throws IOException
536 * in case of I/O error
537 */
538 static void write(OutputStream out, Object data) throws IOException {
539 try {
540 out.write(data.toString().getBytes("UTF-8"));
541 } catch (UnsupportedEncodingException e) {
542 // A conforming JVM is required to support UTF-8
543 e.printStackTrace();
db31c358
NR
544 }
545 }
8c8da42a
NR
546
547 /**
548 * Return the corresponding class or throw an {@link Exception} if it
549 * cannot.
550 *
551 * @param type
552 * the class name to look for
553 *
554 * @return the class (will never be NULL)
555 *
556 * @throws ClassNotFoundException
557 * if the class cannot be found
558 * @throws NoSuchMethodException
559 * if the class cannot be created (usually because it or its
560 * enclosing class doesn't have an empty constructor)
561 */
562 static private Class<?> getClass(String type)
563 throws ClassNotFoundException, NoSuchMethodException {
aad14586
NR
564 Class<?> clazz = null;
565 try {
566 clazz = Class.forName(type);
567 } catch (ClassNotFoundException e) {
568 int pos = type.length();
569 pos = type.lastIndexOf(".", pos);
570 if (pos >= 0) {
571 String parentType = type.substring(0, pos);
572 String nestedType = type.substring(pos + 1);
573 Class<?> javaParent = null;
574 try {
575 javaParent = getClass(parentType);
576 parentType = javaParent.getName();
577 clazz = Class.forName(parentType + "$" + nestedType);
578 } catch (Exception ee) {
579 }
db31c358 580
aad14586
NR
581 if (javaParent == null) {
582 throw new NoSuchMethodException(
583 "Class not found: "
584 + type
585 + " (the enclosing class cannot be created: maybe it doesn't have an empty constructor?)");
586 }
587 }
588 }
589
8c8da42a
NR
590 if (clazz == null) {
591 throw new ClassNotFoundException("Class not found: " + type);
592 }
593
aad14586
NR
594 return clazz;
595 }
8c8da42a 596
e570f7eb 597 @SuppressWarnings({ "unchecked", "rawtypes" })
08f80ac5 598 static private Enum<?> decodeEnum(String escaped) {
e570f7eb
NR
599 // escaped: be.xxx.EnumType.VALUE;
600 int pos = escaped.lastIndexOf(".");
601 String type = escaped.substring(0, pos);
602 String name = escaped.substring(pos + 1, escaped.length() - 1);
603
604 try {
605 return Enum.valueOf((Class<Enum>) getClass(type), name);
606 } catch (Exception e) {
e570f7eb
NR
607 throw new UnknownFormatConversionException("Unknown enum: <" + type
608 + "> " + name);
609 }
610 }
611
db31c358 612 // aa bb -> "aa\tbb"
08f80ac5
NR
613 static void encodeString(OutputStream out, String raw) throws IOException {
614 // TODO: not. efficient.
615 out.write('\"');
616 // TODO !! utf-8 required
db31c358 617 for (char car : raw.toCharArray()) {
08f80ac5
NR
618 encodeString(out, car);
619 }
620 out.write('\"');
621 }
622
623 // aa bb -> "aa\tbb"
624 static void encodeString(OutputStream out, InputStream raw)
625 throws IOException {
626 out.write('\"');
627 byte buffer[] = new byte[4096];
628 for (int len = 0; (len = raw.read(buffer)) > 0;) {
629 for (int i = 0; i < len; i++) {
630 // TODO: not 100% correct, look up howto for UTF-8
631 encodeString(out, (char) buffer[i]);
db31c358
NR
632 }
633 }
08f80ac5
NR
634 out.write('\"');
635 }
636
637 // for encode string, NOT to encode a char by itself!
638 static void encodeString(OutputStream out, char raw) throws IOException {
639 switch (raw) {
640 case '\\':
641 out.write('\\');
642 out.write('\\');
643 break;
644 case '\r':
645 out.write('\\');
646 out.write('r');
647 break;
648 case '\n':
649 out.write('\\');
650 out.write('n');
651 break;
652 case '"':
653 out.write('\\');
654 out.write('\"');
655 break;
656 default:
657 out.write(raw);
658 break;
659 }
db31c358
NR
660 }
661
662 // "aa\tbb" -> aa bb
08f80ac5 663 static String decodeString(String escaped) {
db31c358
NR
664 StringBuilder builder = new StringBuilder();
665
666 boolean escaping = false;
667 for (char car : escaped.toCharArray()) {
668 if (!escaping) {
669 if (car == '\\') {
670 escaping = true;
671 } else {
672 builder.append(car);
673 }
674 } else {
675 switch (car) {
676 case '\\':
677 builder.append('\\');
678 break;
679 case 'r':
680 builder.append('\r');
681 break;
682 case 'n':
683 builder.append('\n');
684 break;
685 case '"':
686 builder.append('"');
687 break;
688 }
689 escaping = false;
690 }
691 }
692
0988831f 693 return builder.substring(1, builder.length() - 1);
db31c358
NR
694 }
695}