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
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:
-
VideoCallRequestResponseEventover WebSocket — the backend forwards a watch acknowledgement (OKor busy/refuse). WhenisOk == truethe controller setsstate.isDeviceRinging = trueso the UI can show "Device ringing".
Code:_onWebSocketEvent(line 525). -
callItemAddStreamfrom the SDK — the SDK reports the outgoing call as aVideocallItemwithdirection == outgoing. The controller stores it instate.currentCall.
Code:_onCallItemAdd(line 450). -
callItemUpdateStreamfrom the SDK — once the remote party (watch) joins the call, the SDK should update theVideocallItemwithisTalking == true, optionally withuploadVideoStreamSelf/uploadVideoStreamOther. The controller transitions toinCalland sends aVIDEO_CALL_ROOM_COUNT_REQUESTto 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 ischatType(0 = single,1 = multi), thenappAccount, thenroomNumber, thensessionId.PRYVCALL,0(ack) — the watch replies acknowledging the request.UPRYROOMCOUNT,0,0,...— the watch updates the room count withcount=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
_callService.startCall(...)returnstrue(the SDK accepts the outgoing call).callItemAddStreamfires once with aVideocallItemwhosedirection == outgoing. We set it asstate.currentCall.- The watch logs
PRYVCALL,...immediately after we sendVIDEO_CALL_REQUEST, so the watch receives the request. - The user can hear the watch ringing.
- We confirm receiving
VideoCallRequestResponseEventvia WebSocket withisOk: true— the backend says the watch acknowledged. - After the user answers on the watch, the SDK on the mobile does not emit any
callItemUpdateStreamevent withisTalking == true. - No error is logged on the SDK side. Network is stable, login is
loggedIn.
What We Need Help With (Asks for Juphoon)
-
Under which conditions is
VideocallItem.isTalking == truepublished overcallItemUpdateStream?
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"? -
Does the watch firmware (RTOS and Android) need to call any specific SDK API after
PRYVCALLis accepted by the user, in order for the mobile SDK to receive the talking event?
The watch logs showPRYVCALLack andUPRYROOMCOUNT,0,0,.... IsUPRYROOMCOUNTenough or are we missing aJCCallJoin/acceptCallequivalent on the watch side? -
Should the mobile and watch use matching
chatType?
Today the mobile uses0 = singlefor 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 sendchatType = 1(multi) and let both peers join the same room? Currently the watch logsPRYVCALL,0,...with aroomNumberof the form<deviceId>_<userPhone>even though the watch is Android — would that break the talking state propagation? -
MediaConfig.MODE_RTOSvsMODE_INTELLIGENT_HARDWARE— we select the mode based ondevice.capabilities.system. Are these the correct mode mappings for the talking event to fire? Could the mode mismatch silently disable the update events? -
UPRYROOMCOUNTreturningcount = 0after the watch acknowledgesPRYVCALL— 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? -
Reference to
meeting_page— the firmware vendor mentioned that Android watches usemeeting_pagefor any call. Is there a Juphoon-side equivalent we should be invoking from the mobile (e.g.,joinChannelinstead ofstartCall) when the peer is an Android watch?
Reproduction
- App build:
apps/mobile_appflavourstaging. - 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 Management→Video call→ taps the device card on the idle screen.
Steps:
- Mobile sends
VIDEO_CALL_REQUEST(visible in app logs). - Watch fires
PRYVCALL,...and starts ringing. - User accepts on the watch.
- Audio / video clearly flows (the watch shows the user's video preview, the user can hear the watch carrier speaking).
- Mobile UI stays in
outgoing("Device ringing…") forever._onCallItemUpdateis never triggered withisTalking == 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).