Files
GsmNode/Phone App/lib/services/gateway_service.dart
T
tajniak81 d6956395c8 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>
2026-07-06 08:53:14 +02:00

166 lines
4.8 KiB
Dart

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();
}
}