Rebrand CommGate to gsmnode and adopt new design system

Rename the whole project from CommGate to gsmnode and re-skin every
surface onto the new signal-green + ink design system (Space Grotesk /
IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark,
lowercase gsm+node wordmark).

- Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme,
  new logo/favicon assets; both builds verified.
- Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive
  launcher icons; rename Android applicationId/package to app.gsmnode.phone;
  bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified.
- API Server (Go): panel routes, User-Agent, health service id, branding.
- Home Assistant Plugin: rename integration domain sms_gateway to gsmnode
  (folder, DOMAIN, services, entity ids, classes); py_compile verified.
- Update all READMEs; add .gitignore (excludes Design/, build artifacts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Flutter / Dart
.dart_tool/
.packages
build/
.flutter-plugins
.flutter-plugins-dependencies
pubspec.lock
# Android
android/.gradle/
android/local.properties
android/key.properties
*.keystore
*.jks
# IDE
.idea/
*.iml
.vscode/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
- platform: android
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+124
View File
@@ -0,0 +1,124 @@
# gsmnode — Phone App (Flutter / Android)
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 App ──► SmsManager (send)
▲ │
└──(status reports / inbox)──────────┘ ◄── BroadcastReceiver (incoming)
```
> The Phone App talks **only** to the API Server (never to PocketBase directly),
> using the device token issued at registration.
## Status: source scaffold
This folder contains the complete Dart app and the native Android (Kotlin) SMS
bridge, but **not** the generated Gradle/platform scaffolding. You generate that
once with `flutter create` after installing the toolchain (below).
## Prerequisites
1. **Flutter SDK** (stable) — https://docs.flutter.dev/get-started/install/windows
2. **JDK 17** (this machine currently has only JDK 8 — Android Gradle needs 17+)
3. **Android SDK** (via Android Studio or `flutter doctor --android-licenses`)
4. A **physical Android phone** with a SIM (the emulator can't send real SMS)
Verify with `flutter doctor` — resolve anything it flags before continuing.
## Generate platform scaffolding & wire in the native code
From this `Phone App/` folder:
```powershell
# 1. Generate the android/ Gradle project (keeps lib/ and pubspec.yaml)
flutter create . --org app.smsgateway --project-name sms_gateway_phone --platforms=android
# 2. Overlay the SMS-enabled manifest + Kotlin (overwrites the generated stubs)
Copy-Item -Recurse -Force android_overlay/* android/
# 3. Fetch packages
flutter pub get
```
`android_overlay/` mirrors the real `android/` paths, so step 2 drops:
- `app/src/main/AndroidManifest.xml` — SMS permissions + the `SmsReceiver`
- `app/src/main/kotlin/app/smsgateway/sms_gateway_phone/MainActivity.kt` — send bridge
- `app/src/main/kotlin/app/smsgateway/sms_gateway_phone/SmsReceiver.kt` — incoming bridge
If Gradle complains about SDK levels, set `minSdkVersion 23` (or higher) in
`android/app/build.gradle`.
## Run
```powershell
flutter devices # confirm your phone is listed
flutter run # build & install on the connected phone
```
1. 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://10.2.1.x:8080`
(the same network as the phone; make sure the API Server is reachable).
2. On the home screen, **grant SMS & phone permissions**, then **Start gateway**.
3. 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**.
## How it maps to the API
| 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` |
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).
## Code layout
```
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
screens/
login_screen.dart login + device registration
home_screen.dart start/stop, permissions, activity log
android_overlay/ native Android files to copy after `flutter create`
```
## Background & delivery reports (implemented)
- **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.sendSms` attaches `sent`/`delivered`
PendingIntents; `SmsStatusReceiver` forwards the outcome (tagged with the
message id) to Dart, which reports `Delivered`/`Failed` to the API Server.
## Known next steps (not yet implemented)
- **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. Requires a Firebase project +
`google-services.json` + server-side FCM sending in the API Server.
- **MMS / data SMS**: only text SMS is implemented.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
avoid_print: false
+13
View File
@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+43
View File
@@ -0,0 +1,43 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "app.gsmnode.phone"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "app.gsmnode.phone"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,76 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Sending and receiving SMS -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<!-- Placing outbound calls remotely -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!-- Optional: needed to choose a specific SIM on dual-SIM devices -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Keep the gateway alive in the background (foreground service path) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android 13+ runtime permission for the ongoing service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="gsmnode"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Incoming SMS broadcast receiver -->
<receiver
android:name=".SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- Receives send/delivery outcomes from PendingIntents (explicit) -->
<receiver
android:name=".SmsStatusReceiver"
android:exported="false" />
<!-- Foreground service that keeps the gateway loop alive -->
<service
android:name=".GatewayForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required so plugins (e.g. url launches) can query intents on Android 11+ -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,99 @@
package app.gsmnode.phone
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/// Foreground service that keeps the app process alive (and the CPU awake via a
/// partial wakelock) so the Dart gateway loop keeps polling/sending while the
/// screen is off or the app is in the background.
///
/// Note: this closes the "survives screen lock" gap. Surviving a full task
/// removal or Doze for hours would additionally need a background Dart isolate
/// (e.g. flutter_background_service) — documented as a further step.
class GatewayForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
companion object {
private const val CHANNEL_ID = "sms_gateway_service"
private const val NOTIFICATION_ID = 4711
fun start(context: Context) {
val intent = Intent(context, GatewayForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, GatewayForegroundService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
acquireWakeLock()
// Restart if the system kills the service.
return START_STICKY
}
private fun startForegroundCompat() {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("gsmnode")
.setContentText("Gateway is running")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
@Suppress("WakelockTimeout")
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "gsmnode:gateway"
).also { it.acquire() }
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "gsmnode",
NotificationManager.IMPORTANCE_LOW
).apply { description = "Keeps the SMS gateway running" }
getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,187 @@
package app.gsmnode.phone
import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager
import android.telephony.SmsManager
import android.telephony.SubscriptionManager
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
const val METHOD_CHANNEL = "app.gsmnode/sms"
const val EVENT_CHANNEL = "app.gsmnode/sms_incoming"
const val STATUS_CHANNEL = "app.gsmnode/sms_status"
const val STATUS_ACTION = "app.gsmnode.SMS_STATUS"
// Sinks used by the broadcast receivers to push events into Dart.
@Volatile var incomingSink: EventChannel.EventSink? = null
@Volatile var statusSink: EventChannel.EventSink? = null
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"sendSms" -> {
val phone = call.argument<String>("phone")
val message = call.argument<String>("message")
val simSlot = call.argument<Int>("simSlot")
val messageId = call.argument<String>("messageId")
if (phone.isNullOrBlank() || message == null) {
result.error("BAD_ARGS", "phone and message are required", null)
return@setMethodCallHandler
}
try {
sendSms(phone, message, simSlot, messageId)
result.success(true)
} catch (e: Exception) {
result.error("SEND_FAILED", e.message, null)
}
}
"placeCall" -> {
val phone = call.argument<String>("phone")
if (phone.isNullOrBlank()) {
result.error("BAD_ARGS", "phone is required", null)
return@setMethodCallHandler
}
try {
placeCall(phone)
result.success(true)
} catch (e: Exception) {
result.error("CALL_FAILED", e.message, null)
}
}
"startService" -> {
GatewayForegroundService.start(this)
result.success(true)
}
"stopService" -> {
GatewayForegroundService.stop(this)
result.success(true)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
incomingSink = sink
}
override fun onCancel(args: Any?) {
incomingSink = null
}
})
EventChannel(messenger, STATUS_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
statusSink = sink
}
override fun onCancel(args: Any?) {
statusSink = null
}
})
}
private fun smsManagerFor(simSlot: Int?): SmsManager {
if (simSlot == null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
else
@Suppress("DEPRECATION") SmsManager.getDefault()
}
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
if (hasPerm) {
val sm = getSystemService(SubscriptionManager::class.java)
val info = sm?.getActiveSubscriptionInfoForSimSlotIndex(simSlot)
if (info != null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
.createForSubscriptionId(info.subscriptionId)
else
@Suppress("DEPRECATION")
SmsManager.getSmsManagerForSubscriptionId(info.subscriptionId)
}
}
return smsManagerFor(null)
}
/// Builds a PendingIntent that, when fired by the radio, broadcasts the
/// send/delivery outcome to SmsStatusReceiver (tagged with the message id).
private fun statusPendingIntent(messageId: String?, phone: String, kind: String): PendingIntent {
val intent = Intent(this, SmsStatusReceiver::class.java).apply {
action = STATUS_ACTION
putExtra("messageId", messageId)
putExtra("phone", phone)
putExtra("kind", kind)
}
val requestCode = (messageId.orEmpty() + phone + kind).hashCode()
return PendingIntent.getBroadcast(
this, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/// Places a phone call. Requires the CALL_PHONE permission (requested in Dart
/// via the phone permission group).
///
/// Uses TelecomManager.placeCall, which routes through the system telecom
/// service rather than starting the dialer activity ourselves. This is what
/// lets the call go through when the screen is locked / the app is in the
/// background — a plain startActivity(ACTION_CALL) is blocked by Android's
/// background-activity-start restrictions in that state. Falls back to
/// ACTION_CALL only on very old devices without TelecomManager.placeCall.
private fun placeCall(phone: String) {
val uri = Uri.fromParts("tel", phone, null)
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.CALL_PHONE
) == PackageManager.PERMISSION_GRANTED
if (!hasPerm) {
throw SecurityException("CALL_PHONE permission not granted")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val telecom = getSystemService(TelecomManager::class.java)
if (telecom != null) {
telecom.placeCall(uri, Bundle())
return
}
}
val intent = Intent(Intent.ACTION_CALL, uri)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun sendSms(phone: String, message: String, simSlot: Int?, messageId: String?) {
val sms = smsManagerFor(simSlot)
val parts = sms.divideMessage(message)
val sentPI = statusPendingIntent(messageId, phone, "sent")
val deliveredPI = statusPendingIntent(messageId, phone, "delivered")
if (parts.size > 1) {
val sentList = ArrayList<PendingIntent>(parts.size)
val deliveredList = ArrayList<PendingIntent>(parts.size)
for (i in parts.indices) {
sentList.add(sentPI)
deliveredList.add(deliveredPI)
}
sms.sendMultipartTextMessage(phone, null, parts, sentList, deliveredList)
} else {
sms.sendTextMessage(phone, null, message, sentPI, deliveredPI)
}
}
}
@@ -0,0 +1,40 @@
package app.gsmnode.phone
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.Telephony
/// Receives incoming SMS and forwards each message to Dart via the EventChannel
/// sink held by MainActivity. Works while the app process is alive.
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return
if (messages.isEmpty()) return
// Multipart SMS arrive as several PDUs from the same sender; concatenate.
val from = messages[0].displayOriginatingAddress ?: ""
val body = StringBuilder()
var timestamp = System.currentTimeMillis()
for (m in messages) {
body.append(m.displayMessageBody ?: "")
timestamp = m.timestampMillis
}
val payload = mapOf(
"from" to from,
"body" to body.toString(),
"timestamp" to timestamp,
)
// EventSink must be touched on the main thread.
Handler(Looper.getMainLooper()).post {
MainActivity.incomingSink?.success(payload)
}
}
}
@@ -0,0 +1,33 @@
package app.gsmnode.phone
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
/// Receives SMS send/delivery outcomes from the PendingIntents created in
/// MainActivity.sendSms and forwards them to Dart, tagged with the message id
/// so the gateway loop can report Sent / Delivered / Failed to the API Server.
class SmsStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messageId = intent.getStringExtra("messageId")
val kind = intent.getStringExtra("kind") ?: return
// For "sent": RESULT_OK means the radio accepted the message.
// For "delivered": RESULT_OK means a positive delivery report arrived.
val success = resultCode == Activity.RESULT_OK
val payload = mapOf(
"messageId" to messageId,
"kind" to kind,
"success" to success,
"resultCode" to resultCode,
)
Handler(Looper.getMainLooper()).post {
MainActivity.statusSink?.success(payload)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2E9E6B</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+18
View File
@@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
+25
View File
@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"
@@ -0,0 +1,76 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Sending and receiving SMS -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<!-- Placing outbound calls remotely -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!-- Optional: needed to choose a specific SIM on dual-SIM devices -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Keep the gateway alive in the background (foreground service path) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android 13+ runtime permission for the ongoing service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="gsmnode"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Incoming SMS broadcast receiver -->
<receiver
android:name=".SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- Receives send/delivery outcomes from PendingIntents (explicit) -->
<receiver
android:name=".SmsStatusReceiver"
android:exported="false" />
<!-- Foreground service that keeps the gateway loop alive -->
<service
android:name=".GatewayForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required so plugins (e.g. url launches) can query intents on Android 11+ -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,99 @@
package app.gsmnode.phone
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/// Foreground service that keeps the app process alive (and the CPU awake via a
/// partial wakelock) so the Dart gateway loop keeps polling/sending while the
/// screen is off or the app is in the background.
///
/// Note: this closes the "survives screen lock" gap. Surviving a full task
/// removal or Doze for hours would additionally need a background Dart isolate
/// (e.g. flutter_background_service) — documented as a further step.
class GatewayForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
companion object {
private const val CHANNEL_ID = "sms_gateway_service"
private const val NOTIFICATION_ID = 4711
fun start(context: Context) {
val intent = Intent(context, GatewayForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, GatewayForegroundService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
acquireWakeLock()
// Restart if the system kills the service.
return START_STICKY
}
private fun startForegroundCompat() {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("gsmnode")
.setContentText("Gateway is running")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
@Suppress("WakelockTimeout")
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "gsmnode:gateway"
).also { it.acquire() }
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "gsmnode",
NotificationManager.IMPORTANCE_LOW
).apply { description = "Keeps the SMS gateway running" }
getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,187 @@
package app.gsmnode.phone
import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager
import android.telephony.SmsManager
import android.telephony.SubscriptionManager
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
const val METHOD_CHANNEL = "app.gsmnode/sms"
const val EVENT_CHANNEL = "app.gsmnode/sms_incoming"
const val STATUS_CHANNEL = "app.gsmnode/sms_status"
const val STATUS_ACTION = "app.gsmnode.SMS_STATUS"
// Sinks used by the broadcast receivers to push events into Dart.
@Volatile var incomingSink: EventChannel.EventSink? = null
@Volatile var statusSink: EventChannel.EventSink? = null
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"sendSms" -> {
val phone = call.argument<String>("phone")
val message = call.argument<String>("message")
val simSlot = call.argument<Int>("simSlot")
val messageId = call.argument<String>("messageId")
if (phone.isNullOrBlank() || message == null) {
result.error("BAD_ARGS", "phone and message are required", null)
return@setMethodCallHandler
}
try {
sendSms(phone, message, simSlot, messageId)
result.success(true)
} catch (e: Exception) {
result.error("SEND_FAILED", e.message, null)
}
}
"placeCall" -> {
val phone = call.argument<String>("phone")
if (phone.isNullOrBlank()) {
result.error("BAD_ARGS", "phone is required", null)
return@setMethodCallHandler
}
try {
placeCall(phone)
result.success(true)
} catch (e: Exception) {
result.error("CALL_FAILED", e.message, null)
}
}
"startService" -> {
GatewayForegroundService.start(this)
result.success(true)
}
"stopService" -> {
GatewayForegroundService.stop(this)
result.success(true)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
incomingSink = sink
}
override fun onCancel(args: Any?) {
incomingSink = null
}
})
EventChannel(messenger, STATUS_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
statusSink = sink
}
override fun onCancel(args: Any?) {
statusSink = null
}
})
}
private fun smsManagerFor(simSlot: Int?): SmsManager {
if (simSlot == null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
else
@Suppress("DEPRECATION") SmsManager.getDefault()
}
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
if (hasPerm) {
val sm = getSystemService(SubscriptionManager::class.java)
val info = sm?.getActiveSubscriptionInfoForSimSlotIndex(simSlot)
if (info != null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
.createForSubscriptionId(info.subscriptionId)
else
@Suppress("DEPRECATION")
SmsManager.getSmsManagerForSubscriptionId(info.subscriptionId)
}
}
return smsManagerFor(null)
}
/// Builds a PendingIntent that, when fired by the radio, broadcasts the
/// send/delivery outcome to SmsStatusReceiver (tagged with the message id).
private fun statusPendingIntent(messageId: String?, phone: String, kind: String): PendingIntent {
val intent = Intent(this, SmsStatusReceiver::class.java).apply {
action = STATUS_ACTION
putExtra("messageId", messageId)
putExtra("phone", phone)
putExtra("kind", kind)
}
val requestCode = (messageId.orEmpty() + phone + kind).hashCode()
return PendingIntent.getBroadcast(
this, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/// Places a phone call. Requires the CALL_PHONE permission (requested in Dart
/// via the phone permission group).
///
/// Uses TelecomManager.placeCall, which routes through the system telecom
/// service rather than starting the dialer activity ourselves. This is what
/// lets the call go through when the screen is locked / the app is in the
/// background — a plain startActivity(ACTION_CALL) is blocked by Android's
/// background-activity-start restrictions in that state. Falls back to
/// ACTION_CALL only on very old devices without TelecomManager.placeCall.
private fun placeCall(phone: String) {
val uri = Uri.fromParts("tel", phone, null)
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.CALL_PHONE
) == PackageManager.PERMISSION_GRANTED
if (!hasPerm) {
throw SecurityException("CALL_PHONE permission not granted")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val telecom = getSystemService(TelecomManager::class.java)
if (telecom != null) {
telecom.placeCall(uri, Bundle())
return
}
}
val intent = Intent(Intent.ACTION_CALL, uri)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun sendSms(phone: String, message: String, simSlot: Int?, messageId: String?) {
val sms = smsManagerFor(simSlot)
val parts = sms.divideMessage(message)
val sentPI = statusPendingIntent(messageId, phone, "sent")
val deliveredPI = statusPendingIntent(messageId, phone, "delivered")
if (parts.size > 1) {
val sentList = ArrayList<PendingIntent>(parts.size)
val deliveredList = ArrayList<PendingIntent>(parts.size)
for (i in parts.indices) {
sentList.add(sentPI)
deliveredList.add(deliveredPI)
}
sms.sendMultipartTextMessage(phone, null, parts, sentList, deliveredList)
} else {
sms.sendTextMessage(phone, null, message, sentPI, deliveredPI)
}
}
}
@@ -0,0 +1,40 @@
package app.gsmnode.phone
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.Telephony
/// Receives incoming SMS and forwards each message to Dart via the EventChannel
/// sink held by MainActivity. Works while the app process is alive.
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return
if (messages.isEmpty()) return
// Multipart SMS arrive as several PDUs from the same sender; concatenate.
val from = messages[0].displayOriginatingAddress ?: ""
val body = StringBuilder()
var timestamp = System.currentTimeMillis()
for (m in messages) {
body.append(m.displayMessageBody ?: "")
timestamp = m.timestampMillis
}
val payload = mapOf(
"from" to from,
"body" to body.toString(),
"timestamp" to timestamp,
)
// EventSink must be touched on the main thread.
Handler(Looper.getMainLooper()).post {
MainActivity.incomingSink?.success(payload)
}
}
}
@@ -0,0 +1,33 @@
package app.gsmnode.phone
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
/// Receives SMS send/delivery outcomes from the PendingIntents created in
/// MainActivity.sendSms and forwards them to Dart, tagged with the message id
/// so the gateway loop can report Sent / Delivered / Failed to the API Server.
class SmsStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messageId = intent.getStringExtra("messageId")
val kind = intent.getStringExtra("kind") ?: return
// For "sent": RESULT_OK means the radio accepted the message.
// For "delivered": RESULT_OK means a positive delivery report arrived.
val success = resultCode == Activity.RESULT_OK
val payload = mapOf(
"messageId" to messageId,
"kind" to kind,
"success" to success,
"resultCode" to resultCode,
)
Handler(Looper.getMainLooper()).post {
MainActivity.statusSink?.success(payload)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+15
View File
@@ -0,0 +1,15 @@
/// Compile-time defaults. The API base URL is also user-editable on the login
/// screen and persisted via [Storage].
class AppConfig {
/// Default API Server base URL. Use the LAN IP of the machine running the
/// API Server (not localhost — that points at the phone itself).
///
/// 10.0.2.2 is the Android emulator's alias for the host machine.
static const String defaultApiBase = 'http://10.0.2.2:8080';
/// How often the gateway polls the API Server for pending messages.
static const Duration pollInterval = Duration(seconds: 10);
/// How often the device sends a heartbeat ping.
static const Duration pingInterval = Duration(minutes: 1);
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'config.dart';
import 'services/api_client.dart';
import 'services/gateway_service.dart';
import 'services/sms_service.dart';
import 'services/storage.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
import 'theme.dart';
late Storage storage;
late ApiClient apiClient;
late GatewayService gateway;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
storage = await Storage.create();
storage.apiBase ??= AppConfig.defaultApiBase;
apiClient = ApiClient(storage);
gateway = GatewayService(apiClient, SmsService());
runApp(const GsmNodeApp());
}
class GsmNodeApp extends StatelessWidget {
const GsmNodeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'gsmnode',
debugShowCheckedModeBanner: false,
theme: gsmnodeLightTheme(),
darkTheme: gsmnodeDarkTheme(),
themeMode: ThemeMode.system,
home: storage.isRegistered ? const HomeScreen() : const LoginScreen(),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
/// An outbound message handed to the device by the API Server.
class GatewayMessage {
final String id;
final String type; // 'sms' or 'call'
final List<String> phoneNumbers;
final String textMessage;
final int? simNumber;
final String status;
GatewayMessage({
required this.id,
required this.type,
required this.phoneNumbers,
required this.textMessage,
this.simNumber,
required this.status,
});
bool get isCall => type == 'call';
factory GatewayMessage.fromJson(Map<String, dynamic> json) {
return GatewayMessage(
id: json['id'] as String? ?? '',
type: json['type'] as String? ?? 'sms',
phoneNumbers: (json['phone_numbers'] as List<dynamic>? ?? [])
.map((e) => e.toString())
.toList(),
textMessage: json['text_message'] as String? ?? '',
simNumber: json['sim_number'] as int?,
status: json['status'] as String? ?? '',
);
}
}
+298
View File
@@ -0,0 +1,298 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import '../main.dart';
import '../theme.dart';
import '../widgets/gsmnode_mark.dart';
import 'login_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
bool _permsGranted = false;
@override
void initState() {
super.initState();
gateway.addListener(_onChange);
_checkPermissions();
}
@override
void dispose() {
gateway.removeListener(_onChange);
super.dispose();
}
void _onChange() => setState(() {});
Future<void> _checkPermissions() async {
final sms = await Permission.sms.status;
final phone = await Permission.phone.status;
setState(() => _permsGranted = sms.isGranted && phone.isGranted);
}
Future<void> _requestPermissions() async {
// SMS + phone gate the gateway; notification lets the foreground service
// post its ongoing notification on Android 13+.
await [Permission.sms, Permission.phone, Permission.notification].request();
await _checkPermissions();
}
void _toggle() {
if (gateway.running) {
gateway.stop();
} else {
gateway.start();
}
}
Future<void> _logout() async {
gateway.stop();
await storage.clearSession();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
@override
Widget build(BuildContext context) {
final running = gateway.running;
final cg = context.cg;
return Scaffold(
appBar: AppBar(
title: const Row(
mainAxisSize: MainAxisSize.min,
children: [
GsmNodeMark(size: 22),
SizedBox(width: 8),
GsmNodeWordmark(size: 17),
],
),
actions: [
IconButton(
onPressed: _logout,
icon: const Icon(Icons.logout),
tooltip: 'Sign out',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_heroCard(running),
const SizedBox(height: 12),
if (!_permsGranted) ...[
_permissionCard(),
const SizedBox(height: 12),
],
_infoCard(),
const SizedBox(height: 16),
Text(
'ACTIVITY',
style: gsmMono(size: 10, color: cg.textMuted, letterSpacing: 1.4),
),
const SizedBox(height: 8),
Expanded(child: _activityLog()),
],
),
),
);
}
/// Hero per the mobile UI kit: brand ring + mark when routing, ink when
/// paused. The primary action carries the brand glow.
Widget _heroCard(bool running) {
final cg = context.cg;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: running ? cg.brandTint : cg.sunkenBg,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: running
? GsmColors.green500.withValues(alpha: 0.22)
: cg.borderSubtle,
),
),
child: Column(
children: [
Container(
width: 84,
height: 84,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: running ? GsmColors.green500 : cg.textMuted,
boxShadow: running
? [
BoxShadow(
color: GsmColors.green500.withValues(alpha: 0.28),
blurRadius: 18,
offset: const Offset(0, 6),
),
]
: null,
),
child: const Center(
child: GsmNodeMark(size: 44, color: Colors.white),
),
),
const SizedBox(height: 14),
Text(
running ? 'Gateway active' : 'Gateway paused',
style: gsmDisplay(size: 20, color: cg.textPrimary),
),
const SizedBox(height: 4),
Text(
running
? 'routing sms · mms · calls'
: 'not routing — messages will queue',
style: gsmMono(
size: 12,
color: running ? GsmColors.green500 : cg.textMuted,
),
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: _permsGranted ? _toggle : null,
icon: Icon(running ? Icons.stop : Icons.play_arrow),
label: Text(running ? 'Stop gateway' : 'Start gateway'),
style: running
? FilledButton.styleFrom(
backgroundColor: cg.danger,
minimumSize: const Size.fromHeight(48),
)
: null,
),
],
),
);
}
Widget _permissionCard() {
final cg = context.cg;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: cg.warningTint,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: cg.warning),
const SizedBox(width: 12),
const Expanded(
child: Text('SMS & phone permissions are required to send and '
'receive messages.'),
),
TextButton(
onPressed: _requestPermissions,
child: const Text('Grant'),
),
],
),
);
}
Widget _infoCard() {
final cg = context.cg;
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
children: [
_infoRow('DEVICE', storage.deviceName ?? ''),
Divider(height: 16, color: cg.borderSubtle),
_infoRow('ACCOUNT', storage.userEmail ?? ''),
Divider(height: 16, color: cg.borderSubtle),
_infoRow('SERVER', storage.apiBase ?? ''),
],
),
),
);
}
Widget _infoRow(String label, String value) {
final cg = context.cg;
return Row(
children: [
SizedBox(
width: 76,
child: Text(
label,
style: gsmMono(size: 9, color: cg.textMuted, letterSpacing: 1.3),
),
),
Expanded(
child: Text(
value,
style: gsmMono(size: 12, color: cg.textPrimary),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
],
);
}
Widget _activityLog() {
final cg = context.cg;
final entries = gateway.log;
if (entries.isEmpty) {
return Center(
child: Text(
'No activity yet.',
style: TextStyle(color: cg.textMuted),
),
);
}
return ListView.separated(
itemCount: entries.length,
separatorBuilder: (_, __) => const SizedBox(height: 6),
itemBuilder: (_, i) {
final e = entries[i];
final t = e.time;
final hh = t.hour.toString().padLeft(2, '0');
final mm = t.minute.toString().padLeft(2, '0');
final ss = t.second.toString().padLeft(2, '0');
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cg.card,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cg.borderSubtle),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: e.error ? cg.danger : cg.success,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(e.text, style: const TextStyle(fontSize: 13)),
),
const SizedBox(width: 10),
Text(
'$hh:$mm:$ss',
style: gsmMono(size: 10, color: cg.textMuted),
),
],
),
);
},
);
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import '../main.dart';
import '../services/api_client.dart';
import '../theme.dart';
import '../widgets/gsmnode_mark.dart';
import 'home_screen.dart';
/// Login + device registration. The user authenticates against the API Server,
/// then this phone is registered as a gateway device.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _apiBase = TextEditingController(text: storage.apiBase ?? '');
final _email = TextEditingController(text: storage.userEmail ?? '');
final _password = TextEditingController();
final _deviceName = TextEditingController(text: storage.deviceName ?? 'My Phone');
bool _busy = false;
String? _error;
@override
void dispose() {
_apiBase.dispose();
_email.dispose();
_password.dispose();
_deviceName.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
try {
storage.apiBase = _apiBase.text.trim();
await apiClient.login(_email.text.trim(), _password.text);
final deviceId = storage.deviceId ??
'android-${DateTime.now().millisecondsSinceEpoch}';
await apiClient.registerDevice(
deviceId: deviceId,
name: _deviceName.text.trim().isEmpty ? 'My Phone' : _deviceName.text.trim(),
);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} on ApiException catch (e) {
setState(() => _error =
e.status == 401 ? 'Invalid email or password.' : e.message);
} catch (e) {
setState(() => _error = 'Could not connect: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final cg = context.cg;
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: GsmNodeMark(size: 56)),
const SizedBox(height: 14),
const Center(child: GsmNodeWordmark(size: 26)),
const SizedBox(height: 8),
Center(
child: Text(
'CONNECT THIS PHONE TO YOUR GATEWAY',
textAlign: TextAlign.center,
style: gsmMono(
size: 10,
color: cg.textMuted,
letterSpacing: 1.4,
),
),
),
const SizedBox(height: 28),
TextField(
controller: _apiBase,
decoration: const InputDecoration(
labelText: 'API Server URL',
hintText: 'http://10.0.2.2:8080',
),
style: gsmMono(size: 14, color: cg.textPrimary),
keyboardType: TextInputType.url,
),
const SizedBox(height: 12),
TextField(
controller: _email,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextField(
controller: _password,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
const SizedBox(height: 12),
TextField(
controller: _deviceName,
decoration: const InputDecoration(labelText: 'Device name'),
),
const SizedBox(height: 16),
if (_error != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cg.dangerTint,
borderRadius: BorderRadius.circular(10),
),
child: Text(_error!, style: TextStyle(color: cg.danger)),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _busy ? null : _submit,
child: _busy
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Sign in & register device'),
),
],
),
),
),
),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/message.dart';
import 'storage.dart';
class ApiException implements Exception {
final int status;
final String message;
ApiException(this.status, this.message);
@override
String toString() => 'ApiException($status): $message';
}
/// Client for the API Server. Uses the user JWT for login/registration and the
/// opaque device token for the mobile gateway endpoints.
class ApiClient {
final Storage storage;
final http.Client _http;
ApiClient(this.storage, {http.Client? client})
: _http = client ?? http.Client();
String get _base => (storage.apiBase ?? '').replaceAll(RegExp(r'/$'), '');
Uri _uri(String path) => Uri.parse('$_base$path');
Map<String, String> _headers(String? token) => {
'Content-Type': 'application/json',
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
};
dynamic _decode(http.Response res) {
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = (body is Map && body['error'] != null)
? body['error'].toString()
: 'HTTP ${res.statusCode}';
throw ApiException(res.statusCode, msg);
}
return body;
}
/// Authenticates a user and stores the JWT. Returns the user email.
Future<String> login(String email, String password) async {
final res = await _http.post(
_uri('/api/auth/login'),
headers: _headers(null),
body: jsonEncode({'email': email, 'password': password}),
);
final data = _decode(res) as Map<String, dynamic>;
storage.jwt = data['access_token'] as String?;
final user = data['user'] as Map<String, dynamic>?;
storage.userEmail = user?['email'] as String?;
return storage.userEmail ?? email;
}
/// Registers this device and stores the returned device token.
Future<void> registerDevice({
required String deviceId,
required String name,
String platform = 'android',
String appVersion = '1.0.0',
String? pushToken,
}) async {
final res = await _http.post(
_uri('/api/mobile/v1/device'),
headers: _headers(storage.jwt),
body: jsonEncode({
'device_id': deviceId,
'name': name,
'platform': platform,
'app_version': appVersion,
if (pushToken != null) 'push_token': pushToken,
}),
);
final data = _decode(res) as Map<String, dynamic>;
storage.deviceId = deviceId;
storage.deviceName = name;
storage.deviceToken = data['auth_token'] as String?;
}
/// Pulls pending messages for this device (the server marks them Processed).
Future<List<GatewayMessage>> pullMessages() async {
final res = await _http.get(
_uri('/api/mobile/v1/messages'),
headers: _headers(storage.deviceToken),
);
final data = _decode(res) as Map<String, dynamic>;
final items = (data['items'] as List<dynamic>? ?? []);
return items
.map((e) => GatewayMessage.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Reports a message's delivery state back to the server.
Future<void> reportMessage(String id, String status, {String? error}) async {
final res = await _http.patch(
_uri('/api/mobile/v1/messages/$id'),
headers: _headers(storage.deviceToken),
body: jsonEncode({'status': status, if (error != null) 'error': error}),
);
_decode(res);
}
/// Posts an incoming SMS to the server's inbox.
Future<void> postInbox(String phoneNumber, String message,
{DateTime? receivedAt}) async {
final res = await _http.post(
_uri('/api/mobile/v1/inbox'),
headers: _headers(storage.deviceToken),
body: jsonEncode({
'phone_number': phoneNumber,
'message': message,
if (receivedAt != null)
'received_at': receivedAt.toUtc().toIso8601String(),
}),
);
_decode(res);
}
/// Sends a heartbeat ping.
Future<void> ping() async {
final res = await _http.post(
_uri('/api/mobile/v1/ping'),
headers: _headers(storage.deviceToken),
);
_decode(res);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../config.dart';
import 'api_client.dart';
import 'sms_service.dart';
/// A single line in the on-screen activity log.
class LogEntry {
final DateTime time;
final String text;
final bool error;
LogEntry(this.text, {this.error = false}) : time = DateTime.now();
}
/// Orchestrates the gateway loop: polls the API Server for pending messages,
/// sends them over the radio, reports delivery state, forwards incoming SMS to
/// the inbox, and sends heartbeats.
///
/// This runs in the foreground while the app is open. Moving the loop to a
/// persistent background/foreground service (WorkManager) is a documented next
/// step — see README.
class GatewayService extends ChangeNotifier {
final ApiClient api;
final SmsService sms;
GatewayService(this.api, this.sms);
bool _running = false;
bool get running => _running;
final List<LogEntry> _log = [];
List<LogEntry> get log => List.unmodifiable(_log);
Timer? _pollTimer;
Timer? _pingTimer;
StreamSubscription<IncomingSms>? _incomingSub;
StreamSubscription<SmsStatus>? _statusSub;
bool _polling = false;
void start() {
if (_running) return;
_running = true;
_log0('Gateway started');
// Keep the process alive while the screen is off / app backgrounded.
sms.startBackgroundService().catchError(
(e) => _log0('Foreground service failed to start: $e', error: true));
_incomingSub = sms.incomingSms().listen(_onIncoming, onError: (e) {
_log0('Incoming SMS stream error: $e', error: true);
});
_statusSub = sms.smsStatus().listen(_onStatus, onError: (e) {
_log0('Status stream error: $e', error: true);
});
_pollTimer = Timer.periodic(AppConfig.pollInterval, (_) => _poll());
_pingTimer = Timer.periodic(AppConfig.pingInterval, (_) => _ping());
_poll();
_ping();
notifyListeners();
}
void stop() {
_running = false;
_pollTimer?.cancel();
_pingTimer?.cancel();
_incomingSub?.cancel();
_statusSub?.cancel();
sms.stopBackgroundService().catchError((_) {});
_log0('Gateway stopped');
notifyListeners();
}
Future<void> _poll() async {
if (_polling || !_running) return;
_polling = true;
try {
final messages = await api.pullMessages();
for (final m in messages) {
var handedOff = true;
for (final phone in m.phoneNumbers) {
try {
if (m.isCall) {
await sms.placeCall(phone);
_log0('Calling $phone');
} else {
await sms.sendSms(phone, m.textMessage,
simSlot: m.simNumber, messageId: m.id);
_log0('Sending to $phone: "${_short(m.textMessage)}"');
}
} catch (e) {
handedOff = false;
await api.reportMessage(m.id, 'Failed', error: e.toString());
_log0('${m.isCall ? "Call" : "Send"} to $phone failed: $e',
error: true);
break;
}
}
// Report Sent once handed off. For SMS the native send/delivery
// PendingIntents then refine this to Delivered (or Failed) via _onStatus;
// a call has no delivery report, so it stays Sent.
if (handedOff) await api.reportMessage(m.id, 'Sent');
}
} catch (e) {
_log0('Poll error: $e', error: true);
} finally {
_polling = false;
}
}
Future<void> _ping() async {
if (!_running) return;
try {
await api.ping();
} catch (e) {
_log0('Ping failed: $e', error: true);
}
}
Future<void> _onStatus(SmsStatus s) async {
final id = s.messageId;
if (id == null || id.isEmpty) return;
try {
if (s.kind == 'delivered') {
if (s.success) {
await api.reportMessage(id, 'Delivered');
_log0('Delivered: $id');
} else {
await api.reportMessage(id, 'Failed', error: 'delivery failed');
_log0('Delivery failed: $id', error: true);
}
} else if (s.kind == 'sent' && !s.success) {
await api.reportMessage(id, 'Failed', error: 'radio rejected message');
_log0('Send rejected by radio: $id', error: true);
}
} catch (e) {
_log0('Report status failed: $e', error: true);
}
}
Future<void> _onIncoming(IncomingSms msg) async {
_log0('Received from ${msg.from}: "${_short(msg.body)}"');
try {
await api.postInbox(msg.from, msg.body, receivedAt: msg.timestamp);
} catch (e) {
_log0('Forward inbox failed: $e', error: true);
}
}
void _log0(String text, {bool error = false}) {
_log.insert(0, LogEntry(text, error: error));
if (_log.length > 100) _log.removeLast();
notifyListeners();
}
String _short(String s) => s.length > 40 ? '${s.substring(0, 40)}' : s;
@override
void dispose() {
stop();
super.dispose();
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/services.dart';
/// An incoming SMS delivered from the native side.
class IncomingSms {
final String from;
final String body;
final DateTime timestamp;
IncomingSms(this.from, this.body, this.timestamp);
}
/// A send/delivery status report for an outbound message.
class SmsStatus {
final String? messageId;
final String kind; // 'sent' or 'delivered'
final bool success;
SmsStatus(this.messageId, this.kind, this.success);
}
/// Bridges to the native Android side: SmsManager (sending), a BroadcastReceiver
/// (incoming), send/delivery PendingIntents (status), and the foreground service
/// that keeps the gateway alive. See android_overlay/ for the Kotlin side.
class SmsService {
static const _method = MethodChannel('app.gsmnode/sms');
static const _events = EventChannel('app.gsmnode/sms_incoming');
static const _status = EventChannel('app.gsmnode/sms_status');
/// Sends an SMS via the device radio. [simSlot] is 0-based; null = default SIM.
/// [messageId] is echoed back on the status stream for correlation.
/// Throws [PlatformException] on failure.
Future<void> sendSms(String phoneNumber, String message,
{int? simSlot, String? messageId}) async {
await _method.invokeMethod('sendSms', {
'phone': phoneNumber,
'message': message,
'simSlot': simSlot,
'messageId': messageId,
});
}
/// Places a phone call to [phoneNumber] via the native dialer (ACTION_CALL).
/// Requires the CALL_PHONE permission. Throws [PlatformException] on failure.
Future<void> placeCall(String phoneNumber) async {
await _method.invokeMethod('placeCall', {'phone': phoneNumber});
}
/// Starts the foreground service so the gateway loop survives screen-off.
Future<void> startBackgroundService() => _method.invokeMethod('startService');
/// Stops the foreground service.
Future<void> stopBackgroundService() => _method.invokeMethod('stopService');
/// Stream of incoming SMS messages (active while the app process is alive).
Stream<IncomingSms> incomingSms() {
return _events.receiveBroadcastStream().map((event) {
final map = Map<String, dynamic>.from(event as Map);
final ts = map['timestamp'] as int?;
return IncomingSms(
map['from'] as String? ?? '',
map['body'] as String? ?? '',
ts != null ? DateTime.fromMillisecondsSinceEpoch(ts) : DateTime.now(),
);
});
}
/// Stream of send/delivery status reports for outbound messages.
Stream<SmsStatus> smsStatus() {
return _status.receiveBroadcastStream().map((event) {
final map = Map<String, dynamic>.from(event as Map);
return SmsStatus(
map['messageId'] as String?,
map['kind'] as String? ?? '',
map['success'] == true,
);
});
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Persists connection settings and credentials across app launches.
class Storage {
static const _kApiBase = 'api_base';
static const _kJwt = 'jwt';
static const _kDeviceToken = 'device_token';
static const _kDeviceId = 'device_id';
static const _kDeviceName = 'device_name';
static const _kUserEmail = 'user_email';
final SharedPreferences _prefs;
Storage(this._prefs);
static Future<Storage> create() async {
return Storage(await SharedPreferences.getInstance());
}
String? get apiBase => _prefs.getString(_kApiBase);
set apiBase(String? v) => _set(_kApiBase, v);
String? get jwt => _prefs.getString(_kJwt);
set jwt(String? v) => _set(_kJwt, v);
String? get deviceToken => _prefs.getString(_kDeviceToken);
set deviceToken(String? v) => _set(_kDeviceToken, v);
String? get deviceId => _prefs.getString(_kDeviceId);
set deviceId(String? v) => _set(_kDeviceId, v);
String? get deviceName => _prefs.getString(_kDeviceName);
set deviceName(String? v) => _set(_kDeviceName, v);
String? get userEmail => _prefs.getString(_kUserEmail);
set userEmail(String? v) => _set(_kUserEmail, v);
bool get isRegistered => (deviceToken ?? '').isNotEmpty;
Future<void> clearSession() async {
await _prefs.remove(_kJwt);
await _prefs.remove(_kDeviceToken);
await _prefs.remove(_kUserEmail);
// Keep device_id stable across logout so re-registering updates the same
// device record instead of creating a new (phantom) one.
}
void _set(String key, String? v) {
if (v == null) {
_prefs.remove(key);
} else {
_prefs.setString(key, v);
}
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// gsmnode design tokens (see Design/SMS Gateway logo design/tokens in the
/// project root). Brand is signal-green #2E9E6B on an ink/paper cool scale.
abstract class GsmColors {
// Signal green (brand) ramp
static const green100 = Color(0xFFE4F4EC);
static const green200 = Color(0xFFBEE6D0);
static const green300 = Color(0xFF6BC79A); // lifted text on dark
static const green500 = Color(0xFF2E9E6B); // primary
static const green600 = Color(0xFF278A5C); // hover
static const green700 = Color(0xFF1F6E49); // active
static const greenOnDark = Color(0xFF4FB985); // accent on ink surfaces
// Ink & paper
static const ink = Color(0xFF12161C); // primary text / dark surface
static const ink900 = Color(0xFF0A0D11); // deepest surface
static const ink800 = Color(0xFF1A1F27);
static const paper = Color(0xFFFAFAF9); // page bg
// Cool gray ramp
static const gray50 = Color(0xFFF4F5F4);
static const gray100 = Color(0xFFE9EBEA);
static const gray200 = Color(0xFFDCDFDE); // subtle border
static const gray300 = Color(0xFFC2C7C6); // strong border
static const gray400 = Color(0xFF9AA0A2); // muted text
static const gray500 = Color(0xFF6B7278);
static const gray600 = Color(0xFF4A5157);
static const gray700 = Color(0xFF333A40); // secondary text
// Semantic (light)
static const warning = Color(0xFFC68A2E);
static const warningTint = Color(0xFFF6EAD6);
static const danger = Color(0xFFC64A3E);
static const dangerTint = Color(0xFFF6E1DC);
// Dark surfaces
static const dBase = Color(0xFF0A0D11);
static const dSunken = Color(0xFF0E1216);
static const dCard = Color(0xFF14191F);
static const dRaised = Color(0xFF1A2027);
static const dBorderSubtle = Color(0xFF262B33);
static const dBorderStrong = Color(0xFF363C46);
static const dTextPrimary = Color(0xFFF5F6F4);
static const dTextSecondary = Color(0xFFC2C7C6);
static const dTextMuted = Color(0xFF9AA0A2);
// Semantic (dark — lifted for contrast)
static const dWarning = Color(0xFFE0A84E);
static const dDanger = Color(0xFFE0655B);
}
/// Semantic role colors that flip between light and dark, exposed as a
/// ThemeExtension so screens theme through roles, not raw ramp values.
class GsmSemantic extends ThemeExtension<GsmSemantic> {
const GsmSemantic({
required this.pageBg,
required this.sunkenBg,
required this.card,
required this.borderSubtle,
required this.borderStrong,
required this.textPrimary,
required this.textSecondary,
required this.textMuted,
required this.brandTint,
required this.success,
required this.successTint,
required this.warning,
required this.warningTint,
required this.danger,
required this.dangerTint,
});
final Color pageBg;
final Color sunkenBg;
final Color card;
final Color borderSubtle;
final Color borderStrong;
final Color textPrimary;
final Color textSecondary;
final Color textMuted;
final Color brandTint;
final Color success;
final Color successTint;
final Color warning;
final Color warningTint;
final Color danger;
final Color dangerTint;
static const light = GsmSemantic(
pageBg: GsmColors.paper,
sunkenBg: GsmColors.gray50,
card: Colors.white,
borderSubtle: GsmColors.gray200,
borderStrong: GsmColors.gray300,
textPrimary: GsmColors.ink,
textSecondary: GsmColors.gray700,
textMuted: GsmColors.gray500,
brandTint: GsmColors.green100,
success: GsmColors.green500,
successTint: GsmColors.green100,
warning: GsmColors.warning,
warningTint: GsmColors.warningTint,
danger: GsmColors.danger,
dangerTint: GsmColors.dangerTint,
);
static const dark = GsmSemantic(
pageBg: GsmColors.dBase,
sunkenBg: GsmColors.dSunken,
card: GsmColors.dCard,
borderSubtle: GsmColors.dBorderSubtle,
borderStrong: GsmColors.dBorderStrong,
textPrimary: GsmColors.dTextPrimary,
textSecondary: GsmColors.dTextSecondary,
textMuted: GsmColors.dTextMuted,
brandTint: Color(0x292E9E6B), // green-500 @ 16%
success: GsmColors.green300,
successTint: Color(0x2E2E9E6B),
warning: GsmColors.dWarning,
warningTint: Color(0x2EC68A2E),
danger: GsmColors.dDanger,
dangerTint: Color(0x2EC64A3E),
);
@override
GsmSemantic copyWith({Color? pageBg}) => this;
@override
GsmSemantic lerp(ThemeExtension<GsmSemantic>? other, double t) => this;
}
extension GsmThemeX on BuildContext {
GsmSemantic get cg => Theme.of(this).extension<GsmSemantic>()!;
}
/// A mono (JetBrains Mono) style for IDs, numbers, timestamps and eyebrows.
TextStyle gsmMono({
double size = 12,
Color? color,
FontWeight weight = FontWeight.w500,
double letterSpacing = 0,
}) =>
GoogleFonts.jetBrainsMono(
fontSize: size,
color: color,
fontWeight: weight,
letterSpacing: letterSpacing,
);
/// Display (Space Grotesk) style for titles & metrics.
TextStyle gsmDisplay({
double size = 20,
Color? color,
FontWeight weight = FontWeight.w700,
}) =>
GoogleFonts.spaceGrotesk(
fontSize: size,
color: color,
fontWeight: weight,
letterSpacing: -0.02 * size,
);
ThemeData _base(Brightness brightness, GsmSemantic s) {
final isDark = brightness == Brightness.dark;
final scheme = ColorScheme(
brightness: brightness,
primary: GsmColors.green500,
onPrimary: Colors.white,
secondary: isDark ? GsmColors.greenOnDark : GsmColors.green600,
onSecondary: Colors.white,
error: s.danger,
onError: Colors.white,
surface: s.card,
onSurface: s.textPrimary,
outline: s.borderStrong,
outlineVariant: s.borderSubtle,
surfaceContainerLowest: s.sunkenBg,
surfaceContainerLow: s.pageBg,
surfaceContainer: s.card,
);
// Body & UI in IBM Plex Sans; titles/metrics reach for Space Grotesk.
final textTheme = GoogleFonts.ibmPlexSansTextTheme(
(isDark ? ThemeData.dark() : ThemeData.light()).textTheme,
).apply(bodyColor: s.textPrimary, displayColor: s.textPrimary);
const controlRadius = BorderRadius.all(Radius.circular(8));
const cardRadius = BorderRadius.all(Radius.circular(18));
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: s.pageBg,
textTheme: textTheme,
extensions: [s],
appBarTheme: AppBarTheme(
backgroundColor: s.card,
foregroundColor: s.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
shape: Border(bottom: BorderSide(color: s.borderSubtle)),
titleTextStyle: GoogleFonts.spaceGrotesk(
fontSize: 17,
fontWeight: FontWeight.w700,
color: s.textPrimary,
letterSpacing: -0.2,
),
),
cardTheme: CardThemeData(
color: s.card,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: cardRadius,
side: BorderSide(color: s.borderSubtle),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: GsmColors.green500,
foregroundColor: Colors.white,
elevation: 0,
minimumSize: const Size.fromHeight(48),
shape: const RoundedRectangleBorder(borderRadius: controlRadius),
textStyle: GoogleFonts.ibmPlexSans(
fontSize: 15,
fontWeight: FontWeight.w600,
),
).copyWith(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return GsmColors.green500.withValues(alpha: 0.45);
}
if (states.contains(WidgetState.pressed)) return GsmColors.green700;
if (states.contains(WidgetState.hovered)) return GsmColors.green600;
return GsmColors.green500;
}),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: s.textPrimary,
side: BorderSide(color: s.borderStrong),
shape: const RoundedRectangleBorder(borderRadius: controlRadius),
textStyle: GoogleFonts.ibmPlexSans(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: isDark ? GsmColors.greenOnDark : GsmColors.green600,
textStyle: GoogleFonts.ibmPlexSans(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: s.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: controlRadius,
borderSide: BorderSide(color: s.borderStrong),
),
enabledBorder: OutlineInputBorder(
borderRadius: controlRadius,
borderSide: BorderSide(color: s.borderStrong),
),
focusedBorder: const OutlineInputBorder(
borderRadius: controlRadius,
borderSide: BorderSide(color: GsmColors.green500, width: 2),
),
labelStyle: TextStyle(color: s.textSecondary),
hintStyle: TextStyle(color: s.textMuted),
),
dividerTheme: DividerThemeData(color: s.borderSubtle, thickness: 1),
snackBarTheme: SnackBarThemeData(
backgroundColor: isDark ? GsmColors.dRaised : GsmColors.ink,
contentTextStyle: GoogleFonts.ibmPlexSans(color: Colors.white),
shape: const RoundedRectangleBorder(borderRadius: controlRadius),
behavior: SnackBarBehavior.floating,
),
);
}
ThemeData gsmnodeLightTheme() => _base(Brightness.light, GsmSemantic.light);
ThemeData gsmnodeDarkTheme() => _base(Brightness.dark, GsmSemantic.dark);
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme.dart';
/// The gsmnode routing mark — two stacked arrows: the top (ink) routes right,
/// the bottom (green) routes left. Geometry matches assets/svg/mark-color.svg
/// (2.6px stroke on a 40px grid, rounded terminals).
///
/// When [color] is given the whole mark is drawn in that single color
/// (e.g. white on a filled tile); otherwise the top arrow takes the surface
/// ink color and the bottom arrow the signal green.
class GsmNodeMark extends StatelessWidget {
const GsmNodeMark({super.key, this.size = 32, this.color});
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
final ink = color ?? Theme.of(context).colorScheme.onSurface;
final green = color ?? GsmColors.green500;
return CustomPaint(
size: Size.square(size),
painter: _MarkPainter(ink: ink, green: green),
);
}
}
class _MarkPainter extends CustomPainter {
_MarkPainter({required this.ink, required this.green});
final Color ink;
final Color green;
@override
void paint(Canvas canvas, Size size) {
final k = size.width / 40; // scale from the 40px design grid
Paint stroke(Color c) => Paint()
..color = c
..style = PaintingStyle.stroke
..strokeWidth = 2.6 * k
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final inkPaint = stroke(ink);
final greenPaint = stroke(green);
// Top arrow — routes right (ink).
canvas.drawLine(Offset(7 * k, 15 * k), Offset(31 * k, 15 * k), inkPaint);
canvas.drawPath(
Path()
..moveTo(26 * k, 10 * k)
..lineTo(32 * k, 15 * k)
..lineTo(26 * k, 20 * k),
inkPaint,
);
// Bottom arrow — routes left (green).
canvas.drawLine(Offset(9 * k, 25 * k), Offset(33 * k, 25 * k), greenPaint);
canvas.drawPath(
Path()
..moveTo(14 * k, 20 * k)
..lineTo(8 * k, 25 * k)
..lineTo(14 * k, 30 * k),
greenPaint,
);
}
@override
bool shouldRepaint(_MarkPainter old) => old.ink != ink || old.green != green;
}
/// The gsmnode wordmark: lowercase `gsm` in ink + `node` in green, monospace.
/// On dark surfaces `node` lifts to the on-dark green. Pass [color] to render
/// the whole wordmark in a single color.
class GsmNodeWordmark extends StatelessWidget {
const GsmNodeWordmark({super.key, this.size = 24, this.color});
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final ink = color ?? Theme.of(context).colorScheme.onSurface;
final green =
color ?? (isDark ? GsmColors.greenOnDark : GsmColors.green500);
return Text.rich(
TextSpan(children: [
TextSpan(text: 'gsm', style: TextStyle(color: ink)),
TextSpan(text: 'node', style: TextStyle(color: green)),
]),
style: GoogleFonts.jetBrainsMono(
fontSize: size,
fontWeight: FontWeight.w600,
letterSpacing: -0.01 * size,
height: 1,
),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
name: sms_gateway_phone
description: gsmnode phone client — turns an Android phone into an SMS gateway controlled via the API Server.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.4.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
http: ^1.2.2
shared_preferences: ^2.3.2
permission_handler: ^11.3.1
google_fonts: ^8.1.0
dev_dependencies:
flutter_lints: ^4.0.0
flutter_launcher_icons: ^0.14.1
flutter:
uses-material-design: true
# App launcher icons — generated from the gsmnode design-system app icon.
# Regenerate with: dart run flutter_launcher_icons
flutter_launcher_icons:
android: true
image_path: "assets/icon/app-icon.png"
min_sdk_android: 21
adaptive_icon_background: "#2E9E6B"
adaptive_icon_foreground: "assets/icon/adaptive-foreground.png"