On-Device Debugging Part II: Timbeeeeeeer!

24 Jun 2019

Over the past year, my team have been steadily building a Developer Options screen for our app. It is a simple PreferenceScreen available on debug builds that aims to help us:

In this series of posts, I will share what these various options are and how we made them.

(If you are not familiar with PreferenceFragmentCompat, I highly suggest to read about that first before proceeding. You can start with this AndroidX guide on Settings.)

Read Part I (Now It’s On, Now It’s Off) here.


One of the tools we utilise a lot at Woolworths is Timber. We use it not just to help us with debugging during development, but also to figure out problems early during testing as well as to keep an eye on things in production.

Before, when we have a build for testing and things don’t go to plan, we get sent a screenshot pretty much like this:


Uh :scream_cat:

We try to figure out what they were trying to do before the app crashed, replicate the exact conditions they had prior, and try and try and try. More often than not, we hit the “that shouldn’t have happened” wall – even though it obviously just did. :woman_facepalming:

To help in these situations, we turned to Timber. We log those “We should never get here!!!” scenarios using Timber.e. In production, those logs get sent to Fabric via Crashlytics.log(). In debug builds, those logs get sent to a crash log screen.

This screen shows the stacktrace, if there’s any, and some basic information about the build and the device. We also decided to use this screen to log any uncaught exceptions. At the bottom of the screen is a button to send the crash log through via an Intent Chooser.


Crash log screen variations

To bring this to life, we override Timber’s e:

class LoggingTree(private val app: Application) : Timber.DebugTree() {

    override fun e(message: String, vararg args: Any) {
        super.e(message, *args)
        printStackTrace(null, message, *args)
    }

    override fun e(t: Throwable, message: String?, vararg args: Any) {
        super.e(t, message, *args)
        printStackTrace(t, message, *args)
    }

And printStackTrace simply passes on the information to a dedicated Activity that would display the error.

We provide the kind of error as an Intent extra with a CrashType:

enum class LogType(val additionalMessage: String, @ColorRes val background: Int) {
    UNCAUGHT_EXCEPTION("!!! DO NOT IGNORE! THIS WILL CAUSE A CRASH IN PRODUCTION !!!", R.color.error_color),

    LOG("We are monitoring an issue. You may still send this log if you want.", R.color.white);
}

The crash screen implementation is pretty straightforward, we retrieve the extras and use data binding to display the message and style the header. (For the curious, I previously wrote about using resource references in databinding)

Since we use the crash log screen for uncaught exceptions as well, we directly call the Activity when something goes wrong. This means that it skips the Timber processing but sometimes it’s still useful to see the logs in Logcat, so we log the stacktrace there as well:

@SuppressLint("LogNotTimber")
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // ...

    // We still want to display the stack trace in Logcat for uncaught exceptions
    // but are not using Timber.e here lest we get into an infinite loop
    Log.e("FIXME", Log.getStackTraceString(stackTrace))

}

The “Send crash” button at the bottom of the screen sends out an ACTION_SEND Intent so that users can send the information via email, Slack, or whatever other app they choose (I also wrote about that here!).

This may seem like a simple thing, but time and again it has helped us quickly figure out issues in a more meaningful way. Even if those helping us test choose to send a screenshot, there is more relevant information rather than the generic crash dialog. :revolving_hearts: