Public screens
3 screens + auth gate Splash · Login · HomeCompact Flutter architecture with automatic Auth Gate state switching.
Android foreground data-sync service and WorkManager queue durability.
Direct cellular carrier delivery without costly third-party VoIP charges.
Hardware-isolated device bearer token stored in encrypted keystore storage.
MonoSMS. Gateway app documentation.
Self-hosted SMS delivery on your own SIM.
A complete operations guide for the sms_gateway Flutter project: the device-side companion that turns one Android phone into the MONO Marketplace SMS sender — pairing through an administrator account, claiming the backend's queued messages, sending them over the phone's own SIM, and forwarding inbound replies.
Application identity
com.mono.sms_gateway 1.0.0+1API default
monomarket.site / api / v1 --dart-defineThe gateway in one paragraph
sms_gateway is a single-purpose Flutter application, declared in pubspec.yaml as “Self-hosted SMS gateway: sends queued messages from the device's own SIM.” It runs on one Android phone that a store administrator owns. The app signs in with admin-panel credentials, exchanges them for a narrow device token, and from then on performs exactly three jobs: report its health to the backend, claim queued SMS messages and send them over the SIM, and forward inbound SMS back to the backend. Every business decision — recipient, body, timing, retry limits — belongs to the backend; the device only dials.
| Detail | Value | Source |
|---|---|---|
| Dart package | sms_gateway | pubspec.yaml |
| Version | 1.0.0+1 | pubspec.yaml |
| Description | Self-hosted SMS gateway: sends queued messages from the device's own SIM | pubspec.yaml |
| App label (launcher) | MonoSMS | AndroidManifest.xml |
| Android application ID | com.mono.sms_gateway | android/app/build.gradle.kts |
| API base URL | String.fromEnvironment('GATEWAY_API_URL'), default https://monomarket.site/api/v1 | lib/core/gateway_config.dart |
| Routes | /login, /home inside one Navigator; an auth gate picks between them | lib/main.dart |
How it fits the marketplace
The marketplace backend owns two SMS providers: a hosted Twilio provider and this self-hosted gateway provider. When the gateway is the active provider, every outgoing message — order and OTP notifications, admin test sends — is written to the sms_messages queue instead of an HTTP call to a third party. This app is the worker that drains that queue.
Pairing
An administrator signs in with panel credentials. The backend verifies the sms.gateway.manage permission, then the app exchanges the admin token for a long-lived device token. Nothing on the phone is user-configurable.
Sending
FCM data pushes are only a wake signal. The device claims a batch, sends each message through Android's SMS manager, and reports sent or failed per message. A 5-minute claim timeout returns silent claims to the queue.
Reliability
Layered recovery: manifest-registered receivers, a data-sync foreground service, WorkManager queue passes, a 15-minute fallback poll, and a scheduled backend recovery command. Missed pushes never lose messages.
A deliberately small source tree
The app is around 2,800 lines of Dart plus a focused Kotlin layer. lib/ is organized by technical layer; there are no feature folders because there is one feature.
sms_gateway/
├─ pubspec.yaml dependencies, launcher icons, assets
├─ pubspec.lock
├─ analysis_options.yaml flutter_lints
├─ assets/
│ └─ icons/ app_icon.png, adaptive foreground, logo.svg
├─ lib/
│ ├─ main.dart bootstrap, Firebase init, auth gate, routes
│ ├─ core/
│ │ ├─ gateway_config.dart compiled-in API base URL
│ │ └─ app_logger.dart ring-buffer logger + payload redaction
│ ├─ providers/
│ │ └─ gateway_provider.dart the whole device state machine (461 lines)
│ ├─ screens/
│ │ ├─ splash_screen.dart neutral boot screen
│ │ ├─ login_screen.dart admin sign-in form
│ │ └─ home_screen.dart dashboard: status, queue, service, log
│ ├─ services/
│ │ ├─ api_client.dart device API (Dio) + token storage
│ │ ├─ admin_session.dart admin sign-in + device registration
│ │ ├─ device_bridge.dart single MethodChannel to Kotlin
│ │ ├─ device_identity.dart SIM-accurate device name and id
│ │ └─ fcm_service.dart Firebase wake listeners
│ ├─ theme/
│ │ └─ app_theme.dart Material 3 dark theme
│ └─ widgets/ AppCard, PrimaryButton
└─ android/
└─ app/src/main/kotlin/com/mono/sms_gateway/
├─ MainActivity.kt Flutter entry, hosts the channel handler
├─ GatewayNative.kt MethodChannel: send, SIM, service control
├─ GatewayService.kt data-sync foreground service
├─ GatewaySync.kt authenticated queue pass (native)
├─ GatewaySyncWorker.kt WorkManager wrapper for background passes
├─ GatewayMessagingService.kt FCM entry, schedules native sync
├─ SmsReceiver.kt manifest inbound-SMS receiver
├─ SmsResultReceiver.kt per-message send result callback
├─ BootReceiver.kt re-arms after reboot / app update
├─ RestartReceiver.kt restart when the app is swiped away
└─ GatewayEvents.kt shared event plumbing
| Layer | Responsibility | Representative files |
|---|---|---|
| Core | Compiled configuration and the shared logger with redaction | gateway_config.dart, app_logger.dart |
| Providers | One ChangeNotifier owning status, permissions, timers, counters, and the on-screen log | gateway_provider.dart |
| Screens | Three public *Screen classes; dashboard cards are internal widgets | home_screen.dart, login_screen.dart |
| Services | HTTP, pairing, the Kotlin bridge, device identity, Firebase wake | api_client.dart, device_bridge.dart, fcm_service.dart |
| Android (Kotlin) | SIM sending, foreground service, WorkManager, receivers — no business logic | GatewayNative.kt, GatewayService.kt, SmsReceiver.kt |
Dart never touches Android APIs directly. Every platform action goes through one MethodChannel, com.mono.sms_gateway/device, and the Kotlin side performs only the action it is asked for — it holds no queue state and makes no decisions.
Three screens, one job
The class count comes from public classes named *Screen in lib/screens/. Dashboard cards, pills, and dialogs are internal widgets of home_screen.dart, not additional screens.
| Screen | Route | Purpose |
|---|---|---|
SplashScreen | — (auth gate) | Neutral boot view while the stored device token is restored, so a paired phone never flashes the login form on restart. |
LoginScreen | /login | Admin e-mail and password. The device name and id are derived automatically — there is nothing else to type. |
HomeScreen | /home | Operations dashboard: status hero, queue counters, background-service control, the rolling activity log, and a diagnostics card. |
Dashboard cards on the home screen
| Card | What it shows and does |
|---|---|
| Hero status | Online / Not ready / Offline pill, device name, SIM state, carrier, battery, last-sync clock, and a Retry button. |
| Queue | Pending, sent, and failed counters from the backend plus a manual Check queue now button. |
| Background | Foreground-service toggle, battery-optimization exemption state with a shortcut into Android settings. |
| Log | Last 100 activity entries; recipients and bodies are never printed. |
| Diagnostics | Exportable diagnostics report from the 300-entry logger ring buffer. |
App-bar menu
The overflow menu exposes the three operator actions: Re-scan inbox (recovery pass for inbound SMS the broadcast receiver missed), Battery settings (deep-link into Android's battery-optimization screen — required on Xiaomi/Oppo/Vivo/Samsung), and Sign out (stops the service, forgets every stored credential, and returns to login).
Connection states
GatewayStatus | Meaning | Shown when |
|---|---|---|
notConfigured | No device token stored | Before first successful pairing; the auth gate shows login |
notReady | Paired but Android permissions missing | SMS or Phone permission denied — named in the status message |
offline | Paired but the backend is unreachable | Status/heartbeat/claim request failed |
online | Working | Last status, heartbeat, or send pass succeeded |
Toolchain and device requirements
| Requirement | Value | Notes |
|---|---|---|
| Flutter SDK | Dart ^3.13.4 | pubspec.yaml environment |
| Android compile SDK | 37 | android/app/build.gradle.kts |
| Android target SDK | 36 | Pinned deliberately |
| Android min SDK | maxOf(flutter.minSdkVersion, 23) | Runtime permissions need 23+ |
| Device hardware | android.hardware.telephony required | Declared in the manifest; tablets without radios cannot install |
| SIM | Active, SMS-capable | Carrier charges apply per sent message — this is real SMS, not VoIP |
| Firebase project | google-services.json | For the FCM wake channel only; the app runs without it, on timers alone |
| Backend account | Admin with sms.gateway.manage | The backend refuses to register a device for an account without it |
Before the first run
- Android Studio with the SDK platforms for API 36/37, or a plain Flutter toolchain.
- A physical Android phone — emulators cannot send SMS over a SIM.
- The backend reachable from the phone's network, and an administrator account with the SMS Gateway permission.
- Your Firebase
google-services.jsondropped intoandroid/app/if you want push wake-ups.
Complete Installation & Deployment Guide
Follow this step-by-step operational guide to prepare an Android phone, compile the Flutter application with your backend endpoint, configure critical battery exemptions, and register the device as the active cellular SMS gateway for the MONO Marketplace.
| Requirement | Specification | Why it is needed |
|---|---|---|
| Flutter SDK | Flutter 3.47+ / Dart ^3.13.0 | Compiles the cross-platform Dart application and native Android artifacts. |
| Android SDK | Compile SDK 37 / Target SDK 36 | Required for Android telephony runtime permissions and background services. |
| Physical Device | Android phone (OS 8.0+ / API 23+) | Emulators and iOS devices do not have cellular telephony radio hardware to send SMS. |
| SIM Card | Active SIM with SMS plan | Real carrier SMS delivery. Ensure sufficient SMS balance or carrier package. |
| Marketplace Admin | Admin with sms.gateway.manage | Required during initial one-time pairing to issue a scoped device bearer token. |
| FCM Project | google-services.json (Optional) | Provides instant real-time wake signals; app still operates via polling without it. |
-
Physical Phone Preparation & Cellular SIM Verification
Insert your active SIM card into the Android phone. Before running any software, verify that the device is operating properly on the mobile network:
- Disable Airplane mode and check that cellular signal bars are active with your carrier name displayed.
- Open the phone's native Messages app and manually send a standard SMS to another number to confirm outbound delivery and active SMS balance.
- Dual-SIM Devices: If the phone has two SIM slots, go to Settings → Network & Internet → SIMs and set your desired sending SIM as the Default SIM for SMS. Android's
SmsManagerwill dispatch messages over this primary subscription.
-
Enable Developer Options & USB Debugging
To install and debug the application from your development workstation, enable developer access on the phone:
- Open Android Settings → About Phone.
- Locate Build Number and tap it repeatedly 7 times until you see the prompt: "You are now a developer!"
- Go back to Settings → System → Developer Options (or Additional Settings on Xiaomi/Oppo).
- Enable USB Debugging. On Xiaomi (MIUI/HyperOS) devices, also enable Install via USB and USB debugging (Security settings).
- Connect the phone to your computer with a USB cable. On the phone screen, check "Always allow from this computer" and tap Allow.
- Verify device detection in your workstation terminal:
Terminal / ADB checkadb devices # Output should list your device attached: # List of devices attached # 84B01248000183 device -
Disable Android Battery Optimization (Doze Mode Whitelist)
CRITICAL REQUIREMENT: Android aggressive power management will freeze background workers and kill foreground services if battery optimization is active. You must exempt MonoSMS from battery restrictions so it can continuously monitor incoming SMS and wake up for queue passes.
Xiaomi / Redmi / POCO (MIUI & HyperOS)
1. Open Settings → Apps → Manage Apps → MonoSMS.
2. Tap Autostart → Toggle ON.
3. Tap Battery Saver → Select No restrictions.
4. Open Recent Apps → Long press MonoSMS card → Tap the Lock icon.Samsung Galaxy (One UI)
1. Open Settings → Apps → MonoSMS → Battery.
2. Change selection from Optimized to Unrestricted.
3. Go to Settings → Battery & device care → Battery → Background usage limits → Add MonoSMS to Never sleeping apps.Oppo / Realme / OnePlus (ColorOS / OxygenOS)
1. Open App Info → Battery Usage.
2. Enable Allow background activity and Allow auto-launch.
3. Pin the app in the recent tasks overview.In-App Shortcut
The MonoSMS dashboard provides a direct deep link into the system battery optimization screen via the Battery settings menu item in the top overflow menu.
-
Clone Repository & Configure Backend Endpoint
Clone or navigate to the
sms_gatewayproject directory and install dependencies:Workspace setupcd /path/to/mono/sms_gateway flutter pub getSpecify the target marketplace API endpoint using the compiled constant
GATEWAY_API_URL:- Via Build Flag: Pass
--dart-define=GATEWAY_API_URL=https://your-domain.com/api/v1at runtime or compilation. - Via Source File: Alternatively, edit
lib/core/gateway_config.dartdirectly to change the fallback default URL:
lib/core/gateway_config.dartclass GatewayApiConfig { static const String baseUrl = String.fromEnvironment( 'GATEWAY_API_URL', defaultValue: 'https://your-domain.com/api/v1', // <-- Set your backend URL here ); }Local Network / LAN Testing: When deploying or testing against your backend server (e.g.
https://example.com/api/v1), the Android Manifest already includesandroid:usesCleartextTraffic="true", allowing unencrypted HTTP connections over Wi-Fi without SSL handshakes. - Via Build Flag: Pass
-
Set Up Firebase Cloud Messaging (FCM) Wake Channel (Optional but Recommended)
FCM provides instantaneous wake signals to the phone whenever the marketplace puts a new SMS into the queue. The payload carries only a wake trigger (
type: sms_queue_available); the message content and recipient are pulled securely from the database queue.- Go to Firebase Console and open or create your project.
- Add an Android App with package name
com.mono.sms_gateway. - Download
google-services.jsonand place it insidesms_gateway/android/app/google-services.json. - In your marketplace backend admin settings (Admin → Settings → SMS Settings), ensure your Firebase Server Key / Credentials are configured.
Works Without Firebase: If you do not configure Firebase, MonoSMS automatically runs its built-in 15-minute fallback poll and the server-side scheduled recovery worker (
sms-gateway:recover), so SMS processing continues without push notifications. -
Build and Install the Application
Deploy MonoSMS directly onto the connected Android device:
Development debug run# Run directly on the attached phone flutter run --dart-define=GATEWAY_API_URL=https://your-domain.com/api/v1To compile a standalone Release APK for production deployment:
Release APK build# Build standalone release APK flutter build apk --release --dart-define=GATEWAY_API_URL=https://your-domain.com/api/v1 # Install the built APK onto the phone via ADB adb install -r build/app/outputs/flutter-apk/app-release.apkYou can also copy the resulting
app-release.apkto phone storage via USB cable or download it from your server browser, then tap to install. -
Grant Android Telephony & Notification Permissions
When the app opens for the first time, Android will prompt for necessary telephony and service permissions:
Permission Dialog Setting Operational Purpose Send and view SMS messages? Allow Enables SmsManagerto transmit outgoing queue messages over the cellular SIM.Make and manage phone calls? Allow Enables READ_PHONE_STATEto monitor carrier name, SIM status, and signal strength.Send notifications? (Android 13+) Allow Keeps the ongoing foreground service notification visible in the status bar. If any permission was accidentally dismissed, open Settings → Apps → MonoSMS → Permissions and ensure both SMS and Phone are set to Allowed.
-
Admin Sign-In & Device Pairing Handshake
Upon launch, the application's Auth Gate detects that no device token is stored and displays the Sign In screen:
- Enter your marketplace Admin Email and Password (the user must have the
sms.gateway.managepermission). - The hardware identity (Manufacturer and Model, e.g. Samsung Galaxy S22) is automatically derived from the device.
- Tap Sign In & Register Device.
What Happens Behind the Scenes:
Pairing authentication sequence1. Phone → POST /admin/auth/login (email, password, deviceName, deviceId) 2. Backend validates admin credentials & sms.gateway.manage permission 3. Phone → POST /admin/auth/register-device (bearer admin token) 4. Backend creates SmsGatewayDevice record and issues a scoped deviceToken 5. Phone stores deviceToken in encrypted hardware storage (FlutterSecureStorage) 6. Phone discards admin password and session from active memory 7. Dashboard status immediately updates to ONLINE with green pulse indicator - Enter your marketplace Admin Email and Password (the user must have the
-
Verify in Admin Panel & Send a Live Test SMS
Confirm that the phone is actively connected and test delivery through your carrier SIM:
- In your web browser, navigate to the Marketplace Admin Panel → Admin → SMS Gateway.
- Verify that your phone appears in the devices list with its correct device name, carrier, battery percentage, and a live green Connected indicator.
- Ensure the device status toggle is set to Active.
- Navigate to Admin → Settings → SMS Settings.
- Under SMS Provider, select Self-Hosted Gateway and save.
- Locate the Send Test SMS card, enter your personal mobile phone number, type a message (e.g. "Hello from Mono SMS Gateway!"), and click Send Test SMS.
- Within seconds, watch the phone: the ongoing foreground service will receive the wake signal, claim the message, send it through the SIM card, and report
sentback to the server.
Direct dependencies, exactly as declared
Every dependency is pinned in pubspec.yaml; pubspec.lock freezes the resolved versions. The list is intentionally short.
| Package | Constraint | Why it is here |
|---|---|---|
provider | ^6.1.5+1 | State management — one GatewayProvider drives everything |
dio | ^5.11.1 | HTTP client with interceptors for the bearer token and redacted logging |
firebase_core | ^4.15.0 | Firebase bootstrap for the wake channel |
firebase_messaging | ^16.7.0 | FCM wake signal and token rotation only — never message content |
flutter_secure_storage | ^11.2.0 | Device and admin tokens at rest |
shared_preferences | ^2.5.5 | Non-secret fallback: the base URL for the native layer |
permission_handler | ^12.0.3 | SMS, phone, and notification runtime permissions |
device_info_plus | ^13.2.0 | Derives the device name/id shown in Admin → SMS Gateway |
flutter_svg | ^2.3.0 | Renders the shared MONO brand artwork |
logger / package_info_plus | ^2.8.0 / ^10.2.1 | Console output and the app version reported in heartbeats |
| Dev dependencies | Constraint | Purpose |
|---|---|---|
flutter_lints | ^6.0.0 | Recommended lint set via analysis_options.yaml |
flutter_launcher_icons | ^0.14.4 | Generates launcher icons from assets/icons/app_icon.png, black adaptive background |
One compile-time constant, nothing else
There is no settings screen for the server and no .env file. The base URL is compiled into the binary:
lib/core/gateway_config.dart
──────────────────────────────
static const String baseUrl = String.fromEnvironment(
'GATEWAY_API_URL',
defaultValue: 'https://monomarket.site/api/v1',
);
| Task | Command / source |
|---|---|
| Point a build at another environment | flutter build apk --dart-define=GATEWAY_API_URL=https://store.example.com/api/v1 |
| Default environment | The compiled-in default (monomarket.site) is used when the define is absent |
| Native credential fallback | The same URL is mirrored into SharedPreferences under base_url — non-secret, kept for native upgrades |
| Transport security | android:usesCleartextTraffic="true" — HTTP is allowed so a LAN backend can be tested; see the security section before shipping |
A gateway phone is configured only by signing in. The address is compiled in rather than typed, so an operator cannot be tricked into pointing the device at a hostile server — whoever built the APK chose the endpoint.
Theme & Brand Customization Guide
The MonoSMS gateway interface is built on Flutter's Material 3 styling engine. All colors, surfaces, borders, and component themes are centralized in lib/theme/app_theme.dart. Use this guide to rebrand the app, customize the color palette, switch between light and dark themes, update the launcher icon, and change application branding.
Theme Architecture Overview
The styling system separates color tokens from widget styling. AppColors defines the raw color constants, and buildAppTheme() maps them into Flutter's ThemeData:
class AppColors {
static const primary = Color(0xFF111111); // Primary brand accent & button fills
static const accent = Color(0xFF059669); // Connection state & active switches (Emerald)
static const background = Color(0xFFF7F7F8); // Scaffold background
static const surface = Color(0xFFFFFFFF); // Card backgrounds, dialogs, inputs
static const surfaceAlt = Color(0xFFF4F4F5); // Subtle secondary containers
static const border = Color(0xFFE4E4E7); // Card borders, input outlines, dividers
static const textDark = Color(0xFF18181B); // Headings, primary text
static const textMedium = Color(0xFF52525B); // Body text, secondary labels
static const textLight = Color(0xFFA1A1AA); // Placeholder hints, faint timestamps
static const danger = Color(0xFFDC2626); // Errors, disconnects, failures
static const warning = Color(0xFFB45309); // Retry notices, battery warnings
}
#111111
#059669
#F7F7F8
#FFFFFF
#E4E4E7
#18181B
#DC2626
#B45309
Ready-to-Use Theme Presets
To switch themes, replace the constants in AppColors in lib/theme/app_theme.dart with any of the curated presets below:
Preset 1: Mono Marketplace Navy Theme
Matches the official MONO Marketplace enterprise brand colors (Deep Navy #18284D with Emerald #059669 active accents):
class AppColors {
static const primary = Color(0xFF18284D); // MONO Deep Navy
static const accent = Color(0xFF059669); // Emerald green
static const background = Color(0xFFF8FAFC); // Clean light slate
static const surface = Color(0xFFFFFFFF); // Pure white cards
static const surfaceAlt = Color(0xFFF1F5F9); // Subtle slate
static const border = Color(0xFFE2E8F0); // Clean hairline borders
static const textDark = Color(0xFF0F172A); // Deep slate heading
static const textMedium = Color(0xFF334155); // Slate body
static const textLight = Color(0xFF64748B); // Slate muted
static const danger = Color(0xFFDC2626);
static const warning = Color(0xFFD97706);
}
Preset 2: Dark AMOLED Console Theme
Designed for dedicated wall-mounted or charging gateway phones requiring an OLED-friendly dark operations interface:
class AppColors {
static const primary = Color(0xFF38BDF8); // Vivid sky blue
static const accent = Color(0xFF10B981); // Neon emerald
static const background = Color(0xFF090D16); // Deep AMOLED night
static const surface = Color(0xFF111827); // Dark slate cards
static const surfaceAlt = Color(0xFF1F2937); // Raised container
static const border = Color(0xFF374151); // Dark hairline border
static const textDark = Color(0xFFF9FAFB); // High-contrast white
static const textMedium = Color(0xFF9CA3AF); // Soft gray body
static const textLight = Color(0xFF6B7280); // Dark muted
static const danger = Color(0xFFEF4444);
static const warning = Color(0xFFF59E0B);
}
Customizing Component Styles in buildAppTheme()
You can adjust border curves, typography, input fields, and button shapes by modifying the ThemeData object in lib/theme/app_theme.dart:
| Component Property | Code Snippet | Visual Effect |
|---|---|---|
| Card & Input Corner Radius | const radius = 12.0; |
Changes border roundness across text inputs, cards, and dialogs. |
| Input Focus Border | borderSide: BorderSide(color: AppColors.primary, width: 2.0) |
Thickens the highlight border when focusing on email/password fields. |
| AppBar Title Font | titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.bold) |
Controls the top navigation bar title weight and size. |
| Active Switch Color | if (states.contains(WidgetState.selected)) return AppColors.accent; |
Controls the toggle color when the foreground service is active. |
Updating the App Launcher Icon
The application icon is automatically generated for Android adaptive launcher icons using flutter_launcher_icons:
- Prepare your new square icon graphic (recommended dimensions: 1024 × 1024 px PNG).
- Save the main icon to
sms_gateway/assets/icons/app_icon.png. - Optionally, save the transparent foreground layer to
sms_gateway/assets/icons/app_icon_foreground.png. - Review the configuration in
pubspec.yaml:
flutter_launcher_icons:
android: "ic_launcher"
ios: false
image_path: "assets/icons/app_icon.png"
adaptive_icon_background: "#18284D" # Set your brand background color
adaptive_icon_foreground: "assets/icons/app_icon_foreground.png"
Run the generator command in your terminal to build all Android mipmap resolutions:
dart run flutter_launcher_icons
Changing App Name & Launcher Label
To rebrand the name shown under the app icon on the phone home screen and in Android settings:
- Android Launcher Label: Open
sms_gateway/android/app/src/main/AndroidManifest.xmland update:android/app/src/main/AndroidManifest.xml<application android:label="YourBrand SMS" android:icon="@mipmap/ic_launcher"> - Flutter App Window Title: Open
sms_gateway/lib/main.dartand update:lib/main.dartreturn MaterialApp( title: 'YourBrand SMS', // <-- Change from 'MonoSMS' debugShowCheckedModeBanner: false, theme: buildAppTheme(), ... );
Build commands and release notes
# debug run on a connected phone
flutter run --dart-define=GATEWAY_API_URL=https://your-store.com/api/v1
# release APK (the gateway ships as a sideloaded APK, not Play Store)
flutter build apk --release --dart-define=GATEWAY_API_URL=https://your-store.com/api/v1
# regenerate launcher icons after changing assets/icons/
dart run flutter_launcher_icons
| Item | State in source |
|---|---|
| Signing | Debug signing by default — add your own keystore to android/app/build.gradle.kts before distributing |
| Versioning | 1.0.0+1 from pubspec.yaml; reported to the backend in heartbeats and device registration |
| Distribution model | Direct APK install on the gateway phone; there is nothing store-specific in the source |
| iOS | No iOS runner is configured; the manifest-level features (SMS receivers, foreground service) are Android-only |
One phone, one build. Because the API URL is compiled in, build a dedicated APK per environment (staging, production) rather than reconfiguring devices by hand.
Pairing: from admin credentials to a device token
Paring is the only privileged moment in the device's life. It uses the administrator's account once, then immediately narrows down to a device-scoped credential.
| Step | Call | What happens |
|---|---|---|
| 1 · Sign in | POST /admin/auth/login with email, password, deviceName, deviceId | Backend validates the credentials and the sms.gateway.manage permission; returns a Sanctum admin token. |
| 2 · Register device | POST /admin/auth/register-device with name, deviceId, appVersion, bearer admin token | Backend creates or refreshes an SmsGatewayDevice row and issues a deviceToken. |
| 3 · Keep the narrow credential | — (local) | The app stores the device token in secure storage, mirrors it into the native encrypted store, and discards the admin session from active use (kept only for logout). |
Device identity is automatic
DeviceIdentity derives both values from the hardware: on Android the id is <androidId>-<model> and the name is a de-duplicated <manufacturer> <model> (so stock devices read “Pixel 8”, not “Google Pixel 8”). On iOS it uses identifierForVendor. If identity is unavailable the app still works, reporting unknown-device / “SMS Gateway”.
Signing out
signOutCleanup() cancels both timers, disposes FCM listeners, stops the foreground service, deletes the device token (secure storage) and native credentials, and returns the app to notConfigured. The backend-side device row survives so the admin can revoke or re-issue its token from the panel.
The device API surface
After pairing, every call carries the device token as a bearer token, enforced by the backend's AuthenticateSmsGateway middleware — an admin token or customer token is rejected on these routes. Responses use the platform envelope { success, data, error }; GatewayResult.fromBody parses exactly that shape.
| Endpoint | Purpose | Payload → response highlights |
|---|---|---|
GET /sms-gateway/status | Dashboard counters | Returns pending, sent, failed for this device. |
POST /sms-gateway/heartbeat | Health + identity refresh | Sends appVersion, simNumber, carrier, simState, batteryLevel, and the current fcmToken so the backend always wakes the right phone. |
POST /sms-gateway/sms/claim | Fetch a send batch | limit 1–25 (app asks for 10). Transactional: reclaims timed-out claims, then locks pending rows with lockForUpdate. Returns the claimed messages. |
POST /sms-gateway/sms/{id}/report | Report one result | status: sent|failed plus an optional human-readable error. Throttled to 60/min. |
POST /sms-gateway/sms/incoming | Forward inbound SMS | messages[] with uid, from, body, receivedAt, optional SIM fields. The backend drops duplicates by uid and reports how many it ignored. |
| Rate limits (backend) | Per minute |
|---|---|
sms/claim and sms/incoming | 30 |
sms/{id}/report | 60 |
status, heartbeat | Unthrottled beyond the global API limiter |
Client behavior details
- Token handling: the token is read from secure storage once at startup;
init()never re-reads over a freshly signed-in token. - Error surfacing: Dio errors are translated into human messages — 401 becomes “The device token was rejected. Sign in again.”, a missing connection becomes “Could not reach the server.”
- Log redaction: the request interceptor logs method and path, and passes bodies through
AppLogger.redact, which masks sensitive keys with***— a queued message can carry a one-time code. - Failure isolation: every call returns a
GatewayResultrather than throwing; UI state never depends on exceptions.
The send pipeline: queue is the source of truth
FCM pushes are wake signals only — their data never contains an SMS body or recipient. Delivery is pulled, always, from the queue:
Backend Device (MonoSMS) Android
──────────────────────── ───────────────────────────── ──────────────
Order placed / OTP due
└─ sms_messages row (pending)
└─ FCM push {type: sms_queue_available} ──▶ wake
├─ foreground: DeviceBridge.runNativeSync()
└─ background: GatewayMessagingService → WorkManager
POST /sms-gateway/sms/claim ◀────────────── authenticated queue pass
reclaim claims older than 5 min
lockForUpdate pending rows (≤ limit)
mark processing, stamp claimed_at
messages[{id, to, body, kind}] ──────────────▶ for each: SmsManager send
│ SmsResultReceiver
POST /sms-gateway/sms/{id}/report ◀────────── sent | failed + error
status = sent / failed
Triggers, in order of importance
| Trigger | Mechanism | Notes |
|---|---|---|
| FCM push | sms_queue_available data message | Foreground listener and a native messaging service that schedules WorkManager — valid even while the UI is backgrounded |
| Manual | Check queue now / pull-to-refresh | Calls fetchAndSend() directly |
| Fallback poll | Every 15 minutes | Recovery net for a missed or delayed push; deliberately long |
| Backend recovery | sms-gateway:recover on the scheduler | Reclaims timed-out claims, parks retry-exhausted messages, nudges up to 20 pending rows with a fresh push |
Queue guarantees
- Single claimer: the claim transaction locks rows and stamps
claimed_at, so two gateway devices can never send the same message. - No silent loss: a claim that never reports back is returned to
pendingafter 5 minutes (CLAIM_TIMEOUT_MINUTES) and re-offered. - Bounded retries: each row carries
attemptsandmax_attempts; a message that exhausts them is parked asfailedwith “Retry limit reached.” instead of looping forever. - Honest accounting: the send result comes from Android's
SmsResultReceiver, not from the absence of an exception; failures report the carrier's reason string.
Timers while the app is alive
Once paired, the provider runs a 5-minute heartbeat (which also re-uploads the FCM token after rotation) and the 15-minute fallback poll. Both are cancelled on sign-out. The foreground service takes over durability when the app is backgrounded or swiped away.
Inbound SMS: capture, forward, deduplicate
Inbound messages (customer replies, carrier notices) flow the opposite way — from Android into the backend's sms_inbound_messages table.
| Stage | Component | Behavior |
|---|---|---|
| Capture | SmsReceiver.kt | Manifest-registered for Telephony.SMS_RECEIVED at priority 999 with the BROADCAST_SMS guard — runs even when the app is closed or the phone is locked. This is the primary path. |
| Handoff | MethodChannel event | The receiver pushes an IncomingSms (uid, from, body, receivedAt, SIM fields) up to Dart via onIncomingSms. |
| Forward | POST /sms-gateway/sms/incoming | One message per call; the uid lets the backend drop duplicates — the same message delivered twice is safe. |
| Recovery | Inbox scan | The foreground service observes the inbox; Re-scan inbox re-offsers anything the broadcast missed while the process was frozen. |
Two capture paths, one truth. The broadcast receiver is primary; the inbox scan exists only because some OEMs freeze apps and swallow broadcasts. Both feed the same deduplicating endpoint.
Android permissions and platform components
The Kotlin layer exists to keep the gateway alive and to dial the radio. Everything is registered in the manifest so it works without the UI.
| Permission | Why | Runtime? |
|---|---|---|
SEND_SMS | Sending queued messages over the SIM | Yes — requested at startup, gate to online |
RECEIVE_SMS | Inbound capture | Yes |
READ_SMS | Inbox recovery pass | Yes |
READ_PHONE_STATE | SIM number/carrier for the admin dashboard | Yes |
POST_NOTIFICATIONS | Foreground-service notification visibility | Yes (13+); denied only hides status |
FOREGROUND_SERVICE + _DATA_SYNC | Keeps the process and inbox observer alive | No |
RECEIVE_BOOT_COMPLETED | Re-arm after reboot or app update | No |
INTERNET, ACCESS_NETWORK_STATE | API access | No |
Manifest components
| Component | Registered for | Role |
|---|---|---|
SmsReceiver | Telephony.SMS_RECEIVED, priority 999 | Primary inbound capture, guarded by BROADCAST_SMS |
GatewayService | foregroundServiceType dataSync | Keeps the process alive; its notification mirrors live dashboard numbers (status, counts, SIM, battery, last sync) |
GatewayMessagingService | com.google.firebase.MESSAGING_EVENT | Replaces FlutterFire's service (tools:node="remove"): keeps Dart delivery and schedules the native worker |
GatewaySyncWorker | WorkManager | Durable authenticated queue pass that survives process death — this is what runs the pipeline when the app is backgrounded |
BootReceiver | BOOT_COMPLETED, MY_PACKAGE_REPLACED, QUICKBOOT_POWERON | Re-arms the gateway after a reboot or update |
RestartReceiver | internal | Restarts the service when the app is swiped away |
SmsResultReceiver | internal | Per-message delivery result feeding honest sent/failed reports |
Battery optimization
Android may freeze the gateway on aggressive OEM skins. The dashboard reads the exemption state and offers a one-tap deep link into the battery settings screen (openBatterySettings). Exempting the app is expected procedure on Xiaomi, Oppo, Vivo, and Samsung.
Firebase is a doorbell, not a courier
Push payloads carry a single datum: type: sms_queue_available. No SMS body, no recipient, ever. If Firebase is unavailable the app still works — the fallback poll and backend recovery nudge cover the difference.
| Aspect | Behavior |
|---|---|
| Foreground | FcmService.listen installs one onMessage listener that calls the native queue pass on sms_queue_available |
| Background | GatewayMessagingService (a FlutterFire subclass) records delivery and schedules GatewaySyncWorker — the authenticated pass runs natively, no Dart MethodChannel needed |
| Token rotation | onTokenRefresh triggers an immediate heartbeat, which carries the new token to the backend |
| First upload | init() heartbeats right after pairing, so the backend can wake the device before the first 5-minute tick |
| Failure mode | Firebase init failures are logged and swallowed; wake-ups degrade to timers, nothing crashes |
Setting up your own Firebase project
- Create a Firebase project and add an Android app with package
com.mono.sms_gateway. - Download
google-services.jsonintoandroid/app/. - Enable Cloud Messaging (no APNs key needed unless you also ship iOS).
- Build and pair — the first heartbeat uploads the FCM token, and the admin panel shows the device as Connected.
What the administrator sees on the other side
The marketplace admin panel has a dedicated Admin → SMS Gateway page (guarded by the sms.gateway.manage permission) that manages the devices this app creates.
| Control | Behavior |
|---|---|
| Device list | Name, app version, SIM/carrier, battery, and a live online dot — online means a heartbeat within the last 3 minutes |
| One-time token | Issuing or rotating a device token shows it exactly once, with copy-to-clipboard; the stored hash is never displayed again |
| Activate / Toggle / Revoke / Delete | One device is the active sender; toggling pauses it; revoking kills its token; deleting removes the row and its queue association |
| Device log | Per-device message history: statuses, attempts, errors, timestamps |
| Nudge | Sends an immediate sms_queue_available push to test the wake path end-to-end |
| SMS settings | /admin/settings/sms chooses the provider (self-hosted gateway vs Twilio), manages OTP settings, and sends a test SMS |
Scheduled hygiene on the server
The sms-gateway:recover command runs on the managed scheduler. Each pass reclaims claims older than five minutes, parks retry-exhausted messages as failed, and wakes the active device for up to twenty still-pending messages. Combined with the device's own timers, no message depends on a single lucky push.
Security and privacy posture
| Control | Implementation |
|---|---|
| Narrow credential | The app keeps a device token, not the admin session. It works only on /sms-gateway/* routes and is rejected everywhere else. |
| Permission gate | The backend refuses device registration for accounts without sms.gateway.manage; the app surfaces a clear message on PERMISSION_DENIED. |
| Token at rest | FlutterSecureStorage on the Dart side, mirrored into a native-owned encrypted store the Kotlin worker can read without Flutter. |
| Log hygiene | Recipients and bodies are never logged; request bodies pass through a redaction pass that masks sensitive keys with ***; only the diagnostics export is meant to leave the device. |
| Push hygiene | FCM payloads contain the queue-available type only — never message content. |
| Revocation | Revoking or rotating a token in the admin panel immediately invalidates the device's bearer token; the next call gets 401 and the app asks for re-pairing. |
Before production: the manifest sets android:usesCleartextTraffic="true" for LAN testing, and the app ships debug-signed. Point the build at an HTTPS domain, remove the cleartext flag if you do not need local HTTP, and sign with your own keystore.
Extending the gateway safely
The architecture has one rule that keeps it safe to extend: the backend decides, the device dials. New features should preserve that split.
Add a device API call
Add the method to GatewayApiClient returning a GatewayResult, and the endpoint to the backend's sms-gateway route group behind gateway.auth. Interceptors already handle the token, logging, and error mapping.
Add a platform capability
Expose it on the single com.mono.sms_gateway/device channel: a method in GatewayNative.kt, a thin wrapper in DeviceBridge, and nothing else. The Kotlin side must stay free of queue logic.
Change the wake strategy
Replace fallbackPoll or heartbeat intervals in GatewayProvider — they are constants by design. Any new trigger should still be a pull from the queue, never a push of content.
Where things live
| To change… | Edit |
|---|---|
| Server URL / environments | lib/core/gateway_config.dart + the --dart-define on your build command |
| Status rules and timers | lib/providers/gateway_provider.dart (fallbackPoll, heartbeatEvery, refreshStatus) |
| Dashboard UI | lib/screens/home_screen.dart — cards are private widgets in that file |
| Theme | lib/theme/app_theme.dart (Material 3, dark) |
| Background send pass | android/.../GatewaySync.kt + GatewaySyncWorker.kt |
Known limitations, checked against source
| Checked item | Source result |
|---|---|
| Platforms | Android only in practice: SMS receivers, the foreground service, and WorkManager are Android constructs; no iOS runner is configured |
| Web / desktop | Not viable — dart:io platform channels and telephony hardware are required (android.hardware.telephony is mandatory) |
| Multi-SIM | SIM state is reported and forwarded with inbound messages, but sending always uses the device default SMS subscription |
| Cleartext traffic | Allowed in the manifest for LAN testing; tighten before production |
| Signing | Debug keystore until you supply a release keystore |
| Firebase optional | The app degrades to timers without google-services.json — pushes only improve latency |
Troubleshooting quick reference
| Symptom | First checks |
|---|---|
| Dashboard says Not ready | Grant the named permission (SMS / Phone) in Android settings; the status message names it |
| Dashboard says Offline | Server reachable from the phone's network? Base URL compiled into this APK? Token revoked in the panel? |
| Messages stay pending | Is the device the active gateway? FCM token connected in the panel? Battery optimization exempt? Use Nudge to test the wake path |
| Service keeps dying | Exempt battery optimization (Xiaomi/Oppo/Vivo/Samsung); check that the foreground-service notification is visible |
| Inbound SMS missing | Use Re-scan inbox; check that no other SMS app holds the default-handler role |
Carrier SMS charges apply to every message this app sends. Keep the gateway phone charged, topped up, and exempt from battery optimization, and it will quietly drain the queue.