Turns an Android phone into the SMS gateway endpoint. It registers with the API Server, polls for pending outbound messages, sends them over the radio, reports delivery state, and forwards incoming SMS to the server's inbox.
API Server ──(pending messages)──► Phone Agent ──► SmsManager (send)
▲ │
└──(status reports / inbox)───────────┘ ◄── BroadcastReceiver (incoming)
The Phone Agent talks only to the API Server (never to PocketBase directly), using the device token issued at registration.
Not to be confused with
../Phone App/— that is a separate surface, a mobile mirror of the Web App, which controls the gateway. This one controls the phone (SMS/MMS + calls). They install separately (app.gsmnode.phoneagentandapp.gsmnode.phoneapp) and can sit side by side on one device.
- Flutter SDK (stable) — https://docs.flutter.dev/get-started/install/windows
- JDK 17+ — Android Gradle will not build on JDK 8
- Android SDK (via Android Studio or
flutter doctor --android-licenses) - A physical Android phone with a SIM (the emulator can't send real SMS)
Verify with flutter doctor — resolve anything it flags before continuing.
The Dart app and the Android Gradle project are both committed, so a fresh clone only needs packages:
flutter pub getThe Gradle wrapper is not committed (gradlew, gradlew.bat,
gradle/wrapper/gradle-wrapper.jar). If those are missing, regenerate the
platform files in place — it leaves lib/ and pubspec.yaml alone:
flutter create . --org app.gsmnode --project-name phoneagent --platforms=androidThe application id is
app.gsmnode.phoneagent(namespace+applicationIdinandroid/app/build.gradle, and the Kotlin package underandroid/app/src/main/kotlin/app/gsmnode/phoneagent/). The Dart package name inpubspec.yamlisphoneagentto match, so the command above reproduces both from--org+--project-name. Don't rename either: it changes the installed app's identity, and Android treats a new id as a different app rather than an upgrade.
flutter create overwrites the generated stubs, so re-apply the native bridge
afterwards. android_overlay/ mirrors the real android/ paths for exactly
this — it is a byte-identical copy of the Kotlin and manifest under android/:
Copy-Item -Recurse -Force android_overlay/* android/It carries app/src/main/AndroidManifest.xml (SMS/MMS/call permissions and the
receivers) plus the Kotlin bridge in app/src/main/kotlin/app/gsmnode/phoneagent/:
MainActivity.kt (send), SmsReceiver.kt / MmsReceiver.kt / CallReceiver.kt
(inbound), SmsStatusReceiver.kt (delivery reports), MmsPduBuilder.kt and
GatewayForegroundService.kt.
If Gradle complains about SDK levels, set minSdkVersion 23 (or higher) in
android/app/build.gradle.
flutter devices # confirm your phone is listed
flutter run # build & install on the connected phone- On first launch, enter the API Server URL, your email/password, and a
device name, then tap Sign in & register device.
- Emulator → host: use
http://10.0.2.2:8080. - Physical phone → use the host's LAN IP, e.g.
http://192.168.1.50:8080(the same network as the phone; make sure the API Server is reachable).
- Emulator → host: use
- On the home screen, grant SMS & phone permissions, then Start gateway.
- Send a test message from the Web App → it appears in the activity log and is delivered via the phone. Texts received by the phone show up in the Web App Inbox.
| Action | Endpoint |
|---|---|
| Login | POST /api/auth/login (JWT) |
| Register device | POST /api/mobile/v1/device → device token |
| Poll pending | GET /api/mobile/v1/messages (marks them Processed) |
| Report state | PATCH /api/mobile/v1/messages/{id} (Sent/Failed) |
| Incoming SMS | POST /api/mobile/v1/inbox |
| Heartbeat | POST /api/mobile/v1/ping |
Scheduling is the server's job: GET /messages withholds anything whose
schedule_at is still in the future, so the gateway sends what it is handed. It
does check schedule_at and park an early message until it comes due, but only
as a backstop against an older server — a parked message is already marked
Processed, so if the app dies first the server's expiry sweeper fails it.
Pulled items carry a type of sms or call. For call the app places a
native phone call via TelecomManager.placeCall (needs CALL_PHONE — covered by
the phone permission) instead of sending SMS, then reports Sent.
TelecomManager.placeCall is used rather than startActivity(ACTION_CALL) so the
call still goes through when the screen is locked / the app is backgrounded
(Android blocks background activity starts, but not telecom-routed calls).
lib/
main.dart entry, bootstraps services, picks first screen
config.dart default API base + poll/ping intervals
models/message.dart outbound message model
services/
storage.dart persisted settings/tokens (shared_preferences)
api_client.dart API Server HTTP client
sms_service.dart platform-channel bridge (send + incoming stream)
gateway_service.dart the poll → send → report loop + inbox forwarding
crypto_service.dart AES-256-GCM + PBKDF2 (matches the Web App)
biometric_service.dart face/fingerprint prompts
app_lock.dart the App lock preference, armed by a prompt
widgets/
app_lock_gate.dart the lock overlay + its lifecycle rules
screens/
login_screen.dart login + device registration
home_screen.dart start/stop, permissions, App lock, activity log
android_overlay/ native Android files to copy after `flutter create`
The Dart and Kotlin halves meet on channels named after the application id:
app.gsmnode.phoneagent/sms (send), /sms_incoming, /sms_status,
/call_incoming, and the app.gsmnode.phoneagent.SMS_STATUS broadcast action.
They agree by string equality alone — nothing checks them at compile time, so
a rename that misses one side builds, installs and starts cleanly, then fails on
the first SMS. Change both, or neither.
- Foreground service (
GatewayForegroundService.kt): starting the gateway launches an ongoing-notification foreground service + partial wakelock, so the poll/send loop keeps running while the screen is off or the app is backgrounded. - Delivery reports:
MainActivity.sendSmsattachessent/deliveredPendingIntents;SmsStatusReceiverforwards the outcome (tagged with the message id) to Dart, which reportsDelivered/Failedto the API Server.
The home screen's App lock toggle puts the phone's biometrics in front of the running gateway. A phone left unlocked on a desk is otherwise a phone anyone can stop, un-register, or read the activity log of.
- It arms only once the device is registered — there is nothing worth guarding on the login screen, and locking it would strand someone who signed out.
- It closes on a cold start and whenever the app has been in the background for 30s, and always on a close, a detach being a deliberate exit where the grace period is only there to absorb an interruption.
- The prompt gates the toggle in both directions: turning it on proves the lock can be cleared before arming it, and turning it off stops someone holding an already-unlocked phone from quietly disarming it.
- It is Android's
BiometricPrompt, notbiometricOnly, so the screen lock (PIN, pattern, password) is its own fallback — a wet finger is not a lockout. A phone that can no longer prompt at all (biometrics gone and the screen lock removed) disarms the lock rather than becoming a one-way door. - The gateway keeps running behind it. The lock covers the UI; it does not stop the foreground service, so SMS keeps flowing while the phone is locked. That is the whole point of locking a gateway rather than signing it out.
The Phone App carries the same lock — same
AppLockController / BiometricService / AppLockGate split, same app_lock
preference key — differing only where a console differs from a gateway.
Beyond text SMS the gateway now also handles:
- Data SMS —
SmsManager.sendDataMessagefor outbound; aDATA_SMS_RECEIVEDfilter onSmsReceiverfor inbound (payload forwarded base64 + port). - MMS — best-effort
SmsManager.sendMultimediaMessagewith an M-Send.req PDU composed byMmsPduBuilder.kt(shared with the platform via aFileProvider);MmsReceiverreports inbound MMS notifications (WAP push). Caveats: real MMS delivery depends on the carrier MMSC/APN, and fetching a full inbound MMS body + attachments is a separate MMSC download a non-default SMS app can't reliably do, so inbound MMS is reported as a notification (sender + subject) without attachments. - Incoming/outgoing calls —
CallReceiverwatchesPHONE_STATEand reports ringing/answered/missed/completed to the server's call log.NEW_OUTGOING_CALLis deprecated on Android 10+ and only fires for the default dialer, so outgoing calls are logged primarily via the message pipeline the gateway itself uses. - End-to-end encryption — enter a passphrase at login;
crypto_service.dart(AES-256-GCM + PBKDF2, matching the Web App) decrypts outbound messages before they hit the radio and encrypts inbound before forwarding them.
New runtime permissions to grant on device: RECEIVE_MMS/RECEIVE_WAP_PUSH, READ_CALL_LOG, and (for the call state) READ_PHONE_STATE. These are declared in the manifest; grant them alongside SMS/phone on first run.
On dual-SIM devices the app enumerates the active SIMs (needs READ_PHONE_STATE,
covered by the phone permission group) and reports them to the server on each
heartbeat, so the Web App / API can offer real slot choices.
- Sending on a specific SIM: the pulled message's
sim_number(0-based slot) selects the SIM. If that slot has no active subscription, the send is rejected and reportedFailedrather than silently using the default SIM. A message with nosim_numberuses the device's default SIM. - Incoming SMS: the receiver records which slot a message arrived on and
forwards it as
sim_slotto the inbox. - Calling from a specific SIM: a call message's
sim_numberselects the calling account handed toTelecomManager.placeCall. Telephony accounts are keyed by subscription id, so the slot is resolved to a subscription exactly as a send does. Unlike a send, an unresolvable slot is not fatal: the call still goes out on the phone's default account. Reaching somebody matters more than the SIM it left on, and a call carries no per-message cost surprise.
- Survive full task removal / long Doze: the foreground service covers
screen-lock, but surviving the user swiping the app away or hours of Doze would
need a dedicated background Dart isolate (e.g.
flutter_background_service). - Push wake-up (FCM): register an FCM token at registration so the server can
wake the device instead of polling.
registerDevicealready accepts apush_token, but nothing populates it. Requires a Firebase project +google-services.json+ server-side FCM sending in the API Server. - Per-recipient delivery state: a message with several recipients is one server-side record, so all of its send/delivery callbacks report against the same id and the last one wins. Mixed outcomes across recipients are therefore not represented faithfully.
This folder is mirrored to its own public repo,
gsmnode/phone-agent, so it can be
cloned and released on its own. The monorepo stays the source of truth;
publishing it is PUBLISHING.md.
Apache License 2.0 — see NOTICE for the marks, which are not covered by that grant.