Skip to main content

One post tagged with "android-r8"

View All Tags

Fixing BadParcelableException ClassNotFoundException After R8/ProGuard: Safe @Parcelize and Bundle Passing

Published: · 8 min read
Don Peter
Cofounder and CTO, Appxiom

Introduction

If you’ve enabled R8/ProGuard and started seeing crashes like BadParcelableException: ClassNotFoundException when unmarshalling: com.example.app.model.YourParcelable, you’ve run into a classloader and obfuscation edge case most Android teams eventually hit. It usually shows up:

  • After an app update (user returns and the system restores a Bundle from the previous version)
  • When reading extras from a PendingIntent/notification created by an older APK
  • In multi-process or instrumentation/test environments where a different classloader is used

This post explains why it happens, how to make @Parcelize and Bundle passing safe with R8/ProGuard, and what you should and shouldn’t parcel in production apps.

Prerequisites

  • Android Studio Ladybug (2024.1) or newer
  • Kotlin 1.9.20+ with kotlin-parcelize plugin
  • Android Gradle Plugin (AGP) 8.2+
  • Min SDK 21+
  • Dependencies (or newer):
    • androidx.core:core-ktx:1.13.1
    • androidx.navigation:navigation-fragment-ktx:2.7.7
    • androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0

The Symptom

Typical stack traces:

  • android.os.BadParcelableException: ClassNotFoundException when unmarshalling: com.example.app.model.User
  • java.lang.ClassNotFoundException: com.example.app.model.User
  • Caused by: java.lang.ClassNotFoundException: com.a.b.c (obfuscated class)

Where it happens:

  • Restoring Fragment arguments or savedInstanceState after process death and app upgrade
  • Reading Intent extras in BroadcastReceiver/Service created by the system
  • Tapping a notification after an app update
  • UI tests/Robolectric when a default classloader is used

While these scenarios can be hard to replicate locally, Appxiom automatically detects these BadParcelableException and ClassNotFoundException crashes in production, matching the obfuscated symptom to the root cause in real time.

Why It Happens

The Bundle/Intent marshalling format for Parcelable stores the class name of your object and uses a ClassLoader to find its CREATOR when reading. Crashes occur when:

  1. Obfuscation renamed your Parcelable classes differently between app versions. The system tries to restore an old Bundle that references the old obfuscated name, which no longer exists.
  2. A different ClassLoader is used (system process, tests, multi-process components) and the receiving side doesn’t set the right class loader before reading.
  3. R8 removed or optimized away classes/fields (aggressive shrinking) that are only referenced reflectively via Bundles.
  4. You parcel non-Parcelable types via @RawValue or deep object graphs that rely on writeValue/readValue, making restoration depend on class names for non-Parcelable types as well.

Key takeaway: Parcelable is not a versioned, long-term persistence format. It’s optimized for in-process, short-lived handoffs. Persisting it across process death or app upgrades is fragile unless you keep names stable and load with the correct ClassLoader.

What You Should Parcel (and What You Shouldn’t)

Do parcel:

  • Small, stable, in-app data passed immediately between components that won’t survive upgrades (e.g., ephemeral navigation within the same run).
  • Enums or small data classes with stable fields.

Avoid parceling:

  • Domain objects in Fragment arguments or savedInstanceState (they can be restored after an upgrade).
  • Custom parcelables in notifications/PendingIntent extras (they might be delivered after you ship a new version).
  • Deep object graphs or @RawValue arbitrary types.

Instead pass:

  • Primitive IDs, small strings, enums with stable names.
  • Re-hydrate the object from a repository in a ViewModel.
  • For cross-version persistence, use a version-tolerant encoding (JSON/Proto) in a String extra, with forward/backward compatibility settings.

Safe Retrieval: Always Use Typed APIs and Set the ClassLoader

Prefer AndroidX typed accessors that handle API differences and reduce classloader hazards.

Example for Intents:

import androidx.core.content.IntentCompat

val user: User? = IntentCompat.getParcelableExtra(intent, "user", User::class.java)

Example for Bundles:

import androidx.core.os.BundleCompat

val args = requireArguments().also {
// Ensures correct loader in older Android/test environments
it.classLoader = User::class.java.classLoader
}
val user: User? = BundleCompat.getParcelable(args, "user", User::class.java)

If you’re not using the compat helpers, set the classloader before reading:

val bundle: Bundle = /* from Intent or Fragment */
bundle.classLoader = YourFragment::class.java.classLoader
val user = bundle.getParcelable<User>("user") // API 33+ overloads are typed

Safe Args note: Navigation Safe Args typically sets and reads arguments robustly, but it cannot guard against obfuscation changes across app updates - keep reading for R8 configuration.

Safer @Parcelize Definitions

Use Kotlin @Parcelize for clean code, but keep classes small and stable.

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

@Parcelize
data class User(
val id: String,
val displayName: String,
val isPro: Boolean
) : Parcelable

Guidelines:

  • Avoid @RawValue unless you fully control and keep those classes stable; prefer explicit Parcelable fields.
  • Avoid large nested graphs. Pass ids, reload data in ViewModel.
  • Adding fields later can break older parcels. If you must evolve, keep defaults and be mindful that Parcelable isn’t versioned. Prefer not persisting these across app restarts.

Do Not Store Custom Parcelables in SavedStateHandle or savedInstanceState

SavedStateHandle persists to a Bundle. That Bundle can be restored after an app upgrade. Store only primitives/IDs and re-fetch the data.

Bad (risky across upgrades):

class UserViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
companion object { private const val KEY_USER = "user" }

fun saveUser(user: User) {
savedStateHandle[KEY_USER] = user // Risky: custom Parcelable in persistent state
}

val user: User? get() = savedStateHandle[KEY_USER]
}

Good:

class UserViewModel(
private val savedStateHandle: SavedStateHandle,
private val repo: UserRepository
) : ViewModel() {
companion object { private const val KEY_USER_ID = "user_id" }

fun setUserId(id: String) {
savedStateHandle[KEY_USER_ID] = id
}

val userFlow = savedStateHandle.getStateFlow(KEY_USER_ID, "")
.filter { it.isNotEmpty() }
.flatMapLatest { id -> repo.userStream(id) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

For Fragment arguments with Navigation, pass only IDs where possible:

// nav_graph.xml arg
<argument
android:name="userId"
app:argType="string"
app:nullable="false" />

R8/ProGuard Rules That Prevent ClassNotFoundException After Upgrades

If you absolutely must parcel custom types that can be restored by the system after an upgrade, you need stable class names and to keep the CREATOR.

Minimum rules (scoped to your package):

# Keep class names for parcelables so old bundles can still resolve them after obfuscation.
-keepnames class com.example.app.model.** implements android.os.Parcelable

# Ensure parcelable classes and their CREATORs are kept.
-keep class com.example.app.model.** implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}

Notes:

  • -keepnames preserves class names but not members. The -keep block above ensures the CREATOR is kept. Scope this to only the packages that hold parcelable arguments or saved state.
  • For sealed hierarchies or nested parcelables, ensure all subclasses live under the kept package.
  • If a Parcelable is only ever referenced via Bundle reflection (no direct code references), the -keep rule ensures it isn’t removed.

Advanced (teams that manage obfuscation stability across releases):

  • Reuse mappings to stabilize obfuscated names between versions: -applymapping path/to/previous/mapping.txt This adds operational overhead and CI orchestration; many teams instead just keep names for Parcelable surface types.

Notifications and PendingIntents: Do Not Put Custom Parcelables in Extras

Notifications often outlive app updates. The system will later deliver a PendingIntent whose extras the app or system needs to unparcel. If that extra held a custom Parcelable and class names changed, you’ll crash.

Instead:

  • Put only primitives and small strings into the PendingIntent extras.
  • If you need structured payloads, encode to JSON/Proto (String) with forward-compatible settings (e.g., kotlinx.serialization with ignoreUnknownKeys = true).

Example:

// Bad
val intent = Intent(context, DetailActivity::class.java)
intent.putExtra("user", userParcelable) // Avoid

// Good
intent.putExtra("userId", user.id)

Testing Your Fixes

Reproduce the problem locally:

  1. Install version A of your app (R8 enabled). Open a screen that puts a custom Parcelable into savedInstanceState or Fragment args. Background the app.
  2. Build and install version B with a different obfuscation (e.g., without -keepnames or with a different mapping).
  3. Return to the app (or tap an old notification). You’ll often see BadParcelableException if names changed.

Verification steps:

  • With -keepnames on your parcelable packages, the crash should disappear.
  • With code changes to stop putting custom parcelables in saved state or PendingIntents, the issue should disappear regardless of obfuscation.

Useful commands:

  • adb shell am kill com.example.app // simulate process death
  • adb shell am start -n com.example.app/.MainActivity
  • Keep a notification around between installs to test PendingIntent restore.

Putting It All Together: A Safe Navigation Example

Data class:

@Parcelize
data class UserSummary(
val id: String,
val displayName: String
) : Parcelable

Navigation and ViewModel: prefer IDs

// nav_graph.xml
<fragment
android:id="@+id/userDetailFragment"
android:name="com.example.app.ui.UserDetailFragment">
<argument
android:name="userId"
app:argType="string"
app:nullable="false" />
</fragment>

Fragment retrieves args with classloader safety for legacy keys if needed:

class UserDetailFragment : Fragment(R.layout.fragment_user_detail) {

private val viewModel: UserDetailViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val args = requireArguments().also {
it.classLoader = javaClass.classLoader
}

val userId = args.getString("userId") ?: error("Missing userId")
viewModel.setUserId(userId)
}
}

ViewModel loads data by ID:

class UserDetailViewModel(
private val savedStateHandle: SavedStateHandle,
private val repo: UserRepository
) : ViewModel() {
companion object { private const val KEY_USER_ID = "userId" }

fun setUserId(id: String) { savedStateHandle[KEY_USER_ID] = id }

val user = savedStateHandle.getStateFlow(KEY_USER_ID, "")
.filter { it.isNotEmpty() }
.flatMapLatest(repo::userStream)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

R8 rules (scoped and minimal):

-keepnames class com.example.app.model.** implements android.os.Parcelable
-keep class com.example.app.model.** implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}

Key Takeaways

  • Parcelable isn’t a durable storage format. Avoid persisting custom parcelables across process death or upgrades.
  • Prefer passing stable, small primitives/IDs and reloading data in ViewModels/Repositories.
  • If you must restore parcelables across versions, keep their class names stable with -keepnames and keep their CREATOR.
  • Always read from Bundles/Intents with the correct classloader (use BundleCompat/IntentCompat or set classLoader manually).
  • Never put custom parcelables in long-lived PendingIntents/notifications.

Next steps:

  • Audit where your app uses Parcelable in saved state, arguments, and notifications.
  • Add scoped R8 rules for the few parcelable types that must be restored post-upgrade.
  • Refactor flows to pass IDs and reload from data sources. Add tests that simulate upgrades and notification restores.

By following these patterns, you’ll eliminate BadParcelableException caused by class obfuscation and classloader mismatches while keeping your code modern, fast, and production-safe.