Skip to content

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

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions