Back to Articles

Source Sets in Android: Clean Debug-Only Diagnostics Without if/else

Source sets let you keep one clean UI API while swapping behavior per build: define Diagnostics in src/main, then implement it in src/debug real diagnostics and src/release disabled message. So the UI never checks BuildConfig.DEBUG—Gradle picks the right class automatically, keeping debug tools i...

Video this article refers to:

Previous article: Android Build Types in CI/CD: Make Variants Obvious, Safe, and Environment-Aware


I used to think debug-only UI is basically a UI problem.

So I’d do this everywhere:

if BuildConfig.DEBUG  /* show debug stuff */ 

It works… until it doesn’t.

Because the moment your app has multiple screens, that check spreads like glitter. Every “just one more debug widget” becomes another conditional, another branch, another place you might forget to remove or update.

A simple way to think about it is: debug-only behavior is not a UI decision. It’s a build decision.

And that’s why source sets are such a clean fit.

Instead of making the UI decide whether to show diagnostics, we make the build decide what diagnostics implementation exists. The UI simply asks: “give me the diagnostics text” and renders it.


The goal in human terms

We want a diagnostics card on screen, but:

  • In debug builds, it should show real info device + app details.
  • In release builds, it should show a safe message like: “disabled”.
  • And the UI should not contain “if debug then…” logic.

This is where most people get stuck: they try to keep the behavior in one file, using conditionals. It’s tempting, but it doesn’t scale.


Source sets: what they actually mean

A source set is just “a folder Gradle treats as code for a specific variant”.

Every Android module has:

  • src/main/ → shared code used in all variants

And it can also have:

  • src/debug/ → code compiled only into debug builds
  • src/release/ → code compiled only into release builds

What usually happens is: when you build debug, Gradle compiles main + debug. When you build release, Gradle compiles main + release.

Now the important part is…

If you have a class with the same package + name in debug/ and release/, then each build will get its own version of that class.

So we can do something clever:

  • Put the API in main/ so UI can always call it
  • Put the implementation in debug/ and release/ so behavior changes by build type

The interface: telling the app what it can ask for

File: src/main/.../Diagnostics.kt

interface Diagnostics 
    fun info: String

If you’re a beginner, this is the part that matters: this interface is a contract.

It’s basically you saying:

“My UI is allowed to ask for a diagnostics string. I don’t care how you produce it.”

So instead of UI depending on BuildConfig.DEBUG, the UI depends on Diagnostics.

That’s already a big upgrade. Because now we’ve separated:

  • What the UI needs a string to show
  • From how we get it debug details vs release message

The provider: hiding the “which implementation?” problem

File: src/main/.../DiagnosticsProvider.kt

object DiagnosticsProvider 
    fun get: Diagnostics = DiagnosticsImpl

At first glance, this looks odd because you don’t see DiagnosticsImpl in main.

That’s intentional.

This provider is doing one job: give the rest of the app a Diagnostics instance.

And DiagnosticsImpl is the “plug” that changes depending on build type.

In my experience, this provider pattern makes things smoother because:

  • The UI doesn’t need to know class names
  • You can change how instances are created later DI, caching, etc.
  • And you keep a single “entry point” into your diagnostics feature

But here’s the catch: for this to work, DiagnosticsImpl must exist somewhere in the project… just not in main/.


Debug implementation: create real diagnostics

File: src/debug/.../DiagnosticsImpl.kt

class DiagnosticsImpl : Diagnostics 
    override fun info: String 
        val tz = TimeZone.getDefault.id
        val time = SimpleDateFormat"yyyy-MM-dd HH:mm:ss", Locale.US
            .apply  timeZone = TimeZone.getDefault 
            .formatDate

        return buildString 
            appendLine"Diagnostics debug"
            appendLine"• Manufacturer: Build.MANUFACTURER"
            appendLine"• Model: Build.MODEL"
            appendLine"• SDK: Build.VERSION.SDK_INT"
            appendLine"• Device: Build.DEVICE"
            appendLine"• Time: time tz"
            appendLine"• AppId: BuildConfig.APPLICATION_ID"
            appendLine"• Version: BuildConfig.VERSION_NAME"
        
    

Let’s unpack what this code is trying to do.

1 It implements the contract

class DiagnosticsImpl : Diagnostics means:

“This class can be used anywhere a Diagnostics is expected.”

So when DiagnosticsProvider.get returns it, the UI is happy because the types match.

2 It builds a readable string

The info function returns a String. Not a data class. Not a JSON blob. Just a string.

That choice is practical: in debug builds, you usually want something you can paste into Slack or a bug report quickly.

3 It gathers useful device and app info

  • Build.MANUFACTURER, Build.MODEL → tells you what phone it is
  • Build.VERSION.SDK_INT → tells you Android version in SDK number form
  • Build.DEVICE → a device identifier useful sometimes when models are confusing
  • BuildConfig.APPLICATION_ID, BuildConfig.VERSION_NAME → tells you what app build is installed

And it also adds:

  • current time + timezone, so you can correlate logs and bug reports

On paper this looks simple, but the real value is: when you test across multiple devices and multiple builds, this removes guesswork.


Release implementation: keep it safe and boring

File: src/release/.../DiagnosticsImpl.kt

class DiagnosticsImpl : Diagnostics 
    override fun info: String = "Diagnostics disabled in release."

This is doing two things at once:

  1. It still satisfies the same interface Diagnostics
  2. But it removes sensitive/internal details from release builds

The mistake people make is leaving debug diagnostics in release “because it’s convenient”.

But in real projects, release builds go to real users. Even if the card is hidden behind a gesture, the code still exists, strings can leak, and it becomes an unnecessary risk.

So release gets a simple message. No device info. No app identifiers. No timestamps.


Using it in the UI: the UI stays boring that’s good

File: src/main/.../MainActivity.kt

@Composable
private fun DiagnosticsCard 
    val info = DiagnosticsProvider.get.info
    SurfacetonalElevation = 1.dp, shape = MaterialTheme.shapes.medium 
        Text
            text = info,
            modifier = Modifier.padding16.dp,
            style = MaterialTheme.typography.bodyMedium
        
    

Let’s translate this into intent.

  • DiagnosticsProvider.get gives you a Diagnostics
  • .info gives you the text to show
  • UI renders it inside a card

And that’s it.

No if BuildConfig.DEBUG.

If you’re a beginner, this is the part that clicks: the UI is the same in both builds.

The only difference is which DiagnosticsImpl Gradle compiled into the app.


“But where does Gradle learn these folders?”

Often, Android Studio recognizes src/debug and src/release automatically.

But sometimes teams make it explicit to avoid confusion:

sourceSets 
    getByName"debug" 
        java.srcDirs"src/debug/java"
    
    getByName"release" 
        java.srcDirs"src/release/java"
    

What this snippet is trying to do is simple: it tells Gradle, clearly,

“Here is the Java/Kotlin code location for debug and release source sets.”

This is especially helpful when:

  • your structure is non-standard
  • or you want new team members to “see it” immediately

What you see when you run the app

  • In debug, the card shows a multi-line diagnostics block.
  • In release, the card shows: “Diagnostics disabled in release.”

Same UI. Different implementation.

What I’ve noticed is: once you use this pattern once, you start using it for everything:

  • debug menus
  • mock API services
  • logging tools
  • internal QA switches
  • analytics stubs in debug

Because it keeps the “messy” stuff out of your main code.


Why this scales and why I prefer it

I used to think “if debug then show X” was harmless.

But the practical downside is that the UI becomes the dumping ground for build logic.

A simple way to think about it is:

  • UI should describe layout and behavior
  • build variants should decide which code exists

So the takeaway is:

  • Put your stable API in main/
  • Put variant behavior in variant source sets
  • Keep your UI clean and boring

That’s how you get debug-only diagnostics without turning your UI into a conditionals festival.


Style Self-Check

  • Reading level used: Beginner

  • Slider used: 8/10

  • Marker count used: 10

  • How this matches my voice:

    • Explains why the pattern exists before listing how to implement it
    • Walks through what each snippet is trying to achieve, not just dumping code
    • Uses “real project” framing and keeps the tone calm and practical