Status
Implemented — PR #1026 (feat/webtrit-signaling-service-plugin-decompose)
Problem
On Android, up to 3 independent WebSocket connections to the signaling server could coexist from a single device simultaneously:
| Context |
Class |
Trigger |
| Main UI |
WebtritSignalingClient in CallBloc |
app lifecycle |
| Foreground service |
SignalingManager in SignalingForegroundIsolateManager |
app backgrounded |
| Push isolate |
SignalingManager in PushNotificationIsolateManager |
FCM push |
All three passed force = true. The server force-closed the previous session (close code 4441) on every new connection, creating a connection race loop. Additionally: ~766 lines of duplicated signaling code, no real-time cross-isolate event delivery, 3 network monitors and 3 reconnect timers per device.
Solution implemented
A standalone webtrit_signaling_service plugin runs the WebSocket inside an Android foreground service and exposes a single typed event stream to any isolate that subscribes — without opening a second connection.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Android process │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ Main isolate │ │ Service isolate │ │
│ │ │ │ (SignalingForegroundService)│ │
│ │ CallBloc │ │ │ │
│ │ └─ HubModule ◄────┼────┼── SignalingHub │ │
│ │ events stream │ │ └─ SignalingModule │ │
│ │ │ │ WebSocket ──► Core │ │
│ │ execute(request) ────┼────┼──────────────────────────► │ │
│ └──────────────────────┘ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ Push isolate │ │
│ │ └─ HubModule ◄────┼────── IsolateNameServer (same port) │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
One WebSocket. Multiple subscribers. Zero extra connections.
Hub ↔ subscriber communication uses IsolateNameServer (pure Dart ports). Pigeon is used only for lifecycle commands (startService, stopService, saveIncomingCallHandler).
Three-layer model
| Layer |
Owns |
Does NOT own |
WebtritSignalingClient |
Raw WebSocket, JSON, keepalive |
App lifecycle, reconnect |
SignalingModule |
Client lifecycle, disconnect codes, session buffer |
Network state, active calls |
CallBloc / IsolateManager |
App lifecycle, network, reconnect decision |
WebSocket internals |
Service modes
| Mode |
Lifecycle |
Incoming calls |
persistent |
Survives app close + reboot (SignalingBootReceiver) |
WebSocket always live → direct IncomingCallEvent |
pushBound |
Stops on onTaskRemoved |
Server sends FCM push → push isolate calls start(pushBound) |
iOS
No foreground service — SignalingModule runs directly in the main isolate. CallBloc is identical on both platforms: one Stream<SignalingModuleEvent> and one execute().
What changed
New: packages/webtrit_signaling_service/
| Package |
Role |
webtrit_signaling_service |
Public façade (WebtritSignalingService) |
webtrit_signaling_service_platform_interface |
Abstract contract + sealed SignalingModuleEvent model |
webtrit_signaling_service_android |
Foreground service + SignalingHub via IsolateNameServer |
webtrit_signaling_service_ios |
Direct main-isolate SignalingModule |
App integration
SignalingServiceModuleAdapter — thin adapter from SignalingModuleInterface to the plugin
bootstrap.dart — setIncomingCallHandler(callback) registered alongside callkeep callback; no PluginUtilities at call site
- Removed: in-app
SignalingModule, SignalingManager, callkeep_signaling_status_converter
- API:
setIncomingCallHandler accepts Function instead of raw int handle — plugin resolves handle internally via PluginUtilities.getCallbackHandle
Key metrics
|
Before |
After |
| WebSocket connections per device |
up to 3 |
1 |
force=true connection races |
possible |
eliminated |
| Signaling code locations |
2 (CallBloc + SignalingManager) |
1 (SignalingHub) |
| Real-time cross-isolate events |
none |
broadcast via SendPort |
| Push latency (hub already connected) |
always reconnect |
subscribes to existing session |
Remaining work
webtrit_callkeep cleanup (separate PR)
The SignalingStatusBroadcaster subsystem was designed to prevent the service isolate from starting a WebSocket when main was already connected. With SignalingHub, main never has its own WebSocket — this coordination is now obsolete.
| Component |
Action |
PCallkeepServiceStatus.mainSignalingStatus |
Remove (Pigeon schema) |
PCallkeepSignalingStatus enum |
Remove (Pigeon schema) |
PHostConnectionsApi.updateActivitySignalingStatus() |
Remove (Pigeon schema) |
SignalingStatusBroadcaster.kt |
Remove |
SignalingStatus.kt |
Remove |
SignalingStatusStrategy in IsolateSelectionStrategy.kt |
Remove |
IsolateSelector.getStrategy() |
Simplify — always ActivityStateStrategy() |
CallBloc — updateActivitySignalingStatus() call |
Remove |
Documentation
Test coverage
| Package |
Tests |
webtrit_signaling_service |
13 — facade delegation |
webtrit_signaling_service_platform_interface |
32 — event model |
webtrit_signaling_service_android |
177 — hub, codec, module, lifecycle |
webtrit_signaling_service_ios |
59 — plugin, SignalingModule |
Related
Status
Implemented — PR #1026 (
feat/webtrit-signaling-service-plugin-decompose)Problem
On Android, up to 3 independent WebSocket connections to the signaling server could coexist from a single device simultaneously:
WebtritSignalingClientinCallBlocSignalingManagerinSignalingForegroundIsolateManagerSignalingManagerinPushNotificationIsolateManagerAll three passed
force = true. The server force-closed the previous session (close code 4441) on every new connection, creating a connection race loop. Additionally: ~766 lines of duplicated signaling code, no real-time cross-isolate event delivery, 3 network monitors and 3 reconnect timers per device.Solution implemented
A standalone
webtrit_signaling_serviceplugin runs the WebSocket inside an Android foreground service and exposes a single typed event stream to any isolate that subscribes — without opening a second connection.Architecture
One WebSocket. Multiple subscribers. Zero extra connections.
Hub ↔ subscriber communication uses
IsolateNameServer(pure Dart ports). Pigeon is used only for lifecycle commands (startService,stopService,saveIncomingCallHandler).Three-layer model
WebtritSignalingClientSignalingModuleCallBloc/IsolateManagerService modes
persistentSignalingBootReceiver)IncomingCallEventpushBoundonTaskRemovedstart(pushBound)iOS
No foreground service —
SignalingModuleruns directly in the main isolate.CallBlocis identical on both platforms: oneStream<SignalingModuleEvent>and oneexecute().What changed
New:
packages/webtrit_signaling_service/webtrit_signaling_serviceWebtritSignalingService)webtrit_signaling_service_platform_interfaceSignalingModuleEventmodelwebtrit_signaling_service_androidSignalingHubviaIsolateNameServerwebtrit_signaling_service_iosSignalingModuleApp integration
SignalingServiceModuleAdapter— thin adapter fromSignalingModuleInterfaceto the pluginbootstrap.dart—setIncomingCallHandler(callback)registered alongside callkeep callback; noPluginUtilitiesat call siteSignalingModule,SignalingManager,callkeep_signaling_status_convertersetIncomingCallHandleracceptsFunctioninstead of rawinthandle — plugin resolves handle internally viaPluginUtilities.getCallbackHandleKey metrics
force=trueconnection racesCallBloc+SignalingManager)SignalingHub)SendPortRemaining work
webtrit_callkeepcleanup (separate PR)The
SignalingStatusBroadcastersubsystem was designed to prevent the service isolate from starting a WebSocket when main was already connected. WithSignalingHub, main never has its own WebSocket — this coordination is now obsolete.PCallkeepServiceStatus.mainSignalingStatusPCallkeepSignalingStatusenumPHostConnectionsApi.updateActivitySignalingStatus()SignalingStatusBroadcaster.ktSignalingStatus.ktSignalingStatusStrategyinIsolateSelectionStrategy.ktIsolateSelector.getStrategy()ActivityStateStrategy()CallBloc—updateActivitySignalingStatus()callDocumentation
Test coverage
webtrit_signaling_servicewebtrit_signaling_service_platform_interfacewebtrit_signaling_service_androidwebtrit_signaling_service_iosRelated