Production-Grade Android SMS & Notification Gateway
Capture. Queue. Deliver. Reliably.
"Never lose an event."
Every SMS and notification is persisted to local Room DB before any delivery is attempted. PulseGate is not a simple SMS forwarder — it is a lightweight, self-hosted event-processing pipeline running on your Android device.
- Overview
- Why PulseGate
- Architecture
- Tech Stack
- Project Structure
- Getting Started
- Configuration
- How It Works
- Database Schema
- OEM Battery Optimization
- Extending PulseGate
- Testing
- Roadmap
- Contributing
- License
PulseGate captures incoming SMS messages and banking/payment notifications and forwards them to your configured destinations — Webhooks or Telegram bots — reliably, even when offline or after a device reboot.
- SMS or notification arrives → persisted to Room DB instantly, before anything else
- Worker picks it up and delivers with
Semaphore(3)concurrency - Delivery fails → exponential retry, up to 6 attempts
- No internet → queue held locally, flushed automatically when reconnected
- Device reboots →
BootReceiverrestores service and resumes the queue from where it stopped
Most SMS-forwarding solutions on Android are either closed-source, fragile when the app is killed, or locked to a single destination. PulseGate is built to not have those problems:
| Problem | How PulseGate solves it |
|---|---|
| Messages dropped when offline | Local persistent queue; ConnectivityObserver flushes on reconnect |
| App killed by OEM battery saver | GatewayForegroundService + BootReceiver restores full state |
| Duplicate events processed twice | SHA-256 hash deduplication with DB unique constraint |
| Only one destination supported | Fan-out — one event queued per active destination, delivered in parallel |
| No visibility into delivery failures | Per-attempt DeliveryLog with HTTP code, latency, retry count |
| Hard to extend with new channels | Sender interface — add Slack, Discord, Firebase without touching core logic |
Clean Architecture with an MVVM presentation layer. No unnecessary abstractions — layers are added only where the complexity actually justifies them.
┌─────────────────────────────────────────────────────────┐
│ PRESENTATION (Compose + ViewModels) │
└──────────────────────────┬──────────────────────────────┘
│ invokes
┌──────────────────────────▼──────────────────────────────┐
│ DOMAIN (Use Cases + Repo Interfaces) │
└──────────────────────────┬──────────────────────────────┘
│ implemented by
┌──────────────────────────▼──────────────────────────────┐
│ DATA (Room + WorkManager + OkHttp + Hilt) │
└─────────────────────────────────────────────────────────┘
Core design decisions:
- DB-First —
SmsReceiverandGatewayNotificationListenernever touch the network. They write to Room and callWorkScheduler. That is all. - Queue-Driven Delivery — all delivery goes through the persistent
delivery_queuetable, never a direct API call from a receiver - Atomic Transactions — inserting an event and creating its queue rows is a single Room transaction. Either both succeed or both roll back.
- Stale Lock Recovery — workers lock rows before processing. If the process is killed mid-flight,
BootReceiverreleases locks older than 5 minutes so items don't get stuck inPROCESSINGforever. - Pluggable Senders —
Senderis a pure interface.SenderEngineroutes byDestinationType. Adding a new transport is isolated entirely toSenderModuleandSenderEngine.
| Category | Library | Version |
|---|---|---|
| Language | Kotlin | 2.3.21 |
| UI | Jetpack Compose + Material 3 | BOM 2026.04.01 |
| Database | Room | 2.8.4 |
| Background Jobs | WorkManager | 2.11.2 |
| Networking | OkHttp | 5.3.2 |
| REST Adapter | Retrofit | 3.0.0 |
| Dependency Injection | Hilt | 2.59.2 |
| Async | Coroutines + StateFlow | 1.10.2 |
| Secure Storage | EncryptedSharedPreferences | 1.1.0 |
| Serialization | Moshi + KSP Codegen | 1.15.2 |
| Navigation | Navigation Compose | 2.9.8 |
| Logging | Timber | 5.0.1 |
| Build | Gradle Kotlin DSL + KSP | AGP 9.2.0 / KSP 2.3.6 |
com.aman.pulsegate/
├── PulseGateApp.kt # Application — Hilt + WorkManager init
├── MainActivity.kt # Single activity, nav host
│
├── data/
│ ├── db/
│ │ ├── AppDatabase.kt # Room v1, schema export enabled
│ │ ├── entity/ # IncomingEventEntity, DeliveryQueueEntity,
│ │ │ # DestinationEntity, DeliveryLogEntity
│ │ ├── dao/ # 4 DAOs — includes atomic queue lock/unlock SQL
│ │ └── repository/ # Repository implementations
│ └── security/
│ └── SecurePreferences.kt # EncryptedSharedPreferences wrapper (AES-256)
│
├── domain/
│ ├── model/ # Domain models, QueueStatus, SourceType, SendResult
│ ├── repository/ # 4 repository interfaces
│ └── usecase/
│ ├── SaveIncomingEventUseCase # SHA-256 dedup + single atomic Room transaction
│ ├── ProcessDeliveryQueueUseCase # Semaphore(3) — concurrent delivery with drain loop
│ ├── AddDestinationUseCase # Validation + encrypted credential save
│ ├── GetDeliveryLogsUseCase
│ ├── RetryFailedEventUseCase
│ └── CleanupOldDataUseCase
│
├── sender/
│ ├── Sender.kt # interface Sender { suspend fun send(...): SendResult }
│ ├── SenderEngine.kt # Routes by DestinationType
│ ├── webhook/WebhookSender.kt # POST / GET / PUT, bearer token, custom headers
│ └── telegram/TelegramSender.kt # Telegram Bot API
│
├── background/
│ ├── ImmediateDeliveryWorker.kt # Expedited, chunk=10, drains full queue in while-loop
│ ├── PeriodicDeliveryWorker.kt # Safety net every 15 min
│ ├── CleanupWorker.kt # Data retention, runs every 24h
│ └── WorkScheduler.kt # Single scheduling facade — all enqueue calls go here
│
├── receiver/
│ ├── SmsReceiver.kt # SMS_RECEIVED, exported=false, goAsync, priority 999
│ └── BootReceiver.kt # BOOT_COMPLETED + MY_PACKAGE_REPLACED
│
├── service/
│ ├── GatewayForegroundService.kt # foregroundServiceType=dataSync, START_STICKY
│ └── GatewayNotificationListener.kt # filter → deduplicate → parse → save → schedule
│
├── notification/
│ ├── NotificationFilterManager.kt # Package-based allowlist
│ ├── NotificationDeduplicator.kt
│ └── parser/ # BankingAppParser, GenericParser, ParserDispatcher
│
├── connectivity/
│ └── ConnectivityObserver.kt # NetworkCallback → WorkScheduler on internet restore
│
├── di/ # DatabaseModule, RepositoryModule, SenderModule,
│ # WorkerModule, NotificationModule, SecurityModule
│
└── ui/
├── theme/ # M3 dark — Primary #4B6EF5, Background #0F1117
├── navigation/ # AppNavGraph + Screen sealed class
├── permission/ # PermissionScreen + PermissionViewModel
├── dashboard/ # Live stats — service status, queue counters
├── destinations/ # Destination CRUD
└── logs/ # Delivery logs with status badges + per-row retry
- Android Studio Hedgehog (2023.1.1) or newer
- JDK 11
- Device or emulator running Android 8.0+ (API 26+)
- Physical device strongly recommended for SMS testing — emulator SMS support is unreliable
git clone https://github.com/Am24an/PulseGate-android.git
cd PulseGate-android
./gradlew assembleDebug
./gradlew installDebugOn first launch, the onboarding screen walks through each permission individually:
| Permission | Required for |
|---|---|
RECEIVE_SMS + READ_SMS |
SMS capture |
| Notification Listener | Banking app notification capture |
POST_NOTIFICATIONS (API 33+) |
Persistent foreground service notification |
| Battery optimization exemption | Service survival on aggressive OEM ROMs |
Partial grants are fine — unavailable features are clearly indicated in the UI.
Go to Destinations → + and fill in:
| Field | Details |
|---|---|
| Name | Any label, e.g. My Backend |
| Type | WEBHOOK |
| URL | https://your-server.com/sms-hook |
| Method | POST / GET / PUT |
| Bearer Token | Optional — sent as Authorization: Bearer <token> |
| Headers JSON | Optional — e.g. {"X-Api-Key": "abc123"} |
| Timeout | 5–60 seconds (default 15s) |
| Field | Details |
|---|---|
| Type | TELEGRAM |
| Bot Token | Create a bot via @BotFather |
| Chat ID | Get yours from @userinfobot |
Multiple destinations are fully supported. One incoming event creates one queue row per active destination, all processed concurrently.
{
"eventId": 42,
"eventHash": "a3f9c1...",
"sourceType": "SMS",
"sender": "+919876543210",
"title": null,
"message": "Your OTP is 123456. Do not share.",
"receivedTimestamp": 1714900000000,
"appPackage": null
}For notifications: sourceType → NOTIFICATION, appPackage → source package (e.g. com.phonepe.app), title → notification title.
Incoming SMS
└─► SmsReceiver.onReceive() [goAsync — exits in <100ms]
└─► SaveIncomingEventUseCase
├── Compute SHA-256(sender + body + timestamp)
├── Reject if hash already exists in DB
└── Single Room transaction
├── INSERT → incoming_events
└── INSERT → delivery_queue (one row per active destination)
└─► WorkScheduler.scheduleImmediateDelivery()
└─► ImmediateDeliveryWorker [Expedited]
└─► ProcessDeliveryQueueUseCase
├── while(true) loop — drain full queue
├── Semaphore(3) — max 3 concurrent
├── Lock row atomically before dispatch
└─► SenderEngine.dispatch()
├── WebhookSender → your server
└── TelegramSender → Telegram Bot API
Banking app notification
└─► GatewayNotificationListener.onNotificationPosted()
├── NotificationFilterManager — check package allowlist
├── NotificationDeduplicator — reject duplicate keys
├── ParserDispatcher → BankingAppParser or GenericParser
└─► SaveIncomingEventUseCase → same delivery pipeline
| Attempt | Delay |
|---|---|
| 1 | 1 min |
| 2 | 5 min |
| 3 | 15 min |
| 4 | 30 min |
| 5 | 1 hr |
| 6 | 6 hr |
Retries on network errors, timeouts, and HTTP 5xx.
No retry on HTTP 4xx — if the server rejects the request, fix the config, not the retry count.
After 6 failures → FAILED. A Retry button in the Logs screen resets it to PENDING.
| Table | Key columns |
|---|---|
incoming_events |
event_hash UNIQUE, source_type, sender, message, received_timestamp |
delivery_queue |
status, retry_count, next_retry_at, locked, locked_at, worker_id |
destinations |
type, base_url, method, api_key, is_active |
delivery_logs |
status, http_code, latency_ms, retry_attempt, error_message |
Queue state machine:
PENDING
└─► PROCESSING ──► SENT
├──► RETRY ──► PROCESSING (after backoff delay)
└──► FAILED ──► PENDING (manual retry from UI)
Retention policy (auto-cleanup via CleanupWorker every 24h):
| Data | Kept for |
|---|---|
| SENT events | 72 hours |
| FAILED events | 7 days |
| Delivery logs | 30 days |
| PENDING events | Never auto-deleted |
Android OEMs — especially Xiaomi, Samsung, and Realme — kill background processes aggressively. Without exempting PulseGate, the foreground service may be stopped and new SMS will not be received. The in-app Settings screen deep-links you directly to the right page for your device.
| OEM | Path |
|---|---|
| Xiaomi (MIUI) | Security → Battery → PulseGate → No restrictions |
| Samsung (One UI) | Settings → Battery → Background usage limits → Never sleeping apps |
| Realme / Oppo (ColorOS) | Settings → Battery → Battery optimization → Not optimized |
| Vivo (Funtouch OS) | Settings → Battery → High background power consumption → Allow |
| OnePlus (OxygenOS) | Settings → Battery → Battery optimization → Don't optimize |
| Pixel / Stock Android | No extra action needed |
Adding Slack, Discord, or any other channel means implementing one interface. The queue, retry, logging, and cleanup pipeline is completely unchanged.
class SlackSender @Inject constructor(
private val okHttpClient: OkHttpClient
) : Sender {
override suspend fun send(payload: EventPayload, destination: Destination): SendResult {
// build your Slack webhook request here
}
}Then:
- Register in
SenderModule.kt - Add
SLACKto theDestinationTypeenum - Add a
whenbranch inSenderEngine.dispatch() - Add the
SLACKoption inAddEditDestinationScreen
That is the entire integration surface.
./gradlew test # unit tests
./gradlew connectedAndroidTest # instrumented testsStack: JUnit 4 · MockK · Kotlin Coroutines Test · Room in-memory DB · Espresso
| Area | What is tested |
|---|---|
SaveIncomingEventUseCase |
Duplicate hash discarded; new hash saves and returns real ID |
WebhookSender |
Request construction, header injection, HTTP error → SendResult mapping |
| Retry logic | Backoff delay values correct per attempt |
DeliveryQueueDao |
Atomic lock/unlock; stale lock release; PROCESSING excluded from re-fetch |
ProcessDeliveryQueueUseCase |
Semaphore correctly caps concurrent deliveries at 3 |
- Events screen — browse captured SMS and notifications
- Notification package manager — manage allowed packages from within the app
- Test Connection button on Destinations screen
- Slack sender
- Discord sender
- Firebase sender
- Certificate pinning for webhook connections
- CSV export for delivery logs
- Play Store release with proper Room migration strategy
git clone https://github.com/Am24an/PulseGate-android.git
git checkout -b feat/your-change
./gradlew test
git commit -m "feat: your change description"
git push origin feat/your-change
# open a PR on GitHubA few things that keep the codebase consistent:
- Business logic lives in use cases, not ViewModels or Composables
- Every new use case needs a unit test
- Keep PRs focused — one change per PR
- Open an issue before adding a new external dependency
MIT License — Copyright (c) 2026 Aman
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software to deal in the Software without restriction, including the rights to use, copy,
modify, merge, publish, distribute, and sublicense. The above copyright notice shall be
included in all copies. The software is provided "as is", without warranty of any kind.
Built with ❤️ by Aman Kumar Gupta
Clean architecture is not about following rules. It is about writing code
you can still read six months later at 2 AM.
github.com/Am24an/PulseGate-android
·
Drop a ⭐ if this helped you build something.