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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user