Skip to content

Instantly share code, notes, and snippets.

@pyricau
Last active May 23, 2022 08:13
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pyricau/5f864374c1b1a028a1a8dfe25b6761df to your computer and use it in GitHub Desktop.
Save pyricau/5f864374c1b1a028a1a8dfe25b6761df to your computer and use it in GitHub Desktop.
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.os.Parcel
import android.util.Base64
/**
* This class implements a fix for https://issuetracker.google.com/issues/147246567
* Investigation: https://twitter.com/Piwai/status/1374129312153038849
*
* Usage:
*
* - Call [fixOutState] at the end of [android.app.Activity.onSaveInstanceState] (i.e. AFTER
* the super call and any extra state saving you might be doing.
* - Call [restoreEncodedState] at the start of [android.app.Activity.onCreate] (i.e. BEFORE the
* super call and any other code), if savedInstanceState is not null.
*
* Note: the crash fixed by this code only happens on Android 10 so these methods are no-op on any
* other Android version.
*
* On Android 10, under specific conditions ActivityThread will receive a command to destroy an
* activity before it has been created, and therefore attempts to log that unexpected command.
* Unfortunately, as part of the logging details, a hashcode is included which itself is based on
* the saved state bundle side, which requires unparceling the bundle. As a result, the bundle gets
* unparceled eagerly but with a classloader that can't handle any custom parcelable defined in the
* APK. As a result, the app crashes before our activity is even created.
*
* Since we can't easily remove all custom parcelables, the fix here consists in replacing the
* saved state parcelable content with a string that contains the parcel bytes encoded as Base64.
*
* However, as that encoding isn't cheap, we only want to do it if the bundle is actually written to
* a parcel. To know when that's happening, we create a custom CharSequence and add it to the parcel.
* The Android Framework doesn't actually serialize CharSequence implementations, but it calls
* toString() on them at serialization time and saves the string content. So we override toString()
* and serialize + encode a copy of the saved bundle at that time.
*/
class BadParcelableFix private constructor(
private val copiedState: Bundle
) : CharSequence {
override fun toString(): String {
// toString() is called when the CharSequence is serialized.
// Time to do the serialization work.
return copiedState.toBase64EncodedString()
}
companion object {
private val BUNDLE_KEY = BadParcelableFix::class.java.name
@JvmStatic
fun fixOutState(outState: Bundle) {
if (SDK_INT != 29) {
return
}
val copiedState = Bundle()
// This is fairly efficient (2 system array copies).
copiedState.putAll(outState)
outState.clear()
val fakeCharSequence = BadParcelableFix(copiedState)
// Note: no serialization happening here.
outState.putCharSequence(BUNDLE_KEY, fakeCharSequence)
}
@JvmStatic
fun restoreEncodedState(savedInstanceState: Bundle) {
if (SDK_INT != 29 || !savedInstanceState.containsKey(BUNDLE_KEY)) {
return
}
val stateCharSequence = savedInstanceState.getCharSequence(BUNDLE_KEY)
val decodedBundle = if (stateCharSequence is BadParcelableFix) {
stateCharSequence.copiedState
} else {
val encodedStateString = stateCharSequence.toString()
encodedStateString.fromBase64EncodedString()
}
savedInstanceState.remove(BUNDLE_KEY)
savedInstanceState.putAll(decodedBundle)
}
private fun Bundle.toBase64EncodedString(): String {
return Base64.encodeToString(toByteArray(), 0)
}
private fun Bundle.toByteArray(): ByteArray {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
val bytes = parcel.marshall()
parcel.recycle()
return bytes
}
private fun String.fromBase64EncodedString(): Bundle {
return Base64.decode(this, 0).toBundle()
}
private fun ByteArray.toBundle(): Bundle {
val parcel = Parcel.obtain()
parcel.unmarshall(this, 0, size)
parcel.setDataPosition(0)
val bundle = parcel.readBundle(BadParcelableFix.Companion::class.java.classLoader)!!
parcel.recycle()
return bundle
}
}
// Methods implemented below aren't used.
override val length: Int
get() = 0
override fun get(index: Int) = 0.toChar()
override fun subSequence(
startIndex: Int,
endIndex: Int
) = this
}
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.Creator
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class BadParcelableFixTest {
@Test fun `fixOutState() removes existing entries`() {
val sourceBundle = Bundle()
sourceBundle.putIntArray("key", intArrayOf(1, 2, 3))
BadParcelableFix.fixOutState(sourceBundle)
assertThat(sourceBundle.containsKey("key")).isFalse()
}
@Test fun `restoreEncodedState() on source bundle returns same value instance`() {
val sourceBundle = Bundle()
val sourceValue = intArrayOf(1, 2, 3)
sourceBundle.putIntArray("key", sourceValue)
BadParcelableFix.fixOutState(sourceBundle)
BadParcelableFix.restoreEncodedState(sourceBundle)
val restoredValue = sourceBundle.getIntArray("key")
assertThat(restoredValue).isSameInstanceAs(sourceValue)
}
@Test fun `fixOutState() removes existing entries even post unmarshalling`() {
val sourceBundle = Bundle()
sourceBundle.putIntArray("key", intArrayOf(1, 2, 3))
BadParcelableFix.fixOutState(sourceBundle)
val unmarshalledBundle = sourceBundle.marshall().unmarshall()
assertThat(unmarshalledBundle.containsKey("key")).isFalse()
}
@Test fun `restoreEncodedState() on unmarshalled bundle returns new equal value instance`() {
val sourceBundle = Bundle()
val sourceValue = intArrayOf(1, 2, 3)
sourceBundle.putIntArray("key", sourceValue)
BadParcelableFix.fixOutState(sourceBundle)
val unmarshalledBundle = sourceBundle.marshall().unmarshall()
BadParcelableFix.restoreEncodedState(unmarshalledBundle)
val restoredValue = unmarshalledBundle.getIntArray("key")
assertThat(restoredValue).isNotSameInstanceAs(sourceValue)
assertThat(restoredValue!!.toList()).containsExactly(1, 2, 3)
}
@Test fun `restoreEncodedState() on unmarshalled bundle unparcels custom parcelable`() {
val sourceBundle = Bundle()
sourceBundle.putParcelable("key", CustomParcelable(state = "state"))
BadParcelableFix.fixOutState(sourceBundle)
val unmarshalledBundle = sourceBundle.marshall().unmarshall()
BadParcelableFix.restoreEncodedState(unmarshalledBundle)
val restoredValue = unmarshalledBundle.getParcelable<CustomParcelable>("key")
assertThat(restoredValue!!.state).isEqualTo("state")
}
private class CustomParcelable(val state: String) : Parcelable {
override fun describeContents(): Int = 0
override fun writeToParcel(
dest: Parcel,
flags: Int
) = with(dest) {
writeString(state)
}
companion object CREATOR : Creator<CustomParcelable> {
override fun createFromParcel(parcel: Parcel): CustomParcelable {
return CustomParcelable(parcel.readString()!!)
}
override fun newArray(size: Int): Array<CustomParcelable?> = arrayOfNulls(size)
}
}
private fun Bundle.marshall(): ByteArray {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
val bytes = parcel.marshall()
parcel.recycle()
return bytes
}
private fun ByteArray.unmarshall(): Bundle {
val parcel = Parcel.obtain()
parcel.unmarshall(this, 0, size)
parcel.setDataPosition(0)
val bundle = parcel.readBundle(BadParcelableFixTest::class.java.classLoader)!!
parcel.recycle()
return bundle
}
}
@consp1racy
Copy link

This is gonna get minified to something else in each release.

private val BUNDLE_KEY = BadParcelableFix::class.java.name
// -->
private const val BUNDLE_KEY = "BadParcelableFix"

@LouisCAD
Copy link

That's not a problem because savedInstanceState doesn't survive app updates anyway.

@consp1racy
Copy link

@LouisCAD For five years I've been wondering about this, so thanks! I have to ask for source tho...

@LouisCAD
Copy link

It if was different, most apps would crash or enter inconsistent state when what's saved changes in a newer version.

Also, you can try it on your own, and look at the doc of Parcelable.

@pyricau
Copy link
Author

pyricau commented Mar 23, 2021

@LouisCAD For five years I've been wondering about this, so thanks! I have to ask for source tho...

I don't think there's one place in the sources, but Louis is right. Saved state is tied to activity stacks, right? If you update I expect the activity stack for that app is gone. The activity manager service in the system server process is responsible for keeping activity stacks and saved stated, and it clears stacks for many different reasons.

@pyricau
Copy link
Author

pyricau commented Mar 23, 2021

Update: added unit tests :)

@JulienGenoud
Copy link

Thanks @pyricau this is our biggest firebase crash on samsungs devices / Android 10 since a while, and now fixed.

@ItzNotABug
Copy link

ItzNotABug commented Jan 3, 2022

Some devices with API level 28 also get this crash, would be better to add a check for that as well.
Edit: Seems to be coming from androidx.fragment.app.FragmentManager.attachController.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment