The mysterious case of the Bundle and the Map

    The mysterious case of the Bundle and the Map

    Because putting Maps in a Bundle is harder than it looks.

    [This post was written with the help of Eugenio Marletti]

    ⚠️ Warning  —  this is a long post.

    Assume that you need to pass a map of values as an extra in an Intent. This might not be a common scenario, admittedly, but it can happen. It definitely happened to Eugenio.

    If you are using a HashMap, the most common type of Map, and you didn’t create a custom class that contains “extra” information, you’re lucky. You can put in:

    intent.putExtra("map", myHashMap);

    And in your receiving Activity, you’ll get your nice map back out of the Intent’s extras:

    HashMap map = (HashMap) getIntent().getSerializableExtra("map");

    But what if you need to pass another kind of Map in the intent extras — say, a TreeMap (or any custom implementation)? Well, when you retrieve it:

    TreeMap map = (TreeMap) getIntent().getSerializableExtra("map");

    Then you get this:

    java.lang.ClassCastException: java.util.HashMap cannot be cast to java.util.TreeMap

    Yep, a nice ClassCastException, because your map has turned into… a HashMap.

    Note: we’ll see why we’re using getSerializableExtra() later on, suffice for now saying it’s because all the default Map implementations are Serializable and there’s no narrower scope of putExtra()/get*Extra() that can accept them.

    Before we move on, let’s get to know the actors involved in this drama.

    [tl:dr; skip to “Workaround” at the end if you just want a solution!]

    On Parcels

    Many of you will know (but maybe some don’t) that all the IPC communication in the Android framework is based upon the concept of Binders. And hopefully many of you know that the main mechanism to allow data marshalling between those processes is based on Parcels.

    A Parcel is an optimised, non-general purpose serialisation mechanism that Android employs for IPC. Contrary to Serializable objects, you should never use Parcels for any kind of persistence, as it does not provision for handling different versions of the data. Whenever you see a Bundle, you’re dealing with a Parcel under the hood.

    Adding extras to an IntentParcel. Setting arguments on a Fragment? Parcel.
    And so on.

    Parcels know how to handle a bunch of types out of the box, including native types, strings, arrays, maps, sparse arrays, parcelables and serializables. Parcelables are the mechanism that you (should) use to write and read arbitrary data to a Parcel, unless you really, really need to use Serializable.

    The advantages of Parcelable over Serializable are mostly about performances, and that should be enough of a reason to prefer the former in most cases, as Serializable comes with a certain overhead.

    Down into the rabbit hole

    So, let’s try to understand what makes us get a ClassCastException. Starting from the code we are using, we can see that our call to Intent#putExtras() resolves to the overload that takes a String and a Serializable. As we’ve said before, this is expected, as Map implementations are Serializable, and they aren’t Parcelable. There also isn’t a putExtras() that explicitly takes a Map.

    Let’s look at what happens in Intent.putExtra(String, Serializable):

    public Intent putExtra(String name, Serializable value) {
      // ...
      mExtras.putSerializable(name, value);
      return this;
    }
    See the Intent.java code

    In here, mExtras is clearly a Bundle. So ok, Intent delegates all the extras to a bundle, just as we expected, and calls Bundle#putSerializable(). Let’s see what that method does:

    @Override
    public void putSerializable(String key, Serializable value) {
      super.putSerializable(key, value);
    }
    See the Bundle.java code

    As it turns out, this just in turn delegates to the super implementation, which is:

    void putSerializable(String key, Serializable value) {
      unparcel();
      mMap.put(key, value);
    }
    See the BaseBundle.java code

    Good, we got to some meat, at last.

    First of all, let’s ignore unparcel(). We can notice that mMap is an ArrayMap<String, Object>. This tells us we’re losing any kind of type information we might have had before — i.e., at this point, everything ends up in one big map that contains Objects as values, no matter how strongly typed the method we used to put the value in the Bundle was.

    Our spider sense starts to tingle…

    Step two: writing the map

    The really interesting stuff starts to happen when we get to actually writing the contents of the Bundle to a Parcel. Until then, if we check the type of our extra, we’re still getting the correct type:

    Intent intent = new Intent(this, ReceiverActivity.class);
    intent.putExtra("map", treeMap);
    Serializable map = intent.getSerializableExtra("map");
    Log.i("MAP TYPE", map.getClass().getSimpleName());

    That prints, as we’d expect, TreeMap to the LogCat. So the transformation must happen between the time the Bundle gets written into the Parcel, and when it’s read again.

    If we look at how writing to a Parcel happens, we see that the nitty gritty goes down in BaseBundle#writeToParcelInner:

    void writeToParcelInner(Parcel parcel, int flags) {
      if (mParcelledData != null) {
        // ...
      } else {
        // ...
        int startPos = parcel.dataPosition();
        parcel.writeArrayMapInternal(mMap);
        int endPos = parcel.dataPosition();
        // ...
      }
    }
    See the BaseBundle.java code

    Skipping all the code that is irrelevant for us, we see that the bulk of the work is performed by Parcel#writeArrayMapInternal() (remember that mMap is an ArrayMap):

    /* package */ void writeArrayMapInternal(
            ArrayMap<String, Object> val) {
      // ...
      int startPos;
      for (int i=0; i<N; i++) {
        // ...
        writeString(val.keyAt(i));
        writeValue(val.valueAt(i));
        // ...
      }
    }
    See the Parcel.java code

    What this basically does is it writes every key-value pair in the BaseBundle’s mMap sequentially as a String (the keys are all strings here) followed by the value. The latter seems not to be considering the value type so far.

    Let’s go one level deeper!

    Step three: writing maps’ values

    So how does Parcel#writeValue() look like, you ask? Here it is, in its if-elseif-else glory:

    public final void writeValue(Object v) {
      if (v == null) {
        writeInt(VAL_NULL);
      } else if (v instanceof String) {
        writeInt(VAL_STRING);
        writeString((String) v);
      } else if (v instanceof Integer) {
        writeInt(VAL_INTEGER);
        writeInt((Integer) v);
      } else if (v instanceof Map) {
        writeInt(VAL_MAP);
        writeMap((Map) v);
      } else if (/* you get the idea, this goes on and on */) {
        // ...
      } else {
        Class<?> clazz = v.getClass();
        if (clazz.isArray() &&
            clazz.getComponentType() == Object.class) {
          // Only pure Object[] are written here, Other arrays of non-primitive types are
          // handled by serialization as this does not record the component type.
          writeInt(VAL_OBJECTARRAY);
          writeArray((Object[]) v);
        } else if (v instanceof Serializable) {
          // Must be last
          writeInt(VAL_SERIALIZABLE);
          writeSerializable((Serializable) v);
        } else {
          throw new RuntimeException("Parcel: unable to marshal value "+ v);
        }
      }
    }
    See the Parcel.java code

    Aha! Gotcha! Even though we put our TreeMap in the bundle as a Serializable, the writeValue() method does in fact catch it in its v instanceOf Map branch, which (for obvious reasons) comes before the else … if (v instanceOf Serializable) branch.

    At this point, the smell is getting really strong.

    I now wonder, are they using some totally undocumented shortcut for Maps, that somehow turns them into HashMaps?

    Step four: writing a Map to the Parcel

    Well, as it turns out, writeMap() doesn’t do an awful lot in and by itself, apart from enforcing the type of Map we’ll be handling later on:

    public final void writeMap(Map val) {
      writeMapInternal((Map<String, Object>) val);
    }
    See the Parcel.java code

    The JavaDoc for this method is pretty clear:

    “The Map keys must be String objects.”

    Type erasure makes sure we’ll actually never have a runtime error here, though, even if we might be passing a Map with keys that aren’t Strings (again, this is totally undocumented at higher level…).

    In fact, as soon as we take a look at writeMapInternal(), this hits us:

    /* package */ void writeMapInternal(Map<String,Object> val) {
      // ...
      Set<Map.Entry<String,Object>> entries = val.entrySet();
      writeInt(entries.size());
      for (Map.Entry<String,Object> e : entries) {
        writeValue(e.getKey());
        writeValue(e.getValue());
      }
    }
    See the Parcel.java code

    Again, type erasure here makes all those casts pretty much worthless at runtime. The fact is that we’re relying on our old type-checking friend writeValue() for both the keys and the values as we “unpack” the map and just dump everything in the Parcel. And as we’ve seen, writeValue() is perfectly able to handle non-String keys.

    Maybe the documentation got out of sync with the code at some point here, but as a matter of fact, putting and retrieving a TreeMap<Integer, Object> in a Bundle works perfectly.

    Well, with the exception of the TreeMap becoming an HashMap, of course.

    Ok, the picture here is pretty clear by now. Maps completely lose their type when they’re written to a Parcel, so there’s no way to recover that information when they get read back.

    Step five: reading back the Map

    As a last quick check of our theory, let’s go and check readValue(), which is writeValue()’s counterpart:

    public final Object readValue(ClassLoader loader) {
      int type = readInt();
    
      switch (type) {
        case VAL_NULL:
          return null;
    
        case VAL_STRING:
          return readString();
    
        case VAL_INTEGER:
          return readInt();
    
        case VAL_MAP:
          return readHashMap(loader);
    
        // ...
      }
    }
    See the Parcel.java code

    The way Parcel works when writing data is, for each item it contains:

    1. it writes an int that defines the data type (one of the VAL_* constants)
    2. dumps the data itself (optionally including other metadata such as the data length for non-fixed size types, e.g. String).
    3. recursively repeat for nested (non-primitive) data types

    Here we see that readValue() reads that data type int, that for our TreeMap was set to VAL_MAP by writeValue(), and then the corresponding switch case simply calls readHashMap() to retrieve the data itself:

    public final HashMap readHashMap(ClassLoader loader)
    {
      int N = readInt();
      if (N < 0) {
         return null;
      }
      HashMap m = new HashMap(N);
      readMapInternal(m, N, loader);
      return m;
    }
    See the Parcel.java code

    Note: the C#-style opening curly brace is actually in AOSP, it’s not my fault.

    You can pretty much imagine that readMapInternal() simply repacks all map items it reads from the Parcel into the map that we pass to it.

    And yes. This is the reason why you get always a HashMap back from a Bundle. The same goes if you create a custom Map that implements Parcelable. Definitely not what we’d expect!

    It’s hard to say if this is an intended effect or simply an oversight. It’s admittedly an edge case, since you have really few valid reasons to pass a Map into an Intent, and you should have just as little good reasons to pass Serializables instead of Parcelables. But the lack of documentation makes me think it might actually be an oversight rather than a design decision.

    Workaround (aka tl;dr)

    Ok, we’ve understood our issue in depth, and now we’ve identified the critical path that messes with us. We need to make sure our TreeMap doesn’t get caught into the v instanceOf Map check in writeValue().

    The first solution that came to my mind when talking to Eugenio was ugly but effective: wrap the map into a Serializable container. Eugenio quickly whipped up this generic wrapper and confirmed it solves the issue.

    Please note the gist is using the Android’s @NonNull annotation to enforce its contracts. If you want to use this in pure Java modules, you can replace it with JetBrains’ @NotNull, or you could strip those annotations altogether.

    Another possible workaround

    Another solution could be to pre-serialize the Map yourself into a byte array before putting it as an Intent extra, and then retrieving it with getByteArrayExtra(), but you’d then have to handle serialisation and deserialisation manually.

    In case you masochistically wanted to opt for this other solution instead, Eugenio has provided a separate Gist with the code.

    When you don’t control upstream Intents

    Lastly, maybe for some reason you can’t control the Bundle creation code — e.g., because it’s in some third-party library.

    In that case, remember that many Map implementations have a constructor that takes a Map as input, like new TreeMap(Map). You can use that constructor, if needed, to “change back” the HashMap you retrieve from the Bundle into your preferred Map type.

    Keep in mind that in this case any “extra” properties on that map will be lost and only the key/value pairs will be preserved.

    Conclusion

    Being an Android developer means juggling your way around pretty much everything, especially the small, seemingly insignificant things.

    What can we learn from this?

    When things don’t work as you’d expect them,
    don’t just stare at the JavaDoc.
    Because that might be outdated.
    Or because the authors of the JavaDoc
    didn’t know about your specific case.
    The answer might be in the AOSP code.

    We have the huge luxury (and curse) of having access to the AOSP code. That’s something almost unique in the mobile landscape. We can know to a certain extent exactly what goes on. And we should.

    Because even though it might look like it’s WTF-land sometimes, you can only become a better developer when you get to know the inner workings of the platform you work on.

    And remember: what doesn’t kill you makes you stronger. Or crazier.

    Sebastiano Poggi

    Sebastiano Poggi

    “It depends” 🤷‍♂️ — Google Developer Expert for Android, Flutter and Identity. A geek 🤓 who has a serious thing for good design ✨ and for emojis 🤟working at JetBrains (opinions my own)

    London and elsewhere https://sebastiano.dev