DEV Community

Cover image for Guarding the Gate - Building a Local-First Core Notification Manager in Android
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Guarding the Gate - Building a Local-First Core Notification Manager in Android

In an era of relentless attention hacks, notifications have transformed from micro-reminders into psychological trigger points. The solution seems simple: customize alert profiles. However, standard Android rule builders are either built-in rigidly or ship user data straight to remote clusters.

Enter Gatekeeper—a local-first, privacy-by-design Open Source Software (OSS) utility built entirely with Kotlin, Jetpack Compose, Room Database, WorkManager, and Android's specialized NotificationListenerService.

In this architectural deep dive, we will decompose the stack, patterns, and codebases that power Gatekeeper on Android.


The Guardrail Paradigm: 100% On-Device

If code is not in your repo, it shouldn't have access to your thoughts. Gatekeeper does not require or declare standard internet usage parameters:

<!-- ❌ ABSENT from androidmanifest.xml: android.permission.INTERNET -->
Enter fullscreen mode Exit fullscreen mode

By refusing outbound network permission, every data structure, log packet, and rule configuration remains encapsulated within the app's local sandbox directories.


System Architecture Flow

Here is how the lifecycle flows when an alert lands from any device app:

[System Notification Event (e.g., WhatsApp, Slack)]
                       |
                       v
     [NotificationListenerService Hook]
                       |
                       v
         (Saves preview clip to cache) 
                       |
                       v
    [Query Rule Table inside local Room DB]
                       |
             +---------+---------+
             |                   |
       No Match Found       Rule Match Found!
             |                   |
             v                   v
      [Deliver Alert]     Execute Action Criteria
                      +----------+----------+
                      |                     |
                  [Action MUTE]       [Action SNOOZE]
                      |                     |
                      v                     v
              Cancel Alert        Cancel Alert
                                            |
                                            v
                                   Schedule WorkManager 
                                    (15 mins later)
                                            |
                                            v
                                 Post Local System Alert 
                                  to Notification Tray
Enter fullscreen mode Exit fullscreen mode

Let's dissect the core pillars step-by-step.


1. Interception Engine: NotificationListenerService

Android restricts global notification tracking for security reasons. To read or cancel third-party events, we tap into NotificationListenerService.

When registered and granted special system authorization, the OS holds a persistent binder to this service, waking it whenever a system level notification event is posted.

Implementing the Daemon Hook

In GatekeeperEngineService.kt, we listen to incoming alerts:

package com.example.service

import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import com.example.data.ActionType
import com.example.data.DatabaseProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

class GatekeeperEngineService : NotificationListenerService() {

    private val job = SupervisorJob()
    private val scope = CoroutineScope(Dispatchers.IO + job) // Run DB searches away from Main UI Thread

    override fun onNotificationPosted(sbn: StatusBarNotification) {
        val packageName = sbn.packageName
        val notification = sbn.notification
        val title = notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
        val text = notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: ""
        val bigText = notification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() ?: ""

        val content = "$title $text $bigText".lowercase()

        scope.launch {
            val db = DatabaseProvider.getDatabase(applicationContext)
            val activeRules = db.ruleDao().getActiveRulesForPackageSync(packageName)

            for (rule in activeRules) {
                // If keyword is left blank, match everything inside this app.
                val match = if (rule.keyword.isBlank()) {
                    true
                } else {
                    content.contains(rule.keyword.lowercase())
                }

                if (match) {
                    if (rule.actionType == ActionType.MUTE) {
                        cancelNotification(sbn.key) // Instantly suppresses the notification
                    } else if (rule.actionType == ActionType.SNOOZE) {
                        cancelNotification(sbn.key) // Suppresses immediately
                        scheduleSnooze(title, text, packageName) // Shifts delivery window
                    }
                    break
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Temporal Delay: Handled by WorkManager & SnoozeWorker

When a "Snooze" action is selected, we dismiss the live system notification and hand over delivery responsibility to Jetpack WorkManager as a background worker.

private fun scheduleSnooze(title: String, text: String, packageName: String) {
    val data = Data.Builder()
        .putString("title", title)
        .putString("text", text)
        .putString("packageName", packageName)
        .build()

    val workRequest = OneTimeWorkRequestBuilder<SnoozeWorker>()
        .setInitialDelay(15, TimeUnit.MINUTES) // Hardcoded 15-minute wait window
        .setInputData(data)
        .build()

    WorkManager.getInstance(applicationContext).enqueue(workRequest)
}
Enter fullscreen mode Exit fullscreen mode

Redelivery with SnoozeWorker

When 15 minutes have elapsed, SnoozeWorker builds a fresh local channel and notifies the tray:

class SnoozeWorker(
    private val context: Context,
    workerParams: WorkerParameters
) : Worker(context, workerParams) {

    override fun doWork(): Result {
        val title = inputData.getString("title") ?: "Snoozed Notification"
        val text = inputData.getString("text") ?: ""
        val packageName = inputData.getString("packageName") ?: ""

        sendNotification(title, text, packageName)
        return Result.success()
    }

    private fun sendNotification(title: String, text: String, packageName: String) {
        val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val channelId = "snoozed_notifications"

        // Android O+ Notification Channel Creation
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId, "Snoozed Notifications", NotificationManager.IMPORTANCE_DEFAULT
            )
            notificationManager.createNotificationChannel(channel)
        }

        val appName = try {
            val pm = context.packageManager
            val ai = pm.getApplicationInfo(packageName, 0)
            pm.getApplicationLabel(ai).toString()
        } catch (e: Exception) { packageName }

        val notification = NotificationCompat.Builder(context, channelId)
            .setSmallIcon(android.R.drawable.ic_dialog_info)
            .setContentTitle("[$appName] $title")
            .setContentText(text)
            .build()

        notificationManager.notify(Random.nextInt(), notification)
    }
}
Enter fullscreen mode Exit fullscreen mode

3. UI State Pipeline & Interaction with Compose

We leverage Room to stream the list of active user rules to our Compose layer cleanly. A reactive flow streams rule entities updates:

@Dao
interface RuleDao {
    @Query("SELECT * FROM rules ORDER BY id DESC")
    fun getAllRules(): Flow<List<Rule>>
}
Enter fullscreen mode Exit fullscreen mode

The GatekeeperViewModel then converts this Flow<List<Rule>> into a Compose state-compatible lifecycle-aware lifecycle state utilizing stateIn on the Kotlin viewModelScope:

val rules = repository.allRules.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000),
    initialValue = emptyList()
)
Enter fullscreen mode Exit fullscreen mode

Within our UI composables, we capture this via:

val rules by viewModel.rules.collectAsStateWithLifecycle()
Enter fullscreen mode Exit fullscreen mode

Aesthetic Evolution: Minimalist Utility Design

Gatekeeper features a gorgeous "Clean Utility / Minimal" layout, styled with high contrast slate buttons, responsive state indicators, and subtle warning accents:

  • Adaptive Bottom Sheets: Double-clicking or simple tapping on any rule switches state flags, pre-populating fields instantly for deep refinement edits.
  • Notification Sneak Heuristics: Recent notification text previews from system apps are tracked on-device in a temporary cache (NotificationCache), assisting users in formulating precise regex or literal rules without leaving the workflow page!

Gatekeeper offers a scalable blueprint for building local-first Android utility daemons. The architecture stays light, reliable, and strictly inside the user's pocket.

Code and more: https://www.dailybuild.xyz/project/152-gatekeeper

Top comments (0)