All checks were successful
Create Release / build (push) Successful in 4s
Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for mobile VoIP, implement unhold_call() with call leg detection, wire FCM push notifications into call queue and webhook missed call handlers, add data-only FCM message support for Android background wake, and add Twilio API Key / Push Credential settings fields. Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth with auto-refresh, SSE real-time queue updates, FCM push notifications, Material 3 UI with dashboard, active call screen, dialpad, and call controls (mute/speaker/hold/transfer). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
120 lines
3.2 KiB
Dart
120 lines
3.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import '../config/app_config.dart';
|
|
import 'api_client.dart';
|
|
|
|
class SseEvent {
|
|
final String event;
|
|
final Map<String, dynamic> data;
|
|
|
|
SseEvent({required this.event, required this.data});
|
|
}
|
|
|
|
class SseService {
|
|
final ApiClient _api;
|
|
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
|
final StreamController<SseEvent> _eventController =
|
|
StreamController<SseEvent>.broadcast();
|
|
final StreamController<bool> _connectionController =
|
|
StreamController<bool>.broadcast();
|
|
|
|
CancelToken? _cancelToken;
|
|
Timer? _reconnectTimer;
|
|
int _reconnectAttempt = 0;
|
|
bool _shouldReconnect = true;
|
|
|
|
Stream<SseEvent> get events => _eventController.stream;
|
|
Stream<bool> get connectionState => _connectionController.stream;
|
|
|
|
SseService(this._api);
|
|
|
|
Future<void> connect() async {
|
|
_shouldReconnect = true;
|
|
_reconnectAttempt = 0;
|
|
await _doConnect();
|
|
}
|
|
|
|
Future<void> _doConnect() async {
|
|
_cancelToken?.cancel();
|
|
_cancelToken = CancelToken();
|
|
|
|
try {
|
|
final token = await _storage.read(key: 'access_token');
|
|
final response = await _api.dio.get(
|
|
'/stream/events',
|
|
options: Options(
|
|
headers: {'Authorization': 'Bearer $token'},
|
|
responseType: ResponseType.stream,
|
|
),
|
|
cancelToken: _cancelToken,
|
|
);
|
|
|
|
_connectionController.add(true);
|
|
_reconnectAttempt = 0;
|
|
|
|
final stream = response.data.stream as Stream<List<int>>;
|
|
String buffer = '';
|
|
|
|
await for (final chunk in stream) {
|
|
buffer += utf8.decode(chunk);
|
|
final lines = buffer.split('\n');
|
|
buffer = lines.removeLast(); // keep incomplete line in buffer
|
|
|
|
String? eventName;
|
|
String? dataStr;
|
|
|
|
for (final line in lines) {
|
|
if (line.startsWith('event:')) {
|
|
eventName = line.substring(6).trim();
|
|
} else if (line.startsWith('data:')) {
|
|
dataStr = line.substring(5).trim();
|
|
} else if (line.isEmpty && eventName != null && dataStr != null) {
|
|
try {
|
|
final data = jsonDecode(dataStr) as Map<String, dynamic>;
|
|
_eventController.add(SseEvent(event: eventName, data: data));
|
|
} catch (_) {}
|
|
eventName = null;
|
|
dataStr = null;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
|
_connectionController.add(false);
|
|
}
|
|
|
|
if (_shouldReconnect) {
|
|
_scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
void _scheduleReconnect() {
|
|
_reconnectTimer?.cancel();
|
|
final delay = Duration(
|
|
milliseconds: min(
|
|
AppConfig.sseMaxReconnect.inMilliseconds,
|
|
AppConfig.sseReconnectBase.inMilliseconds *
|
|
pow(2, _reconnectAttempt).toInt(),
|
|
),
|
|
);
|
|
_reconnectAttempt++;
|
|
_reconnectTimer = Timer(delay, _doConnect);
|
|
}
|
|
|
|
void disconnect() {
|
|
_shouldReconnect = false;
|
|
_reconnectTimer?.cancel();
|
|
_cancelToken?.cancel();
|
|
_connectionController.add(false);
|
|
}
|
|
|
|
void dispose() {
|
|
disconnect();
|
|
_eventController.close();
|
|
_connectionController.close();
|
|
}
|
|
}
|