Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 60 additions & 6 deletions docs/signaling_architecture_target.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,9 @@ bootstrap.dart
└── WebtritSignalingService().setIncomingCallHandler(onSignalingBackgroundIncomingCall)

MainShellState.initState()
└── SignalingServiceModuleAdapter(WebtritSignalingService())
│ implements SignalingModule interface
│ delegates start()/attach() to WebtritSignalingService
└── WebtritSignalingService(config, mode)
│ implements SignalingModule directly
│ delegates events/execute/connect to platform instance
│ Stream<SignalingModuleEvent> (replay buffer → live events)
Expand All @@ -360,9 +360,9 @@ MainShellState.initState()
│ subscribes in constructor — pure state mapping, no reconnect logic
│ commands: connect() / disconnect() / module.execute(request)
└──► background isolates (via plugin foreground service on Android)
PushNotificationIsolateManager — no reconnect, run()/close() API
SignalingForegroundIsolateManager — own timer, reconnects while started
└──► background isolates
PushNotificationIsolateManager — direct WebSocket (no FGS), no reconnect, run()/close() API
SignalingForegroundIsolateManager — FGS (persistent mode only), own timer, reconnects while started


WebtritSignalingClient ← owned by SignalingModuleImpl (1 instance at a time)
Expand All @@ -372,6 +372,60 @@ WebtritSignalingClient ← owned by SignalingModuleImpl (1 instance at a time

---

## Push-bound handoff mechanism (Android)

When the app is in push-bound mode, two isolates may open a direct WebSocket
for the same incoming call. The plugin uses `IsolateNameServer` (process-scoped
C++ runtime, shared across all Dart VMs in the process) to let the later isolate
signal the earlier one to shut down.

**Role detection — `_handoffCallback != null`:**

The plugin does not know about "Activity" or "push isolate" as concepts.
It detects the role of each isolate purely by whether `setHandoffCallback()` was
called before `_startDirect()`:

| `_handoffCallback` | Role | Behaviour in `_startDirect()` |
|--------------------|------|-------------------------------|
| set | Push isolate | Registers `ReceivePort` under `kPushHandoffPortName` in `IsolateNameServer`; waits for a signal |
| null | Non-push isolate | On `SignalingConnected`, looks up `kPushHandoffPortName`; sends null if found |

In practice the non-push isolate is always the Activity, but the mechanism is
agnostic — any isolate that omits `setHandoffCallback()` will act as the signaller.

**Sequence:**

```
Push isolate
setHandoffCallback(callback) ← registers _handoffCallback
_startDirect()
isPushIsolate = true
ReceivePort registered as kPushHandoffPortName in IsolateNameServer
WebSocket opens, push isolate handles incoming call

Non-push isolate (Activity)
_startDirect()
isPushIsolate = false
WebSocket opens
SignalingConnected →
lookupPortByName(kPushHandoffPortName) → found
port.send(null) ← signals push isolate

Push isolate receives signal
_handoffCallback() ← notifyActivityTookOver()
IsolateManager completes early, push WebSocket closes
```

**Parallel path — code 4441:**

The server also sends `controllerForceAttachClose` (code 4441) to the push
isolate when it detects a duplicate session. This produces
`SignalingDisconnected(recommendedReconnectDelay: Duration.zero)`, which the
`IsolateManager` treats as an early-exit signal. Whichever path fires first
(handoff port or 4441) closes the push session.

---

## What was deleted

| What | Where |
Expand Down
37 changes: 15 additions & 22 deletions lib/features/call/services/background_isolate_callbacks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Future<PushNotificationIsolateManager> _getOrInit() async {
if (_manager != null) return _manager!;

_context = await PushIsolateContext.init();

// The push isolate is a separate Dart VM — it never receives the setModuleFactory()
// call made in bootstrap.dart (Activity isolate). Register the factory here so
// _startDirect() can create a SignalingModule when connect() is called from run().
await WebtritSignalingService.setModuleFactory(createSignalingModule);
WebtritSignalingService.setHandoffCallback(() => _manager?.notifyActivityTookOver());
_logger.info('_getOrInit: module factory and handoff callback registered');

_manager = PushNotificationIsolateManager(
callLogsRepository: _context!.callLogsRepository,
localPushRepository: _context!.localPushRepository,
Expand All @@ -48,7 +56,7 @@ Future<PushNotificationIsolateManager> _getOrInit() async {
logger: Logger('PushNotificationIsolateManager'),
);
// init() constructs WebtritSignalingService and wires up the event subscription.
// Hub discovery and FGS start happen in connect(), which is called from run().
// The WebSocket connection starts in connect(), which is called from run().
_logger.info('_getOrInit: initialising signaling module...');
_manager!.init();
_logger.info('_getOrInit: init complete');
Expand Down Expand Up @@ -77,32 +85,17 @@ Future<void> _disposeContext() async {
///
/// ## Lifecycle and handoff
///
/// The push isolate runs until one of three outcomes:
/// The push isolate opens its own WebSocket directly (no FGS). It runs until
/// one of three outcomes:
/// - **Missed call**: [HangupEvent] received before the user answers →
/// `releaseCall()` terminates the [PhoneConnection] and stops [IncomingCallService].
/// - **Answered via push UI**: `performAnswerCall` fires before the timeout →
/// `handoffCall()` stops [IncomingCallService] without terminating the connection,
/// leaving the Activity to adopt the live call.
/// - **Activity took over via full-screen intent**: the Activity subscribes to the
/// [SignalingHub] and shows the incoming-call UI before the push isolate finishes.
/// When the timeout fires the call is still active server-side (no [HangupEvent]
/// received), so `handoffCall()` is used here too — the push isolate only
/// unsubscribes from the hub and stops [IncomingCallService]; the [PhoneConnection]
/// stays alive and the Activity continues handling the call normally.
///
/// ## SignalingForegroundService lifetime after this callback
///
/// When the push isolate unsubscribes and no other subscriber (Activity) is
/// connected, [SignalingForegroundIsolateManager] starts a grace timer
/// (`pushBoundNoSubscriberGrace`, default 10 s). During this window:
/// - If the Activity subscribes within the grace period (normal answer flow),
/// the timer is cancelled and the service keeps running.
/// - If no subscriber arrives (call was declined before the Activity launched),
/// the service stops itself after the grace period expires.
///
/// This means the [SignalingForegroundService] may stay alive for up to
/// [_kPushNotificationSyncTimeout] + `pushBoundNoSubscriberGrace` after a push
/// arrives before self-terminating in the worst case (timeout + no Activity).
/// - **Activity took over**: the Activity opens its own WebSocket, the server sends
/// 4441 (`controllerForceAttachClose`) to the push isolate, or the plugin detects
/// the Activity via [IsolateNameServer] and calls the handoff callback — whichever
/// arrives first completes the push lifecycle early via `notifyActivityTookOver()`.
@pragma('vm:entry-point')
Future<void> onPushNotificationSyncCallback(CallkeepIncomingCallMetadata? metadata) async {
try {
Expand Down
37 changes: 24 additions & 13 deletions lib/features/call/services/isolate_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import '../models/jsep_value.dart';
/// releases the incoming call service when all work is done.
/// Never reconnects — the isolate is short-lived by design.
///
/// On Android, signaling runs through the FGS hub so push isolate and Activity
/// share a single WebSocket connection. On iOS the connection runs directly in
/// the main isolate. Call [init] after construction and before [run].
/// On both Android and iOS the connection runs directly in the current isolate.
/// Call [init] after construction and before [run].
class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegate {
PushNotificationIsolateManager({
required this.callLogsRepository,
Expand Down Expand Up @@ -88,9 +87,8 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
/// Initialises the signaling module.
///
/// Must be called once after construction and before [run]. Constructs
/// [WebtritSignalingService] and wires up the event subscription. Hub
/// discovery and FGS start happen later when [connect] is called from
/// [run] via the Android plugin's [HubConnectionManager].
/// [WebtritSignalingService] and wires up the event subscription.
/// The WebSocket connection starts when [connect] is called from [run].
void init() {
_initSignaling();
_initialized = true;
Expand All @@ -109,12 +107,20 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
logger.info('run: callId=${metadata?.callId} isConnected=${_signalingModule.isConnected}');
// WebtritSignalingService.connect() is idempotent: the internal
// _startPending / _isConnected guard makes repeated calls safe.
// Always call it so HubConnectionManager starts FGS discovery on the
// first run() and is a no-op on any subsequent call.
// Always call it — idempotent on any subsequent call.
_signalingModule.connect();
return _completer!.future;
}

/// Called by the plugin when the Activity's WebSocket has connected in direct
/// push-bound mode. Completes [run]'s future early so [close] executes via the
/// [onPushNotificationSyncCallback] finally block, disposing the module and
/// cancelling any pending reconnect timers before they fire.
void notifyActivityTookOver() {
logger.info('notifyActivityTookOver: Activity WebSocket connected — completing push session early');
_complete();
}

/// Cancels all timers and pending requests, then disposes the signaling module.
Future<void> close() async {
logger.info(
Expand All @@ -139,6 +145,10 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
// instead of releasing so the ringing call is not terminated prematurely.
await _handoffCall(_metadata?.callId);
} else {
// In direct mode the push isolate's WebSocket is independent from the
// Activity's. If the Activity took over before IncomingCallEvent arrived
// (empty _incomingCallEvents), releaseCall only stops IncomingCallService
// here — the Activity keeps its own connection and handles the call normally.
await _releaseCall(_metadata?.callId);
}
_completeWithError(StateError('PushNotificationIsolateManager closed'));
Expand Down Expand Up @@ -178,11 +188,9 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
// ---------------------------------------------------------------------------

/// Sets up [WebtritSignalingService] for this isolate in
/// [SignalingServiceMode.pushBound] mode — the same mechanism the Activity
/// uses, so push isolate and Activity share exactly one FGS WebSocket on
/// Android. [HubConnectionManager] inside the service handles FGS start and
/// hub discovery. [connect] is called from [run], not here, so the
/// connection starts only when processing begins.
/// [SignalingServiceMode.pushBound] mode. Each isolate (push and Activity)
/// opens its own direct WebSocket — no shared FGS hub. [connect] is called
/// from [run], not here, so the connection starts only when processing begins.
void _initSignaling() {
logger.info('_initSignaling: creating WebtritSignalingService (pushBound)');
_signalingModule = WebtritSignalingService(
Expand All @@ -209,6 +217,9 @@ class PushNotificationIsolateManager implements CallkeepBackgroundServiceDelegat
logger.info('Signaling: disconnecting');
case SignalingDisconnected(:final code, :final reason, :final knownCode):
logger.info('Signaling: disconnected code=$code reason=$reason knownCode=$knownCode');
if (knownCode == SignalingDisconnectCode.controllerForceAttachClose) {
_complete();
}
case SignalingConnectionFailed(:final error):
_onSignalingError(error);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:flutter/foundation.dart' show VoidCallback;
import 'package:logging/logging.dart';
import 'package:webtrit_signaling/webtrit_signaling.dart';
import 'package:webtrit_signaling_service_platform_interface/webtrit_signaling_service_platform_interface.dart';
Expand Down Expand Up @@ -227,13 +228,6 @@ class WebtritSignalingService implements SignalingModule {
static Future<void> setIncomingCallHandler(Function callback) =>
SignalingServicePlatform.instance.setIncomingCallHandler(callback);

/// Connects to an already-running service hub without starting a new service.
///
/// Call this from the main isolate when the Activity opens after a push has
/// already started the service in [SignalingServiceMode.pushBound]. No-op on
/// iOS.
static Future<void> attach() => SignalingServicePlatform.instance.attach();

/// Switches the service lifecycle mode without restarting the connection.
static Future<void> updateMode(SignalingServiceMode mode) => SignalingServicePlatform.instance.updateMode(mode);

Expand All @@ -259,4 +253,14 @@ class WebtritSignalingService implements SignalingModule {
///
/// No-op on iOS. Intended for debug/QA use only to verify service-restart behaviour.
static Future<void> simulateKill() => SignalingServicePlatform.instance.simulateKill();

/// Registers a callback invoked when another isolate's WebSocket connects in
/// push-bound mode, signalling that it has taken over the call.
///
/// Call this in the push isolate before the first [WebtritSignalingService]
/// instance is created. Its presence tells the Android plugin that this is the
/// push isolate — it registers an [IsolateNameServer] port so any other isolate
/// can signal on connect. No-op on iOS.
static void setHandoffCallback(VoidCallback callback) =>
SignalingServicePlatform.instance.setHandoffCallback(callback);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ class _FakePlatform extends Fake implements SignalingServicePlatform {

final List<SignalingServiceConfig> startedConfigs = [];
final List<SignalingServiceMode> startedModes = [];
int attachCount = 0;
final List<Request> executedRequests = [];
Object? executeError;
final List<SignalingServiceMode> updatedModes = [];
Expand All @@ -41,9 +40,6 @@ class _FakePlatform extends Fake implements SignalingServicePlatform {
startedModes.add(mode);
}

@override
Future<void> attach() async => attachCount++;

@override
Future<void> execute(Request request) async {
if (executeError != null) throw executeError!;
Expand Down Expand Up @@ -302,11 +298,6 @@ void main() {
expect(platform.incomingCallHandles, [_anotherHandler]);
});

test('attach delegates to platform', () async {
await WebtritSignalingService.attach();
expect(platform.attachCount, 1);
});

test('updateMode persistent delegates to platform', () async {
await WebtritSignalingService.updateMode(SignalingServiceMode.persistent);
expect(platform.updatedModes, [SignalingServiceMode.persistent]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
/// [SendPort] to other isolates.
const kSignalingHubPortName = 'webtrit.signaling.hub';

/// [IsolateNameServer] port name registered by the push isolate in direct
/// push-bound mode. The Activity sends null to this port on [SignalingConnected]
/// so the push isolate can close early without waiting for the 20-second timeout.
const kPushHandoffPortName = 'webtrit.signaling.push_handoff';

/// Timeout for establishing a WebSocket connection.
const kSignalingClientConnectionTimeout = Duration(seconds: 10);

Expand Down
Loading
Loading