Blog Infos
Author
Published
🧠 If it extends an Android class — it probably shouldn’t contain your business logic.

Photo by Nubelson Fernandes on Unsplash

A lot of Android developers unknowingly fall into the same architectural trap: they put too much logic inside framework classes like FirebaseMessagingServiceBroadcastReceiverActivity, or Service. At first, it feels fast and easy. But soon the code becomes brittle, hard to test, and almost impossible to maintain.

This article shows how to avoid that trap by applying one simple rule:

Keep Android components dumb. Reza!

We’ll use Firebase Cloud Messaging (FCM) as a case study, but this principle applies broadly across your app architecture.

📦 FCM Push Notifications: A Case Study:

Setting up Firebase Cloud Messaging (FCM) on Android is straightforward. You set up the AndroidManifest.xml, request permissions, and override two key methods:

  • onNewToken(String token) — called when a new FCM token is generated
  • onMessageReceived(RemoteMessage message) — triggered when a push message arrives

Then you’d have something like:

override fun onMessageReceived(remoteMessage: RemoteMessage) {
val title = remoteMessage.data["title"]
val body = remoteMessage.data["body"]
val deeplink = remoteMessage.data["deeplink"]
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplink))
val notification = NotificationCompat.Builder(this, "channel_id")
.setContentTitle(title)
.setContentText(body)
.setContentIntent(PendingIntent.getActivity(this, 0, intent, 0))
.build()
NotificationManagerCompat.from(this).notify(1, notification)
}

⚠️ It works… until it doesn’t. Why?

  • ❌ You can’t unit test it
  • ❌ You can’t reuse the logic
  • ❌ You need real pushes to test it

Let’s clean it up.

✅ The Better Way: Delegate Everything
🧩 1. Keep FirebaseMessagingService Thin

Your service shouldn’t contain logic. It should forward work to injected, testable components.

@AndroidEntryPoint
class MyFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var pushHandler: dagger.Lazy<PushHandler>
@Inject lateinit var pushMessageMapper: dagger.Lazy<PushMessageMapper>
override fun onNewToken(token: String) {
//Communicate with your server to update the token
}
override fun onMessageReceived(message: RemoteMessage) {
val mapped = pushMessageMapper.get().map(message)
pushHandler.get().handle(mapped)
}
}

This gives you the power of dependency injection (e.g., Hilt or Koin), and isolates the logic for reuse and testing.

🧱 2. Map to a Clean Domain Model

Don’t pass around Firebase’s RemoteMessage. Instead, extract a platform-agnostic model that reflects your app’s needs

data class PushMessage(
val title: String?,
val body: String?,
val deeplink: String?,
val type: String?
)
view raw PushMessage.kt hosted with ❤ by GitHub

Map FCM data into this class:

class PushMessageMapper @Inject constructor() {
fun map(message: RemoteMessage): PushMessage {
val data = message.data
return PushMessage(
title = data["title"],
body = data["body"],
deeplink = data["deeplink"],
type = data["type"]
)
}
}

By abstracting RemoteMessage, you decouple your core logic from Firebase.

🛠️ 3. Inject and Test Business Logic

Now create a PushHandler that takes your domain model and decides what to do.

interface PushHandler {
fun handle(message: PushMessage)
}
class DefaultPushHandler @Inject constructor(
private val notificationFactory: NotificationFactory,
private val deeplinkParser: DeeplinkParser,
@ApplicationContext private val context: Context
) : PushHandler {
override fun handle(message: PushMessage) {
val notification = notificationFactory.createNotification(context, message)
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) {
NotificationManagerCompat.from(context).notify(1001, notification)
}
}
}
view raw PushHandler.kt hosted with ❤ by GitHub

Now your logic is testable, extendable, and easy to maintain.

🔁 Same Pattern for BroadcastReceivers

This mistake isn’t limited to push notifications. BroadcastReceiver is another classic offender.

Bad pattern — logic inside the receiver:

class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val workManager = WorkManager.getInstance(context)
workManager.enqueue(...) // logic here
}
}

Better pattern — delegate logic:

class BootCompletedReceiver : BroadcastReceiver() {
private val rescheduler: TaskRescheduler by inject()
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
rescheduler.reschedule()
}
}
}
interface TaskRescheduler {
fun reschedule()
}
class DefaultTaskRescheduler @Inject constructor(
private val workManager: WorkManager
) : TaskRescheduler {
override fun reschedule() {
workManager.enqueue(...) // clean and testable
}
}

This allows you to test rescheduling logic with unit tests — no need to simulate broadcasts.

🧪 Unit Testing with Robolectric and Fakes

This is where it gets fun. Because your core logic is no longer tied to Android components, testing is simple:

@RunWith(RobolectricTestRunner::class)
class DefaultPushHandlerTest {
private lateinit var context: Context
private val posted = mutableListOf<Notification>()
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
}
@Test
fun `push handler posts notification`() {
val handler = DefaultPushHandler(
FakeNotificationFactory(context, posted),
FakeDeeplinkParser(),
context
)
handler.handle(PushMessage("Hello", "World", null, null))
assertEquals("Hello", posted.first().extras.getString(Notification.EXTRA_TITLE))
}
}

You don’t need a device or emulator — just plain Kotlin and Robolectric.

📲 End-to-End Verification with UIAutomator

You can also test real system-level behavior with UIAutomator:

@RunWith(AndroidJUnit4::class)
class NotificationUITest {
@Test
fun notificationIsDisplayed() {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
val context = ApplicationProvider.getApplicationContext<Context>()
val handler = DefaultPushHandler(
FakeNotificationFactoryThatShowsRealNotif(context),
FakeDeeplinkParser(),
context
)
handler.handle(PushMessage("Test Title", "Test Body", null, null))
device.openNotification()
device.wait(Until.hasObject(By.textContains("Test Title")), 5000)
val notif = device.findObject(UiSelector().textContains("Test Title"))
assertTrue("Notification not found", notif.exists())
}
}
class FakeNotificationFactoryThatShowsRealNotif(
private val context: Context
) : NotificationFactory {
init {
createNotificationChannel()
}
override fun createNotification(context: Context, message: PushMessage): Notification {
return NotificationCompat.Builder(context, TEST_CHANNEL_ID)
.setContentTitle(message.title ?: "Test Title")
.setContentText(message.body ?: "Test Body")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build().also {
NotificationManagerCompat.from(context).notify(TEST_NOTIFICATION_ID, it)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Test Channel"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(TEST_CHANNEL_ID, name, importance)
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
}
companion object {
private const val TEST_CHANNEL_ID = "test_channel"
private const val TEST_NOTIFICATION_ID = 42
}
}

Perfect for verifying real device behavior on CI or pre-release tests.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

💡 Final Advice
Keep your Android components dumb, and your logic clean.
  • 🤖 FirebaseMessagingService should not contain notification code
  • 📡 BroadcastReceiver should not enqueue jobs
  • 🎬 Activity should not contain business logic
Use @Inject@Binds, and interfaces to make every part of your system testable and modular.
✅ TL;DR
  • Android components should delegate, not do
  • Move logic into injectable classes
  • Create domain models to decouple from SDKs
  • Unit test with fakes, verify UI with UIAutomator

 

🧼 Clean architecture starts with one rule: Keep Components Dumb.

 

Great job on making it to the end of this article! 💪

  • If you need help with your Android project, Book a 1:1 or a Pair-Programming meeting with me, Book a time now🧑‍💻🧑‍💻🧑‍💻
  • Follow my YouTube channel for video tutorials and tips on Android development 📺📺

If you like this article please can you do me a little favour and hit the 👏clap button as many times! I really appreciate your kindness x❤️👊

This article was previously published on proandroiddev.com.

Menu