A native Android application that acts as a transparent gateway between a physical SIM card and a REST API. It receives incoming SMS/MMS and forwards them to your server, polls your server for outbound messages to send, and logs call metadata (CDR) and call recordings.
Designed to run silently in the background as a persistent foreground service.
- Receive SMS/MMS — incoming messages are POSTed to your server immediately
- Send SMS/MMS — polls your server for queued outbound messages and delivers them via the device's SIM
- Delivery status — reports send success/failure back to your server
- Call Detail Records (CDR) — logs inbound, outbound, and missed calls with timestamps and duration
- Call recording upload — uploads call recordings produced by the device's dialer app
- Android device with a SIM card
- Android 16 (API 36) or higher
- Your own REST server implementing the API endpoints below
Copy secrets.properties.example to secrets.properties and fill in your server details:
api_host=your-api-host.example.com
api_key=your-api-key-here
These values are injected at build time via BuildConfig and used as the default values in the app's settings screen. They are never committed to source control.
./gradlew assembleDebug
adb install app/build/outputs/apk/debug/app-debug.apkOr open in Android Studio and run directly to a device.
Open the app. The main screen shows the status of every required permission:
| Permission | Purpose |
|---|---|
| Receive / Read / Send SMS | SMS gateway core |
| Read Phone State + Phone Numbers | Device number, call state detection |
| Read Call Log | Outgoing number resolution, CDR |
| Read Media Audio | Access dialer's call recording files |
| Post Notifications | Foreground service notification |
| Draw Over Other Apps | Keeps the service process alive during calls |
Tap Request Permissions to prompt for all runtime permissions at once. Tap Grant Overlay to open the system overlay permission screen.
For background MMS sending (no user interaction required), this app must be the device's default SMS app. Tap Set as Default SMS App on the main screen.
If not set as default, MMS will fall back to launching the system messaging app, which requires the user to manually tap Send.
Enter your API host and key in the API Settings section. All endpoint paths are configurable and default to the paths documented below.
Tap Save Settings, then Test API Connection to verify connectivity.
SmsReceiver is a BroadcastReceiver registered for SMS_DELIVER (when default SMS app) and SMS_RECEIVED. When a message arrives, Android delivers all segments of a multipart SMS in a single broadcast as an array of PDUs. The app concatenates them into a single body before uploading, so your server always receives the complete message.
MMS messages are observed via MmsObserver, which watches the system MMS content provider and forwards new messages to the same /sms/receive endpoint with attachments encoded as base64.
PollingForegroundService runs a continuous polling loop (configurable interval, default 5 seconds). It calls GET /sms/send on your server, which returns a list of queued messages. Each message is dispatched via MessageSender, which routes to either SmsSender (standard Android SMS API) or MmsSender (WAP-209 PDU encoding). Delivery results are reported back via POST /sms/status.
The polling service is a dataSync foreground service that starts on boot and restarts if killed by the system.
CallReceiver is a BroadcastReceiver for ACTION_PHONE_STATE_CHANGED. It runs a state machine:
IDLE → RINGING incoming call arrives, number captured
RINGING → OFFHOOK call answered
IDLE → OFFHOOK outgoing call dialled
OFFHOOK → IDLE call ended → POST CDR
RINGING → IDLE missed call → POST CDR (state: "missed")
For outgoing calls, the number is not available in the broadcast on Android 10+. The app waits ~2.5 seconds after the call ends and resolves the number from the CallLog.
Note: Direct audio capture from phone calls (
AudioSource.VOICE_COMMUNICATION) is blocked for non-system apps on stock Android (AOSP, GrapheneOS, Pixel). The Android audio policy manager restricts in-call capture to privileged processes. Any recording made this way will be silent.
The app therefore relies on the device's built-in dialer to produce the recording file. The dialer (a system/privileged app) can record both call legs and saves files to Recordings/CallRecordings/ on shared storage.
Two options for triggering dialer recording:
Option A — Accessibility auto-tap (GrapheneOS dialer, some OEMs): Enable Record call content and Use Accessibility Services to try to press Record on every call in the app settings, then enable the Call Recording Assistant in Android Accessibility settings.
When a call is answered, CallRecordingAccessibilityService monitors the dialer's window events and performs a programmatic click on any button whose content description contains "record" (but not "stop"). This works reliably with the GrapheneOS dialer. The Google dialer does support a similar Record button but has an unavoidable system announcement to both parties ("This call is being recorded") and requires every contact to be individually opted in or "Record all unknown callers" to be enabled — making the accessibility tap less useful there.
Option B — Manual or automatic dialer recording:
Configure your dialer to record all calls automatically (where supported), then enable only Record call content in the app settings (leave the accessibility option off). The app will still pick up and upload any recording file it finds in CallRecordings/ after the call ends.
In both cases, after the call ends the app waits ~5 seconds for the dialer to finish writing the file, then queries MediaStore for an audio file in Recordings/CallRecordings/ with a DATE_ADDED timestamp at or after the call was answered. If found, it uploads the file via POST /call/recording and the local copy can be deleted.
Tap Upload Existing Recordings on the main screen to manually upload any files already sitting in CallRecordings/ that were never uploaded (e.g. from before the app was installed).
All endpoints use HTTPS. All requests include:
Authorization: Bearer <api_key>
https://{api_host}
Endpoint paths are fully configurable in the app. Defaults are shown below.
Called when the device receives an incoming SMS or MMS.
Request Content-Type: application/json
{
"message": {
"from": "+61400000000",
"to": "+61411111111",
"body": "Hello, world!",
"type": "text",
"timestamp": "Tue Jan 01 12:00:00 GMT+10:00 2008",
"attachments": [
{
"filename": "image.jpg",
"contentType": "image/jpeg",
"data": "<base64>"
}
]
},
"metadata": {
"receivedAt": "Tue Jan 01 12:00:00 GMT+10:00 2008",
"sender": "+61400000000",
"recipient": "+61411111111"
}
}type:"text"for SMS,"mms"for MMSattachments: present for MMS only; omitted for plain SMStimestamp: the timestamp from the SMS PDU (sender's carrier time)
Response — any 2xx is treated as success.
Polled by the device on a configurable interval (default every 5 seconds) to retrieve queued outbound messages.
Request — no body; Authorization header only.
Response Content-Type: application/json
[
{
"id": "msg_abc123",
"to": "+61400000000",
"body": "Your verification code is 1234",
"type": "sms"
}
]- Return an empty array
[]when there are no messages queued. type:"sms"or"mms"- For MMS, include an
attachmentsarray matching the same structure as the receive payload. idis echoed back in the status update so you can correlate delivery results.
Called after each send attempt to report delivery status.
Request Content-Type: application/json
{
"id": "msg_abc123",
"status": "sent",
"error": null
}status:"sent"on success,"failed"on errorerror: human-readable error string on failure,nullon success
Response — any 2xx is treated as success.
Called at the end of every call (answered or missed) when CDR logging is enabled.
Request Content-Type: application/json
{
"call": {
"state": "ended",
"direction": "inbound",
"phoneNumber": "+61400000000",
"startedAt": 1700000000000,
"answeredAt": 1700000060000,
"endedAt": 1700000120000,
"durationSeconds": 60
},
"device": {
"deviceManufacturer": "google",
"deviceModel": "Pixel 8",
"deviceBrand": "google",
"androidSdkVersion": 36,
"androidRelease": "16"
},
"loggedAt": "2024-11-15T10:41:07Z"
}| Field | Notes |
|---|---|
state |
"ended" for answered calls, "missed" for unanswered inbound calls |
direction |
"inbound" or "outbound" |
startedAt |
Epoch ms — RINGING time for inbound, OFFHOOK time for outbound |
answeredAt |
Epoch ms — OFFHOOK time; 0 for missed calls |
endedAt |
Epoch ms |
durationSeconds |
(endedAt - answeredAt) / 1000; 0 for missed calls |
Response — any 2xx is treated as success.
Called after an answered call when a recording file is found. Sent as multipart/form-data.
Fields:
| Field | Type | Description |
|---|---|---|
recording |
File | Audio file produced by the dialer (M4A/MP4/AAC) |
call |
String | JSON-encoded object containing the same fields as the CDR payload, plus call_id |
call JSON structure:
{
"call_id": "550e8400-e29b-41d4-a716-446655440000",
"state": "ended",
"direction": "inbound",
"phoneNumber": "+61400000000",
"startedAt": 1700000000000,
"answeredAt": 1700000060000,
"endedAt": 1700000120000,
"durationSeconds": 60,
"device": {
"deviceManufacturer": "google",
"deviceModel": "Pixel 8",
"deviceBrand": "google",
"androidSdkVersion": 36,
"androidRelease": "16"
}
}The server should validate the recording file as mimes:mp4,m4a,aac.
Response — any 2xx is treated as success. After a successful upload the local recording file is deleted from the device.
All settings are persisted in SharedPreferences and configurable from the app's main screen.
| Setting | Default | Description |
|---|---|---|
| API Host | (from secrets.properties) | Hostname only, no scheme or trailing slash |
| API Key | (from secrets.properties) | Sent as Authorization: Bearer on every request |
| Poll Interval | 5s | How often to check for outbound messages (1s–60s) |
| Poll for outbound messages | On | Disable to stop outbound polling without stopping the service |
| Connect Timeout | 10,000 ms | HTTP connection timeout |
| Read Timeout | 30,000 ms | HTTP read timeout |
| Max Retries | 3 | Retry attempts on transient network errors |
| Receive Endpoint | /api/v1/public/sms/receive |
Path for incoming SMS/MMS |
| Outbound Endpoint | /api/v1/public/sms/send |
Path for polling outbound queue |
| Status Endpoint | /api/v1/public/sms/status |
Path for delivery status |
| Call CDR Endpoint | /api/v1/public/call/cdr |
Path for call metadata |
| Call Recording Endpoint | /api/v1/public/call/recording |
Path for recording upload |
| Record call metadata | Off | Enable CDR logging |
| Record call content | Off | Enable recording upload |
| Use Accessibility Services to press Record | Off | Auto-tap the dialer's Record button on answer |
- The app targets Android 16 (API 36) and does not include legacy version checks.
PollingForegroundServiceis adataSyncforeground service that also holds themicrophoneservice type when started from the foreground (app opened by user). This type is required on API 31+ to start a microphone-capable foreground service — it cannot be started from a background context such as a boot receiver.CallRecordingService.javais kept in the source tree and compiles, but is not used at runtime. All recording orchestration goes through the dialer via the accessibility service.- Outgoing call numbers are not available in
ACTION_PHONE_STATE_CHANGEDbroadcasts on API 29+. The app resolves them by queryingCallLog.Callsapproximately 2.5 seconds after the call ends. - MMS sending uses WAP-209 PDU encoding via the
android-smsmmslibrary and requires the app to be the default SMS handler for background (silent) delivery.