-
-
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 | |
} | |
} |
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.
@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.
Update: added unit tests :)
Thanks @pyricau this is our biggest firebase crash on samsungs devices / Android 10 since a while, and now fixed.
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
.
@LouisCAD For five years I've been wondering about this, so thanks! I have to ask for source tho...