The goal
Watch WhatsApp for messages containing specified keywords and push matches to a webhook. Ship two versions: a light notification-listener (minimal permissions) and a deeper accessibility-service (full UI inspection with sender + phone extraction).
The solution
Two companion Android apps. WhatsAppListener uses NotificationListenerService with a keyword UI, per-device ID, deduplication and webhook POST. WhatsAppMonitor uses an AccessibilityService + Jetpack Compose + Room, walks WhatsApp's UI tree, extracts phone numbers with configurable country prefixes (1/972/…), persists via DAOs and reps, and exposes a Compose UI with navigation. A shared KeyStore project holds signing keys.
Highlights
- Two permission tiers (light notification vs deep accessibility) in one family of apps
- Centralized WhatsAppSelectors.kt survives WhatsApp updates with a one-file patch
- Cross-path dedup (notification + UI crawl) keeps webhook consumers sane
- Room DB + Compose UI on Monitor; lightweight Listener for low-risk deployment
The challenge
WhatsApp has no API for reading messages on Android — any scraping is adversarial.
Two apps for two trust tiers. Listener uses NotificationListenerService (minimal permissions, notification-only signal). Monitor uses AccessibilityService (full UI tree walk, phone number + sender + context — requires accessibility permission).
Raw phone numbers captured from WhatsApp UI need consistent normalization across regions.
setSupportedCountryPrefixes(prefixes) (default ['1','972']) strips non-digits and normalizes leading + / 0 / country code to emit a canonical E.164-ish number.
The Listener and Monitor can see the same message in different ways and would otherwise double-fire the webhook.
Deduplicator.kt uses a fingerprint (sender + trimmed text + timestamp bucket) so the same hit is emitted at most once per window.
WhatsApp's accessibility tree changes with app updates — hard-coded selectors break.
WhatsAppSelectors.kt centralizes every selector/resource-id so a WhatsApp version bump is a one-file patch.
Modern Android throttles and kills long-running services.
NotificationListenerService runs inside the system listener process (Doze-insensitive); accessibility runs on the OS's always-on accessibility runtime — both avoid foreground-service drain.
What was delivered
- WhatsAppListener/ — NotificationListenerService app (WhatsAppNotificationListener, KeywordMatcher, WebhookClient, Deduplicator, KeywordAdapter, DeviceIdProvider, AppPreferences, MainActivity)
- WhatsAppMonitor/ — AccessibilityService + Compose app (MonitorAccessibilityService, WhatsAppNavigator, WhatsAppSelectors, KeywordMatcher, CrawlController, WebhookClient; Room: AppDatabase, Dao, Entities, Repos, SettingsStore; UI: AppNav, MainVm, Screens, Theme)
- KeyStore/ — signing-key management
- Both apps at API 26+ / compile SDK 35 / Kotlin 1.9 / Compose
Results
2 + shared KeyStore
Apps
Notification / Accessibility
Permission tiers
HTTP webhook POST
Output
API 26+
Min SDK
What it taught me
- Offering both a low-permission path (notification listener) and a high-capability path (accessibility) is more useful than picking one
- Centralizing WhatsApp selectors in one file turns a WhatsApp update from a hunt into a one-file patch
- Cross-path deduplication (notification + UI crawl) is mandatory — anything else spams the webhook consumer
