Skip to content

fix: restore accepted call from signaling handshake on app restart (WT-1167)#1070

Merged
SERDUN merged 22 commits into
developfrom
fix/wt-1167-call-restoration
Apr 6, 2026
Merged

fix: restore accepted call from signaling handshake on app restart (WT-1167)#1070
SERDUN merged 22 commits into
developfrom
fix/wt-1167-call-restoration

Conversation

@SERDUN

@SERDUN SERDUN commented Apr 5, 2026

Copy link
Copy Markdown
Member

Summary

Restores active calls (both incoming and outgoing) after the app is killed mid-call and relaunched. The signaling server delivers current call state via StateHandshake on reconnect; this PR translates that into the correct BLoC and Callkeep actions.

Root causes fixed

1. HandshakeProcessor did not detect calls after re-INVITE / transfer
After a transfer or SDP renegotiation the server inserts UpdatedEvent as the newest call_logs entry. The processor checked only firstOrNull for AcceptedEvent, so the call was never restored. Fixed by searching the full call_logs list for any AcceptedEvent and guarding against server-terminated calls via HangupEvent/MissedCallEvent as the latest entry.

2. stateDisconnected check used a whitelist that excluded UpdatedEvent
The whitelist AcceptedEvent | ProceedingEvent caused calls that ended with UpdatedEvent as their last log entry to produce no HangupSignalingAction. Replaced with a blacklist (skip only HangupEvent and MissedCallEvent, which mean the server already terminated the call).

3. Outgoing call restoration did not register with Android Telecom
_onRestoreAcceptedCall called reportNewIncomingCall + answerCall only for incoming direction. Outgoing calls had no Callkeep registration, so no native call UI appeared. Added startCall for the outgoing branch (symmetric to the existing incoming branch).

4. performStartCall ran the full outgoing flow for restored calls
After startCall, Android Telecom fires performStartCall unconditionally. Without a guard, __onCallPerformEventStarted would try to create a new SDP offer and send OutgoingCallRequest for an already-active call. Added a canPerformStart switch (mirrors the existing canPerformAnswer in __onCallPerformEventAnswered) that detects a restored call by its processingStatus and instead calls reportConnectedOutgoingCall to advance Telecom to ACTIVE state, then returns.

Changes

File Change
lib/features/call/utils/handshake_processor.dart Search AcceptedEvent in full log list; blacklist-based termination and stateDisconnected checks
lib/features/call/bloc/call_bloc.dart _onRestoreAcceptedCall: outgoing Callkeep branch (startCall); __onCallPerformEventStarted: canPerformStart guard
test/features/call/bloc/handshake_processor_test.dart New tests: re-INVITE restoration, HangupEvent termination guard, outgoing restoration cases

Test plan

  • All 308 unit tests pass (flutter test)
  • Incoming call active → kill app → relaunch → call restored with audio
  • Outgoing call active → kill app → relaunch → call restored with audio and native UI
  • Incoming call → transfer (re-INVITE) → kill app → relaunch → call restored
  • Call hungup before kill → relaunch → no restoration attempt

…T-1167)

- Add HandshakeAction sealed hierarchy and stateless HandshakeProcessor
- Add _RestoreAcceptedIncomingCall event and handler in CallBloc
- Add incomingRestoringMedia processing status
- Dispatch renegotiationNeeded explicitly after peer connection setup to
  bypass null signalingState guard on brand-new RTCPeerConnection
@SERDUN SERDUN force-pushed the fix/wt-1167-call-restoration branch from eb65136 to bc6a849 Compare April 5, 2026 19:15

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

SERDUN added 8 commits April 6, 2026 07:35
- Guard reportNewIncomingCall/answerCall behind direction == incoming check in
  _onRestoreAcceptedCall to avoid registering a restored outgoing call as incoming
- Remove connection == null guard from HandshakeProcessor restoration condition:
  if stateDisconnected already triggers early exit, stateActive connections should
  not block restoration when no BLoC state exists
- Update handshake_processor_test.dart: fix stale guest-line test to check
  AcceptedEvent.line (not IncomingCallEvent.line); add outgoing restoration tests;
  update import to utils/handshake_processor.dart
…tCall (WT-1167)

- _onRestoreAcceptedCall: added outgoing Callkeep branch (startCall +
  warning on error), symmetric to the existing incoming branch
- __onCallPerformEventStarted: added canPerformStart switch guard
  (mirrors the existing canPerformAnswer in __onCallPerformEventAnswered)
  so that the Telecom-mandatory performStartCall callback for a restored
  call skips the normal outgoing flow and only advances Telecom to ACTIVE
  via reportConnectedOutgoingCall
… full call_logs (WT-1167)

HandshakeProcessor previously checked only the newest call_logs entry for
AcceptedEvent. After a transfer or SDP re-INVITE the server inserts
UpdatedEvent/UpdatingCallEvent as the newest entry, causing restoration to
be silently skipped.

Changes:
- Restoration: search the full call_logs list for the first AcceptedEvent
  instead of checking only firstOrNull; guard against server-terminated
  calls via HangupEvent/MissedCallEvent as latest entry
- stateDisconnected: replace AcceptedEvent|ProceedingEvent whitelist with
  a blacklist (HangupEvent, MissedCallEvent) so UpdatedEvent and any other
  non-terminal event correctly produces HangupSignalingAction
- Tests: replace the obsolete "specific order required" test with two new
  cases: re-INVITE restoration and HangupEvent termination guard
@SERDUN SERDUN force-pushed the fix/wt-1167-call-restoration branch from b011d1d to 23241e7 Compare April 6, 2026 08:36

This comment was marked as resolved.

Revert dart_define.json to main — local dev config values should not be tracked in this PR.
@SERDUN SERDUN force-pushed the fix/wt-1167-call-restoration branch from 1a72a0e to 432697a Compare April 6, 2026 09:50
SERDUN added 3 commits April 6, 2026 12:54
…hakeProcessor (WT-1167)

- HangupSignalingAction: clarify trigger condition (any non-terminal event, not only AcceptedEvent/ProceedingEvent)
- RestoreCallAction: clarify that AcceptedEvent is searched in the full log, not just the latest entry
- HandshakeProcessor: align Loop B bullet list with actual implementation
- fix typo 'due stale' -> 'due to stale' in three log messages
…T-1167)

incomingRestoringMedia was used for both incoming and outgoing restored calls.
Introduce outgoingRestoringMedia in the enum and assign it based on direction
in _onRestoreAcceptedCall. Both statuses fall outside canPerformAnswer and
canPerformStart whitelists, preserving the existing bypass logic.
@SERDUN SERDUN force-pushed the fix/wt-1167-call-restoration branch 3 times, most recently from f922fbb to 639446c Compare April 6, 2026 10:45
@SERDUN SERDUN marked this pull request as ready for review April 6, 2026 10:53
@SERDUN SERDUN force-pushed the fix/wt-1167-call-restoration branch from afed5de to a97e09f Compare April 6, 2026 10:58
@SERDUN SERDUN requested a review from digiboridev April 6, 2026 11:02
@SERDUN SERDUN merged commit 7ffd42a into develop Apr 6, 2026
1 check passed
@SERDUN SERDUN deleted the fix/wt-1167-call-restoration branch April 6, 2026 11:07
SERDUN added a commit that referenced this pull request Apr 6, 2026
…T-1167) (#1070)

* fix: restore accepted call from signaling handshake on app restart (WT-1167)

- Add HandshakeAction sealed hierarchy and stateless HandshakeProcessor
- Add _RestoreAcceptedIncomingCall event and handler in CallBloc
- Add incomingRestoringMedia processing status
- Dispatch renegotiationNeeded explicitly after peer connection setup to
  bypass null signalingState guard on brand-new RTCPeerConnection

* refactor: move HandshakeProcessor and HandshakeAction to utils/, merge into one file

* refactor: export HandshakeProcessor via utils barrel

* revert: restore device_auto_rotate pubspec.lock to develop state

* refactor: remove task references from comments

* style: replace non-ASCII symbols in comments with ASCII equivalents

* style: clean up Copilot comments in call restoration code

* fix: log warning on duplicate call restoration guard

* fix: restore outgoing calls from handshake by checking AcceptedEvent as latest log

* fix: skip Callkeep incoming registration for outgoing call restoration

- Guard reportNewIncomingCall/answerCall behind direction == incoming check in
  _onRestoreAcceptedCall to avoid registering a restored outgoing call as incoming
- Remove connection == null guard from HandshakeProcessor restoration condition:
  if stateDisconnected already triggers early exit, stateActive connections should
  not block restoration when no BLoC state exists
- Update handshake_processor_test.dart: fix stale guest-line test to check
  AcceptedEvent.line (not IncomingCallEvent.line); add outgoing restoration tests;
  update import to utils/handshake_processor.dart

* fix: register restored outgoing call in Telecom and guard performStartCall (WT-1167)

- _onRestoreAcceptedCall: added outgoing Callkeep branch (startCall +
  warning on error), symmetric to the existing incoming branch
- __onCallPerformEventStarted: added canPerformStart switch guard
  (mirrors the existing canPerformAnswer in __onCallPerformEventAnswered)
  so that the Telecom-mandatory performStartCall callback for a restored
  call skips the normal outgoing flow and only advances Telecom to ACTIVE
  via reportConnectedOutgoingCall

* fix: detect restoration after re-INVITE by searching AcceptedEvent in full call_logs (WT-1167)

HandshakeProcessor previously checked only the newest call_logs entry for
AcceptedEvent. After a transfer or SDP re-INVITE the server inserts
UpdatedEvent/UpdatingCallEvent as the newest entry, causing restoration to
be silently skipped.

Changes:
- Restoration: search the full call_logs list for the first AcceptedEvent
  instead of checking only firstOrNull; guard against server-terminated
  calls via HangupEvent/MissedCallEvent as latest entry
- stateDisconnected: replace AcceptedEvent|ProceedingEvent whitelist with
  a blacklist (HangupEvent, MissedCallEvent) so UpdatedEvent and any other
  non-terminal event correctly produces HangupSignalingAction
- Tests: replace the obsolete "specific order required" test with two new
  cases: re-INVITE restoration and HangupEvent termination guard

* docs: clarify performStartCall interception for restored outgoing call (WT-1167)

* style: replace non-ASCII characters with ASCII equivalents in comments (WT-1167)

* docs: clarify incomingOffer role and media re-establishment path during restoration (WT-1167)

* style: restore UTF-8 symbols in restoration comment (WT-1167)

* style: shorten redundant startCall comment in restoration path (WT-1167)

Revert dart_define.json to main — local dev config values should not be tracked in this PR.

* docs: fix inaccurate docstrings in HandshakeAction subtypes and HandshakeProcessor (WT-1167)

- HangupSignalingAction: clarify trigger condition (any non-terminal event, not only AcceptedEvent/ProceedingEvent)
- RestoreCallAction: clarify that AcceptedEvent is searched in the full log, not just the latest entry
- HandshakeProcessor: align Loop B bullet list with actual implementation
- fix typo 'due stale' -> 'due to stale' in three log messages

* fix: use outgoingRestoringMedia status for restored outgoing calls (WT-1167)

incomingRestoringMedia was used for both incoming and outgoing restored calls.
Introduce outgoingRestoringMedia in the enum and assign it based on direction
in _onRestoreAcceptedCall. Both statuses fall outside canPerformAnswer and
canPerformStart whitelists, preserving the existing bypass logic.

* fix: abort restoration when answerCall fails in _onRestoreAcceptedCall (WT-1167)

* fix: log proceeding status in canPerformStart else branch (WT-1167)

* fix: remove noisy transport DIAG log from RtpTrafficMonitor poll loop (WT-1167)
SERDUN added a commit that referenced this pull request Jun 21, 2026
* fix: add 5s timeout to getInitialMessage() to prevent splash freeze (WT-1061) (#1033)

* refactor: remove tryUse from AppDatabaseScope, migrate callers to useOrNull (#1034)

* fix: eliminate write-write SQLite contention via shared DriftIsolate server (WT-1061) (#1035)

* fix: eliminate write-write SQLite contention via shared DriftIsolate server (WT-1061)

Spawns a single dedicated DriftIsolate server in the main isolate bootstrap
and registers its SendPort in IsolateNameServer under a fixed key.

Background isolates (FCM handler, WorkManager) now connect to the same server
via IsolateDatabase.connectOrCreate(), which looks up the port and creates a
client connection — falling back to a direct NativeDatabase connection when the
main app is not running (cold push with no foreground app).

All writes are serialized through the single server isolate, making
write-write SQLITE_BUSY (code=5) between concurrent isolates impossible.

Changes:
- app_database: add createAppDatabaseNative() for synchronous NativeDatabase
  creation inside the server isolate (no createInBackground needed)
- IsolateDatabase: add spawnServer(), connectOrCreate(), kDbPortName
- AppDatabaseScope.use(): connect via connectOrCreate() instead of create()
- bootstrap(): spawn DriftIsolate server, register DriftIsolate in InstanceRegistry
- AppDatabaseLifecycleHolder: connect to DriftIsolate, shutdown server on dispose

* fix: address Copilot review — robust spawnServer error handling and stale port cleanup (WT-1061)

* test: add integration tests for IsolateDatabase stale port handling (WT-1061)

* refactor: introduce SignalingModule stream abstraction (phase 1) (#1024)

* refactor: introduce SignalingModule stream abstraction (phase 1)

Replace SignalingManager callback-based API with SignalingModule — a
sealed-event broadcast stream that owns the WebtritSignalingClient
lifecycle without any BLoC, CallState, or UI dependency.

Key changes:
- Add SignalingModule with fire-and-forget connect(), disconnect(),
  dispose() and a sealed SignalingModuleEvent hierarchy
  (Connecting, Connected, ConnectionFailed, Disconnecting,
  Disconnected, HandshakeReceived, ProtocolEvent)
- Add isRepeated deduplication on ConnectionFailed to suppress
  repeated identical error notifications
- Map disconnect codes to recommendedReconnectDelay:
  4441 → Duration.zero, protocolError → null, all others → 3 s
- Migrate CallBloc from direct WebtritSignalingClient callbacks to
  SignalingModule stream subscription; new _SignalingClientEvent
  variants: connecting, connected, failed, disconnecting, disconnected
- Migrate IsolateManager (Push + Foreground) to SignalingModule,
  replacing SignalingManager; add connectivity monitoring and pending
  request queue inside IsolateManager
- Construct SignalingModule in main_shell.dart and inject into CallBloc
- Delete SignalingManager and remove its export from common.dart
- Add 31 unit and integration tests for SignalingModule

* fix(test): update ExternalContactsSyncBloc tests for getAndListen API

The BLoC was updated to call userRepository.getAndListen() instead of
getLocalInfo(), but the mocks were never updated. Fix the setUp mock
and correct the RefreshFailure test to use load() failure (which is
the actual trigger for that state) rather than userRepository failure.

* fix(test): rename local function to avoid leading underscore lint warning

* docs: translate signaling architecture doc to English

* docs: remove phase 1 requirements planning doc from repo

* refactor: remove coreUrl/tenantId/token/trustedCertificates from CallBloc

These four fields were passed through CallBloc only to construct
SignalingModule internally. Now that SignalingModule is constructed
externally and injected via the constructor, the fields are dead code.
Remove them and the corresponding import from ssl_certificates.

* fix: address Copilot review comments on SignalingModule/IsolateManager

- Guard delayed reconnect callbacks with signalingClient == null check to
  avoid tearing down a healthy connection that connected during the delay
- Populate _incomingCallEvents from handshake and protocol events so
  _findIncomingEventLog returns real caller data instead of null
- Use disconnect() instead of dispose() in handleLifecycleStatus so the
  module remains reusable when the app returns to the foreground
- Fix post-dispose connect() test to actually subscribe to the event stream
  and assert Connecting/Connected events are absent after dispose

* feat: replay session events to late subscribers in SignalingModule

Adds a per-subscriber replay buffer so that consumers created after
connect() (e.g. CallBloc constructed after SignalingModule already
connected and received a handshake) do not miss any events from the
current session.

- events getter now returns a single-subscription stream that first
  replays all events buffered since the last connect() call, then
  pipes live events from the broadcast controller
- connect() clears the buffer so late subscribers see only the
  current session, not stale events from previous reconnect cycles
- dispose() also clears the buffer on teardown
- Uses sync: true on the intermediate StreamController to avoid an
  extra async hop and keep delivery ordering consistent with callers
  that await module operations
- Adds two integration tests covering the late-subscriber replay
  and the buffer-clear-on-reconnect behaviours

* feat: connect SignalingModule early in initState to reduce call setup latency

SignalingModule is now created and connected in _MainShellState.initState(),
running the WebSocket handshake in parallel while the widget tree and
CallBloc are being built. When CallBloc is eventually created it subscribes
to the replay stream and receives all buffered session events without missing
anything.

_MainShellState.dispose() owns the module lifecycle; CallBloc.close()
still calls dispose() on the module (idempotent, safe).

* docs: update signaling architecture doc with layer descriptions and diagrams

* fix: add concurrency lock to _connectAsync to prevent parallel connects

* fix: await disconnect ack in dispose() to prevent SignalingDisconnected drop on race

* fix: suppress reconnect hint on intentional disconnect to prevent spurious reconnect

* fix: remove SignalingModule.dispose() from CallBloc.close() — ownership belongs to MainShellState

* fix: snapshot buffer before live subscribe to prevent replay duplicates in events getter

* fix: store reconnect Timer in IsolateManager so it can be cancelled on close()

* fix: forward recommendedReconnectDelay from SignalingDisconnected to _scheduleReconnect in CallBloc

* fix: suppress _onDisconnect after _onError to prevent double reconnect scheduling

* fix: exclude SignalingProtocolEvent from session buffer to prevent unbounded growth

* fix: replace force-unwrap of session.coreUrl/token with null-safe logout in initState

* fix: remove performEndCall early return so pre-handshake declines are queued

* fix: close liveController on subscription cancel to prevent StreamController leak

* fix: use _networkNone state instead of stale results snapshot in connectivity timer closure

* docs: fix _scheduleReconnectIfNeeded → _scheduleReconnect in signaling architecture doc

* test: replace Future.delayed(Duration.zero) with pumpEventQueue() in signaling module tests

* fix: make _controller sync:true to eliminate async-dispatch event duplication

* docs: clarify disconnect() docstring — SignalingDisconnected is callback-driven, not synchronous

* fix: wrap _signalingModule.disconnect() in unawaited() in _disconnectInitiated

* fix: remove unused shouldReconnect variable from __onSignalingClientEventDisconnected

* test: add 8 tests to reach 100% SignalingModule coverage

- concurrent connect() dropped while factory in-flight (_connecting guard)
- intentional disconnect() emits SignalingDisconnected with null delay
- disconnect() passes goingAway code to the underlying client
- _onError suppresses subsequent _onDisconnect (_errorHandled flag)
- SignalingProtocolEvent excluded from replay buffer
- cancelled subscription receives no further events
- dispose() awaits disconnect ack before closing the stream
- _onHandshake/_onEvent are no-ops after dispose()

* test: add scenario-driven SignalingModule tests from CallBloc usage analysis

Covers scenarios observed in CallBloc's signaling subscription:

internet dropped mid-session:
- _onError after handshake → ConnectionFailed not Disconnected, signalingClient cleared
- Unexpected socket close (null code) → Disconnected with kSignalingClientReconnectDelay
- ConnectionFailed buffered so late subscribers reconstruct last-known failure

handshake not completed:
- Disconnect before handshake → no HandshakeReceived in buffer, Disconnected with delay
- Late subscriber after no-handshake disconnect → gets Connecting+Connected+Disconnected only
- Error before handshake → ConnectionFailed buffered, no HandshakeReceived
- Reconnect after no-handshake failure delivers fresh session events

late subscriber mid-session:
- Factory still pending → gets Connecting from buffer, Connected arrives live
- After full connect+handshake → all three lifecycle events replayed, no protocol events

disconnect() robustness:
- client.disconnect() throws → dispose() still completes without hanging
- Second disconnect() with no active client is a silent no-op

* fix: restore callkeep_signaling_status_converter.dart lost during rebase

* fix: remove unused fields in _ThrowingDisconnectClient test helper

* fix: use normalClosure (1000) instead of goingAway (1001) when client disconnects WebSocket

* test: update disconnect test to expect normalClosure (1000) instead of goingAway (1001)

* fix: address Copilot review comments in SignalingModule and IsolateManager

- Fix doc comment on events getter: protocol events are not replayed, only lifecycle/handshake events
- Guard connect() buffer clear behind _connecting check to avoid clearing on redundant calls
- Remove stale comment in _onDisconnect that contradicted the !_disposed guard
- Treat empty connectivity result as offline in _monitorConnectivity (results.isEmpty || any(none))
- Treat empty connectivity result as offline in performAnswerCall (isNotEmpty && !contains(none))

* fix: address post-review issues in SignalingModule and IsolateManager

- isolate_manager: fix connectivityNoneCounter reset — error now fires
  exactly once at maxConnectivityNoneRepeats; subsequent none-events are
  silently ignored until connectivity is restored and counter resets to 0

- main_shell: split SignalingModule construction into valid/invalid-creds
  branches to remove ?? '' fallbacks and make intent explicit

- signaling_module: document sync:true reentrancy assumption, single-use
  constraint on events getter, and _errorHandled ordering invariant

* revert: restore main_shell.dart SignalingModule construction with ?? '' fallback

The split-branch approach still created a module with empty strings in the
null-creds case — identical in behaviour to the original. Reverted to the
original form which is honest about the fallback until a proper nullable
refactor is done.

* fix: remove dead null-guard in MainShellState.initState for SignalingModule

The router guard (onMainShellRouteGuardNavigation) redirects to login
when state.status != authenticated, so coreUrl and token are always
non-null when MainShell is mounted. Replace ?? '' fallbacks and the
unreachable null-branch with direct ! unwraps.

* docs: sync signaling_architecture_target.md and call_architecture.md with current code

- signaling_architecture_target: add timer cancellation to IsolateManager
  and CallBloc _scheduleReconnect snippets (Future.delayed → Timer with cancel)
- signaling_architecture_target: add missing SignalingDisconnecting case to
  CallBloc subscription snippet
- call_architecture: update ownership — CallBloc owns SignalingModule, not
  WebtritSignalingClient directly

* fix: replace non-ASCII characters with ASCII equivalents in Dart sources

Replace em dash (U+2014) with '-' and right arrow (U+2192) with '->'
in comments and test descriptions across signaling_module.dart,
isolate_manager.dart, signaling_module_test.dart, and call_bloc.dart.

* fix: incorrect styling of status bar on app start (#1006)

Fixed status bar rendering with incorrect styling on initial app launch when theme is light

* feat: show progress indicator while sharing logs (#1036)

* feat: show progress indicator while sharing logs

Co-Authored-By: Dmytro Serdun <serdun@webtrit.com>

* refactor: replace inline SizedBox+CircularProgressIndicator with SizedCircularProgressIndicator

---------

Co-authored-by: Dmytro Serdun <serdun@webtrit.com>

* fix: upgrade to video resets hold (#1038)

* fix: media settings parsing (#1039)

* fix: call drops after theme or lang change (#1041)

* fix: cannot make calls after blind transfer — skip hangup + reconnect safety net (WT-1214) (#1040)

* fix: trigger reconnect when starting outgoing call with no signaling (WT-1214)

After a blind transfer, the signaling WebSocket is closed with code 4610
and the disconnect is marked intentional by SignalingModule, so no
reconnect is scheduled. Subsequent outgoing call attempts enter
outgoingConnectingToSignaling and wait passively — neither signalingReady
nor signalingFailed ever fires, causing the call to fail on timeout.

Add _scheduleReconnect(Duration.zero) at the start of the waiting block
so that initiating an outgoing call always recovers the signaling
connection, regardless of whether the previous disconnect was intentional.

* fix: skip hangup after successful blind transfer to avoid 4610 disconnect (WT-1214)

When a blind transfer completes (NOTIFY SIP/2.0 200 OK +
subscription_state: terminated), the SIP dialog is already closed
server-side via REFER. Sending a hangup request on the freed dialog
causes the server to close the WebSocket with code 4610 ("call request
on wrong line error"), triggering an unintended signaling disconnect.

Check the call's transfer state: if it is Transfering(fromBlindTransfer:
true), skip the hangup request and clean up the peer connection locally
only. This removes the root cause of the 4610 disconnect that led to
signaling not being reconnected for subsequent outgoing calls.

* test: cover blind-transfer hangup skip and 4610 reconnect hint

Add tests for two fixes from WT-1214:

- Transfer — isBlindTransferCompleted detection (call_state_test.dart):
  verifies the switch pattern that decides whether to skip the hangup
  request after a blind transfer. Covers Transfering(fromBlindTransfer:
  true/false), earlier transfer states, and null.

- SignalingModule — requestCallIdError (4610) reconnect hint
  (signaling_module_test.dart): verifies that a non-intentional 4610
  carries a non-null recommendedReconnectDelay (reconnect scheduled),
  while an intentional disconnect() followed by a server 4610 emits
  null (reconnect suppressed — the scenario that triggered WT-1214).

* refactor: rename isBlindTransferCompleted → isBlindTransferInTransferingState

Transfering state in the model means "server started to process the
transfer", not "transfer completed". Rename the local variable and update
the surrounding comments and test group names to match the actual Transfer
model semantics, avoiding misinterpretation of the hangup guard.

Addresses Copilot review comments on PR #1040.

* fix: call or transfer to myself handling (#1046)

* fix: hide video for held call (#1048)

* fix: hide video for held call

* fix: tap area

* fix: transfer to same recipient (#1049)

* refactor: extract SignalingModuleInterface; migrate CallBloc and IsolateManager; extract toLinesState (#1045)

* refactor: extract SignalingModuleInterface; decouple IsolateManager from SignalingModule

Add local SignalingModuleInterface abstract class to signaling_module.dart
with the contract needed by IsolateManager: events, isConnected, connect(),
disconnect(), execute(Request), dispose().

SignalingModule implements the interface, gaining isConnected and execute()
alongside the existing signalingClient getter (kept for backward compat).

IsolateManager field type changed from SignalingModule to SignalingModuleInterface.
All signalingClient null-checks replaced with isConnected; direct client.execute()
calls replaced with module.execute(). IsolateManager no longer depends on the
concrete class, making it ready for a plugin-backed implementation.

* fix: decouple NetworkCubit from WebtritSignalingService; fix push dedup race

NetworkCubit held a concrete WebtritSignalingService only to call
updateMode(). Replaced with a Future<void> Function(SignalingServiceMode)
callback so the cubit has no plugin dependency and is mock-testable.
Call site passes WebtritSignalingService().updateMode as a tear-off.

_onCallPushEventIncoming checked the incomingFromOffer guard before
awaiting contactNameResolver.resolveWithNumber(). The signaling path
could create an ActiveCall during that async gap, causing both paths
to emit separate entries for the same callId. Added a post-await guard
that checks for any existing ActiveCall with the same callId before emitting.

* fix: prevent premature call routing state emission before signaling handshake (#1044)

LinesState.blank() is emitted at app startup before the signaling handshake
arrives. combineLatest fires immediately with cached UserInfo + blank LinesState,
causing CallRoutingCubit to emit a non-null state with empty mainLines.
CallController then skips _waitForRoutingState() and fails with
"no idle lines available".

Fix: add LinesState.isBlank discriminator (guestLine == null is an unambiguous
pre-handshake marker — CallBloc always sets guestLine to non-null after any
handshake). Return null from _combineInfo when linesState.isBlank so the cubit
stays in its unready state and CallController waits correctly.

* fix: preserve LinesState.blank in onChange until signaling handshake arrives

The previous fix relied on guestLine == null as a pre-handshake discriminator,
but CallBloc.onChange always set guestLine = LineState.idle regardless of
linesCount, overwriting LinesState.blank() almost immediately after startup.

Root cause: onChange fired with linesCount = 0 on any early state change
(e.g. connecting status) and produced LinesState([], LineState.idle).
isBlank returned false, so _combineInfo emitted a non-null CallRoutingState
with empty mainLines, and hasIdleMainLine = false blocked the call.

Fix: when linesCount == 0 (handshake not yet received), onChange now stores
LinesState.blank() explicitly. Once the handshake sets linesCount > 0,
normal LinesState with non-null guestLine is produced as before.

* fix: remove unused models import from NetworkScreenPage

* fix: wire BackgroundSignalingBootstrapService into NetworkCubit callback

* refactor: migrate CallBloc to SignalingModuleInterface

Apply the same interface migration already done for IsolateManager:
- change _signalingModule field/param type from SignalingModule to SignalingModuleInterface
- replace signalingClient?.execute() with execute() from the interface
- replace signalingClient != null checks with isConnected

* fix: address Copilot review comments on signaling module interface

- Handle null execute() in _executePendingRequests: complete completer
  with error and clean up instead of leaving request to time out silently
- Handle null execute() in _sendRequest: log warning and return early
  instead of awaiting null when module disconnects after isConnected check
- Fix linesCount == 0 guard: use isHandshakeEstablished to distinguish
  pre-handshake blank state from valid post-handshake 0-lines state
- Update LinesState.isBlank doc comment to remove inaccurate claim that
  guestLine == null is an unambiguous pre-handshake marker

* refactor: extract toLinesState() from CallBloc.onChange into CallState

Pure deterministic logic moved to CallState.toLinesState() so it can be
tested without standing up CallBloc. onChange becomes a single line.

Tests cover: pre-handshake blank, 0-lines post-handshake with guest line,
main line idle/inUse combinations, guest line inUse alongside main calls.

* fix: rename _kRegistered to kRegistered (lint: no_leading_underscores_for_local)

* refactor: rename SignalingModuleInterface → SignalingModule, impl → SignalingModuleIsolateImpl

Drop the `Interface` postfix from the abstract contract and suffix the
concrete WebSocket implementation with `IsolateImpl` to clarify its role.
Add doc comments to every member of the SignalingModule interface.

Files touched:
- lib/features/call/services/signaling_module.dart
- lib/features/call/services/isolate_manager.dart
- lib/features/call/bloc/call_bloc.dart
- lib/app/router/main_shell.dart

* fix: update signaling_module_test to use SignalingModuleIsolateImpl concrete type

* fix: use SignalingModule.execute instead of signalingClient getter (#1050)

signalingClient is not part of the SignalingModule interface — only
SignalingModuleIsolateImpl exposes it. Replace with execute() which is
defined on the interface and returns null when not connected.

* fix: skip decline when push-registered call receives signaling line (#1051)

* fix: skip decline when push-registered call receives signaling line (WT-1091)

When an incoming call arrives via FCM push, it is registered with
line _kUndefinedLine (-1) as a placeholder. When the signaling
WebSocket subsequently delivers the same call with a real line (e.g. 0),
the guard that detects 'call to myself' incorrectly fires because
-1 != 0, causing a DECLINE to be sent on the wrong line and the call
to be dropped with server error 4610.

Fix: exclude _kUndefinedLine from the line-mismatch check so that
push-placeholder calls are updated with the real line rather than
declined.

* test: add push → signaling line handoff tests for WT-1091 fix

* feat: media settings ptime warning (#1053)

* fix: call glare (#1052)

* fix: call glare

* fix: commented code

* feat: call interaction guard if any updating (#1056)

* fix: stop ringback sound on forced call termination (#1055)

__onResetStateEventCompleteCall is the path taken when signaling disconnects
unexpectedly (network loss). Unlike other termination paths it was not calling
_stopRingbackSound(), leaving the ringback tone playing after the call UI disappeared.

* fix: sanitize keypad input before initiating a call (WT-1026) (#1057)

* fix: sanitize keypad input before initiating a call (WT-1026)

Apply PhoneParser.normalize() in _popNumber() so that Unicode lookalike
digits and stray formatting characters are stripped before the number
reaches CallController/CallBloc.

* fix: normalize keypad input at entry point via TextInputFormatter (WT-1026)

Replace the call-time normalize approach with a TextInputFormatter so
pasted or typed Unicode lookalike characters are sanitized immediately
in the TextField, keeping the displayed text and the dialed number
consistent.

* refactor: move PhoneNormalizingFormatter to keypad/utils (WT-1026)

Extract formatter into its own file under features/keypad/utils/ with a
barrel export, following the existing package structure convention.

* fix: strip non-dialable characters from keypad input (WT-1026)

After Unicode normalization, remove any character outside [0-9*#+] so
pasting arbitrary text (e.g. *"{) leaves only valid phone number chars
in the field.

* fix: address Copilot review comments on PhoneNormalizingFormatter (WT-1026)

- Cache _nonDialableChars as static final to avoid per-keystroke allocation
- Translate both baseOffset and extentOffset (preserving affinity/isDirectional)
  instead of always returning a collapsed selection
- Extract sanitize() static helper and reuse it in _popNumber() as a
  belt-and-suspenders guard for programmatic controller updates

* test: add unit tests for PhoneNormalizingFormatter (WT-1026)

Covers sanitize() and formatEditUpdate() — including Unicode normalization,
non-dialable character stripping, cursor translation, and selection range
preservation.

* refactor: extract renegotiation support (#1058)

* fix: prevent null crash in InkWell on hardware keyboard event during navigation (WT-1012) (#1060)

* fix: prevent null crash in InkWell on hardware keyboard event during navigation (WT-1012)

Remove debug focusColor/hoverColor from CdrTile InkWell to stop it
subscribing to _HighlightModeManager, and add FocusScope.unfocus()
before all fullscreenDialog navigations to cover ListTile-based widgets.

* fix: use FocusManager and mounted guard in openNotificationsScreen (WT-1012)

Replace FocusScope.of(context).unfocus() with
FocusManager.instance.primaryFocus?.unfocus() to avoid depending on
a potentially unmounted context, and add a mounted guard before
navigating, since the callback can fire from a stream after disposal.

* fix: voicemail audio cache collision on Android (WT-1016) (#1061)

* fix: resolve voicemail audio cache collision on Android (WT-1016)

All voicemails shared the same cache file path because the last URL
path segment is always 'attachment'. Concurrent LockCachingAudioSource
instances raced to rename the same .part file, causing a crash.

- Add cacheKey param to AudioView; VoicemailTile passes voicemail.id
- _getCacheFile falls back to joined URI segments when cacheKey is absent
- Ensure media_cache directory exists before screen initialises

* refactor: ensure media_cache dir exists in AppPath.init

Move Directory.create to AppPath.init so the path is ready to use
by the time any feature accesses mediaCacheBasePath, consistent with
how getApplicationDocumentsDirectory / getTemporaryDirectory work.

* refactor: move media_cache dir creation into AudioView._getCacheFile

AppPath is platform-agnostic and must not import dart:io (web support).
Directory is now created in _getCacheFile, co-located with the code
that writes the cache file and already guarded by the Platform.isIOS check.

* fix: address Copilot review comments on AudioView cache handling

- Move Directory.create to _initialize() as async IO, avoiding
  synchronous filesystem call in _getCacheFile on every retry
- Guard empty rawKey with early return null so just_audio handles
  caching when URI has no path segments
- Sanitize cacheKey against path separators to prevent path traversal
  from server-provided voicemail IDs

* refactor: renegotiation handlers per call (#1065)

* fix: fall back to audio-only when camera permission is denied on incoming video call (WT-1049) (#1062)

* fix: fall back to audio-only when camera permission is denied on incoming video call (WT-1049)

When answering an incoming video call without camera permission, the app
no longer drops the call. Instead, it retries media acquisition with
video disabled and answers audio-only. If the microphone itself is
unavailable the error still propagates as before.

* refactor: check camera permission before requesting media instead of relying on error fallback (WT-1049)

Added `isVideoAvailable()` to the `UserMediaBuilder` contract so the BLoC
can explicitly query camera permission status before deciding whether to
request video. Replaces the previous error-based fallback approach with a
proactive permission check.

* refactor: inject camera permission check as callback into UserMediaBuilder (WT-1049)

Replaced the direct `permission_handler` dependency in `user_media_builder.dart`
with an optional `isCameraPermissionGranted` callback. `DefaultUserMediaBuilder`
remains free of plugin dependencies; the check is wired at construction time in
`main_shell.dart` via `AppPermissions.isPermissionGranted(Permission.camera)`.

* refactor: move permission-aware video resolution into UserMediaBuilder.build() (WT-1049)

`DefaultUserMediaBuilder.build()` now resolves the effective video flag
internally via `_isCameraAvailable()` before calling `getUserMedia`, so
callers pass their intent (`video: offer.hasVideo`) and the builder handles
the permission check transparently.

`call_bloc.dart` derives the resulting video state from the actual stream
tracks (`localStream.getVideoTracks().isNotEmpty`) instead of pre-computing
it, removing all permission logic from the BLoC layer.

* fix: scope audio fallback to incoming answer only via allowAudioFallback flag (WT-1049)

The unconditional permission-check fallback inside `build()` was silently
downgrading video for all callers, including the camera-enable action which
relies on `UserMediaError` to show a notification and skip the `video: true`
state update.

Introduced `allowAudioFallback: bool = false` on `build()`. Default behaviour
(throw on failure) is preserved for existing callers; `__onCallPerformEventAnswered`
opts in with `allowAudioFallback: true` to get the permission-aware fallback.

* test: add unit tests for DefaultUserMediaBuilder permission-aware fallback (WT-1049)

* fix: retry getUserMedia audio-only when video acquisition fails with allowAudioFallback (WT-1049)

* fix: stop voicemail polling after server responds with unsupported (WT-1107) (#1067)

* fix: stop voicemail polling after server returns unsupported (WT-1107)

Add isActive getter to Refreshable (default true). VoicemailRepositoryImpl
returns _featureSupported so polling and connectivity services skip it once
the server responds with voicemail_not_configured or endpoint_not_supported.
PollingService unregisters the listener on the next tick; ConnectivityLifecycleService
filters inactive refreshables before each reconnect cycle.

* fix: add isActive override to all Refreshable implementors (WT-1107)

* fix: address Copilot review comments on isActive polling (WT-1107)

- Remove duplicate @override on isActive in all repository implementors
- Check isActive in leading refresh path (_triggerOnceWithKnownReachability)
- Add isActive field to MockRefreshableRepository for controllable test state
- Add PollingService tests: inactive listener unregistered on tick and skipped on resume
- Add ConnectivityLifecycleService test: inactive refreshable skipped on reconnect

* chore: misc cleanup (#1068)

* chore: regenerate login_cubit.freezed.dart and update pubspec.lock (#1069)

* fix: restore accepted call from signaling handshake on app restart (WT-1167) (#1070)

* fix: restore accepted call from signaling handshake on app restart (WT-1167)

- Add HandshakeAction sealed hierarchy and stateless HandshakeProcessor
- Add _RestoreAcceptedIncomingCall event and handler in CallBloc
- Add incomingRestoringMedia processing status
- Dispatch renegotiationNeeded explicitly after peer connection setup to
  bypass null signalingState guard on brand-new RTCPeerConnection

* refactor: move HandshakeProcessor and HandshakeAction to utils/, merge into one file

* refactor: export HandshakeProcessor via utils barrel

* revert: restore device_auto_rotate pubspec.lock to develop state

* refactor: remove task references from comments

* style: replace non-ASCII symbols in comments with ASCII equivalents

* style: clean up Copilot comments in call restoration code

* fix: log warning on duplicate call restoration guard

* fix: restore outgoing calls from handshake by checking AcceptedEvent as latest log

* fix: skip Callkeep incoming registration for outgoing call restoration

- Guard reportNewIncomingCall/answerCall behind direction == incoming check in
  _onRestoreAcceptedCall to avoid registering a restored outgoing call as incoming
- Remove connection == null guard from HandshakeProcessor restoration condition:
  if stateDisconnected already triggers early exit, stateActive connections should
  not block restoration when no BLoC state exists
- Update handshake_processor_test.dart: fix stale guest-line test to check
  AcceptedEvent.line (not IncomingCallEvent.line); add outgoing restoration tests;
  update import to utils/handshake_processor.dart

* fix: register restored outgoing call in Telecom and guard performStartCall (WT-1167)

- _onRestoreAcceptedCall: added outgoing Callkeep branch (startCall +
  warning on error), symmetric to the existing incoming branch
- __onCallPerformEventStarted: added canPerformStart switch guard
  (mirrors the existing canPerformAnswer in __onCallPerformEventAnswered)
  so that the Telecom-mandatory performStartCall callback for a restored
  call skips the normal outgoing flow and only advances Telecom to ACTIVE
  via reportConnectedOutgoingCall

* fix: detect restoration after re-INVITE by searching AcceptedEvent in full call_logs (WT-1167)

HandshakeProcessor previously checked only the newest call_logs entry for
AcceptedEvent. After a transfer or SDP re-INVITE the server inserts
UpdatedEvent/UpdatingCallEvent as the newest entry, causing restoration to
be silently skipped.

Changes:
- Restoration: search the full call_logs list for the first AcceptedEvent
  instead of checking only firstOrNull; guard against server-terminated
  calls via HangupEvent/MissedCallEvent as latest entry
- stateDisconnected: replace AcceptedEvent|ProceedingEvent whitelist with
  a blacklist (HangupEvent, MissedCallEvent) so UpdatedEvent and any other
  non-terminal event correctly produces HangupSignalingAction
- Tests: replace the obsolete "specific order required" test with two new
  cases: re-INVITE restoration and HangupEvent termination guard

* docs: clarify performStartCall interception for restored outgoing call (WT-1167)

* style: replace non-ASCII characters with ASCII equivalents in comments (WT-1167)

* docs: clarify incomingOffer role and media re-establishment path during restoration (WT-1167)

* style: restore UTF-8 symbols in restoration comment (WT-1167)

* style: shorten redundant startCall comment in restoration path (WT-1167)

Revert dart_define.json to main — local dev config values should not be tracked in this PR.

* docs: fix inaccurate docstrings in HandshakeAction subtypes and HandshakeProcessor (WT-1167)

- HangupSignalingAction: clarify trigger condition (any non-terminal event, not only AcceptedEvent/ProceedingEvent)
- RestoreCallAction: clarify that AcceptedEvent is searched in the full log, not just the latest entry
- HandshakeProcessor: align Loop B bullet list with actual implementation
- fix typo 'due stale' -> 'due to stale' in three log messages

* fix: use outgoingRestoringMedia status for restored outgoing calls (WT-1167)

incomingRestoringMedia was used for both incoming and outgoing restored calls.
Introduce outgoingRestoringMedia in the enum and assign it based on direction
in _onRestoreAcceptedCall. Both statuses fall outside canPerformAnswer and
canPerformStart whitelists, preserving the existing bypass logic.

* fix: abort restoration when answerCall fails in _onRestoreAcceptedCall (WT-1167)

* fix: log proceeding status in canPerformStart else branch (WT-1167)

* fix: remove noisy transport DIAG log from RtpTrafficMonitor poll loop (WT-1167)

* fix: resolve unsafe ancestor lookups on deactivated widgets (WT-578) (#1059)

* fix: resolve unsafe ancestor lookups on deactivated widgets (WT-578)

Fixes all locations where InheritedWidget lookups (MediaQuery, Localizations,
Provider) were performed on deactivated BuildContext instances.

- call_to_actions_shell: replace late final lazy initializer (root cause) with
  nullable field initialized in didChangeDependencies; guard dispose with null check
- main_shell: cache AppBloc, NotificationsBloc and l10n string in initState
  instead of capturing context in RouterLogoutSessionGuard closures
- call_screen: add context.mounted guard at start of BlocConsumer listener
- draggable_thumbnail: add mounted check before setState in addPostFrameCallback
- call_actions: cache MediaQuery and Theme in didChangeDependencies; computeDimensions
  reads cached values only

* fix: move l10n lookup from initState to didChangeDependencies in main_shell

context.l10n calls dependOnInheritedWidgetOfExactType which is not allowed
inside initState(). Cache the localized string in didChangeDependencies()
instead and reference the field from the RouterLogoutSessionGuard closure.

* fix: recreate DemoActionOverlay on dependency change when not inserted

When the overlay is not currently visible, recreate it with updated
MediaQuery values so the initial button offset reflects the current
screen size and safe area insets (e.g. after device rotation).

If the overlay is inserted, DraggableThumbnail handles layout updates
via its own didChangeDependencies, so no recreation is needed.

* fix: prevent OOM by exposing log buffer cap and share export (WT-1107) (#1064)

* feat: show record count hint with share prompt in log console (WT-1107)

* chore: regenerate l10n after adding recordsCountHint key (WT-1107)

* fix: use ICU plural forms for recordsCountHint in all locales (WT-1107)

* refactor: move records count hint to AppBar info dialog (WT-1107)

* fix: replace # with {count} in ICU plural forms for correct interpolation (WT-1107)

* refactor: merge info and clear into overflow menu in log console (WT-1107)

* refactor: centralize signaling reconnect logic in SignalingReconnectController (WT-1221) (#1066)

* refactor: centralize signaling reconnect logic in SignalingReconnectController (WT-1221)

Introduces SignalingReconnectController — a single component that owns all
reconnect timer scheduling, failure counting, and notification decisions.
Fixes a spurious 'Connecting to the core failed' toast on screen unlock caused
by a transient DNS failure that auto-resolved within one reconnect cycle.

Key changes:
- Add SignalingConnectionLost event to distinguish a runtime error on an
  established session (notify immediately) from an initial connect failure
  (notify only after 2+ consecutive attempts).
- Remove isRepeated flag and _lastConnectErrorString from SignalingModule —
  deduplication concerns no longer belong at the transport layer.
- Extract _scheduleReconnect / _reconnectInitiated / _disconnectInitiated out
  of CallBloc into SignalingReconnectController.
- Replace IsolateManager inline reconnect timer with the same controller
  (reconnectEnabled: false for PushNotificationIsolateManager).
- CallBloc and IsolateManager signal listeners now only drive state
  transitions; all reconnect and notification logic is in the controller.

* refactor: use barrel import for signaling services in CallBloc

* fix: address Copilot review comments on SignalingReconnectController

- notifyAppPaused: preserve _appActive when hasActiveCalls is true so
  reconnects are not suppressed during a backgrounded active call
- threshold notification: use == instead of >= to notify exactly once
  per outage rather than on every attempt after the threshold
- add _disposed guard in timer callback to prevent connect() calls
  after dispose()
- assert notifyAfterConsecutiveFailures >= 1 in constructor
- add signaling_reconnect_controller_test.dart covering threshold,
  hasActiveCalls, app/network guards, force reconnect, dispose safety,
  and SignalingConnectionLost immediate notification (23 tests)

* refactor: log signaling connection failure in IsolateManager instead of null callback

* refactor: introduce SignalingReconnectable interface for SignalingReconnectController

* feat: add onConnectionPresenceChanged callback to SignalingReconnectController

Tracks persistent connection availability transitions (available ↔
unavailable) for driving a UI presence indicator such as a banner.
Emits false when: consecutive failures reach threshold,
SignalingConnectionLost occurs, or notifyNetworkUnavailable is called.
Emits true when SignalingConnected follows an unavailable state.
Deduplicates consecutive identical values via _lastPresence guard.

* refactor: log signaling presence changes in CallBloc and IsolateManager

* fix: address remaining Copilot review comments

- Add _hasActiveCalls flag and notifyHasActiveCalls() to allow reconnects
  during background active calls (fixes app-active guard blocking recovery)
- Wire notifyHasActiveCalls() in CallBloc.onChange when app is inactive
- Remove isRepeated from SignalingConnectionFailed and SignalingConnectionLost:
  identical() comparison doesn't work for typical new exception objects,
  and threshold logic in SignalingReconnectController is the correct dedup
- Remove _lastConnectionError tracking from SignalingModuleIsolateImpl
- Use .ignore() on _module.disconnect() Future in _disconnect() to make
  fire-and-forget intent explicit

* fix: replace non-ASCII characters with ASCII equivalents in SignalingReconnectController

* fix: address latest Copilot review comments in SignalingReconnectController

* fix: replace non-ASCII arrow characters with ASCII in comments

* chore: restore device_auto_rotate example pubspec.lock to develop version

* feat: presence, blf, callpull remake (#1008)

* feat: signaling networking improvemets (#1072)

* feat: replace callkeep signaling with webtrit_signaling_service plugin (#1047)

* feat: add webtrit_signaling_service plugin packages

* feat: add IsolateContext and update bootstrap for signaling service

* feat: replace SignalingModule with SignalingServiceModuleAdapter

* feat: update network screen for signaling service mode

* docs: update call architecture and signaling docs

* fix: update SignalingReconnectController and tests for new SignalingModule types

* refactor: extract HubConnectionManager from WebtritSignalingServiceAndroid (#1073)

* refactor: extract HubConnectionManager from WebtritSignalingServiceAndroid

Moves hub-init polling loop, generation-based cancellation, and
SignalingHubModule lifecycle into a dedicated HubConnectionManager class.
plugin.dart is reduced from 386 to ~230 lines with a single responsibility:
Android service lifecycle coordination.

* refactor: add TODO for push-based hub discovery to replace polling

* test: add integration tests for HubConnectionManager

* fix: prevent whenComplete from restarting polling loop during tearDown

* refactor: replace raw Map hub commands with SignalingHubCommand sealed class (#1074)

* refactor: replace raw Map hub commands with SignalingHubCommand sealed class

* refactor: fix lint warnings in hub_connection_manager_integration_test

* refactor: remove section divider comments from changed files

* refactor: replace dynamic with concrete types in hub command handlers

* fix: encode hub commands to isolate-safe wire format before sending

* fix: guard SignalingHubCommand.decode against malformed wire payloads

* docs: add comments to hub command wire tag constants

* refactor: split entry_point.dart into bootstrap and sync handler (#1075)

* refactor: split entry_point.dart into bootstrap and sync handler

* refactor: make _pendingSync private, move handler to signaling_sync_handler

* docs: document two-level buffering in SignalingHubModule

* refactor: replace int discriminators with string tags in hub codec (#1078)

* refactor: replace int discriminators with string tags in hub codec

* fix: address Copilot review comments for hub codec

* feat: port request queue and retry logic to SignalingServiceModuleAdapter

Port the queued-request and timeout-retry logic from develop's
SignalingModuleIsolateImpl into SignalingServiceModuleAdapter so that
requests sent during a connectivity gap are not silently dropped.

- When not connected, requests are queued and flushed on the next
  SignalingConnected event instead of returning null
- Requests in the queue fail with NotConnectedException after 30 seconds
- Execute retries up to 3 times on WebtritSignalingTransactionTimeoutException
- dispose() fails all pending queued requests immediately

* feat: add request queue and retry to SignalingModuleImpl

Port the queued-request and timeout-retry logic from develop's
SignalingModuleIsolateImpl into SignalingModuleImpl (platform interface)
so that requests sent during a connectivity gap are queued rather than
dropped.

- When not connected, execute() queues the request and flushes it on
  the next successful connection instead of returning null
- Requests in the queue fail with NotConnectedException after 30 seconds
- Execute retries up to 3 times on WebtritSignalingTransactionTimeoutException
- dispose() fails all pending queued requests immediately
- NotConnectedException now lives in the platform interface and is
  exported from webtrit_signaling_service; removed duplicate definition
  from SignalingServiceModuleAdapter

* fix: update toLinesState tests to use LineState factory constructors

LineState.idle and LineState.inUse are factory constructors, not static
const values. Updating assertions to call them with parentheses and
pass required callId arguments.

* fix: repair pre-existing test failures inherited from develop

- feature_access_factories: stub systemInfo.core with CoreInfo after
  WebtritSystemInfo.core became a required non-nullable field
- external_contacts_sync_bloc_test: initialize bloc in setUp so
  tearDown never hits LateInitializationError regardless of test order

* fix: update StateHandshake test fixtures after field rename

StateHandshake fields were renamed in develop:
  userActiveCalls → presenceInfos
  contactsPresenceInfo → dialogInfos

Update all affected test fixtures across signaling_service packages
to match the new API.

* fix: resolve remaining analyze issues inherited from develop

- signaling_hub_codec_test: remove unnecessary ! operators on
  non-nullable return values
- contact_tile: replace deprecated withOpacity() with withValues()

* fix: replace launchSignaling with run() and unconditional releaseCall (#1080)

* feat: replace launchSignaling with run() and unconditional releaseCall

- Replace launchSignaling with run(metadata) that returns a Future
  completing after all isolate work is done (notifications, logs,
  releaseCall); caller awaits the future with a 20s Dart-side timeout
- Remove CallkeepPushNotificationSyncStatus status param from
  onPushNotificationSyncCallback; isolate now owns its full lifecycle
- Replace endCall/endCalls with releaseCall(callId) in all terminal
  paths; native service stops unconditionally regardless of Telecom
  connection state
- Add Completer-based work tracking so run() resolves only after
  releaseCall completes; _disposeContext in finally handles cleanup

* feat: clean up onPushNotificationSyncCallback — extract timeout constant, simplify _getOrInit return type, improve docs

* fix: call releaseCall in close() to release native service on timeout

* fix: remove unreachable default branch in signaling event switch

* docs: actualize call and signaling architecture docs

- Add missing CallProcessingStatus values (incomingSubmittedAnswer,
  incomingRestoringMedia, outgoingCreatedFromRefer, outgoingRestoringMedia,
  outgoingRinging) to call_architecture.md
- Add _GlobalEvent row, currentAppLifecycleState field, call restoration
  and blind transfer key patterns to call_architecture.md
- Replace inline _scheduleReconnect guard chain in signaling doc with
  SignalingReconnectController section (constructor, notify* API, guard
  chain, failure notification state machine)
- Update CallBloc consumer subscription to actual simplified switch
- Update IsolateManager section: PushNotificationIsolateManager run()/close()
  API, no-reconnect design; SignalingForegroundIsolateManager own timer
- Add SignalingReconnectController to dependency diagram and decisions table
- Add deleted fields (_scheduleReconnect, _signalingClientReconnectTimer)
  to what-was-deleted table

* refactor: extract SignalingRequestQueue to eliminate queue duplication

SignalingModuleImpl and SignalingServiceModuleAdapter shared identical
request-queue logic (_QueuedRequest, _enqueueRequest, _flushQueuedRequests,
_executeWithRetry, _onQueuedRequestTimeout, _failAllQueuedRequests) and
NotConnectedException.

Extract all of it into SignalingRequestQueue in the platform-interface
package alongside SignalingEventBuffer. Both consumers now use
_requestQueue.enqueue / executeNow / flush / failAll.

- Add SignalingRequestQueue with enqueue, executeNow, flush, failAll
- Move NotConnectedException into signaling_request_queue.dart
- Remove duplicate code from SignalingModuleImpl and SignalingServiceModuleAdapter
- Export SignalingRequestQueue from platform-interface barrel and signaling_service.dart

* feat: merge SignalingServiceModuleAdapter into WebtritSignalingService

WebtritSignalingService now directly implements SignalingModule, removing
the need for the separate adapter layer. Config and mode are constructor
parameters; static setup methods (setModuleFactory, setIncomingCallHandler,
attach, updateMode) delegate to SignalingServicePlatform.instance.

- Delete SignalingServiceModuleAdapter
- WebtritSignalingService implements SignalingModule with connect/disconnect/execute/dispose
- connect() is idempotent (_startPending guard prevents duplicate hub init on Android)
- disconnect() remains a no-op so signaling stays alive in background
- execute() queues requests when not connected; flush on SignalingConnected
- dispose() fails queued requests with NotConnectedException
- Update all call sites: main_shell.dart, bootstrap.dart, network_screen_page.dart
- Rewrite unit and integration tests for the new contract

* feat: remove example app from webtrit_signaling_service plugin

* feat: network stability improvements for media streams / negotiation (#1079)

* fix: janus video stub sanitizing (#1081)

* fix: initialize logging in signaling background isolate (#1082)

* fix: initialize logging in signaling background isolate

Dart isolates do not share memory, so Logger.root in the background
isolate (signalingServiceCallbackDispatcher) had no listeners attached.
All log records from WebtritSignalingClient, SignalingForegroundIsolateManager,
SignalingHub, and related classes were silently dropped.

Attaches a dart:developer log listener to Logger.root at isolate startup
so all logging is visible in logcat.

* fix: use PrintAppender for logging in signaling background isolate

dart:developer log() is not visible in logcat — it only posts to the
Dart VM service protocol. Replace with PrintAppender/ColorFormatter
(same setup as AppLogger in the main isolate) so all WebtritSignalingClient
and related logs appear in logcat output.

* fix: reconnect signaling when background isolate is already started but disconnected (#1083)

* fix: reconnect signaling when background isolate is already started but disconnected

When the WebSocket closes with code 1002 (protocol error), the background
isolate emits SignalingDisconnected with recommendedReconnectDelay=null and
does not auto-reconnect. The _started flag in SignalingForegroundIsolateManager
remained true, so subsequent handleStatus(enabled: true) calls from the main
isolate triggered _start() which exited early on the _started guard — leaving
the WebSocket permanently disconnected even after WiFi was restored.

Fix: when _start() is called on an already-initialized manager but the module
is no longer connected, call connect() on the existing module instead of
returning silently.

* test: add SignalingForegroundIsolateManager tests covering reconnect after 1002 disconnect

Adds injectable moduleFactory and hubFactory parameters to
SignalingForegroundIsolateManager to bypass PluginUtilities handle
resolution and IsolateNameServer in unit tests.

Extracts handle resolution into _resolveModuleFactory() to keep _start()
readable.

Tests cover: initial start, idempotency while connected, reconnect after
code-1002 (the bug scenario), auto-reconnect via delay hint, and
stop/restart lifecycle.

* refactor: introduce SignalingHubFactory typedef for readability

* fix: cancel pending reconnect timer before manual reconnect in _start()

* fix: stop signaling service on logout to prevent stale token reconnects (#1084)

* fix: stop signaling service on logout to prevent stale token reconnects

On Android the foreground signaling service kept reconnecting with an
expired token after logout because dispose() only tore down the Dart-side
hub, leaving the Kotlin service running.

- Add stopService() to SignalingServicePlatform (no-op default for iOS)
- WebtritSignalingServiceAndroid.stopService() already called _hostApi.stopService();
  now properly @override the platform interface method
- Expose WebtritSignalingService.stopService() as a static helper
- MainShell stores _appBloc in initState (safe for dispose()) and calls
  WebtritSignalingService.stopService() after dispose() when the status
  is teardown (explicit logout), leaving the service untouched on OS kill
  so persistent mode continues working after swipe-from-recents

* feat: add test coverage for stopService in signaling service layer

- WebtritSignalingService: add stopServiceCount to _FakePlatform and test
  that static stopService() delegates to the platform instance
- WebtritSignalingServiceAndroid: inject BinaryMessenger via forTesting()
  constructor so stopService() can be verified without a live Android
  service; add two tests: stopService() calls the host channel, and
  dispose() does not call the stop channel

* fix: add meta dependency and fix local variable naming in plugin test

* fix: add TODO for SignalingCleanupCoordinator testability refactor

* fix: address copilot review comments

- _tearDownSignaling: wrap in try/finally so stopService() runs even
  if signalingModule.dispose() throws; log both failures via Logger
- plugin.dart: use flutter/foundation.dart for @visibleForTesting
  instead of a separate meta dependency (consistent with iOS plugin)
- pubspec.yaml: remove meta direct dependency (covered by Flutter SDK)

* fix: capture teardown flag synchronously before first await

The logout check was reading _appBloc.state.status after
await _signalingModule.dispose(). During that await the cleanup
resolver had time to complete and transition the status from
teardown to unauthenticated, so stopService() was never called.

Capturing the flag synchronously at the top of _tearDownSignaling()
(before any await) locks in the correct value at the moment dispose()
is triggered from MainShell.

* fix: stop signaling service from TeardownScreen on logout

Move stopService() call to TeardownScreen.initState() so the native
Android foreground service is stopped synchronously before AppCleanupRequested
is dispatched. This avoids the race where the Kotlin service kept
reconnecting with an expired token after logout.

Remove the isLogout status check from _tearDownSignaling() in MainShell —
it was unreliable because AppBloc may transition to unauthenticated before
the async dispose path executes. TeardownScreen is exclusively rendered
during explicit logout, so no status check is needed there.

* fix: gracefully disconnect WebSocket before stopping signaling service

Add gracefulStop() to SignalingForegroundService that sends
onSynchronize(enabled=false) to the background isolate before stopping
the service. The isolate runs _stop() → signalingModule.dispose() →
client.disconnect(), producing a clean "disconnected" log instead of
being killed mid-connection.

A 3-second timeout calls stopService() anyway if the isolate does not
ACK, so logout is not blocked if the isolate is unresponsive.

Also add a log line to WebtritSignalingServiceAndroid.stopService()
so the call is visible in Flutter logs.

* fix: gracefully disconnect WebSocket on app swipe in pushBound mode

Replace stopSelf() with gracefulStop { stopSelf() } in onTaskRemoved
so the background isolate gets a chance to run _stop() and close the
WebSocket cleanly before the service is destroyed.

Without this, the server only detects the disconnect via TCP timeout
(30-60s), which can delay FCM push delivery for the next incoming call.

* test: add widget tests for TeardownScreen stopService behavior

Verify that:
- stopService() is called synchronously during initState
- AppCleanupRequested is dispatched on the first frame
- stopService() is called before AppCleanupRequested (ordering)

* fix: handle stopService() errors in TeardownScreen

* fix(signaling): eliminate 1s call-setup delay in notifyForceReconnect (WT-1239) (#1086)

* fix(signaling): use Duration.zero in notifyForceReconnect to eliminate 1s call-setup delay (WT-1239)

notifyForceReconnect was using kSignalingClientFastReconnectDelay (1 s) before
scheduling the WebSocket reconnect. This added ~1 s of dead time between the user
tapping "call" (or answering from a push notification) and the SDP offer reaching
the server — visible as a "Connecting to signaling" spinner during that window.

The 1 s delay was originally a copy of the value used by notifyAppResumed, which is
a proactive reconnect (no urgency). Force-reconnect callers (outgoing call start,
push-answer) need the socket ready as fast as possible.

The spurious "connection failed" toast that was the original concern (WT-1221) is
suppressed by the consecutive-failure threshold (notifyAfterConsecutiveFailures),
not by the reconnect delay, so reducing the delay here is safe.

Regression tests added that verify:
- notifyForceReconnect fires on Duration.zero, not kSignalingClientFastReconnectDelay
- screen-unlock → outgoing call sequence gets an immediate reconnect
- WT-1221 toast guard remains intact after the timing change

* refactor(signaling): remove task reference from notifyForceReconnect comment

* test(signaling): address review comments — fix event-loop wording, remove stale TDD notes and task refs

* fix: audio routing if only one device (e.g avd simulator) (WT-1097) (#1087)

* fix(android): minimize app instead of destroying Activity when back pressed at root (#1090)

* fix(android): move app to background on back press instead of destroying Activity

When pressing Back on the root screen, Android was calling finish() on
MainActivity, destroying the Flutter engine and tearing down any active
WebRTC call (DTLS alert -> ice_hangup -> BYE). Override onBackPressed to
call moveTaskToBack(true) so the app minimizes without destroying the engine.

* fix(android): minimize app instead of destroying Activity when Flutter stack is empty

Override popSystemNavigator() which is called only after Flutter exhausts
its own navigation stack. Replaces the default finish() with moveTaskToBack(true)
so the Flutter engine stays alive with any active WebRTC call.

Previous attempt overrode onBackPressed() which bypassed Flutter router entirely.

* fix(android): handle moveTaskToBack failure and document intentional minimize-always behavior

* fix(signaling): bidirectional hub protocol + persistent-mode reconnect (#1091)

* fix(signaling): make hub protocol bidirectional — remove isolate auto-reconnect

The background foreground-service isolate was managing its own reconnect
timer independently of SignalingReconnectController in the main isolate.
This caused a race: the background could reconnect while the app was locked,
producing a SignalingConnected event that set _wasConnected=true, so the
next disconnect triggered an error toast visible on unlock (green + error).

Root fix: extend the hub protocol with connect/disconnect commands so the
main isolate (SignalingReconnectController via SignalingHubModule) is the
single decision-maker for all reconnects.

Changes:
- signaling_hub_command: add SignalingHubConnectCommand / SignalingHubDisconnectCommand
- signaling_hub_client: add sendConnect() / sendDisconnect() fire-and-forget methods
- signaling_hub: handle connect/disconnect commands, forward to SignalingModule
- signaling_hub_module: connect()/disconnect() forward commands to hub client
  instead of being no-ops
- signaling_foreground_isolate_manager: remove _reconnectTimer and
  _scheduleReconnect(); isolate no longer auto-reconnects on disconnect
  or connection failure — it only reconnects when main isolate calls
  handleStatus(enabled:true) and the module is not connected
- tests: update hub_module_test and foreground_isolate_manager_test to
  cover new behavior; all 158 tests pass

* fix(signaling): handle persistent-service mode reconnect when app is closed

In persistent signaling mode the foreground service outlives the app.
When the app is closed there are no hub subscribers, so
SignalingReconnectController is not running and cannot drive reconnects.

Fix: restore the reconnect timer in SignalingForegroundIsolateManager but
gate it on SignalingHub.hasSubscribers. When subscribers are present (app
open), reconnect is delegated to SignalingReconnectController as before.
When no subscribers are present (app closed, persistent mode), the
background isolate schedules a local reconnect using the delay hint from
the disconnect/failure event. Null delay (e.g. code 1002) is treated as
"do not reconnect" in both modes.

When handleStatus(enabled: true) is called while a timer is pending, the
timer is cancelled so the incoming caller takes responsibility.

- SignalingHub: add hasSubscribers getter
- SignalingForegroundIsolateManager: restore _reconnectTimer and
  _scheduleReconnect(); schedule only when !hub.hasSubscribers
- Tests: 4 new persistent-mode tests covering auto-reconnect on disconnect,
  auto-reconnect on connection failed, no reconnect on null delay, and
  timer cancellation on stop(); all 68 isolate+hub tests pass

* fix(signaling): address Copilot review comments

- signaling_hub: guard connect/disconnect commands with subscriber check —
  reject commands from unknown consumers (consistent with execute command)
- signaling_hub: fix hasSubscribers doc — says "any subscriber" not
  "main-isolate subscriber" (push-notification isolate can also subscribe)
- signaling_hub_module: update class doc — connect/disconnect now forward
  commands to hub client, not no-ops

* refactor(WT-1221): centralize disconnect notification decisions in SignalingReconnectController (#1089)

* refactor(signaling): centralize disconnect notification decisions in SignalingReconnectController

Move all notification decisions out of CallBloc.__onSignalingClientEventDisconnected
into the single onConnectionFailed callback of SignalingReconnectController.

The callback now receives SignalingDisconnectCode? knownCode so the consumer
can decide what to show:
- signalingKeepaliveTimeoutError / controllerForceAttachClose → silent (no toast)
- sessionMissedError → SignalingSessionMissedNotification
- null (connect failure) → SignalingConnectFailedNotification
- other codes → SignalingDisconnectNotification(knownCode)

__onSignalingClientEventDisconnected now only updates CallState.
This aligns the code with the comment that was already there:
"notification decisions are fully handled by _reconnectController".

Fixes keepalive timeout (4502) appearing as a user-visible error on lock-screen
unlock — it is now silently swallowed and the reconnect proceeds transparently.

* refactor(signaling): log silent reconnect codes in onConnectionFailed

* refactor(signaling): add comment for silent reconnect codes in onConnectionFailed

* refactor(signaling): address Copilot review comments on PR#1089

- SignalingFailureInfo record replaces bare knownCode in onConnectionFailed,
  forwarding systemCode/systemReason so SignalingDisconnectNotification
  retains full diagnostic details
- signalingKeepaliveTimeoutError sets lastSignalingDisconnectCode=null
  to prevent connectIssue UI state (same as controllerForceAttachClose)
- onConnectionFailed uses switch expression for clarity
- fix doc comment example and __onSignalingClientEventDisconnected comment wording

* fix(signaling): reset _wasConnected on app pause to prevent spurious toast on unlock (WT-1221)

When notifyAppPaused disconnects intentionally, _wasConnected is now reset to
false. Previously it stayed true after a successful session, so the first
post-unlock SignalingConnectionFailed hit the '_wasConnected' fast-path and
fired onConnectionFailed immediately — bypassing the consecutive-failure
threshold and showing 'Connecting to the core failed' on screen unlock.

With this fix the post-unlock reconnect is treated as a fresh attempt:
the threshold applies and transient DNS/network failures are suppressed.

* fix(signaling): suppress background notifications and reset state on resume

On Android, SignalingHubModule.connect/disconnect are no-ops — the
foreground-service isolate owns the WebSocket lifecycle and reconnects
independently. When the app is backgrounded, background reconnects set
_wasConnected = true. A subsequent failure then fires onConnectionFailed,
which queues a toast that appears incorrectly when the app resumes
(green status + error toast simultaneously).

Two fixes:
1. Guard _onConnectionFailed calls with (_appActive || _hasActiveCalls).
   No notifications are queued while the user cannot see them.
2. Reset _wasConnected and _consecutiveFailures in notifyAppResumed so
   background reconnect state does not bypass the failure threshold after
   the app comes to foreground.

Adds two new regression tests covering both scenarios.

* docs(signaling): update stale comments in SignalingReconnectController

After PR #1091 SignalingHubModule.connect()/disconnect() are no longer
no-ops — the hub protocol is now bidirectional. Update two comments that
still referenced the old "hub handles reconnects independently" behaviour:

- notifyAppResumed(): explain that the _wasConnected reset is needed for
  persistent-service mode session buffer replay, not for independent
  background reconnects
- _onEvent: replace the outdated no-ops no…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants