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>
@@ -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/
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
avoid_print: false
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||