androidkotlinsealed-class

How can this code throw a NoWhenBranchMatchedException?


In our most recent app release we see a handful kotlin.NoWhenBranchMatchedExceptions reported to Fabric/Crashlytics.

This is the code snippet in question:

private lateinit var welcome: Welcome

// ...

welcome.welcomeStateLoginStatus.let {
    val handled = when (it) {
        UnknownUser -> {
            btn_login.visibility = View.VISIBLE
            btn_logout.visibility = View.GONE

            secondButtonFocusedInfoText = getString(R.string.welcome_login_button_info)
            tv_user_description.text = null
        }
        is InternalUser -> {
            btn_login.visibility = View.GONE
            btn_logout.visibility = View.VISIBLE

            secondButtonFocusedInfoText = "Logout"
            tv_user_description.text = "Logged in as internal user"
        }
        ExternalUser -> {
            btn_login.visibility = View.GONE
            btn_logout.visibility = View.VISIBLE

            secondButtonFocusedInfoText = "Logout"
            tv_user_description.text = "Logged in as external user"
        }
    }
}

And the class definitions:

data class Welcome(val welcomeStateLoginStatus: WelcomeStateLoginStatus, val userCanBuySubscription: UserCanBuySubscription? = null) : WelcomeState()

sealed class WelcomeStateLoginStatus() : Serializable
object UnknownUser : WelcomeStateLoginStatus()
data class InternalUser(var user: User) : WelcomeStateLoginStatus()
object ExternalUser : WelcomeStateLoginStatus()

I am puzzled as to how this code can even theoretically throw that exception - as you can see we even introduced the handled value just to force the compiler to make sure that all cases are handled...


Solution

  • Serialization was indeed the problem:

    package com.drei.tv.ui.welcome
    
    import junit.framework.Assert.assertEquals
    import org.junit.Test
    import java.io.*
    
    
    class WelcomeStateLoginStatusTest {
    
        @Test
        fun testSerialization() {
            val original: UnknownUser = UnknownUser
    
            val copy: UnknownUser = unpickle(pickle(original), UnknownUser::class.java)
    
            println("singleton: $UnknownUser")
            println("original: $original")
            println("copy: $copy")
    
            val handled1 = when (copy) {
                original -> println("copy matches original")
                else -> println("copy does not match original")
            }
    
            val handled2 = when (copy) {
                is UnknownUser -> println("copy is an instance of UnknownUser")
                else -> println("copy is no instance of UnknownUser")
            }
    
            assertEquals(original, copy)
        }
    
        private fun <T : Serializable> pickle(obj: T): ByteArray {
            val baos = ByteArrayOutputStream()
            val oos = ObjectOutputStream(baos)
            oos.writeObject(obj)
            oos.close()
            return baos.toByteArray()
        }
    
        private fun <T : Serializable> unpickle(b: ByteArray, cl: Class<T>): T {
            val bais = ByteArrayInputStream(b)
            val ois = ObjectInputStream(bais)
            val o = ois.readObject()
            return cl.cast(o)
        }
    }
    

    Produces the following output:

    singleton: com.drei.tv.ui.welcome.UnknownUser@75828a0f
    original: com.drei.tv.ui.welcome.UnknownUser@75828a0f
    copy: com.drei.tv.ui.welcome.UnknownUser@5f150435
    copy does not match original
    copy is an instance of UnknownUser
    
    junit.framework.AssertionFailedError: 
    Expected :com.drei.tv.ui.welcome.UnknownUser@75828a0f
    Actual   :com.drei.tv.ui.welcome.UnknownUser@5f150435
    

    As for solutions: Either implement Serializable properly or just use an is check instead of an equality check.

    Thanks to Lionel Briand and Hong Duan for pointing us in the right direction and to Jason S for the code of pickle and unpickle posted in this answer