Self-Hosted Gateway Active
APP / 01 3 Screens
Splash · Login · Home

Compact Flutter architecture with automatic Auth Gate state switching.

APP / 02 SDK 36 / 37
com.mono.sms_gateway

Android foreground data-sync service and WorkManager queue durability.

APP / 03 Telephony SIM
100% On-Device SIM

Direct cellular carrier delivery without costly third-party VoIP charges.

APP / 04 Auth Handshake
Sanctum Token Gate

Hardware-isolated device bearer token stored in encrypted keystore storage.

SMS gateway documentationPair · Send · Operate

Android device

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.

A / 01

Public screens

3 screens + auth gate Splash · Login · Home
A / 02

Application identity

com.mono.sms_gateway 1.0.0+1
A / 03

API default

monomarket.site / api / v1 --dart-define

The 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.

DetailValueSource
Dart packagesms_gatewaypubspec.yaml
Version1.0.0+1pubspec.yaml
DescriptionSelf-hosted SMS gateway: sends queued messages from the device's own SIMpubspec.yaml
App label (launcher)MonoSMSAndroidManifest.xml
Android application IDcom.mono.sms_gatewayandroid/app/build.gradle.kts
API base URLString.fromEnvironment('GATEWAY_API_URL'), default https://monomarket.site/api/v1lib/core/gateway_config.dart
Routes/login, /home inside one Navigator; an auth gate picks between themlib/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
LayerResponsibilityRepresentative files
CoreCompiled configuration and the shared logger with redactiongateway_config.dart, app_logger.dart
ProvidersOne ChangeNotifier owning status, permissions, timers, counters, and the on-screen loggateway_provider.dart
ScreensThree public *Screen classes; dashboard cards are internal widgetshome_screen.dart, login_screen.dart
ServicesHTTP, pairing, the Kotlin bridge, device identity, Firebase wakeapi_client.dart, device_bridge.dart, fcm_service.dart
Android (Kotlin)SIM sending, foreground service, WorkManager, receivers — no business logicGatewayNative.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.

ScreenRoutePurpose
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/loginAdmin e-mail and password. The device name and id are derived automatically — there is nothing else to type.
HomeScreen/homeOperations dashboard: status hero, queue counters, background-service control, the rolling activity log, and a diagnostics card.

Dashboard cards on the home screen

CardWhat it shows and does
Hero statusOnline / Not ready / Offline pill, device name, SIM state, carrier, battery, last-sync clock, and a Retry button.
QueuePending, sent, and failed counters from the backend plus a manual Check queue now button.
BackgroundForeground-service toggle, battery-optimization exemption state with a shortcut into Android settings.
LogLast 100 activity entries; recipients and bodies are never printed.
DiagnosticsExportable 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

GatewayStatusMeaningShown when
notConfiguredNo device token storedBefore first successful pairing; the auth gate shows login
notReadyPaired but Android permissions missingSMS or Phone permission denied — named in the status message
offlinePaired but the backend is unreachableStatus/heartbeat/claim request failed
onlineWorkingLast status, heartbeat, or send pass succeeded

Toolchain and device requirements

RequirementValueNotes
Flutter SDKDart ^3.13.4pubspec.yaml environment
Android compile SDK37android/app/build.gradle.kts
Android target SDK36Pinned deliberately
Android min SDKmaxOf(flutter.minSdkVersion, 23)Runtime permissions need 23+
Device hardwareandroid.hardware.telephony requiredDeclared in the manifest; tablets without radios cannot install
SIMActive, SMS-capableCarrier charges apply per sent message — this is real SMS, not VoIP
Firebase projectgoogle-services.jsonFor the FCM wake channel only; the app runs without it, on timers alone
Backend accountAdmin with sms.gateway.manageThe 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.json dropped into android/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.

RequirementSpecificationWhy it is needed
Flutter SDKFlutter 3.47+ / Dart ^3.13.0Compiles the cross-platform Dart application and native Android artifacts.
Android SDKCompile SDK 37 / Target SDK 36Required for Android telephony runtime permissions and background services.
Physical DeviceAndroid phone (OS 8.0+ / API 23+)Emulators and iOS devices do not have cellular telephony radio hardware to send SMS.
SIM CardActive SIM with SMS planReal carrier SMS delivery. Ensure sufficient SMS balance or carrier package.
Marketplace AdminAdmin with sms.gateway.manageRequired during initial one-time pairing to issue a scoped device bearer token.
FCM Projectgoogle-services.json (Optional)Provides instant real-time wake signals; app still operates via polling without it.
  1. 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 SmsManager will dispatch messages over this primary subscription.
  2. 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 check
    adb devices
    # Output should list your device attached:
    # List of devices attached
    # 84B01248000183    device
  3. 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.

  4. Clone Repository & Configure Backend Endpoint

    Clone or navigate to the sms_gateway project directory and install dependencies:

    Workspace setup
    cd /path/to/mono/sms_gateway
    flutter pub get

    Specify 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/v1 at runtime or compilation.
    • Via Source File: Alternatively, edit lib/core/gateway_config.dart directly to change the fallback default URL:
    lib/core/gateway_config.dart
    class 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 includes android:usesCleartextTraffic="true", allowing unencrypted HTTP connections over Wi-Fi without SSL handshakes.

  5. 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.

    1. Go to Firebase Console and open or create your project.
    2. Add an Android App with package name com.mono.sms_gateway.
    3. Download google-services.json and place it inside sms_gateway/android/app/google-services.json.
    4. 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.

  6. 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/v1

    To 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.apk

    You can also copy the resulting app-release.apk to phone storage via USB cable or download it from your server browser, then tap to install.

  7. Grant Android Telephony & Notification Permissions

    When the app opens for the first time, Android will prompt for necessary telephony and service permissions:

    Permission DialogSettingOperational Purpose
    Send and view SMS messages?AllowEnables SmsManager to transmit outgoing queue messages over the cellular SIM.
    Make and manage phone calls?AllowEnables READ_PHONE_STATE to monitor carrier name, SIM status, and signal strength.
    Send notifications? (Android 13+)AllowKeeps 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.

  8. 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.manage permission).
    • 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 sequence
    1. 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
  9. Verify in Admin Panel & Send a Live Test SMS

    Confirm that the phone is actively connected and test delivery through your carrier SIM:

    1. In your web browser, navigate to the Marketplace Admin Panel → Admin → SMS Gateway.
    2. Verify that your phone appears in the devices list with its correct device name, carrier, battery percentage, and a live green Connected indicator.
    3. Ensure the device status toggle is set to Active.
    4. Navigate to Admin → Settings → SMS Settings.
    5. Under SMS Provider, select Self-Hosted Gateway and save.
    6. 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.
    7. 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 sent back 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.

PackageConstraintWhy it is here
provider^6.1.5+1State management — one GatewayProvider drives everything
dio^5.11.1HTTP client with interceptors for the bearer token and redacted logging
firebase_core^4.15.0Firebase bootstrap for the wake channel
firebase_messaging^16.7.0FCM wake signal and token rotation only — never message content
flutter_secure_storage^11.2.0Device and admin tokens at rest
shared_preferences^2.5.5Non-secret fallback: the base URL for the native layer
permission_handler^12.0.3SMS, phone, and notification runtime permissions
device_info_plus^13.2.0Derives the device name/id shown in Admin → SMS Gateway
flutter_svg^2.3.0Renders the shared MONO brand artwork
logger / package_info_plus^2.8.0 / ^10.2.1Console output and the app version reported in heartbeats
Dev dependenciesConstraintPurpose
flutter_lints^6.0.0Recommended lint set via analysis_options.yaml
flutter_launcher_icons^0.14.4Generates 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',
);
TaskCommand / source
Point a build at another environmentflutter build apk --dart-define=GATEWAY_API_URL=https://store.example.com/api/v1
Default environmentThe compiled-in default (monomarket.site) is used when the define is absent
Native credential fallbackThe same URL is mirrored into SharedPreferences under base_url — non-secret, kept for native upgrades
Transport securityandroid: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:

lib/theme/app_theme.dart — Core palette
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
}
Primary#111111
Accent / Live#059669
Background#F7F7F8
Surface#FFFFFF
Border#E4E4E7
Text Dark#18181B
Danger#DC2626
Warning#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):

Mono Navy Palette
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:

Dark AMOLED Palette
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 PropertyCode SnippetVisual 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:

  1. Prepare your new square icon graphic (recommended dimensions: 1024 × 1024 px PNG).
  2. Save the main icon to sms_gateway/assets/icons/app_icon.png.
  3. Optionally, save the transparent foreground layer to sms_gateway/assets/icons/app_icon_foreground.png.
  4. Review the configuration in pubspec.yaml:
pubspec.yaml — flutter_launcher_icons config
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:

Generate icons command
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.xml and 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.dart and update:
    lib/main.dart
    return MaterialApp(
      title: 'YourBrand SMS', // <-- Change from 'MonoSMS'
      debugShowCheckedModeBanner: false,
      theme: buildAppTheme(),
      ...
    );

Build commands and release notes

Build targets
# 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
ItemState in source
SigningDebug signing by default — add your own keystore to android/app/build.gradle.kts before distributing
Versioning1.0.0+1 from pubspec.yaml; reported to the backend in heartbeats and device registration
Distribution modelDirect APK install on the gateway phone; there is nothing store-specific in the source
iOSNo 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.

StepCallWhat happens
1 · Sign inPOST /admin/auth/login with email, password, deviceName, deviceIdBackend validates the credentials and the sms.gateway.manage permission; returns a Sanctum admin token.
2 · Register devicePOST /admin/auth/register-device with name, deviceId, appVersion, bearer admin tokenBackend 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.

EndpointPurposePayload → response highlights
GET /sms-gateway/statusDashboard countersReturns pending, sent, failed for this device.
POST /sms-gateway/heartbeatHealth + identity refreshSends appVersion, simNumber, carrier, simState, batteryLevel, and the current fcmToken so the backend always wakes the right phone.
POST /sms-gateway/sms/claimFetch a send batchlimit 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}/reportReport one resultstatus: sent|failed plus an optional human-readable error. Throttled to 60/min.
POST /sms-gateway/sms/incomingForward inbound SMSmessages[] 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/incoming30
sms/{id}/report60
status, heartbeatUnthrottled 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 GatewayResult rather 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

TriggerMechanismNotes
FCM pushsms_queue_available data messageForeground listener and a native messaging service that schedules WorkManager — valid even while the UI is backgrounded
ManualCheck queue now / pull-to-refreshCalls fetchAndSend() directly
Fallback pollEvery 15 minutesRecovery net for a missed or delayed push; deliberately long
Backend recoverysms-gateway:recover on the schedulerReclaims 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 pending after 5 minutes (CLAIM_TIMEOUT_MINUTES) and re-offered.
  • Bounded retries: each row carries attempts and max_attempts; a message that exhausts them is parked as failed with “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.

StageComponentBehavior
CaptureSmsReceiver.ktManifest-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.
HandoffMethodChannel eventThe receiver pushes an IncomingSms (uid, from, body, receivedAt, SIM fields) up to Dart via onIncomingSms.
ForwardPOST /sms-gateway/sms/incomingOne message per call; the uid lets the backend drop duplicates — the same message delivered twice is safe.
RecoveryInbox scanThe 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.

PermissionWhyRuntime?
SEND_SMSSending queued messages over the SIMYes — requested at startup, gate to online
RECEIVE_SMSInbound captureYes
READ_SMSInbox recovery passYes
READ_PHONE_STATESIM number/carrier for the admin dashboardYes
POST_NOTIFICATIONSForeground-service notification visibilityYes (13+); denied only hides status
FOREGROUND_SERVICE + _DATA_SYNCKeeps the process and inbox observer aliveNo
RECEIVE_BOOT_COMPLETEDRe-arm after reboot or app updateNo
INTERNET, ACCESS_NETWORK_STATEAPI accessNo

Manifest components

ComponentRegistered forRole
SmsReceiverTelephony.SMS_RECEIVED, priority 999Primary inbound capture, guarded by BROADCAST_SMS
GatewayServiceforegroundServiceType dataSyncKeeps the process alive; its notification mirrors live dashboard numbers (status, counts, SIM, battery, last sync)
GatewayMessagingServicecom.google.firebase.MESSAGING_EVENTReplaces FlutterFire's service (tools:node="remove"): keeps Dart delivery and schedules the native worker
GatewaySyncWorkerWorkManagerDurable authenticated queue pass that survives process death — this is what runs the pipeline when the app is backgrounded
BootReceiverBOOT_COMPLETED, MY_PACKAGE_REPLACED, QUICKBOOT_POWERONRe-arms the gateway after a reboot or update
RestartReceiverinternalRestarts the service when the app is swiped away
SmsResultReceiverinternalPer-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.

AspectBehavior
ForegroundFcmService.listen installs one onMessage listener that calls the native queue pass on sms_queue_available
BackgroundGatewayMessagingService (a FlutterFire subclass) records delivery and schedules GatewaySyncWorker — the authenticated pass runs natively, no Dart MethodChannel needed
Token rotationonTokenRefresh triggers an immediate heartbeat, which carries the new token to the backend
First uploadinit() heartbeats right after pairing, so the backend can wake the device before the first 5-minute tick
Failure modeFirebase init failures are logged and swallowed; wake-ups degrade to timers, nothing crashes

Setting up your own Firebase project

  1. Create a Firebase project and add an Android app with package com.mono.sms_gateway.
  2. Download google-services.json into android/app/.
  3. Enable Cloud Messaging (no APNs key needed unless you also ship iOS).
  4. 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.

ControlBehavior
Device listName, app version, SIM/carrier, battery, and a live online dot — online means a heartbeat within the last 3 minutes
One-time tokenIssuing or rotating a device token shows it exactly once, with copy-to-clipboard; the stored hash is never displayed again
Activate / Toggle / Revoke / DeleteOne device is the active sender; toggling pauses it; revoking kills its token; deleting removes the row and its queue association
Device logPer-device message history: statuses, attempts, errors, timestamps
NudgeSends 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

ControlImplementation
Narrow credentialThe app keeps a device token, not the admin session. It works only on /sms-gateway/* routes and is rejected everywhere else.
Permission gateThe backend refuses device registration for accounts without sms.gateway.manage; the app surfaces a clear message on PERMISSION_DENIED.
Token at restFlutterSecureStorage on the Dart side, mirrored into a native-owned encrypted store the Kotlin worker can read without Flutter.
Log hygieneRecipients 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 hygieneFCM payloads contain the queue-available type only — never message content.
RevocationRevoking 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 / environmentslib/core/gateway_config.dart + the --dart-define on your build command
Status rules and timerslib/providers/gateway_provider.dart (fallbackPoll, heartbeatEvery, refreshStatus)
Dashboard UIlib/screens/home_screen.dart — cards are private widgets in that file
Themelib/theme/app_theme.dart (Material 3, dark)
Background send passandroid/.../GatewaySync.kt + GatewaySyncWorker.kt

Known limitations, checked against source

Checked itemSource result
PlatformsAndroid only in practice: SMS receivers, the foreground service, and WorkManager are Android constructs; no iOS runner is configured
Web / desktopNot viable — dart:io platform channels and telephony hardware are required (android.hardware.telephony is mandatory)
Multi-SIMSIM state is reported and forwarded with inbound messages, but sending always uses the device default SMS subscription
Cleartext trafficAllowed in the manifest for LAN testing; tighten before production
SigningDebug keystore until you supply a release keystore
Firebase optionalThe app degrades to timers without google-services.json — pushes only improve latency

Troubleshooting quick reference

SymptomFirst checks
Dashboard says Not readyGrant the named permission (SMS / Phone) in Android settings; the status message names it
Dashboard says OfflineServer reachable from the phone's network? Base URL compiled into this APK? Token revoked in the panel?
Messages stay pendingIs the device the active gateway? FCM token connected in the panel? Battery optimization exempt? Use Nudge to test the wake path
Service keeps dyingExempt battery optimization (Xiaomi/Oppo/Vivo/Samsung); check that the foreground-service notification is visible
Inbound SMS missingUse 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.