The internal implementation of ContentValues changed from using a Hashmap to using an ArrayMap in Android 10. I've included some sample code below to demonstrate how it fails now.
ContentValues values = new ContentValues();
values.put("Key 1", "Key 1 value");
values.put("Key 2", "Key 2 value");
values.put("Key 3", "Key 3 value");
String json = new Gson().toJson(values);
Log.d("XXX", String.format("Content values json: '%s'", json));
On Android 10 the output looks like:
Output: XXX: Content values json: '{}'
On Android 9 the output looks like:
Output: XXX: Content values json: {"mValues":{"Key 1":"Key 1 value","Key 2":"Key 2 value","Key 3":"Key 3 value"}}
Not a bug: 1) you should not (de)serialize classes you don't control (do you really have your content values stored under the mValues property key in your JSON documents?); 2) ... unless you implement a custom type adapter that might use, I guess, the built-in Map type adapter.
1) Good point. However, in this case, Gson was just used to pass a lot of information to an instance of a worker with the new WorkManager JetPack library. The data you pass to it must be Parcelable. Using ContentValues with Gson to serialize was an easy hack that used to work. The JSON that was output didn't matter as long as the worker could deserialize it and pull the values from the ContentValues instance.
2) That's the interesting part to me. I wrote my own serialization/deserialization code to fix the issue. I'm still unsure as to why this fails now. The implementation is using an ArrayMap instead of a HashMap. I would imagine that it would use the same Map type adapter to serialize both objects, but I haven't traced that far into the Gson code base. Do you see the issue?
@tethridge
I'm not into the Android API, but I don't really believe using Gson for this case is a good choice. (Except probably an attempt of nesting/packing some data as string values, however Parcelable looks like a (de)serialization tool already.) If you still need using Gson, you probably might fine-tune the example below (avoid intermediate maps, avoid the map type adapter and use readers/writers directly, etc):
@SuppressWarnings("all")
class FakeContentValues {
private final Map<String, Object> mValues = new LinkedHashMap<>();
FakeContentValues() {
}
// @formatter:off
void putNull(final String key) { mValues.put(key, null); }
void put(final String key, final Short value) { mValues.put(key, value); }
void put(final String key, final Long value) { mValues.put(key, value); }
void put(final String key, final Double value) { mValues.put(key, value); }
void put(final String key, final Integer value) { mValues.put(key, value); }
void put(final String key, final String value) { mValues.put(key, value); }
void put(final String key, final Boolean value) { mValues.put(key, value); }
void put(final String key, final Float value) { mValues.put(key, value); }
void put(final String key, final byte[] value) { mValues.put(key, value); }
void put(final String key, final Byte value) { mValues.put(key, value); }
Object get(final String key) { return mValues.get(key); }
Set<String> keySet() { return mValues.keySet(); }
// @formatter:on
@Override
public boolean equals(final Object o) {
if ( this == o ) {
return true;
}
if ( o == null || getClass() != o.getClass() ) {
return false;
}
final FakeContentValues that = (FakeContentValues) o;
return mValues.equals(that.mValues);
}
@Override
public int hashCode() {
return mValues.hashCode();
}
@Override
public String toString() {
return "{mValues=" + mValues + "}";
}
}
private static final Gson gson = new GsonBuilder()
.disableHtmlEscaping()
.disableInnerClassSerialization()
.registerTypeAdapterFactory(new TypeAdapterFactory() {
@Override
@SuppressWarnings("ReturnOfNull")
public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
final Class<? super T> rawType = typeToken.getRawType();
if ( !FakeContentValues.class.isAssignableFrom(rawType) ) {
return null;
}
final TypeAdapter<LinkedHashMap<String, Object>> delegateTypeAdapter = gson.getDelegateAdapter(this, mapStringToObjectTypeToken);
final TypeAdapter<FakeContentValues> typeAdapter = new TypeAdapter<FakeContentValues>() {
@Override
public void write(final JsonWriter out, final FakeContentValues contentValues)
throws IOException {
final LinkedHashMap<String, Object> intermediate = new LinkedHashMap<>();
for ( final String key : contentValues.keySet() ) {
intermediate.put(key, contentValues.get(key));
}
delegateTypeAdapter.write(out, intermediate);
}
@Override
@SuppressWarnings("IfStatementWithTooManyBranches")
public FakeContentValues read(final JsonReader in)
throws IOException {
final Map<String, Object> intermediate = delegateTypeAdapter.read(in);
final FakeContentValues contentValues = new FakeContentValues();
for ( final Map.Entry<String, Object> e : intermediate.entrySet() ) {
final String k = e.getKey();
final Object v = e.getValue();
// @formatter:off
if ( v == null ) { contentValues.putNull(k); }
else if ( v instanceof Short ) { contentValues.put(k, (Short) v); }
else if ( v instanceof Long ) { contentValues.put(k, (Long) v); }
else if ( v instanceof Double ) { contentValues.put(k, (Double) v); }
else if ( v instanceof Integer ) { contentValues.put(k, (Integer) v); }
else if ( v instanceof String ) { contentValues.put(k, (String) v); }
else if ( v instanceof Boolean ) { contentValues.put(k, (Boolean) v); }
else if ( v instanceof Float ) { contentValues.put(k, (Float) v); }
else if ( v instanceof byte[] ) { contentValues.put(k, (byte[]) v); }
else if ( v instanceof Byte ) { contentValues.put(k, (Byte) v); }
else { throw new UnsupportedOperationException(String.valueOf(v.getClass())); }
// @formatter:on
}
return contentValues;
}
};
@SuppressWarnings("unchecked")
final TypeAdapter<T> castTypeAdapter = (TypeAdapter<T>) typeAdapter;
return castTypeAdapter;
}
})
.create();
final FakeContentValues before = new FakeContentValues();
before.put("Key 1", "Key 1 value");
before.put("Key 2", "Key 2 value");
before.put("Key 3", "Key 3 value");
System.out.println("before=" + before);
final String json = gson.toJson(before);
System.out.println(json);
final FakeContentValues after = gson.fromJson(json, FakeContentValues.class);
System.out.println("after=" + after);
System.out.println("equals=" + before.equals(after));
gives
before={mValues={Key 1=Key 1 value, Key 2=Key 2 value, Key 3=Key 3 value}}
{"Key 1":"Key 1 value","Key 2":"Key 2 value","Key 3":"Key 3 value"}
after={mValues={Key 1=Key 1 value, Key 2=Key 2 value, Key 3=Key 3 value}}
equals=true
Thanks for the advice. I'm still curious why their small change broke Gson serialization. They switched from one class that implements Map to another that also implements Map.
Each Android version, starting with 9, tightens hidden API usage.
Gson is known as sun.misc.Unsafe "abuser". It's nice to have private (and even final) fields accessible, but Android doesn't like it.
Check your logs for Accessing hidden method logs.
Thanks for the feedback. I'll close this issue. I resolved the issue in my app by implementing my own serialization code.
Most helpful comment
@tethridge
I'm not into the Android API, but I don't really believe using Gson for this case is a good choice. (Except probably an attempt of nesting/packing some data as string values, however
Parcelablelooks like a (de)serialization tool already.) If you still need using Gson, you probably might fine-tune the example below (avoid intermediate maps, avoid the map type adapter and use readers/writers directly, etc):gives