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
+130
View File
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/message.dart';
import 'storage.dart';
class ApiException implements Exception {
final int status;
final String message;
ApiException(this.status, this.message);
@override
String toString() => 'ApiException($status): $message';
}
/// Client for the API Server. Uses the user JWT for login/registration and the
/// opaque device token for the mobile gateway endpoints.
class ApiClient {
final Storage storage;
final http.Client _http;
ApiClient(this.storage, {http.Client? client})
: _http = client ?? http.Client();
String get _base => (storage.apiBase ?? '').replaceAll(RegExp(r'/$'), '');
Uri _uri(String path) => Uri.parse('$_base$path');
Map<String, String> _headers(String? token) => {
'Content-Type': 'application/json',
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
};
dynamic _decode(http.Response res) {
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
if (res.statusCode < 200 || res.statusCode >= 300) {
final msg = (body is Map && body['error'] != null)
? body['error'].toString()
: 'HTTP ${res.statusCode}';
throw ApiException(res.statusCode, msg);
}
return body;
}
/// Authenticates a user and stores the JWT. Returns the user email.
Future<String> login(String email, String password) async {
final res = await _http.post(
_uri('/api/auth/login'),
headers: _headers(null),
body: jsonEncode({'email': email, 'password': password}),
);
final data = _decode(res) as Map<String, dynamic>;
storage.jwt = data['access_token'] as String?;
final user = data['user'] as Map<String, dynamic>?;
storage.userEmail = user?['email'] as String?;
return storage.userEmail ?? email;
}
/// Registers this device and stores the returned device token.
Future<void> registerDevice({
required String deviceId,
required String name,
String platform = 'android',
String appVersion = '1.0.0',
String? pushToken,
}) async {
final res = await _http.post(
_uri('/api/mobile/v1/device'),
headers: _headers(storage.jwt),
body: jsonEncode({
'device_id': deviceId,
'name': name,
'platform': platform,
'app_version': appVersion,
if (pushToken != null) 'push_token': pushToken,
}),
);
final data = _decode(res) as Map<String, dynamic>;
storage.deviceId = deviceId;
storage.deviceName = name;
storage.deviceToken = data['auth_token'] as String?;
}
/// Pulls pending messages for this device (the server marks them Processed).
Future<List<GatewayMessage>> pullMessages() async {
final res = await _http.get(
_uri('/api/mobile/v1/messages'),
headers: _headers(storage.deviceToken),
);
final data = _decode(res) as Map<String, dynamic>;
final items = (data['items'] as List<dynamic>? ?? []);
return items
.map((e) => GatewayMessage.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Reports a message's delivery state back to the server.
Future<void> reportMessage(String id, String status, {String? error}) async {
final res = await _http.patch(
_uri('/api/mobile/v1/messages/$id'),
headers: _headers(storage.deviceToken),
body: jsonEncode({'status': status, if (error != null) 'error': error}),
);
_decode(res);
}
/// Posts an incoming SMS to the server's inbox.
Future<void> postInbox(String phoneNumber, String message,
{DateTime? receivedAt}) async {
final res = await _http.post(
_uri('/api/mobile/v1/inbox'),
headers: _headers(storage.deviceToken),
body: jsonEncode({
'phone_number': phoneNumber,
'message': message,
if (receivedAt != null)
'received_at': receivedAt.toUtc().toIso8601String(),
}),
);
_decode(res);
}
/// Sends a heartbeat ping.
Future<void> ping() async {
final res = await _http.post(
_uri('/api/mobile/v1/ping'),
headers: _headers(storage.deviceToken),
);
_decode(res);
}
}
+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../config.dart';
import 'api_client.dart';
import 'sms_service.dart';
/// A single line in the on-screen activity log.
class LogEntry {
final DateTime time;
final String text;
final bool error;
LogEntry(this.text, {this.error = false}) : time = DateTime.now();
}
/// Orchestrates the gateway loop: polls the API Server for pending messages,
/// sends them over the radio, reports delivery state, forwards incoming SMS to
/// the inbox, and sends heartbeats.
///
/// This runs in the foreground while the app is open. Moving the loop to a
/// persistent background/foreground service (WorkManager) is a documented next
/// step — see README.
class GatewayService extends ChangeNotifier {
final ApiClient api;
final SmsService sms;
GatewayService(this.api, this.sms);
bool _running = false;
bool get running => _running;
final List<LogEntry> _log = [];
List<LogEntry> get log => List.unmodifiable(_log);
Timer? _pollTimer;
Timer? _pingTimer;
StreamSubscription<IncomingSms>? _incomingSub;
StreamSubscription<SmsStatus>? _statusSub;
bool _polling = false;
void start() {
if (_running) return;
_running = true;
_log0('Gateway started');
// Keep the process alive while the screen is off / app backgrounded.
sms.startBackgroundService().catchError(
(e) => _log0('Foreground service failed to start: $e', error: true));
_incomingSub = sms.incomingSms().listen(_onIncoming, onError: (e) {
_log0('Incoming SMS stream error: $e', error: true);
});
_statusSub = sms.smsStatus().listen(_onStatus, onError: (e) {
_log0('Status stream error: $e', error: true);
});
_pollTimer = Timer.periodic(AppConfig.pollInterval, (_) => _poll());
_pingTimer = Timer.periodic(AppConfig.pingInterval, (_) => _ping());
_poll();
_ping();
notifyListeners();
}
void stop() {
_running = false;
_pollTimer?.cancel();
_pingTimer?.cancel();
_incomingSub?.cancel();
_statusSub?.cancel();
sms.stopBackgroundService().catchError((_) {});
_log0('Gateway stopped');
notifyListeners();
}
Future<void> _poll() async {
if (_polling || !_running) return;
_polling = true;
try {
final messages = await api.pullMessages();
for (final m in messages) {
var handedOff = true;
for (final phone in m.phoneNumbers) {
try {
if (m.isCall) {
await sms.placeCall(phone);
_log0('Calling $phone');
} else {
await sms.sendSms(phone, m.textMessage,
simSlot: m.simNumber, messageId: m.id);
_log0('Sending to $phone: "${_short(m.textMessage)}"');
}
} catch (e) {
handedOff = false;
await api.reportMessage(m.id, 'Failed', error: e.toString());
_log0('${m.isCall ? "Call" : "Send"} to $phone failed: $e',
error: true);
break;
}
}
// Report Sent once handed off. For SMS the native send/delivery
// PendingIntents then refine this to Delivered (or Failed) via _onStatus;
// a call has no delivery report, so it stays Sent.
if (handedOff) await api.reportMessage(m.id, 'Sent');
}
} catch (e) {
_log0('Poll error: $e', error: true);
} finally {
_polling = false;
}
}
Future<void> _ping() async {
if (!_running) return;
try {
await api.ping();
} catch (e) {
_log0('Ping failed: $e', error: true);
}
}
Future<void> _onStatus(SmsStatus s) async {
final id = s.messageId;
if (id == null || id.isEmpty) return;
try {
if (s.kind == 'delivered') {
if (s.success) {
await api.reportMessage(id, 'Delivered');
_log0('Delivered: $id');
} else {
await api.reportMessage(id, 'Failed', error: 'delivery failed');
_log0('Delivery failed: $id', error: true);
}
} else if (s.kind == 'sent' && !s.success) {
await api.reportMessage(id, 'Failed', error: 'radio rejected message');
_log0('Send rejected by radio: $id', error: true);
}
} catch (e) {
_log0('Report status failed: $e', error: true);
}
}
Future<void> _onIncoming(IncomingSms msg) async {
_log0('Received from ${msg.from}: "${_short(msg.body)}"');
try {
await api.postInbox(msg.from, msg.body, receivedAt: msg.timestamp);
} catch (e) {
_log0('Forward inbox failed: $e', error: true);
}
}
void _log0(String text, {bool error = false}) {
_log.insert(0, LogEntry(text, error: error));
if (_log.length > 100) _log.removeLast();
notifyListeners();
}
String _short(String s) => s.length > 40 ? '${s.substring(0, 40)}' : s;
@override
void dispose() {
stop();
super.dispose();
}
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/services.dart';
/// An incoming SMS delivered from the native side.
class IncomingSms {
final String from;
final String body;
final DateTime timestamp;
IncomingSms(this.from, this.body, this.timestamp);
}
/// A send/delivery status report for an outbound message.
class SmsStatus {
final String? messageId;
final String kind; // 'sent' or 'delivered'
final bool success;
SmsStatus(this.messageId, this.kind, this.success);
}
/// Bridges to the native Android side: SmsManager (sending), a BroadcastReceiver
/// (incoming), send/delivery PendingIntents (status), and the foreground service
/// that keeps the gateway alive. See android_overlay/ for the Kotlin side.
class SmsService {
static const _method = MethodChannel('app.gsmnode/sms');
static const _events = EventChannel('app.gsmnode/sms_incoming');
static const _status = EventChannel('app.gsmnode/sms_status');
/// Sends an SMS via the device radio. [simSlot] is 0-based; null = default SIM.
/// [messageId] is echoed back on the status stream for correlation.
/// Throws [PlatformException] on failure.
Future<void> sendSms(String phoneNumber, String message,
{int? simSlot, String? messageId}) async {
await _method.invokeMethod('sendSms', {
'phone': phoneNumber,
'message': message,
'simSlot': simSlot,
'messageId': messageId,
});
}
/// Places a phone call to [phoneNumber] via the native dialer (ACTION_CALL).
/// Requires the CALL_PHONE permission. Throws [PlatformException] on failure.
Future<void> placeCall(String phoneNumber) async {
await _method.invokeMethod('placeCall', {'phone': phoneNumber});
}
/// Starts the foreground service so the gateway loop survives screen-off.
Future<void> startBackgroundService() => _method.invokeMethod('startService');
/// Stops the foreground service.
Future<void> stopBackgroundService() => _method.invokeMethod('stopService');
/// Stream of incoming SMS messages (active while the app process is alive).
Stream<IncomingSms> incomingSms() {
return _events.receiveBroadcastStream().map((event) {
final map = Map<String, dynamic>.from(event as Map);
final ts = map['timestamp'] as int?;
return IncomingSms(
map['from'] as String? ?? '',
map['body'] as String? ?? '',
ts != null ? DateTime.fromMillisecondsSinceEpoch(ts) : DateTime.now(),
);
});
}
/// Stream of send/delivery status reports for outbound messages.
Stream<SmsStatus> smsStatus() {
return _status.receiveBroadcastStream().map((event) {
final map = Map<String, dynamic>.from(event as Map);
return SmsStatus(
map['messageId'] as String?,
map['kind'] as String? ?? '',
map['success'] == true,
);
});
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Persists connection settings and credentials across app launches.
class Storage {
static const _kApiBase = 'api_base';
static const _kJwt = 'jwt';
static const _kDeviceToken = 'device_token';
static const _kDeviceId = 'device_id';
static const _kDeviceName = 'device_name';
static const _kUserEmail = 'user_email';
final SharedPreferences _prefs;
Storage(this._prefs);
static Future<Storage> create() async {
return Storage(await SharedPreferences.getInstance());
}
String? get apiBase => _prefs.getString(_kApiBase);
set apiBase(String? v) => _set(_kApiBase, v);
String? get jwt => _prefs.getString(_kJwt);
set jwt(String? v) => _set(_kJwt, v);
String? get deviceToken => _prefs.getString(_kDeviceToken);
set deviceToken(String? v) => _set(_kDeviceToken, v);
String? get deviceId => _prefs.getString(_kDeviceId);
set deviceId(String? v) => _set(_kDeviceId, v);
String? get deviceName => _prefs.getString(_kDeviceName);
set deviceName(String? v) => _set(_kDeviceName, v);
String? get userEmail => _prefs.getString(_kUserEmail);
set userEmail(String? v) => _set(_kUserEmail, v);
bool get isRegistered => (deviceToken ?? '').isNotEmpty;
Future<void> clearSession() async {
await _prefs.remove(_kJwt);
await _prefs.remove(_kDeviceToken);
await _prefs.remove(_kUserEmail);
// Keep device_id stable across logout so re-registering updates the same
// device record instead of creating a new (phantom) one.
}
void _set(String key, String? v) {
if (v == null) {
_prefs.remove(key);
} else {
_prefs.setString(key, v);
}
}
}