Files
sf-app-platform/docs/videocall-juphoon-talking-issue.md
JulianAlcala abdbc2bf2e release: 1.0.0+6 — chat notifications + videocall refactor
Chat notifications (production-ready)
- ChatDeeplinkService: resolves client chat ID from incoming push/WS payloads using the (senderId, chatId) matrix and switches selectedDeviceProvider when the payload references a different watch (multi-device deeplink)
- IncomingChatResolver in domain layer with full unit coverage of the 4-case matrix
- ChatContext provider (sealed: outsideChat / list / conversation) wired into ChatListScreen and ChatConversationScreen via initState/dispose to enable WhatsApp-style suppression
- notifications_init refactor: foreground CHAT_MESSAGE notifications are suppressed on chat list and matching conversation; tap navigation goes through ChatDeeplinkService
- ChatSyncService.subscribeToReconnect: reconciles from REST when the WebSocket comes back from a disconnect (recovers messages missed in background)
- 5s message-id dedup window in the WS listener (mitigates server-side duplicate chat-message-received events)
- Reconcile from remote on conversation mount (covers cached controller from background)
- AppBar refresh button + inverted pull-to-refresh in the conversation (overscroll either edge of the reverse list)

WS event parser fix
- chat-message-received normalises to chat_message_received; parser now accepts both that and chat_message so the conversation reactively refreshes when a watch sends a message

Chat application layer
- Split the conversation controller into services: chat_send_service, chat_sync_service, chat_participants_service, chat_permission_flow_service, chat_media_cleanup_service
- chat_conversation_config centralises page size, polling interval, dedup window
- chat_bubble_shell extracted; input bar split into smaller widgets
- emoji picker sheet + emoji blocking input formatter + watch_emoji_catalog
- Multipart upload header race fixed via synchronized.Lock around the shared Dio instance

Videocall (carryover from earlier work in this branch)
- Application services: incoming, outgoing, session
- Domain entities: VideocallIncomingArgs, VideocallRoom, VideocallUserId, parseDeviceIdFromRoom helper
- Views split: idle, incoming, active call, group call
- Widgets: picture_in_picture_video, remote_or_fallback_video, video_call_header
- videocall_config centralises timeouts, ringing duration, battery threshold
- Incoming via push (channel mode) with full-screen notification + ringtone
- Hangup-on-remote-left moved to controller; redirect on participant update documented
- treezor_token_interceptor: distinguish session expiry from operation-denied 401s

Localization & misc
- New keys for chat conversation, refresh, errors across en/es/de/fr/it/pt
- Location map: dispose ref-after-unmount fix; route history layer cleanup
- Legacy device view model: position update event handling
- AndroidManifest: notification channel + permissions for incoming-call full-screen intent
2026-05-08 07:20:40 -05:00

14 KiB

Videocall — Missing isTalking State Transition

Issue Summary

When the mobile app initiates a video call to a watch (RTOS or Android firmware), the call is answered on the watch and audio/video can be exchanged, but the Juphoon SDK on the mobile side never emits a callItemUpdateStream event with isTalking == true.

As a consequence the client-side state machine stays in outgoing (the UI keeps showing "Connecting…" / "Device ringing…") and never transitions to inCall. The _reportRoomCount notification (VIDEO_CALL_ROOM_COUNT_REQUEST) is therefore never sent, the local UI controls (mute, hang up, switch camera) stay in their pre-call state, and the call cannot be properly hung up by the user.

We need help from Juphoon to identify which side is failing to publish the "talking" state and what we need to send/configure for the SDK on the mobile to recognise the watch as connected.


Architecture Overview

The mobile side of the videocall feature is implemented in Flutter and wraps the Juphoon Android/iOS SDK through a dedicated videocall_sdk package.

modules/legacy/modules/device_management/lib/src/features/videocall/
├── domain/                       (entities, repositories interfaces)
├── data/                         (signaling datasource + repository impl)
├── providers/                    (Riverpod providers for signaling)
└── presentation/
    ├── videocall_screen.dart     (idle / outgoing / incoming / inCall / groupCall UI)
    ├── providers/
    │   ├── videocall_controller.dart   ← orchestrates SDK + signaling
    │   ├── videocall_state.dart
    │   ├── group_call_controller.dart  (group/conference mode)
    │   └── group_call_state.dart
    └── widgets/                  (UI for each call state)

packages/videocall_sdk/lib/src/
├── manager/videocall_sdk_manager.dart
├── services/
│   ├── videocall_client.dart           (login)
│   ├── videocall_call_service.dart     (start/answer/hangup, callItem*Stream)
│   ├── videocall_device_service.dart   (camera/mic/speaker)
│   ├── videocall_channel_service.dart  (group room)
│   ├── videocall_net_service.dart
│   ├── videocall_log_service.dart
│   └── videocall_push_service.dart
└── models/                             (CallParam, VideocallItem, CallDirection, MediaConfig, …)

Outgoing 1:1 Call — Expected Sequence

User taps the device card on the idle screen of the videocall feature → vm.startCall('') is invoked on the controller.

videocall_controller.dart:184 (startCall):

Step Action Code reference
1 Request runtime permissions for camera + microphone via permission_handler. _requestMediaPermissions (line 167)
2 Resolve target Juphoon user ID. Defaults to w_${device.imei} if no explicit remoteUserId is provided. line 191
3 Build chatType, roomNumber, sessionId. roomNumber = ${deviceId}_${sanitizedAppAccount} for 1:1, ${deviceId}_group for multi. _buildRoomNumber (line 152), _buildSessionId (line 163)
4 Set screenMode = outgoing so the UI swaps to the "calling" view. line 199
5 Send signaling to the watch via the backend: POST /commands with command: VIDEO_CALL_REQUEST, payload { chatType, appAccount, roomNumber, sessionId }. The backend forwards this as PRYVCALL to the watch firmware. _signaling.initiateCall (line 206), see datasource videocall_signaling_datasource_impl.dart:11
6 Start local audio + camera on the SDK. _deviceService.startAudio() / startCamera() (line 224-225)
7 Call _callService.startCall(userId, isVideo: true, callParam) to start the SIP/RTC session via the Juphoon SDK. line 228

After step 7 the controller waits for two asynchronous signals:

  • VideoCallRequestResponseEvent over WebSocket — the backend forwards a watch acknowledgement (OK or busy/refuse). When isOk == true the controller sets state.isDeviceRinging = true so the UI can show "Device ringing".
    Code: _onWebSocketEvent (line 525).

  • callItemAddStream from the SDK — the SDK reports the outgoing call as a VideocallItem with direction == outgoing. The controller stores it in state.currentCall.
    Code: _onCallItemAdd (line 450).

  • callItemUpdateStream from the SDK — once the remote party (watch) joins the call, the SDK should update the VideocallItem with isTalking == true, optionally with uploadVideoStreamSelf / uploadVideoStreamOther. The controller transitions to inCall and sends a VIDEO_CALL_ROOM_COUNT_REQUEST to inform the backend that the room now has 2 participants.
    Code: _onCallItemUpdate (line 466).

This last event is the one that never arrives in the buggy flow.


Watch Firmware Logs (Real Capture)

[3G*1106971865*0037*PRYVCALL,0,18606224072,1106971865_18606224072,123456789]
[3G*1106971865*000a*PRYVCALL,0]
[3G*1106971865*0028*UPRYROOMCOUNT,0,0,1106971865_18606224072]
  • PRYVCALL,0,... — the watch received the request from the backend. The first arg is chatType (0 = single, 1 = multi), then appAccount, then roomNumber, then sessionId.
  • PRYVCALL,0 (ack) — the watch replies acknowledging the request.
  • UPRYROOMCOUNT,0,0,... — the watch updates the room count with count=0 (the watch is not in the room yet, or is reporting that nobody is connected).

Despite the watch beeping/ringing and the user pressing the answer button on the watch, the mobile-side SDK callback callItemUpdateStream never receives an event with isTalking == true.


Mobile SDK Configuration

videocall_controller.dart:118 (_configureDevice):

_deviceService.setDefaultSpeakerOn(true);
_deviceService.setCameraProperty(640, 360, 30);   // _cameraWidth, _cameraHeight, _cameraFps
_deviceService.setVideoAngle(0);
_callService.setMaxCallNum(1);
_callService.setTermWhenNetDisconnected(true);

final isRtos = device?.capabilities?.isRtos ?? false;
final mediaConfig = await VideocallCallService.generateMediaConfigByMode(
  isRtos
      ? MediaConfig.MODE_RTOS
      : MediaConfig.MODE_INTELLIGENT_HARDWARE,
);
_callService.updateMediaConfig(mediaConfig);

Login is performed in _autoLogin (line 93):

final userId = 'p_$sanitizedEmail';            // mobile user ID
await _client.login(userId: userId, password: user.id);

The watch is registered with Juphoon under another account (the watch firmware logs in by itself with its own credentials).

callParam for the outgoing call:

final callParam = CallParam(ticket: state.deviceId);
await _callService.startCall(
  userId: targetUserId,                           // 'w_<imei>'
  isVideo: true,
  callParam: callParam,
);

Commands Sent From the Mobile to the Watch (via backend)

Defined in videocall_signaling_datasource_impl.dart. They are sent as POST /commands to the SaveFamily backend, which forwards them to the watch as raw firmware commands.

Mobile event Backend command Payload Watch firmware command
startCall VIDEO_CALL_REQUEST {chatType, appAccount, roomNumber, sessionId} PRYVCALL,<chatType>,<appAccount>,<roomNumber>,<sessionId>
cancelCall, hangUp VIDEO_CALL_CANCEL {chatType} (cancel)
rejectCall VIDEO_CALL_REFUSE {chatType, appAccount, roomNumber} (refuse)
_reportRoomCount (after inCall) VIDEO_CALL_ROOM_COUNT_REQUEST {type, count, room_num} UPRYROOMCOUNT,<type>,<count>,<roomNumber>

type in VIDEO_CALL_ROOM_COUNT_REQUEST is 0 for single and 1 for multi.


What We Have Verified

  1. _callService.startCall(...) returns true (the SDK accepts the outgoing call).
  2. callItemAddStream fires once with a VideocallItem whose direction == outgoing. We set it as state.currentCall.
  3. The watch logs PRYVCALL,... immediately after we send VIDEO_CALL_REQUEST, so the watch receives the request.
  4. The user can hear the watch ringing.
  5. We confirm receiving VideoCallRequestResponseEvent via WebSocket with isOk: true — the backend says the watch acknowledged.
  6. After the user answers on the watch, the SDK on the mobile does not emit any callItemUpdateStream event with isTalking == true.
  7. No error is logged on the SDK side. Network is stable, login is loggedIn.

What We Need Help With (Asks for Juphoon)

  1. Under which conditions is VideocallItem.isTalking == true published over callItemUpdateStream?
    We assume it is when both peers have joined the media session. Is there an additional handshake or media event that has to fire on the watch firmware before the mobile SDK considers the call as "talking"?

  2. Does the watch firmware (RTOS and Android) need to call any specific SDK API after PRYVCALL is accepted by the user, in order for the mobile SDK to receive the talking event?
    The watch logs show PRYVCALL ack and UPRYROOMCOUNT,0,0,.... Is UPRYROOMCOUNT enough or are we missing a JCCallJoin / acceptCall equivalent on the watch side?

  3. Should the mobile and watch use matching chatType?
    Today the mobile uses 0 = single for 1:1 calls regardless of the watch firmware. If the watch is Android (which according to the firmware vendor always operates in "room mode" / meeting_page), should the mobile send chatType = 1 (multi) and let both peers join the same room? Currently the watch logs PRYVCALL,0,... with a roomNumber of the form <deviceId>_<userPhone> even though the watch is Android — would that break the talking state propagation?

  4. MediaConfig.MODE_RTOS vs MODE_INTELLIGENT_HARDWARE — we select the mode based on device.capabilities.system. Are these the correct mode mappings for the talking event to fire? Could the mode mismatch silently disable the update events?

  5. UPRYROOMCOUNT returning count = 0 after the watch acknowledges PRYVCALL — is this expected before the watch joins the room, or does it mean the watch never actually joined the room from the SDK's perspective?

  6. Reference to meeting_page — the firmware vendor mentioned that Android watches use meeting_page for any call. Is there a Juphoon-side equivalent we should be invoking from the mobile (e.g., joinChannel instead of startCall) when the peer is an Android watch?


Reproduction

  • App build: apps/mobile_app flavour staging.
  • Mobile: Android phone (Samsung, API 34+) signed in as testapps.savefamily@gmail.com.
  • Watch: Android firmware, identificator 1106971865. The watch is logged into Juphoon as a separate account.
  • The user opens the app → Device ManagementVideo call → taps the device card on the idle screen.

Steps:

  1. Mobile sends VIDEO_CALL_REQUEST (visible in app logs).
  2. Watch fires PRYVCALL,... and starts ringing.
  3. User accepts on the watch.
  4. Audio / video clearly flows (the watch shows the user's video preview, the user can hear the watch carrier speaking).
  5. Mobile UI stays in outgoing ("Device ringing…") forever. _onCallItemUpdate is never triggered with isTalking == true.

Relevant Files

Feature

File Purpose
modules/legacy/modules/device_management/lib/src/features/videocall/presentation/providers/videocall_controller.dart Main controller. Holds the SDK lifecycle + signaling orchestration. The talking state transition is expected to happen in _onCallItemUpdate (line 466).
modules/legacy/modules/device_management/lib/src/features/videocall/presentation/providers/videocall_state.dart Freezed state (screenMode, currentCall, isDeviceRinging, chatType, localUserId, remoteUserId, etc.).
modules/legacy/modules/device_management/lib/src/features/videocall/data/datasources/videocall_signaling_datasource_impl.dart Sends the VIDEO_CALL_REQUEST, VIDEO_CALL_CANCEL, VIDEO_CALL_REFUSE, VIDEO_CALL_ROOM_COUNT_REQUEST commands to the backend.
modules/legacy/modules/device_management/lib/src/features/videocall/presentation/videocall_screen.dart UI; reflects screenMode from the controller.

SDK Wrapper

File Purpose
packages/videocall_sdk/lib/src/manager/videocall_sdk_manager.dart Singleton orchestrator over the Juphoon SDK.
packages/videocall_sdk/lib/src/services/videocall_call_service.dart Owns callItemAddStream, callItemUpdateStream, callItemRemoveStream, missedCallStream. The talking state should arrive via callItemUpdateStream.
packages/videocall_sdk/lib/src/services/videocall_client.dart Login lifecycle.
packages/videocall_sdk/lib/src/services/videocall_device_service.dart Camera/mic/speaker control.
packages/videocall_sdk/lib/src/services/videocall_channel_service.dart Group/room mode (used in groupCall flow).
packages/videocall_sdk/lib/src/models/videocall_item.dart Carries state, isTalking, uploadVideoStreamSelf, uploadVideoStreamOther.

Backend Side

Path Purpose
packages/sf_infrastructure/lib/src/websocket/websocket_event.dart VideoCallEvent, VideoCallRefusedEvent, VideoCallRoomCountEvent, VideoCallRequestResponseEvent.
packages/sf_infrastructure/lib/src/websocket/websocket_event_parser.dart Parses incoming WebSocket frames into the events above.

Sample Mobile Logs During the Bug

[Videocall] build() called, scheduling _initSdk
[Videocall] _initSdk: isInitialized=true
[Videocall] _autoLogin: userId=p_testapps_savefamily_gmail_com, clientState=loggedIn
[Videocall] _autoLogin: already logged in, skipping login
… user taps device card …
[Videocall] _requestMediaPermissions: granted
… signaling.initiateCall succeeds (HTTP 201) …
[Videocall] WS video_call_request_response: ok
… (watch starts ringing, user accepts on watch) …
… (no further callItemUpdateStream events) …

Expected (but missing):

[Videocall] _onCallItemUpdate: state=<X>, isTalking=true, uploadSelf=true, uploadOther=true
[Videocall] screenMode -> inCall
[Videocall] _reportRoomCount → VIDEO_CALL_ROOM_COUNT_REQUEST {type:0, count:2, room_num:<…>}

Contact

Please reach out to the SaveFamily mobile team with any clarifications. We can provide additional logs from the Juphoon SDK if needed (the wrapper currently exposes videocall_log_service.dart but verbose logging is off by default; we can re-enable it on demand).