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
}
}
@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