Rebrand CommGate to gsmnode and adopt new design system

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "app.gsmnode.phone"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "app.gsmnode.phone"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,76 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Sending and receiving SMS -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<!-- Placing outbound calls remotely -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!-- Optional: needed to choose a specific SIM on dual-SIM devices -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Keep the gateway alive in the background (foreground service path) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android 13+ runtime permission for the ongoing service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="gsmnode"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Incoming SMS broadcast receiver -->
<receiver
android:name=".SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- Receives send/delivery outcomes from PendingIntents (explicit) -->
<receiver
android:name=".SmsStatusReceiver"
android:exported="false" />
<!-- Foreground service that keeps the gateway loop alive -->
<service
android:name=".GatewayForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required so plugins (e.g. url launches) can query intents on Android 11+ -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,99 @@
package app.gsmnode.phone
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/// Foreground service that keeps the app process alive (and the CPU awake via a
/// partial wakelock) so the Dart gateway loop keeps polling/sending while the
/// screen is off or the app is in the background.
///
/// Note: this closes the "survives screen lock" gap. Surviving a full task
/// removal or Doze for hours would additionally need a background Dart isolate
/// (e.g. flutter_background_service) — documented as a further step.
class GatewayForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
companion object {
private const val CHANNEL_ID = "sms_gateway_service"
private const val NOTIFICATION_ID = 4711
fun start(context: Context) {
val intent = Intent(context, GatewayForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, GatewayForegroundService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
acquireWakeLock()
// Restart if the system kills the service.
return START_STICKY
}
private fun startForegroundCompat() {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("gsmnode")
.setContentText("Gateway is running")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
@Suppress("WakelockTimeout")
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "gsmnode:gateway"
).also { it.acquire() }
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "gsmnode",
NotificationManager.IMPORTANCE_LOW
).apply { description = "Keeps the SMS gateway running" }
getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,187 @@
package app.gsmnode.phone
import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager
import android.telephony.SmsManager
import android.telephony.SubscriptionManager
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
const val METHOD_CHANNEL = "app.gsmnode/sms"
const val EVENT_CHANNEL = "app.gsmnode/sms_incoming"
const val STATUS_CHANNEL = "app.gsmnode/sms_status"
const val STATUS_ACTION = "app.gsmnode.SMS_STATUS"
// Sinks used by the broadcast receivers to push events into Dart.
@Volatile var incomingSink: EventChannel.EventSink? = null
@Volatile var statusSink: EventChannel.EventSink? = null
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"sendSms" -> {
val phone = call.argument<String>("phone")
val message = call.argument<String>("message")
val simSlot = call.argument<Int>("simSlot")
val messageId = call.argument<String>("messageId")
if (phone.isNullOrBlank() || message == null) {
result.error("BAD_ARGS", "phone and message are required", null)
return@setMethodCallHandler
}
try {
sendSms(phone, message, simSlot, messageId)
result.success(true)
} catch (e: Exception) {
result.error("SEND_FAILED", e.message, null)
}
}
"placeCall" -> {
val phone = call.argument<String>("phone")
if (phone.isNullOrBlank()) {
result.error("BAD_ARGS", "phone is required", null)
return@setMethodCallHandler
}
try {
placeCall(phone)
result.success(true)
} catch (e: Exception) {
result.error("CALL_FAILED", e.message, null)
}
}
"startService" -> {
GatewayForegroundService.start(this)
result.success(true)
}
"stopService" -> {
GatewayForegroundService.stop(this)
result.success(true)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
incomingSink = sink
}
override fun onCancel(args: Any?) {
incomingSink = null
}
})
EventChannel(messenger, STATUS_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
statusSink = sink
}
override fun onCancel(args: Any?) {
statusSink = null
}
})
}
private fun smsManagerFor(simSlot: Int?): SmsManager {
if (simSlot == null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
else
@Suppress("DEPRECATION") SmsManager.getDefault()
}
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
if (hasPerm) {
val sm = getSystemService(SubscriptionManager::class.java)
val info = sm?.getActiveSubscriptionInfoForSimSlotIndex(simSlot)
if (info != null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
.createForSubscriptionId(info.subscriptionId)
else
@Suppress("DEPRECATION")
SmsManager.getSmsManagerForSubscriptionId(info.subscriptionId)
}
}
return smsManagerFor(null)
}
/// Builds a PendingIntent that, when fired by the radio, broadcasts the
/// send/delivery outcome to SmsStatusReceiver (tagged with the message id).
private fun statusPendingIntent(messageId: String?, phone: String, kind: String): PendingIntent {
val intent = Intent(this, SmsStatusReceiver::class.java).apply {
action = STATUS_ACTION
putExtra("messageId", messageId)
putExtra("phone", phone)
putExtra("kind", kind)
}
val requestCode = (messageId.orEmpty() + phone + kind).hashCode()
return PendingIntent.getBroadcast(
this, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/// Places a phone call. Requires the CALL_PHONE permission (requested in Dart
/// via the phone permission group).
///
/// Uses TelecomManager.placeCall, which routes through the system telecom
/// service rather than starting the dialer activity ourselves. This is what
/// lets the call go through when the screen is locked / the app is in the
/// background — a plain startActivity(ACTION_CALL) is blocked by Android's
/// background-activity-start restrictions in that state. Falls back to
/// ACTION_CALL only on very old devices without TelecomManager.placeCall.
private fun placeCall(phone: String) {
val uri = Uri.fromParts("tel", phone, null)
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.CALL_PHONE
) == PackageManager.PERMISSION_GRANTED
if (!hasPerm) {
throw SecurityException("CALL_PHONE permission not granted")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val telecom = getSystemService(TelecomManager::class.java)
if (telecom != null) {
telecom.placeCall(uri, Bundle())
return
}
}
val intent = Intent(Intent.ACTION_CALL, uri)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun sendSms(phone: String, message: String, simSlot: Int?, messageId: String?) {
val sms = smsManagerFor(simSlot)
val parts = sms.divideMessage(message)
val sentPI = statusPendingIntent(messageId, phone, "sent")
val deliveredPI = statusPendingIntent(messageId, phone, "delivered")
if (parts.size > 1) {
val sentList = ArrayList<PendingIntent>(parts.size)
val deliveredList = ArrayList<PendingIntent>(parts.size)
for (i in parts.indices) {
sentList.add(sentPI)
deliveredList.add(deliveredPI)
}
sms.sendMultipartTextMessage(phone, null, parts, sentList, deliveredList)
} else {
sms.sendTextMessage(phone, null, message, sentPI, deliveredPI)
}
}
}
@@ -0,0 +1,40 @@
package app.gsmnode.phone
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.Telephony
/// Receives incoming SMS and forwards each message to Dart via the EventChannel
/// sink held by MainActivity. Works while the app process is alive.
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return
if (messages.isEmpty()) return
// Multipart SMS arrive as several PDUs from the same sender; concatenate.
val from = messages[0].displayOriginatingAddress ?: ""
val body = StringBuilder()
var timestamp = System.currentTimeMillis()
for (m in messages) {
body.append(m.displayMessageBody ?: "")
timestamp = m.timestampMillis
}
val payload = mapOf(
"from" to from,
"body" to body.toString(),
"timestamp" to timestamp,
)
// EventSink must be touched on the main thread.
Handler(Looper.getMainLooper()).post {
MainActivity.incomingSink?.success(payload)
}
}
}
@@ -0,0 +1,33 @@
package app.gsmnode.phone
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
/// Receives SMS send/delivery outcomes from the PendingIntents created in
/// MainActivity.sendSms and forwards them to Dart, tagged with the message id
/// so the gateway loop can report Sent / Delivered / Failed to the API Server.
class SmsStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messageId = intent.getStringExtra("messageId")
val kind = intent.getStringExtra("kind") ?: return
// For "sent": RESULT_OK means the radio accepted the message.
// For "delivered": RESULT_OK means a positive delivery report arrived.
val success = resultCode == Activity.RESULT_OK
val payload = mapOf(
"messageId" to messageId,
"kind" to kind,
"success" to success,
"resultCode" to resultCode,
)
Handler(Looper.getMainLooper()).post {
MainActivity.statusSink?.success(payload)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

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

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2E9E6B</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>