Skip to content

feat: standalone webtrit_signaling_service plugin — single WebSocket hub across all isolates #1019

@SERDUN

Description

@SERDUN

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.dartsetIncomingCallHandler(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()
CallBlocupdateActivitySignalingStatus() 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions