tldr: A parcelable is read from and written into a Parcel’s marshaled byte array in a different format on Android 13 compared to earlier versions. We should not save Parcel data in persistent storage that may last across OS version upgrades.
I started receiving reports of NPE errors on the Alkitab app even though the app had not been updated for almost 2 years.
In Alkitab, there are songs. A song is stored in the Song class (irrelevant parts removed):
public class Song implements Parcelable {
public String code;
public String title;
.... other fields
public List<Lyric> lyrics;
}
public class Lyric implements Parcelable {
public String caption;
public List<Verse> verses;
}
The NPE error was java.lang.NullPointerException: Attempt to read from field ‘java.util.List yuku.kpri.model.Lyric.verses’ on a null object reference
…
When we constructed a song, we made sure that the lyrics
has only non-null elements. Therefore it was not possible to access verses
from a null instance of a Lyric
.
After examining further, the way the app works is by storing the serialized form of Song
instance by utilizing the Parcelable-ness of it to a local database. Here is the code that converts a Song
instance to a byte array:
private static byte[] marshallSong(Song song) {
Parcel p = Parcel.obtain();
song.writeToParcel(p, 0);
byte[] buf = p.marshall();
p.recycle();
return buf;
}
And when the app needs a song to display, it will convert back a byte array to an instance of Song
:
private static Song unmarshallSong(byte[] buf, int dataFormatVersion) {
Parcel p = Parcel.obtain();
p.unmarshall(buf, 0, buf.length);
p.setDataPosition(0);
Song res = Song.createFromParcel(p);
p.recycle();
return res;
}
After many years passed since the time of writing this code, I understood that Parcels are designed for in-memory transactions only; it is not supposed to be persisted because the serialized format is not guaranteed to be compatible across Android versions.
Parcel is not a general-purpose serialization mechanism. This class (and the corresponding
Parcelable
API for placing arbitrary objects into a Parcel) is designed as a high-performance IPC transport. As such, it is not appropriate to place any Parcel data in to persistent storage: changes in the underlying implementation of any of the data in the Parcel can render older data unreadable.
However, I need to ensure that was the cause of the issue we had.
I picked a song and dumped the serialized byte array of it on Android 9 and again on Android 13.
Android 9:
0: 02 00 00 00 35 00 35 00 00 00 00 00 1a 00 00 00 ....5.5.........
1: 59 00 61 00 20 00 53 00 75 00 6d 00 62 00 65 00 Y.a. .S.u.m.b.e.
2: 72 00 20 00 4b 00 61 00 73 00 69 00 68 00 2c 00 r. .K.a.s.i.h.,.
3: 20 00 52 00 6f 00 68 00 20 00 4b 00 75 00 64 00 .R.o.h. .K.u.d.
4: 75 00 73 00 00 00 00 00 ff ff ff ff ff ff ff ff u.s.............
5: ff ff ff ff ff ff ff ff 03 00 00 00 31 00 3d 00 ............1.=.
6: 46 00 00 00 03 00 00 00 34 00 2f 00 34 00 00 00 F.......4./.4...
7: 01 00 00 00 04 00 00 00 15 00 00 00 79 00 75 00 ............y.u.
8: 6b 00 75 00 2e 00 6b 00 70 00 72 00 69 00 2e 00 k.u...k.p.r.i...
9: 6d 00 6f 00 64 00 65 00 6c 00 2e 00 4c 00 79 00 m.o.d.e.l...L.y.
10: 72 00 69 00 63 00 00 00 ff ff ff ff 04 00 00 00 r.i.c...........
11: 04 00 00 00 15 00 00 00 79 00 75 00 6b 00 75 00 ........y.u.k.u.
12: 2e 00 6b 00 70 00 72 00 69 00 2e 00 6d 00 6f 00 ..k.p.r.i...m.o.
Android 13:
0: 02 00 00 00 35 00 35 00 00 00 00 00 1a 00 00 00 ....5.5.........
1: 59 00 61 00 20 00 53 00 75 00 6d 00 62 00 65 00 Y.a. .S.u.m.b.e.
2: 72 00 20 00 4b 00 61 00 73 00 69 00 68 00 2c 00 r. .K.a.s.i.h.,.
3: 20 00 52 00 6f 00 68 00 20 00 4b 00 75 00 64 00 .R.o.h. .K.u.d.
4: 75 00 73 00 00 00 00 00 ff ff ff ff ff ff ff ff u.s.............
5: ff ff ff ff ff ff ff ff 03 00 00 00 31 00 3d 00 ............1.=.
6: 46 00 00 00 03 00 00 00 34 00 2f 00 34 00 00 00 F.......4./.4...
7: 01 00 00 00 04 00 00 00 2c 04 00 00 15 00 00 00 ........,.......
8: 79 00 75 00 6b 00 75 00 2e 00 6b 00 70 00 72 00 y.u.k.u...k.p.r.
9: 69 00 2e 00 6d 00 6f 00 64 00 65 00 6c 00 2e 00 i...m.o.d.e.l...
10: 4c 00 79 00 72 00 69 00 63 00 00 00 ff ff ff ff L.y.r.i.c.......
11: 04 00 00 00 04 00 00 00 f4 00 00 00 15 00 00 00 ................
12: 79 00 75 00 6b 00 75 00 2e 00 6b 00 70 00 72 00 y.u.k.u...k.p.r.
We can see that starting from chunk 7 (bytes 112–127), there are differences. Let’s see more in detail by putting that line alone consecutively, the one coming from Android 9 and then Android 13.
7: 01 00 00 00 04 00 00 00 15 00 00 00 79 00 75 00 ............y.u.
7: 01 00 00 00 04 00 00 00 2c 04 00 00 15 00 00 00 ........,.......
^ here the difference starts!
I haven’t shown you what the method writeToParcel
contains.
out.writeString(code);
out.writeString(title);
out.writeString(title_original);
out.writeStringList(authors_lyric);
out.writeStringList(authors_music);
out.writeString(tune);
out.writeString(keySignature);
out.writeString(timeSignature);
out.writeList(lyrics);
Let’s display the common byte dump and put comments to indicate which write
they are a result of.
0: 02 00 00 00 35 00 35 00 00 00 00 00 ....5.5.
code (string len=2) "55"
0: 1a 00 00 00 ....
1: 59 00 61 00 20 00 53 00 75 00 6d 00 62 00 65 00 Y.a. .S.u.m.b.e.
2: 72 00 20 00 4b 00 61 00 73 00 69 00 68 00 2c 00 r. .K.a.s.i.h.,.
3: 20 00 52 00 6f 00 68 00 20 00 4b 00 75 00 64 00 .R.o.h. .K.u.d.
4: 75 00 73 00 00 00 00 00 u.s.....
title (string len=0x1a) "Ya Sumber Kasih, Roh Kudus"
4: ff ff ff ff ff ff ff ff u.s.............
5: ff ff ff ff ff ff ff ff ........
title_original (null string, indicated by 0xffffffff)
authors_lyric (null string list, indicated by 0xffffffff)
authors_music (null string list, indicated by 0xffffffff)
tune (null string, indicated by 0xffffffff)
5: 03 00 00 00 31 00 3d 00 ....1.=.
keySignature (string len=3) "1=F"
6: 46 00 00 00 03 00 00 00 34 00 2f 00 34 00 00 00 F.......4./.4...
timeSignature (string len=3) "4/4"
Now comes the interesting part (we are almost at "writeList")
7: 01 00 00 00 ....
(list len=1, we only have one Lyric)
7: 04 00 00 00 15 00 00 00 79 00 75 00 ........y.u.
The element of the list is written using writeValue
.
How writeValue
is implemented differently in Android 9 and Android 13.
Android 9
if (v == null) {
writeInt(VAL_NULL);
} else if (v instanceof String) {
writeInt(VAL_STRING);
writeString((String) v);
...
} else if (v instanceof Parcelable) {
// IMPOTANT: cases for classes that implement Parcelable must
// come before the Parcelable case, so that their specific VAL_*
// types will be written.
writeInt(VAL_PARCELABLE);
writeParcelable((Parcelable) v, 0);
...
It is just by writing an int 0x04 to indicate a “parcelable” type, then writeParcelable
is called.
Android 13
int type = getValueType(v);
writeInt(type);
if (isLengthPrefixed(type)) {
// Length
int length = dataPosition();
writeInt(-1); // Placeholder
// Object
int start = dataPosition();
writeValue(type, v);
int end = dataPosition();
// Backpatch length
setDataPosition(length);
writeInt(end - start);
setDataPosition(end);
} else {
writeValue(type, v);
}
It is also writing an int 0x04 to indicate a “parcelable” type (found by getValueType
), but before writeParcelable
is called a number that indicates the length of the whole parcelable is written.
Now we know that the 2c 04 00 00
(0x042c) is the length of the parcelable, only written/read on Android 13.
7: 01 00 00 00 04 00 00 00 2c 04 00 00 15 00 00 00 ........,.......
^^^^^^^^^^^ Length of the parcelable
Conclusion: A serialized parcelable written on Android 9 will surely fail to be read on Android 13.
A little bit more investigation: I wanted to know when this change was made. I looked up the source from cs.android.com.
- Android 9: https://cs.android.com/android/platform/superproject/+/pie-release:frameworks/base/core/java/android/os/Parcel.java
- Android 13: https://cs.android.com/android/platform/superproject/+/android13-release:frameworks/base/core/java/android/os/Parcel.java
- Android 12L: https://cs.android.com/android/platform/superproject/+/android12L-release:frameworks/base/core/java/android/os/Parcel.java
Android 12L shows a very similar implementation to Android 13’s. So, the change really did happen at the release of Android 13.