BeepFinder logs the source of every audible notification on your phone, so you can answer "what just beeped?" It was built as a personal utility and deliberately kept small, making it a good end-to-end example of a modern Android app.
This README is written for an experienced developer who is new to Android. It explains the Android-specific concepts in context rather than abstractly.
I've been vibe engineering for the past while -- using LLM aids to accelerate my programming tasks. But this was my my first experiement at true vibe coding.
I have not done any Android programming since years ago, back in the day of Android 1.0 and 1.5. But I am an Android user and have long been annoyed by random beeps on my phone from unknown applications. I figured this was a great chance to try vibe coding...
This was coded by Claude Code, guided by Beads. The only interactions from me were:
- About ten minutes of initial design
- A round of brainstorming where I asked Claude to suggest improvements and I voted yes/no on each, finally accepting fourteen changes
- Rough ideas for the icon
- Asking Claude to do a final round of refactoring and documentation
This "Back story" section is the only human-written part of this repo to date.
- A background service listens for every notification posted by any app.
- Each audible notification is recorded to a local SQLite database.
- The main screen shows a live-updating list of recent notifications, grouped by app, newest first.
- A settings screen lets you ignore specific apps and choose how much history to keep.
app/
src/main/
AndroidManifest.xml ← App registration: activities, services,
│ permissions. The equivalent of a web app's
│ server config + routing table.
│
java/com/degel/beepfinder/
│
├── MainActivity.kt ← The single screen entry point (Activity).
│ Owns top-level state (permission, battery).
│
├── data/ ← Persistence layer (Room ORM over SQLite).
│ ├── NotificationEntity ← The DB row (annotated data class).
│ ├── NotificationDao ← The query interface (annotated interface).
│ ├── NotificationDatabase ← The DB singleton (Room builder).
│ ├── NotificationRepository ← The only data-access point for the UI.
│ └── AppSettings ← Simple key-value settings (SharedPreferences).
│
├── service/
│ ├── BeepListenerService ← The core: NotificationListenerService subclass.
│ └── BootReceiver ← BroadcastReceiver stub for device boot.
│
└── ui/
├── Theme.kt ← Material You color scheme (dark/light).
├── NotificationViewModel← Bridge between data layer and UI (ViewModel).
├── NotificationGroup ← Data class + grouping logic for list display.
├── NotificationListScreen ← Main list (Jetpack Compose).
├── SettingsScreen ← Settings (Jetpack Compose).
└── AppIcon.kt ← Async app icon loader (Compose + coroutine).
gradle/
libs.versions.toml ← Central version catalog (all dep versions here).
app/build.gradle.kts ← Module-level build config.
build.gradle.kts ← Root build config (plugin declarations only).
gradle.properties ← JVM args, AndroidX flag.
AndroidManifest.xml ← See above.
Every Android app has a manifest that declares its components and permissions to the OS. Think of it as a contract between your app and Android.
<activity>— A screen. Android launches your app by starting an Activity, not amain()function.<service>— A component that runs without a UI. OurNotificationListenerServiceis declared here.<receiver>— A component that receives system-wide broadcast events (e.g.,BOOT_COMPLETED).<uses-permission>— Declares permissions your app needs. Some are granted automatically; others require explicit user approval at runtime.
An Activity (screen) has a lifecycle managed by Android:
onCreate → onStart → onResume ↔ onPause → onStop → onDestroy
We use onResume to re-check permissions each time the user returns to the app
(e.g., after visiting the system Settings screen). This is the standard pattern
for reacting to out-of-app changes.
A special Service subclass that Android binds to when you grant "Notification
Access" permission. The OS calls onNotificationPosted() for every notification
posted by any app, giving you the package name, channel, and metadata.
Key points:
- The OS starts and stops it automatically — you don't call
startService(). - It runs in your app's process, so it shares memory with the UI.
- We call
startForeground()inside it to raise its process priority and prevent Android from killing it under memory pressure. - The permission is "sensitive" — Play Store requires justification.
Android aggressively kills background processes to save battery. A foreground service opts out of this by posting a persistent visible notification, which tells Android "this process is doing something the user is aware of."
Our service calls startForeground() in onListenerConnected() and updates
the notification text on every beep — this doubles as the quick-glance feature.
Room is Android's official ORM. It wraps SQLite with type safety and compile-time query validation. Three pieces:
@Entity— A data class annotated to map to a DB table.@Dao(Data Access Object) — An interface with annotated methods (@Insert,@Query). Room generates the implementation at compile time.@Database— ARoomDatabasesubclass that ties entities and DAOs together. Always used as a singleton.
Room integrates with Kotlin Flow natively: a @Query that returns
Flow<List<T>> automatically re-emits whenever the underlying table changes.
This is the reactive data pipeline that keeps the UI live.
Compose is Android's modern declarative UI toolkit (similar to React or SwiftUI). Key ideas:
- UI is defined as
@Composablefunctions, not XML layouts. - State drives recomposition: when a
State<T>orStateFlowchanges, Compose re-runs only the composables that read it. remembercaches a value for the lifetime of a composable's presence in the tree.rememberUpdatedState/LaunchedEffect/produceStatehandle side effects and async work.LazyColumnis the Compose equivalent ofRecyclerView— it only renders visible items.
ViewModel survives screen rotations (it outlives the Activity). It holds and
transforms the data the UI needs, without knowing about the UI itself.
Our NotificationViewModel:
- Holds
AppSettingsstate (ignored apps, history hours) as ComposeState. - Chains Room's
FlowthroughflatMapLatest(switch to new query when history window changes) andmap(apply ignore filter, group entries). - Exposes the result as a
Flow<List<ListItem>>that the UI collects.
Flow is Kotlin's reactive stream type (similar to RxJava Observable or a JS async generator). Key operators used here:
flatMapLatest— when the source emits, cancel the previous inner flow and start a new one. Used to switch DB queries when the history window changes.map— transform each emitted value. Used for filtering and grouping.collectAsStateWithLifecycle— Compose extension that collects a Flow as ComposeState, automatically pausing collection when the app is in the background (lifecycle-aware).produceState— launches a coroutine inside a composable and stores its result asState. Used inAppIconto load icons off the main thread.
The Android key-value store for simple settings (analogous to localStorage
in a browser, or a small .ini file). Synchronous reads, async writes via
edit().apply(). For more complex persistent state, DataStore is the modern
successor.
Android 8+ supports adaptive icons: a foreground layer (the artwork) on a background layer (a solid color or pattern). The launcher clips them into whatever shape it prefers (circle, squircle, teardrop, etc.).
Our icon files:
res/drawable/ic_launcher_foreground.xml— the magnifying glass + bell.res/drawable/ic_launcher_background.xml— solid blue rectangle.res/mipmap-anydpi-v26/ic_launcher.xml— the<adaptive-icon>wrapper.
The mipmap-anydpi-v26/ directory name means "any DPI, API 26+". API 26 is
when adaptive icons were introduced.
Android uses Gradle with the Android Gradle Plugin (AGP). Key files:
gradle/libs.versions.toml— the version catalog: all library versions in one place, referenced by alias throughout the project.settings.gradle.kts— declares which modules exist and where to find dependencies.build.gradle.kts(root) — applies plugins across all modules.app/build.gradle.kts— the actual app module config: SDK versions, dependencies, build types.
compileSdk is the SDK version the code compiles against (determines which
APIs are available). targetSdk tells Android how to treat your app at runtime
(newer = stricter behavior). minSdk is the minimum Android version you support.
Device notification arrives
│
▼
BeepListenerService.onNotificationPosted()
• Skip if FLAG_GROUP_SUMMARY (synthetic rollup)
• Skip if channel importance < DEFAULT (silent)
• Skip if same (pkg, id) within 3 seconds (dedup)
• Resolve app label from PackageManager
• Resolve sound name from NotificationChannel
• Update foreground notification text
│
▼ (Dispatchers.IO coroutine)
NotificationRepository.record()
│
▼
Room: INSERT INTO notifications ...
│ (Room emits to all active Flow collectors)
▼
NotificationViewModel.listItems (Flow)
• flatMapLatest on historyHours → new DB query
• map: toListItems() — group consecutive same-app entries,
interleave service start/stop markers
• map: filter ignored packages
│
▼ (collectAsStateWithLifecycle in Compose)
NotificationListScreen recomposes
• LazyColumn renders updated list
# First time only:
brew install --cask android-studio
brew install --cask android-commandlinetools
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
yes | sdkmanager --licenses
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
# Build debug APK:
./gradlew assembleDebug
# Install on connected device (USB debugging must be enabled):
./gradlew installDebugOn first launch, the app shows a permission prompt. Go to Settings → Apps → Special app access → Notification access and enable BeepFinder. Also tap "Fix" on the battery optimization banner.
If you want to trace the full lifecycle from boot to screen:
AndroidManifest.xml— understand what's registereddata/NotificationEntity.kt— the DB schemadata/NotificationDao.kt— the query interfacedata/NotificationDatabase.kt— Room setup and migrationsdata/NotificationRepository.kt— the data access facadeservice/BeepListenerService.kt— where data enters the systemui/NotificationGroup.kt— how raw DB rows become display itemsui/NotificationViewModel.kt— the reactive pipelineui/Theme.kt— Material You themingMainActivity.kt— the Activity and permission flowui/NotificationListScreen.kt— the Compose UIui/SettingsScreen.kt— a second Compose screenui/AppIcon.kt— async work inside a composablegradle/libs.versions.toml+app/build.gradle.kts— the build system