+ * Note: this module is a no-op when no Android-desugared records are being deserialized,
+ * so it is safe to use in code shared between Android and non-Android platforms.
+ *
+ *
+ * Note: The canonical record constructor is found
+ * through matching of parameter types with field types.
+ * Therefore, this module doesn't allow a deserialized desugared record class to have a custom
+ * constructor with a signature that's any permutation of the canonical one's.
+ *
+ * @author Eran Leshem
+ **/
+public class AndroidRecordModule extends SimpleModule {
+ @Override
+ public void setupModule(SetupContext context) {
+ super.setupModule(context);
+ context.addValueInstantiators(AndroidRecordModule::findValueInstantiator);
+ }
+
+ private static ValueInstantiator findValueInstantiator(DeserializationConfig config, BeanDescription beanDesc,
+ ValueInstantiator defaultInstantiator) {
+ Class> raw = beanDesc.getType().getRawClass();
+ if (defaultInstantiator instanceof StdValueInstantiator && raw.getSuperclass() != null
+ && raw.getSuperclass().getName().equals("com.android.tools.r8.RecordTag")) {
+ Map componentTypes = typeMap(Arrays.stream(raw.getDeclaredFields())
+ .filter(field -> !Modifier.isStatic(field.getModifiers())).map(Field::getGenericType));
+ boolean found = false;
+ for (Constructor> constructor: raw.getDeclaredConstructors()) {
+ Parameter[] parameters = constructor.getParameters();
+ Map parameterTypes = typeMap(Arrays.stream(parameters).map(Parameter::getParameterizedType));
+ if (! parameterTypes.equals(componentTypes)) {
+ continue;
+ }
+
+ if (found) {
+ throw new IllegalArgumentException(String.format(
+ "Multiple constructors match set of components for record %s", raw.getName()));
+ }
+
+ SettableBeanProperty[] properties = new SettableBeanProperty[parameters.length];
+ for (int i = 0; i < parameters.length; i++) {
+ Parameter parameter = parameters[i];
+ properties[i] = CreatorProperty.construct(PropertyName.construct(parameter.getName()), config.getTypeFactory()
+ .constructType(parameter.getParameterizedType()), null, null, null, null, i, null, null);
+ }
+
+ ((StdValueInstantiator) defaultInstantiator).configureFromObjectSettings(null, null, null, null,
+ new AnnotatedConstructor(null, constructor, null, null), properties);
+ constructor.setAccessible(true);
+ found = true;
+ }
+ }
+
+ return defaultInstantiator;
+ }
+
+ private static Map typeMap(Stream extends Type> typeStream) {
+ return typeStream.collect(HashMap::new, (map, type) -> map.merge(type, 1, Integer::sum), Map::putAll);
+ }
+}
diff --git a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in
new file mode 100644
index 00000000..7860aa14
--- /dev/null
+++ b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/PackageVersion.java.in
@@ -0,0 +1,20 @@
+package @package@;
+
+import com.fasterxml.jackson.core.Version;
+import com.fasterxml.jackson.core.Versioned;
+import com.fasterxml.jackson.core.util.VersionUtil;
+
+/**
+ * Automatically generated from PackageVersion.java.in during
+ * packageVersion-generate execution of maven-replacer-plugin in
+ * pom.xml.
+ */
+public final class PackageVersion implements Versioned {
+ public final static Version VERSION = VersionUtil.parseVersion(
+ "@projectversion@", "@projectgroupid@", "@projectartifactid@");
+
+ @Override
+ public Version version() {
+ return VERSION;
+ }
+}
diff --git a/android-record/src/main/resources/META-INF/LICENSE b/android-record/src/main/resources/META-INF/LICENSE
new file mode 100644
index 00000000..3433e7e1
--- /dev/null
+++ b/android-record/src/main/resources/META-INF/LICENSE
@@ -0,0 +1,8 @@
+This copy of Jackson JSON processor `jackson-module-android-record` module is licensed under the
+Apache (Software) License, version 2.0 ("the License").
+See the License for details about distribution rights, and the
+specific rights regarding derivative works.
+
+You may obtain a copy of the License at:
+
+http://www.apache.org/licenses/LICENSE-2.0
\ No newline at end of file
diff --git a/android-record/src/main/resources/META-INF/NOTICE b/android-record/src/main/resources/META-INF/NOTICE
new file mode 100644
index 00000000..4c976b7b
--- /dev/null
+++ b/android-record/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,20 @@
+# Jackson JSON processor
+
+Jackson is a high-performance, Free/Open Source JSON processing library.
+It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has
+been in development since 2007.
+It is currently developed by a community of developers, as well as supported
+commercially by FasterXML.com.
+
+## Licensing
+
+Jackson core and extension components may licensed under different licenses.
+To find the details that apply to this artifact see the accompanying LICENSE file.
+For more information, including possible other licensing options, contact
+FasterXML.com (http://fasterxml.com).
+
+## Credits
+
+A list of contributors may be found from CREDITS file, which is included
+in some artifacts (usually source distributions); but is always available
+from the source code management (SCM) system project uses.
diff --git a/android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module
new file mode 100644
index 00000000..ec03e628
--- /dev/null
+++ b/android-record/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module
@@ -0,0 +1 @@
+com.fasterxml.jackson.module.androidrecord.AndroidRecordModule
diff --git a/android-record/src/moditect/module-info.java b/android-record/src/moditect/module-info.java
new file mode 100644
index 00000000..73c3b9cc
--- /dev/null
+++ b/android-record/src/moditect/module-info.java
@@ -0,0 +1,9 @@
+module com.fasterxml.jackson.module.androidrecord {
+
+ requires com.fasterxml.jackson.databind;
+
+ exports com.fasterxml.jackson.module.androidrecord;
+
+ provides com.fasterxml.jackson.databind.Module with
+ com.fasterxml.jackson.module.androidrecord.AndroidRecordModule;
+}
diff --git a/android-record/src/test/java/com/android/tools/r8/RecordTag.java b/android-record/src/test/java/com/android/tools/r8/RecordTag.java
new file mode 100644
index 00000000..51aacedb
--- /dev/null
+++ b/android-record/src/test/java/com/android/tools/r8/RecordTag.java
@@ -0,0 +1,9 @@
+package com.android.tools.r8;
+
+/**
+ * Simulates the super class of Android-desugared records.
+ *
+ * @author Eran Leshem
+ **/
+public class RecordTag {
+}
diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java
new file mode 100644
index 00000000..ba307a4d
--- /dev/null
+++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordTest.java
@@ -0,0 +1,144 @@
+package com.fasterxml.jackson.module.androidrecord;
+
+import com.android.tools.r8.RecordTag;
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import junit.framework.TestCase;
+import org.junit.Assert;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+
+
+/**
+ * Inner test classes simulate Android-desugared records.
+ *
+ * @author Eran Leshem
+ **/
+public class AndroidRecordTest extends TestCase {
+ private static final class Simple extends RecordTag {
+ static int si = 7;
+ private final int i;
+ private final int j;
+ private final String s;
+ private final List l;
+ private final AtomicInteger ai;
+
+ private Simple(int i, int j, String s, List l, AtomicInteger ai) {
+ this.i = i;
+ this.j = j;
+ this.s = s;
+ this.l = l;
+ this.ai = ai;
+ }
+
+ int i() {
+ return i;
+ }
+
+ String s() {
+ return s;
+ }
+
+ List l() {
+ return l;
+ }
+
+ int j() {
+ return j;
+ }
+
+ AtomicInteger ai() {
+ return ai;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Simple)) {
+ return false;
+ }
+ Simple simple = (Simple) o;
+ return i == simple.i && j == simple.j && Objects.equals(s, simple.s) && Objects.equals(l, simple.l)
+ && Objects.equals(ai.get(), simple.ai.get());
+ }
+ }
+
+ private static final class MultipleConstructors extends RecordTag {
+ private final int i;
+
+ private MultipleConstructors(int i) {
+ this.i = i;
+ }
+
+ private MultipleConstructors(String s) {
+ i = Integer.parseInt(s);
+ }
+
+
+ private MultipleConstructors(int i, String s) {
+ this.i = i;
+ }
+
+ int i() {
+ return i;
+ }
+ }
+
+
+ private static final class ConflictingConstructors extends RecordTag {
+ private final int i;
+ private final String s;
+
+ private ConflictingConstructors(int i, String s) {
+ this.i = i;
+ this.s = s;
+ }
+
+ private ConflictingConstructors(String s, int i) {
+ this.i = i;
+ this.s = s;
+ }
+
+ public int i() {
+ return i;
+ }
+
+ public String s() {
+ return s;
+ }
+ }
+
+
+ private final ObjectMapper _objectMapper = JsonMapper.builder()
+ .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
+ .addModule(new AndroidRecordModule()).build();
+
+ public void testSimple() throws JsonProcessingException {
+ Simple simple = new Simple(9, 3, "foo", Arrays.asList("bar", "baz"), new AtomicInteger(8));
+ assertEquals(simple, _objectMapper.readValue(_objectMapper.writeValueAsString(simple), Simple.class));
+ }
+
+ public void testMultipleConstructors() throws JsonProcessingException {
+ assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString(new MultipleConstructors(9)),
+ MultipleConstructors.class).i());
+ assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString(
+ new MultipleConstructors("9")), MultipleConstructors.class).i());
+ assertEquals(9, _objectMapper.readValue(_objectMapper.writeValueAsString(
+ new MultipleConstructors(9,"foobar")), MultipleConstructors.class).i());
+ }
+
+ public void testConflictingConstructors() {
+ Assert.assertThrows(InvalidDefinitionException.class,
+ () -> _objectMapper.readValue(_objectMapper.writeValueAsString(
+ new ConflictingConstructors(9, "foo")), ConflictingConstructors.class));
+ }
+}
diff --git a/pom.xml b/pom.xml
index 656004ab..33970cb0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,7 @@ not datatype, data format, or JAX-RS provider modules.
afterburner
+ android-recordblackbirdguiceguice7
From d6c4b7dffc2c7b630fb40c301e6a41274c6d47c6 Mon Sep 17 00:00:00 2001
From: eranl <1707552+eranl@users.noreply.github.com>
Date: Sat, 28 Oct 2023 03:19:50 +0300
Subject: [PATCH 2/4] Add jackson-core dependency, animal-sniffer-maven-plugin,
per review comments
---
android-record/pom.xml | 17 +++++++++++++++++
pom.xml | 4 ++++
2 files changed, 21 insertions(+)
diff --git a/android-record/pom.xml b/android-record/pom.xml
index 83ab2aed..fa8eaf5a 100644
--- a/android-record/pom.xml
+++ b/android-record/pom.xml
@@ -33,6 +33,10 @@
+
+ com.fasterxml.jackson.core
+ jackson-core
+ com.fasterxml.jackson.corejackson-databind
@@ -57,6 +61,19 @@
org.moditectmoditect-maven-plugin
+
+
+ org.codehaus.mojo
+ animal-sniffer-maven-plugin
+ ${version.plugin.animal-sniffer}
+
+
+ com.toasttab.android
+ gummy-bears-api-${version.android.sdk}
+ ${version.android.sdk.signature}
+
+
+
diff --git a/pom.xml b/pom.xml
index 33970cb0..21d2ce33 100644
--- a/pom.xml
+++ b/pom.xml
@@ -55,6 +55,10 @@ not datatype, data format, or JAX-RS provider modules.
9.6
+
+ 26
+ 0.5.1
+ 1.23
From 102e00b5b2eba0266472ca8743fef2a6bc7256fd Mon Sep 17 00:00:00 2001
From: eranl <1707552+eranl@users.noreply.github.com>
Date: Fri, 3 Nov 2023 02:39:58 +0200
Subject: [PATCH 3/4] Addressed review comments With the goal of maximizing
consistency with built-in record support, I copied and "desugared" some unit
tests from
https://github.com/FasterXML/jackson-databind/tree/2.16/src/test-jdk17/java/com/fasterxml/jackson/databind/records.
A few of the test cases are failing, and I marked them with a "Failing"
comment and a "notest" name prefix. I'm hoping for guidance about whether and
how I should fix them. Fixed handling of getters Added support for injected
values Added use of constructor parameter names Skip module if class already
has a withArgsCreator
---
android-record/pom.xml | 17 +
.../androidrecord/AndroidRecordModule.java | 112 +++-
android-record/src/moditect/module-info.java | 2 +
.../module/androidrecord/BaseMapTest.java | 376 ++++++++++++
.../module/androidrecord/BaseTest.java | 374 +++++++++++
.../androidrecord/RecordBasicsTest.java | 581 ++++++++++++++++++
.../androidrecord/RecordCreatorsTest.java | 118 ++++
7 files changed, 1560 insertions(+), 20 deletions(-)
create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java
create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseTest.java
create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordBasicsTest.java
create mode 100644 android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/RecordCreatorsTest.java
diff --git a/android-record/pom.xml b/android-record/pom.xml
index fa8eaf5a..217d3964 100644
--- a/android-record/pom.xml
+++ b/android-record/pom.xml
@@ -37,6 +37,10 @@
com.fasterxml.jackson.corejackson-core
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ com.fasterxml.jackson.corejackson-databind
@@ -45,6 +49,19 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ true
+
+
+ -parameters
+
+ true
+ true
+
+
+
com.google.code.maven-replacer-plugin
diff --git a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java
index 851acebd..7cda0243 100644
--- a/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java
+++ b/android-record/src/main/java/com/fasterxml/jackson/module/androidrecord/AndroidRecordModule.java
@@ -1,23 +1,35 @@
package com.fasterxml.jackson.module.androidrecord;
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.databind.AnnotationIntrospector;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
+import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.PropertyName;
+import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.deser.CreatorProperty;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import com.fasterxml.jackson.databind.deser.ValueInstantiator;
import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator;
+import com.fasterxml.jackson.databind.introspect.AccessorNamingStrategy;
+import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
import com.fasterxml.jackson.databind.introspect.AnnotatedConstructor;
+import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
+import com.fasterxml.jackson.databind.introspect.AnnotatedParameter;
+import com.fasterxml.jackson.databind.introspect.BasicClassIntrospector;
+import com.fasterxml.jackson.databind.introspect.DefaultAccessorNamingStrategy;
+import com.fasterxml.jackson.databind.introspect.POJOPropertiesCollector;
import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.util.ClassUtil;
-import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.Arrays;
-import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -38,32 +50,85 @@
* so it is safe to use in code shared between Android and non-Android platforms.
*
*
- * Note: The canonical record constructor is found
- * through matching of parameter types with field types.
+ * Note: the canonical record constructor is found
+ * through matching of parameter names and types with fields.
* Therefore, this module doesn't allow a deserialized desugared record class to have a custom
- * constructor with a signature that's any permutation of the canonical one's.
+ * constructor with the same set of parameter names and types as the canonical one.
*
* @author Eran Leshem
**/
public class AndroidRecordModule extends SimpleModule {
+ private static final class AndroidRecordNaming
+ extends DefaultAccessorNamingStrategy
+ {
+ /**
+ * Names of actual Record components from definition; auto-detected.
+ */
+ private final Set _componentNames;
+
+ private AndroidRecordNaming(MapperConfig> config, AnnotatedClass forClass) {
+ super(config, forClass,
+ // no setters for (immutable) Records:
+ null,
+ // trickier: regular fields are ok (handled differently), but should
+ // we also allow getter discovery? For now let's do so
+ "get", "is", null);
+ _componentNames = getDesugaredRecordComponents(forClass.getRawType()).map(Field::getName)
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ public String findNameForRegularGetter(AnnotatedMethod am, String name)
+ {
+ // By default, field names are un-prefixed, but verify so that we will not
+ // include "toString()" or additional custom methods (unless latter are
+ // annotated for inclusion)
+ if (_componentNames.contains(name)) {
+ return name;
+ }
+ // but also allow auto-detecting additional getters, if any?
+ return super.findNameForRegularGetter(am, name);
+ }
+ }
+
+ private static class AndroidRecordClassIntrospector extends BasicClassIntrospector {
+ @Override
+ protected POJOPropertiesCollector collectProperties(MapperConfig> config, JavaType type, MixInResolver r,
+ boolean forSerialization) {
+ if (isDesugaredRecordClass(type.getRawClass())) {
+ AnnotatedClass classDef = _resolveAnnotatedClass(config, type, r);
+ AccessorNamingStrategy accNaming = new AndroidRecordNaming(config, classDef);
+ return constructPropertyCollector(config, classDef, type, forSerialization, accNaming);
+ }
+
+ return super.collectProperties(config, type, r, forSerialization);
+ }
+ }
+
@Override
public void setupModule(SetupContext context) {
super.setupModule(context);
context.addValueInstantiators(AndroidRecordModule::findValueInstantiator);
+ context.setClassIntrospector(new AndroidRecordClassIntrospector());
+ }
+
+ static boolean isDesugaredRecordClass(Class> raw) {
+ return raw.getSuperclass() != null && raw.getSuperclass().getName().equals("com.android.tools.r8.RecordTag");
}
private static ValueInstantiator findValueInstantiator(DeserializationConfig config, BeanDescription beanDesc,
ValueInstantiator defaultInstantiator) {
Class> raw = beanDesc.getType().getRawClass();
- if (defaultInstantiator instanceof StdValueInstantiator && raw.getSuperclass() != null
- && raw.getSuperclass().getName().equals("com.android.tools.r8.RecordTag")) {
- Map componentTypes = typeMap(Arrays.stream(raw.getDeclaredFields())
- .filter(field -> !Modifier.isStatic(field.getModifiers())).map(Field::getGenericType));
+ if (! defaultInstantiator.canCreateFromObjectWith() && defaultInstantiator instanceof StdValueInstantiator
+ && isDesugaredRecordClass(raw)) {
+ Map components = getDesugaredRecordComponents(raw)
+ .collect(Collectors.toMap(Field::getName, Field::getGenericType));
boolean found = false;
- for (Constructor> constructor: raw.getDeclaredConstructors()) {
- Parameter[] parameters = constructor.getParameters();
- Map parameterTypes = typeMap(Arrays.stream(parameters).map(Parameter::getParameterizedType));
- if (! parameterTypes.equals(componentTypes)) {
+ for (AnnotatedConstructor constructor: beanDesc.getConstructors()) {
+ Parameter[] parameters = constructor.getAnnotated().getParameters();
+ Map parameterTypes = Arrays.stream(parameters)
+ .collect(Collectors.toMap(Parameter::getName, Parameter::getParameterizedType));
+ if (! parameterTypes.equals(components)) {
continue;
}
@@ -72,16 +137,23 @@ private static ValueInstantiator findValueInstantiator(DeserializationConfig con
"Multiple constructors match set of components for record %s", raw.getName()));
}
+ AnnotationIntrospector intro = config.getAnnotationIntrospector();
SettableBeanProperty[] properties = new SettableBeanProperty[parameters.length];
for (int i = 0; i < parameters.length; i++) {
- Parameter parameter = parameters[i];
- properties[i] = CreatorProperty.construct(PropertyName.construct(parameter.getName()), config.getTypeFactory()
- .constructType(parameter.getParameterizedType()), null, null, null, null, i, null, null);
+ AnnotatedParameter parameter = constructor.getParameter(i);
+ JacksonInject.Value injectable = intro.findInjectableValue(parameter);
+ PropertyName name = intro.findNameForDeserialization(parameter);
+ if (name == null || name.isEmpty()) {
+ name = PropertyName.construct(parameters[i].getName());
+ }
+
+ properties[i] = CreatorProperty.construct(name, parameter.getType(),
+ null, null, parameter.getAllAnnotations(), parameter, i, injectable, null);
}
((StdValueInstantiator) defaultInstantiator).configureFromObjectSettings(null, null, null, null,
- new AnnotatedConstructor(null, constructor, null, null), properties);
- constructor.setAccessible(true);
+ constructor, properties);
+ ClassUtil.checkAndFixAccess(constructor.getAnnotated(), false);
found = true;
}
}
@@ -89,7 +161,7 @@ private static ValueInstantiator findValueInstantiator(DeserializationConfig con
return defaultInstantiator;
}
- private static Map typeMap(Stream extends Type> typeStream) {
- return typeStream.collect(HashMap::new, (map, type) -> map.merge(type, 1, Integer::sum), Map::putAll);
+ private static Stream getDesugaredRecordComponents(Class> raw) {
+ return Arrays.stream(raw.getDeclaredFields()).filter(field -> ! Modifier.isStatic(field.getModifiers()));
}
}
diff --git a/android-record/src/moditect/module-info.java b/android-record/src/moditect/module-info.java
index 73c3b9cc..12fbad1f 100644
--- a/android-record/src/moditect/module-info.java
+++ b/android-record/src/moditect/module-info.java
@@ -1,5 +1,7 @@
module com.fasterxml.jackson.module.androidrecord {
+ requires com.fasterxml.jackson.core;
+ requires com.fasterxml.jackson.annotation;
requires com.fasterxml.jackson.databind;
exports com.fasterxml.jackson.module.androidrecord;
diff --git a/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java
new file mode 100644
index 00000000..d084fc16
--- /dev/null
+++ b/android-record/src/test/java/com/fasterxml/jackson/module/androidrecord/BaseMapTest.java
@@ -0,0 +1,376 @@
+package com.fasterxml.jackson.module.androidrecord;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.core.FormatSchema;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.ObjectReader;
+import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer;
+import com.fasterxml.jackson.databind.type.TypeFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public abstract class BaseMapTest
+ extends BaseTest
+{
+ private final static Object SINGLETON_OBJECT = new Object();
+
+ /*
+ /**********************************************************
+ /* Shared helper classes
+ /**********************************************************
+ */
+
+ public static class BogusSchema implements FormatSchema {
+ @Override
+ public String getSchemaType() {
+ return "TestFormat";
+ }
+ }
+
+ /**
+ * Simple wrapper around boolean types, usually to test value
+ * conversions or wrapping
+ */
+ protected static class BooleanWrapper {
+ public Boolean b;
+
+ public BooleanWrapper() { }
+ public BooleanWrapper(Boolean value) { b = value; }
+ }
+
+ protected static class IntWrapper {
+ public int i;
+
+ public IntWrapper() { }
+ public IntWrapper(int value) { i = value; }
+ }
+
+ protected static class LongWrapper {
+ public long l;
+
+ public LongWrapper() { }
+ public LongWrapper(long value) { l = value; }
+ }
+
+ protected static class FloatWrapper {
+ public float f;
+
+ public FloatWrapper() { }
+ public FloatWrapper(float value) { f = value; }
+ }
+
+ protected static class DoubleWrapper {
+ public double d;
+
+ public DoubleWrapper() { }
+ public DoubleWrapper(double value) { d = value; }
+ }
+
+ /**
+ * Simple wrapper around String type, usually to test value
+ * conversions or wrapping
+ */
+ protected static class StringWrapper {
+ public String str;
+
+ public StringWrapper() { }
+ public StringWrapper(String value) {
+ str = value;
+ }
+ }
+
+ protected static class ObjectWrapper {
+ final Object object;
+ protected ObjectWrapper(final Object object) {
+ this.object = object;
+ }
+ public Object getObject() { return object; }
+ @JsonCreator
+ static ObjectWrapper jsonValue(final Object object) {
+ return new ObjectWrapper(object);
+ }
+ }
+
+ protected static class ListWrapper
+ {
+ public List list;
+
+ public ListWrapper(@SuppressWarnings("unchecked") T... values) {
+ list = new ArrayList();
+ for (T value : values) {
+ list.add(value);
+ }
+ }
+ }
+
+ protected static class MapWrapper
+ {
+ public Map map;
+
+ public MapWrapper() { }
+ public MapWrapper(Map m) {
+ map = m;
+ }
+ public MapWrapper(K key, V value) {
+ map = new LinkedHashMap<>();
+ map.put(key, value);
+ }
+ }
+
+ protected static class ArrayWrapper
+ {
+ public T[] array;
+
+ public ArrayWrapper(T[] v) {
+ array = v;
+ }
+ }
+
+ /**
+ * Enumeration type with sub-classes per value.
+ */
+ protected enum EnumWithSubClass {
+ A { @Override public void foobar() { } }
+ ,B { @Override public void foobar() { } }
+ ;
+
+ public abstract void foobar();
+ }
+
+ public enum ABC { A, B, C; }
+
+ // since 2.8
+ public static class Point {
+ public int x, y;
+
+ protected Point() { } // for deser
+ public Point(int x0, int y0) {
+ x = x0;
+ y = y0;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Point)) {
+ return false;
+ }
+ Point other = (Point) o;
+ return (other.x == x) && (other.y == y);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("[x=%d, y=%d]", x, y);
+ }
+ }
+
+ /*
+ /**********************************************************
+ /* Shared serializers
+ /**********************************************************
+ */
+
+ @SuppressWarnings("serial")
+ public static class UpperCasingSerializer extends StdScalarSerializer
+ {
+ public UpperCasingSerializer() { super(String.class); }
+
+ @Override
+ public void serialize(String value, JsonGenerator gen,
+ SerializerProvider provider) throws IOException {
+ gen.writeString(value.toUpperCase());
+ }
+ }
+
+ @SuppressWarnings("serial")
+ public static class LowerCasingDeserializer extends StdScalarDeserializer
+ {
+ public LowerCasingDeserializer() { super(String.class); }
+
+ @Override
+ public String deserialize(JsonParser p, DeserializationContext ctxt)
+ throws IOException {
+ return p.getText().toLowerCase();
+ }
+ }
+
+ /*
+ /**********************************************************
+ /* Construction
+ /**********************************************************
+ */
+
+ protected BaseMapTest() { super(); }
+
+ /*
+ /**********************************************************
+ /* Factory methods
+ /**********************************************************
+ */
+
+ private static ObjectMapper SHARED_MAPPER;
+
+ protected ObjectMapper sharedMapper() {
+ if (SHARED_MAPPER == null) {
+ SHARED_MAPPER = newJsonMapper();
+ }
+ return SHARED_MAPPER;
+ }
+
+ protected ObjectMapper objectMapper() {
+ return sharedMapper();
+ }
+
+ protected ObjectWriter objectWriter() {
+ return sharedMapper().writer();
+ }
+
+ protected ObjectReader objectReader() {
+ return sharedMapper().reader();
+ }
+
+ protected ObjectReader objectReader(Class> cls) {
+ return sharedMapper().readerFor(cls);
+ }
+
+ // `public` since 2.16, was only `protected` before then.
+ // @since 2.10
+ public static ObjectMapper newJsonMapper() {
+ return new JsonMapper().registerModule(new AndroidRecordModule());
+ }
+
+ // `public` since 2.16, was only `protected` before then.
+ // @since 2.10
+ public static JsonMapper.Builder jsonMapperBuilder() {
+ return JsonMapper.builder();
+ }
+
+ // @since 2.7
+ protected TypeFactory newTypeFactory() {
+ // this is a work-around; no null modifier added
+ return TypeFactory.defaultInstance().withModifier(null);
+ }
+
+ /*
+ /**********************************************************
+ /* Additional assert methods
+ /**********************************************************
+ */
+
+ protected void assertEquals(int[] exp, int[] act)
+ {
+ assertArrayEquals(exp, act);
+ }
+
+ protected void assertEquals(byte[] exp, byte[] act)
+ {
+ assertArrayEquals(exp, act);
+ }
+
+ /**
+ * Helper method for verifying 3 basic cookie cutter cases;
+ * identity comparison (true), and against null (false),
+ * or object of different type (false)
+ */
+ protected void assertStandardEquals(Object o)
+ {
+ assertTrue(o.equals(o));
+ assertFalse(o.equals(null));
+ assertFalse(o.equals(SINGLETON_OBJECT));
+ // just for fun, let's also call hash code...
+ o.hashCode();
+ }
+
+ /*
+ /**********************************************************
+ /* Helper methods, serialization
+ /**********************************************************
+ */
+
+ @SuppressWarnings("unchecked")
+ protected Map writeAndMap(ObjectMapper m, Object value)
+ throws IOException
+ {
+ String str = m.writeValueAsString(value);
+ return (Map) m.readValue(str, LinkedHashMap.class);
+ }
+
+ protected String serializeAsString(ObjectMapper m, Object value)
+ throws IOException
+ {
+ return m.writeValueAsString(value);
+ }
+
+ protected String serializeAsString(Object value)
+ throws IOException
+ {
+ return serializeAsString(sharedMapper(), value);
+ }
+
+ protected String asJSONObjectValueString(Object... args)
+ throws IOException
+ {
+ return asJSONObjectValueString(sharedMapper(), args);
+ }
+
+ protected String asJSONObjectValueString(ObjectMapper m, Object... args)
+ throws IOException
+ {
+ LinkedHashMap