gravix_rtc 0.7.2
gravix_rtc: ^0.7.2 copied to clipboard
Real-time video and audio rooms for Flutter on Gravix Cloud: one room service, region-aware connect, token providers, join analytics and a video-effect hook.
Changelog #
0.7.2 — 2026-10-28 #
Changed: the standby upgrade waits 1.5 s on a low-RTT path (was 0.4/0.5 s) #
GravixStandby.lowRttUpgradeFloornow defaults to 1.5 s (RTT under 30 ms only; set null for the old floor). Field 2026-10-27, Redmi on Wi-Fi 11-18 ms from dac1: with 0.4 s, 2 of 14 joins gave up on a standby upgrade the server was still answering and redialled (4.3 s / 5.7 s joins); with 1.5 s, 0 of 30. Applies in the signal isolate too.
Fixed: a join that hit a server restart failed for good #
- A WebSocket refused with 502/503/504 and no reason text (the SFU restarting behind its proxy,
e.g. during a drain roll) is now tried again by
ConnectOptions.joinRetries(default 2: after 0.5 s and 1.5 s). Field 2026-10-27: such a join failed in 0.1 s and the viewer sat in a silent room while the new server process was up a second later. Refusals with a reason (401/403, "app user limit reached", "no internet connection") stay final.
Added: GravixJoin(retryCapacityRefusals: true) (opt-in, off by default) #
- A join the server refuses because the app owner is at its concurrent-user cap
(
ConnectException403 "app user limit reached") is tried again silently on the same Room, waiting 1, 2, 3, 5, 8 s, then every 10 s (each spread +-20 %), while the join is alive. The screen'sattach()just waits longer; no error is surfaced. It stops when the join is left, superseded or abandoned. The cap stays enforced on the server; "app plan limit reached" (billing) and every other error still fail at once. - An app
connecthook must rethrow the SDK's error unchanged (and keep the Room) whenGravixPrejoin.isCapacityRefusal(e)is true; a wrapped error is not retried. - Log lines:
capacityRetry n= in=per wait,capacityRetryOk after= waited=when it connects. - Each refused attempt still emits the Room's
RoomDisconnectedEvent(joinFailure)to listeners bound increateRoom.
0.7.1 — 2026-10-27 #
Fixed: an Android crash while joining (native abort on WebRTC's signalling thread) #
- About 1 join in 20 (Redmi Note 11, joining a room that has publishers while publishing the
mic; 0.6.1 and 0.7.0 alike) the app died with
SIGABRT/jvm.cc, line 81as the remote tracks arrived. flutter_webrtc's AndroidgetUserMedialooks every new track id up on every peer connection, which disposes the transceiver/receiver/track objects itsonAddTrackis using on the signalling thread ("MediaStreamTrack/RtpTransceiver has been disposed"). - The SDK now keeps local track creation (
getUserMedia/getDisplayMedia) andsetRemoteDescriptionapart: a capture start waits for a remote description in flight (milliseconds), and a remote description waits for a capture start that began less than 300 ms ago (at most that long). Logged as[GravixGate] ...when a wait happens. - Calls that name a remote track (enabling/disabling it, attaching it to a
VideoTrackRenderer, the audio visualizer/renderer) are sent only once no remote description is in flight: the plugin looks remote tracks up through the same disposinggetTransceivers(). - Track stats no longer use the per-track
sender/receiver.getStats(), which disposes the same objects: they come from the connection-widegetStats(), scoped to the track (the same reports as before). - Tests fail if a raw
setRemoteDescription/getUserMedia/getDisplayMediaor a per-trackgetStats()call comes back. Not covered: an app calling flutter_webrtc directly (its owngetUserMedia,RTCVideoRenderer.srcObject,track.enabled) while remote tracks arrive; use the SDK's tracks andVideoTrackRendererfor those.
0.7.0 — 2026-10-27 #
Added: sign join tokens on the device #
GravixTokenBuilder.create(appId, appCertificate, GravixTokenOptions(...)): the Dart twin of the npmgravix-rtc-tokenpackage (byte-for-byte the same token; tested against vectors from the JS build). Pure Dart (crypto), every platform.GravixTokenProvider.local(appId:, appCertificate:, url:): no token request on any join; cached like the other providers. WithoutregionEntriesthe join uses the start-up region measurement, and a video join whoseurlis on an audio-only region (doh1) still moves to a region allowing video.- The App Certificate is then on the device: whoever extracts it can sign tokens for any user, room and grant of that app until it is rotated. The endpoint provider remains the default.
Fixed: a video join no longer lands on an audio-only region it never measured #
- When the token's url is a region that allows audio only (doh1) and this device never measured it
(no region list in the token either), a video join now goes to the best measured region that
allows video, else the first shortlist region that allows it; an audio join stays. Before, the
video join dialed the audio-only region and the camera was refused (
media_not_allowed). Lookup only: no network call on the join path. The region-pick log line now showspinned=.
0.6.1 — 2026-10-25 #
Fixed: a needless reconnect about 16 s after a host joins a room alone #
- The uplink watch (
uplinkFeedbackStallFor) resumed a healthy connection once per join when the server paused the camera layers (nobody watching): packets sent just before the pause counted as unanswered. A stream must now still be sending to count; a dead uplink still resumes.
Changed: Gravix Effects 1.1.1 #
- Requires
gravix_effects_webrtc/gravix_effects^1.1.1: licence tokens from the Gravix Cloud panel (production key) now remove the watermark in release builds. - Flipping the camera on a phone with one camera no longer freezes the picture: the current camera
keeps running and
switchCamera()leavescameraPositionunchanged (E_NO_CAMERA).
0.6.0 — 2026-10-24 #
Added: built-in beauty effects (Android) #
GravixBeautyEffect(licenseToken:): Gravix Effects 1.1.0 (looks, face reshape, makeup, stickers, 3D effects) as the room's camera. Parameters:look,lookBytes(Gravix Studio.gfx),tone,region,reshape. New dependency:gravix_effects_webrtc(+gravix_effects).GravixCameraEffect: an effect that opens the camera itself; the service publishes its track, flips through it and closes it on camera off / disconnect.GravixRoomService(videoEffects: [...]): several effects in order (at most one camera owner).- Removable: the native SDK (~13 MB) can be stripped with Gradle excludes + R8
-dontwarn(README, "Removing them"); a native check then makesGravixBeautyEffectdecline and the room uses its normal camera.
0.5.2 — 2026-10-22 #
Added: GravixAudioScenario.chatRoom (Agora ChatRoom parity, Android) #
GravixRtcClient.initialize(audioScenario: GravixAudioScenario.chatRoom), orgravixUseAudioScenario(...)merged into an app's ownWebRTC.initializeoptions: the communication profile (MODE_IN_COMMUNICATION, VOICE_COMMUNICATION capture), the platform AEC, a 16 kHz mono capture (playout stays 48 kHz) and no AGC. The same as Agora'sAUDIO_SCENARIO_CHATROOM(verified by reverse engineering and on a device).- Why: the platform AEC also cancels sounds the app plays OUTSIDE WebRTC (gift / entrance SVGA and MP4 effects), so they no longer reach the room while the user keeps talking. Xiaomi measurement, a separate player's music: sent -11 dBFS before, about -112 dBFS mean with chatRoom; speech during the music unchanged.
- Trade-offs, as on Agora ChatRoom: call volume, voice-band (16 kHz) capture, another app cannot record while the microphone runs, screen recordings do not capture the room.
Added #
GravixNsAgcOrder.none: noise suppression without any AGC (ours or WebRTC's). The default underchatRoom: an AGC after the platform AEC lifted the echo residual by up to 10 dB.- Android diagnostic:
adb shell setprop debug.gravix.capdump 1before a capture records what is sent (before and after noise suppression) as WAV files under the app's external files directory (gxdump/). Off by default.
0.5.1 — 2026-10-22 #
Fixed #
- Android: 0.5.0 did not compile in apps on Kotlin 2.2.x (
Returns are prohibited in functions with expression bodyinGxNsStudio.kt, task:gravix_rtc:compileReleaseKotlin). No behaviour change. - The
llvm-strip ... not recognized as a valid object filelines forlibgxns_dfn3_*.soduring an Android build are harmless: those files carry the noise-suppression model data, which Gradle packs unstripped.
0.5.0 — 2026-10-22 #
Noise suppression is on by default, Opus DTX is on by default in GravixRoomService, and a join
routes by what each region takes (audio-only regions). Native code changed (a new C noise core on
Android and iOS); the Dart API is additive.
Added: noise suppression, on by default #
GravixNoiseSuppressionOptions(modeauto): RNNoise everywhere; DeepFilterNet3 ("studio") on devices that pass the static check, the policy and a first-run benchmark (fast iPhones), with a mid-call fallback to RNNoise when it runs too slow. Other modes:off,standard,studio.- On by default through every entry point:
RoomOptions.noiseSuppressionand thenoiseSuppressionparameter ofGravixRoomService. To opt out passGravixNoiseSuppressionOptions.off(alias oflegacy: WebRTC's own noise suppression as before). - A
Roomcreated with the default options never overrides an earlierGravixNoiseSuppression.configurecall: an app that turned it off keeps it off. - Quiet microphones: a pre-gain in front of the suppressor and a pause gate that keeps the gain and AGC from lifting the noise floor over DTX; a quiet voice is still judged as talk (the DFN3 quiet-mic fix).
[GravixNS]log lines show the engine, the reason and the route.
Added: Opus DTX on by default #
GravixRoomService(dtx: true)is the default: no audio, almost nothing sent.dtx: falseopts out.- The publisher's answer is pinned to our microphone's Opus format (
usedtx=1, mono), so DTX holds for every app. AudioPublishOptions.stereopublishes stereo explicitly (stereo=1, no DTX). Stereo is no longer guessed from the bitrate (a 64 kbps mono microphone was misread).
Added: region-aware join routing (audio-only regions) #
- A region's
mediaentry inGET /v1/regions(["audio"]= audio only) is remembered; a join that uses video goes to the best measured region that takes video, an audio-only join may use any. - If a server refuses a medium (
media_not_allowed), the session makes one move through the engine's reconnect path and republishes the refused camera. Hysteresis is per medium.GravixRecoveryOptions.mediaRefusalFailover(default on) switches the move off.
Fixed / Changed #
- The call foreground service is held through a whole rejoin incident, so the microphone keeps working in the background; it also starts before the noise suppression setup.
GravixRoomService.connectWithTokenProvidertakesredandredLossThresholdPctlikeconnect().- iOS: an app that needs a microphone in the background must declare
UIBackgroundModesaudioin its Info.plist (the example app does).
0.4.19 — 2026-10-15 #
Fast joining in the package: what every app wrote by hand around Room.connect (the token
cache rule, the standby pre-connects, the tap-time prejoin, the lifecycle guards, one session at
a time, the room switch, the viewer rejoin), with one secret-free [GravixJoin] log line per step,
and rooms this SDK cannot join (old servers) refused instead of failed. All of it is new and
opt-in: Room.connect, GravixRoomService, GravixStandby and GravixTokenProvider without
reuse: behave exactly as in 0.4.18. See doc/FAST_JOIN.md.
Also fixes in the core's publisher negotiation that are always on: one publisher offer at a time
(no more full rejoins from "order of m-lines" when room music changes tracks), the first answer no
longer held behind a deferred sender change (no native round trips in front of a non-restart offer
or a remote candidate, no replay of a change the pending offer already carried), and no SDP body in
any log line. With the socket in the signal isolate, a standby connection's upgrade is raced by a
fresh dial sooner (3 x RTT, at least 0.4 s). No native change.
Fixed: one publisher offer at a time (room music no longer sends the host to a full rejoin) #
- A publisher renegotiation that fired while the previous offer was still being created (createOffer, munging and setLocalDescription take 60-200 ms on a phone), or while the answer's replay was, also found the connection stable and created a second offer. libwebrtc names a transceiver without a mid afresh in every createOffer, so it refused the second one ("The order of m-lines in subsequent offer doesn't match order from previous offer/answer"), and the NegotiationError ended in a full rejoin. Room music republishes the microphone twice per track change: up to 7 full rejoins in one host session. It was there in 0.4.17 and 0.4.18 builds too.
- Offer creation and answer application now run one at a time: a later call finds the offer pending and is replayed after the answer; an offer created after the answer carries every change, so a satisfied replay is skipped instead of sent empty. A step that never finishes holds the next one at most 10 s (logged). ICE restarts keep their replay.
- One secret-free line per publisher negotiation step through
debugPrint, with the stable prefix[GravixPub]:senderAdd/senderRemove,offerCreate,setLocal/setRemote,offerSent(with the m-line listkind:mid/direction),offerWait/remoteWait(and...WaitTimeout),offerDefer/offerHold/offerReplay/offerReplaySkip,remoteSet <type>,offerRollback.
Fixed: the first publisher answer is no longer held behind a deferred sender change #
- With publisher offers run one at a time (above: an offer being created and the answer's
application are serialised), a sender change made while an offer was out (e.g. the camera
published while the join's microphone offer was waiting for its answer) queued in front of that
answer, only to read the native signaling state and defer; under main-thread load that round trip
held the answer 24-423 ms (in 4 of 10 cold host joins of a device run), and the microphone and
camera went live that much later. The publisher now keeps an "offer pending" mark: a non-restart
offer request while it is set only marks the change for the replay after the answer
(
[GravixPub] offerDefer) and returns without a chain step or a native call. ICE restarts keep the serialised path. - The replay after an answer is skipped when its fresh offer negotiates exactly what the last
offer did (m-lines, mids, directions, tracks, codecs, simulcast; not the origin line or the
candidates and addresses gathering added)
(
offerReplaySkip): the answered offer already carried the change, so no identical extra offer and round trip. After an ICE restart's answer the held change is still replayed. - A sender change already contained in the pending offer causes no replay at all (device run:
in one of three cold host video joins the first offer already carried the camera and an
identical replay followed the answer; the skip never fired on that path). Offer requests are
counted; a request covered by the pending offer (made before its createOffer) logs
offerCoveredand needs neither a replay nor a createOffer. Otherwise the replay's offer is compared on its senders only (per m-line: kind, mid, direction, msid, ssrc, rid / simulcast). The skip that never fired compared the first offer of a connection (one transport per m-line) with a re-offer after BUNDLE was accepted (one transport); which line differed is not in the device log. A sent replay logsofferReplayDiff attrs=<attribute names>(names only), a skip names the other differing lines the same way. - A non-restart offer reads the Dart-side "offer pending" mark instead of the native signaling state (an ICE restart still reads it): a real change goes out right after the answer, with no native round trip in front of its createOffer.
- A remote ICE candidate is added at once once a remote description was applied (a Dart-side
mark instead of a native
getRemoteDescription()per candidate). A candidate that arrived while the answer was being applied could read "no description", land after the answer's pending list was flushed and stay unused until the next answer.
Fixed: no SDP body in the logs #
- A refused munged offer, the engine's offer / answer trace lines, a
NegotiationErrorand a failed createAnswer printed the whole SDP or the raw native error (which quotes SDP lines), i.e. the ice-pwd, ice-ufrag and fingerprint. Each now names the type and the m-line list; error text is cut before any SDP body, ice-pwd / ice-ufrag / fingerprint / crypto values are redacted, and the length is capped. Signal offer / answer events print the type and the m-lines.
Changed: the standby connection in a join #
- Every join that took a standby connection logs what happened to it:
standbyTaken(outcome, age, wait, path, upgrade bound, dial time, reused),standbyRaceWon=fresh|standbywhen a fresh dial raced a stalled upgrade,standbyStale reason=expired|dead|stalled|failed|reopenedwhen it could not carry the join (device run: two host joins paid ~3 s on a stalled standby and the log showed onlystandbyReuse). - With the socket in the signal isolate, the upgrade over a standby connection gets 3 x RTT, at
least 0.4 s (was: the pool's RTT hint of at least 400 ms, i.e. 1.2 s, and a 0.5 s floor) before
a fresh dial races it; at most 1.5 s as before. Those floors were for a busy main isolate reading
the reply late; the two stalled joins waited the full 1.2 s before their fresh dial. Without the
signal isolate nothing changes. (
GravixStandby.lowRttUpgradeFlooris not passed to the signal isolate, before or now.) The fresh dial is raced, not queued: whichever connection is up first carries the join, and the other is closed before the fresh join is written (one server session). A standby connection that is half-open cannot be detected before its upgrade (no ping exists on a connection with no WebSocket yet); the race is the safeguard.
Added: GravixJoin #
warm(request, role:, rank:, activity:): the token into the provider's cache and a standby pre-connect to the server the join will dial; no connect, never throws.activity: false(a periodic refresh, e.g. the own room's token every 40 s) does not count as user activity: it neither wakes an idle standby pool nor keeps it rotating (GravixStandbyPool.noteCard/touchCardtake the sameactivity:assetOwnJoin).prepare(request): the tap's join, started now (GravixPrejoin); the same handle while the session (room + identity + publish right) is live. Another session is stopped first and the new connect starts only after its leave returned: never two sessions.prepare(request, abandonInBackground:, attachTimeout:): override the constructor's for that one handle (a host's own Go Live join whose room screen is pushed after the app's Go Live flow survives a Home press in that window;attachTimeout: null= never, omitted = the constructor's,GravixJoin.inheritAttachTimeout).attach/attachResult/attachOrThrowtake the sameattachTimeout:for their cold join.attach(request, createRoom:, connect:, leave:, joinMicrophone:, joinCamera:, abandonInBackground:): the room screen's connectedRoom(a cold join when there was no prepare; the hooks andabandonInBackgroundare used only for that cold join, a live handle keeps its own).switchTo(request, beforeConnect:): the new room's token and server warmed at the tap, the old session stopped at once, the app's own work beside it, the new connect the moment both are done.adopt(request, room, leave:): a Room the app connected itself (its own reconnect, e.g. a role change with a new token whosecanPublishdiffers) becomes the current session, already attached: found bypending/attach, left by the nextprepare/switchToand byleave(). The previous session endsreplacedwithout its leave hook (the app tore it down); one still joining is abandoned normally. No lifecycle guard on an adopted room.abandon,leave,forgetAll,current,pending;onEvent(GravixJoinEvent: timings on the tap's clock,switchStart/switchOldLeft/switchConnectedincluded).
Added: the app's join layer (the wrapper every app wrote around GravixJoin) #
GravixJoinHooks(createRoom:, connect:, leave:): an app's own connect as one value, accepted ashooks:byprepare,attach,attachResult,attachOrThrow,switchToandadopt(itsleave). A null field falls back to the constructor's; an explicit argument wins.attachOrThrow(request, hooks:, onHandle:, throwIf:): the room screen's Room; null when the session ended without an error; throws the token callback's own exception (unwrapped fromGravixTokenException) or the connect's error (throwIf: which ones; false returns null).onHandlegets the session's handle at the call. Started before a permission dialog, a failure meanwhile is no longer reported as an unhandled async error: the laterawaitthrows it.attachResult(request, hooks:): the same, never throwing:GravixAttach(room,handle,errorunwrapped,connectedBeforeAttach,isLive).GravixJoin.unwrapError(e),GravixJoin.isLive(room),join.hasToken(request),join.dropToken(request).GravixPrejoin.parseSessionKey(key)andGravixJoinEvent.session: the room / identity / publish right of a session key, for a join trace.GravixPrejoinEndReason.leftBeforeShown:background/neverShown, the reasons a room screen closes normally.
Added: GravixPrejoin (the handle, usable without GravixJoin) #
start(key:, target:, createRoom:, connect:, leave:),pendingFor(key),attach(),abandon(),leave(),connected(the join gate),ended(GravixPrejoinEndReason).adopt(key:, room:, leave:, replaces:): the low-levelGravixJoin.adopt;GravixPrejoinEndReason.replacedandGravixJoinEventKind.adopted.- Abandoned when the app is hidden / paused / detached before
attach()is called or the handle was started in the background. The screen claims the session when it CALLSattach()(not when the connect returned: a Home press with the room screen visible and the connect running ended the join): from then on only the background before the screen's first frame was shown with the app resumed leaves (neverShown,leaveIfNeverShown; a frame drawn whileinactivedoes not count); once shown, never (GravixJoinEventKind.shownmay now come beforeattached).markShown(): the app says a screen of the session is shown. Left afterattachTimeout(20 s) when no screen calledattach(); the clock counts only foreground time (paused while the app is hidden / paused, resumed with what was left; a handle started in the background starts paused): a host's Go Live join kept through a Home press was timed out by the time spent away. Abandon while joining ends the join at once. - The leave reaches the server when the app goes away: an abandon of a connecting / connected
handle, or one for the background (
background/neverShown), callsroom.disconnect()first (not awaited: the Leave is written at once) and runs the app's leave hook after it (a Back right after the tap could detach the engine ~20 ms later, before the hook reached its disconnect: a ghost viewer until the server timeout, ~30 s). The real exit of an attached room keeps the hook first; a detach while it runs sends the Leave at once. A detached app ends a handle no screen has shown, also one prepared withabandonInBackground: false. - An abandoned join never ends joined:
Room.connect()clears the leave mark, so a handle abandoned before the app's connect hook calledconnect()was joined after the abandon (a ghost). The Room is now marked so that the nextconnect()returns at once without connecting (only a Room whoseconnect()was never called; other callers unchanged), and a connect that joins anyway is left at once with the signal leave (leaveAfterLateConnect). - A dead Room is an ended session: a connected / attached handle whose Room was disconnected after
the connect (
RoomDisconnectedEvent, ordisconnectedwith its engine closed) or disposed is not handed out again (pendingFor/start/attach/GravixJoin.prepare/attach/pending/current) and ends with the newGravixPrejoinEndReason.roomGone; the next join is fresh. A resume or full reconnect under way is not a dead Room. Its leave hook never runs, also when it is superseded / abandoned / left (the app's disconnect handling owns the cleanup; a viewer rejoin does not restart the audio session); the SDK disposes it only when it built it with the default createRoom and leave.
Added: GravixJoinLog (one line per fast-join step) #
- Every step of a fast join writes one secret-free line with a stable prefix:
[GravixJoin] <step> key=<room>|u<id hash>|pub=<bool> [+<ms since tap>ms] [<detail>]. Steps:warmStart/warmOk/warmFail/warmRefused/warmSkip,refused,prepare(new / same handle / supersedes),tokenStart/tokenOk/tokenFail,urlChosen,connectStart/connectOk/connectFail,attachCall/attachCold/attached,shown,abandon(reason),left,leaveStart/leaveSignalFirst/leaveAfterLateConnect/leaveDone/leaveSkipped/leaveFail,connectBlocked,standbyTaken/standbyRaceWon=fresh|standby/standbyStale,attachTimeoutPaused/attachTimeoutResumed,detached,switchStart/switchOldLeft/switchConnected/switchCancelled,adopt,forgetAll,standbyOpen/standbyReuse/standbyFail/standbyClosed,rejoinScheduled/rejoinAttempt/rejoinOk/rejoinFail/rejoinRefused/rejoinGaveUp/rejoinCheck/rejoinLiveBack, anderrorfor every caught exception in the join, standby and rejoin code (its type; no silent catch). - Never a token, a JWT, a url path or query (hosts only), an error message or a full identity (a stable FNV-1a hash); every detail is scrubbed of JWTs and secret query parameters as well.
GravixJoinLog.level(off/info/verbose; defaultinfoin release,verbosein debug and profile),GravixJoinLog.enabled,GravixJoinLog.sink(defaultdebugPrint) and alog:sink onGravixJoin,GravixPrejoin.start/adopt,GravixStandbyPoolandGravixRejoinPolicy.GravixPrejoin.marktakes an optional line detail;logStepadds a line.- The warm failure says why:
warmFail ... not warmed (<reason>[, cause <type>])(field 2026-10-15:not warmed (GravixTokenException)hid an app's refusal of old-app rooms). The rejoin policy's and the standby pool's formerGravix rejoin:/Gravix standby pool:lines are now these steps.
Added: rooms this SDK cannot join (old servers) are refused, not failed #
- Many live rooms are hosted by old app builds on an old server: the token callback refuses them
(the app's "old app version" exception) or the token answer names a retired Gravix server. Before,
warmloggedwarmFailfor each and every list refresh warmed them again (one line and one token call per card per rebuild). - A refusal is a
RetiredServerException(built in), the newGravixRoomRefusedthrown by the token callback, or what the newGravixJoin(isRefusal:)says of the app's own (unwrapped) exception.join.isRefusalError(e),GravixRoomRefused.classify/isBuiltIn/unwrap. warmof a refused room:warmRefused key=... reason=<type>(notwarmFail) and the newGravixJoinEventKind.refused; the room is remembered forGravixJoin(refusalMemo:)(default 60 s): warms inside it do nothing (no token call, no standby; one verbosewarmSkip reason=refusedline at most).join.isRefused(request)(no I/O) for a list badge.prepare/attach/attachResult/attachOrThrowof a refused room still ask the token callback, end at once with the app's own error (attachOrThrowthrows it,GravixAttach.error), logrefused(notconnectFail, notokenFail) and renew the memo;GravixPrejoin.refused;GravixPrejoin.start(isRefusal:)(default: the built-in refusals). A token for the room andforgetAll()end the memo.GravixRejoinPolicy(isRefusal:, onRefused:): arejointhat throws a refusal is not a failed attempt (not counted, no retry, noonGaveUp);roomRefuseduntilliveBack.GravixJoin.unwrapErrornow also unwraps aGravixTokenExceptionnested in another.- Note: an exhaustive
switchoverGravixJoinEventKindneeds arefusedcase.
Added: the pieces #
GravixTokenReuse(GravixTokenProvider.callback/endpoint/fromTokenSource(reuse:));GravixTokenReuse.fastJoin: reused only while younger than half its lifetime and 5 min, with at least 90 s left. Withoutreuse:the 0.4.18 rule.gravixJoinUrl(credentials)/GravixJoinUrlChoice.withStandby: the url a join dials (measured region, a viewer's home region within the margin, the token url otherwise; an open standby in another of the token's regions wins, never over the home region).GravixStandbyPool/gravixPlanStandbys: one standby per server for the servers the user is likeliest to join (touched > own region > visible, at most 3), rotated every 40 s, closed after 3 min idle and in the background, re-opened on resume;explain().GravixRejoinPolicyandgravixConnectWhileAlive: the viewer rejoin (1/2/4/8 s; never after a kick, another device, a deleted room or a refused join; 3 per incident, 12 per live).
0.4.18 — 2026-10-14 #
Recovery after a phone's Wi-Fi drop (a short drop resumes instead of rejoining, a resume no longer
reports the network's failure as a connection failure, no rejoin storm), every reconnect attempt as an
event, the session's ICE transport and a slow-TCP signal, a muted join microphone, and an experimental
joinCamera. 0.4.17 was not published; its changes are part of this release. The new recovery switches
are in GravixRecoveryOptions, on by default (their off value is the previous behaviour).
RoomOptions(recovery: GravixRecoveryOptions.before0418) turns every 0.4.18 recovery switch off in one
step (the 0.4.16 behaviour; the 0.4.15-0.4.16 recovery stays on); GravixRecoveryOptions.legacy turns
all recovery switches off. Everything else is opt-in and off by default.
Fixed: a resume after the phone's own network changed no longer says a connection failed #
- The SFU takes a peer connection failure on a connection younger than 90 s for "UDP blocked" and
prefers ICE/TCP for that participant (for 5 minutes, renewed by every join). After Wi-Fi going off or
back on, a handover between Wi-Fi and mobile, or the phone reading offline, the failure is the
network's: the resume now tells the server
RR_SIGNAL_DISCONNECTEDinstead ofRR_PUBLISHER_FAILED/RR_SUBSCRIBER_FAILED. A connection failing on a steady network still says so. resumeReasonNetworkWindow(30 s): how long before the reconnect began a network change counts.Duration.zero: off.
Fixed: no rejoin storm from a new connection's transient FAILED #
- Over ICE/TCP a new peer connection's first checks can fail at once (checking -> failed -> connected
within about 0.1-1.7 s). Each such FAILED asked for another full rejoin: on a device, 8 full rejoins in
2 minutes, each recreating the same TCP session. A connection that never connected now gets
freshPeerFailedGrace(3 s) to connect before it asks for a reconnect; its join or rejoin still times out as before, and a connection that was connected and fails reconnects at once.Duration.zero: off.
Fixed: a short network drop resumes the session instead of rejoining it #
- A resume whose dial failed with a socket error (the phone's network going away under it) made the
next attempt a full rejoin, started the instant the network came back: the far side saw the host
leave and join again although the server still held the session. Now, when the phone's network
changed (within
resumeReasonNetworkWindow) or reads offline, the next attempt waits for the network and resumes; two failed resumes in a row still become a full rejoin (resumeAfterNetworkSocketError). - A connected peer connection that reports FAILED during a network change now gets one resume (its ICE
restart) before the full rejoin; when that resume does not bring it back, the full rejoin follows (a
connection other than the primary gets
resumePeerRestartWait, 3 s, after the resume). On a steady network a FAILED is a full rejoin at once, as before (resumeBeforeFullOnNetworkFailure).
Added: RoomReconnectAttemptEvent #
- One per reconnect attempt, before its dial:
mode(GravixReconnectMode.resume/full),cause(e.g.peerConnectionClosed,peerConnectionFailed,signal,reconnectRetry),reconnectReason(what a resume tells the server, e.g.RR_SIGNAL_DISCONNECTED; null for a full rejoin),attempt(1 for the first of an outage) andafterNetworkChange. Until now only a full rejoin was visible (RoomReconnectingEvent).
Added: the session's ICE transport #
Room.selectedTransport(GravixIceTransport.udp/tcp/relay, from each peer connection's selected candidate pair: tcp when either runs over ICE/TCP, relay when either is relayed), plusRoom.publisherTransportandRoom.subscriberTransport. Null until the first reading.RoomIceTransportChangedEvent(transport,previous,publisher,subscriber) when it changes.RoomSlowTcpTransportEvent(rtt,over) once when a TCP session's round trip stays aboveslowTcpRtt(1 s) forslowTcpFor(10 s), again after it recovered.- The transport is taken from the stats the SDK already reads when a connection's ICE connects (no read
of its own). Its own readings (one getStats per connected peer connection) come every
iceTransportWatchUdp(30 s) on UDP or a relay and everyiceTransportWatch(5 s) on ICE/TCP, for the slow-TCP detection; never while joining or reconnecting (the first one comes 3 s after a connection connected or a reconnect completed) and never while the app is in the background.iceTransportWatch: Duration.zero: off (the getters stay null). The getters are cleared on a leave and on a full rejoin.
Added (experimental): the host's camera in the join's first offer (joinCamera, opt-in) #
- Experimental. Off by default. In device tests it gave no measurable speed-up for the camera, and
combined with
joinMicrophonethe microphone went live later (the shared first offer waits for the camera). Use it only to measure on your own devices; the API may change. Room.connect(joinCamera: GravixJoinCamera(...)), besidejoinMicrophone. The camera opens with each join attempt (createTrack, e.g. the app's own camera or preview track, or a capture withcaptureOptions), and at the JoinResponse its AddTrack and video transceiver go into the publisher connection's first offer: the camera is live when that connection connects instead of after a publish of its own onceconnect()returned. WithjoinMicrophonetoo, ONE first offer carries both.- The publish is the normal camera publish with
publishOptions(null = the room's default video publish options): the same codec choice (falling back to the server's enabled codecs), simulcast layers, encodings, plan cap and start bitrate aspublishVideoTrack. - The first offer waits at most
GravixJoinCamera.offerWait(1 s) for a camera that is still opening (the join microphone waits as long for it); a later camera is published with an offer of its own. A server that announces no publish codecs gets the camera published as any camera. - A camera that cannot be published does not fail the join: the room emits
GravixJoinCameraFailedEvent(reason:GravixJoinCameraFailure.captureFailed,trackEnded,notAllowed,rejected,sessionReplaced,publishFailed;error;attempt) afterconnect()returned, and the app may publish a camera the normal way afterwards. A camera turned off during the join (wantedreturns false) is not published and reports nothing. - Privacy: a camera that is not published is released (stopped and disposed, then
GravixJoinCamera.onReleased, for an app that closes a capture of its own) on every path: a failed attempt (before a retry opens the next one), a failed join, a leave while joining, a leave afterconnect()returned while the publish was still in flight, any failure. It is stopped also withRoomOptions.stopLocalTrackOnUnpublish: false. - Join retries and the region fallback keep working (one camera publication, one AddTrack per socket). A resume keeps the publication; a full rejoin republishes it once, with the same camera.
publishVideoTrack/setCameraEnabledfor a camera called while the join's camera is being published wait for it and find its publication.GravixJoinPhase.cameraPublishedInJoin(detail:inFirstOffer,attempt).- Pass it only when the camera permission is already granted. Ignored beside a
FastConnectOptionscamera. No server change is needed.
Added: a muted join microphone (GravixJoinMicrophone(muted: true), opt-in) #
- The microphone is negotiated in the first offer but muted and WITHOUT a capture: the track
createTrackreturns is stopped before the publish, the AddTrack says muted and the audio transceiver's sender has no track, so no audio can be sent.setMicrophoneEnabled(true)later (e.g. a host taking a seat) opens the capture on that sender and signals the unmute: no AddTrack and no offer/answer at that moment. - Needs a token that may publish a microphone at the join; with a listener token nothing is published (the join goes on).
- Fixed with it: unmuting a track that was published without a capture (also a stopped muted track
republished after a rejoin) opens the capture even when
stopOnMuteis false.
0.4.16 — 2026-10-13 #
Faster joins (the host's microphone in the first offer, signalling off a busy app isolate), the signal round trip from the start of the session and without the app's own delay, an opt-in fix for a native Android crash as the microphone starts, and the retired Gravix servers refused. The retired-server guard and the first ping are always on; everything else is opt-in and off by default.
Breaking: retired Gravix servers are refused #
- Connecting to a host of Gravix's retired server deployment (the servers that ran before the current
regions) now fails at once with a typed
RetiredServerExceptioninstead of connecting.hostnames it and the message is "retired Gravix server ( - A token response whose
urlis on a retired host is aGravixTokenException(malformed) with theRetiredServerExceptionas itscause(connectWithTokenProviderreturns false with it inlastTokenError). Region entries on a retired host are dropped fromregion_urls;GravixTokenProvider.endpointrefuses a retired host when it is created; the default region probes refuse one without sending a request. - What to do: connect with the
urlyour token response returns, never a hard-coded server url. Apps that already do so are not affected. - Unchanged: the current region hosts, the gateway's
/v1/tokenpath and the wire protocol.
Fixed: Room.signalRttMs from the start of the session, and without the app's own delay #
- The first signal ping goes out as soon as the join is answered, then every
pingIntervalas before.Room.signalRttMs(what an app's network indicator reads) used to stay 0 for the first ping interval of every session. - After a resume the pings start again at once. Before, a resume stopped them for the rest of the
session:
signalRttMsfroze at its last value and the ping timeout no longer watched the resumed connection. A full reconnect is a new join and was already covered. - With
GravixSignalIsolateon, the round trip is the network's: the signal isolate stamps the ping's write and the pong's arrival, so work on the app's isolate in between no longer counts. Right after an app's launch, a phone a few ms from its region read 221-1710 ms (10-30 ms from a settled app). With the switch off the measurement is unchanged. - Added
Room.signalRttMinMs(the smallest of the last 5 round trips of the current signal connection) andRoom.signalRtt(GravixSignalRtt:recentMs,count,minMs,lastMs, andnetworkOnly: every recent sample was timed in the signal isolate), so an app can skip samples taken while it was busy and show a number it trusts. The window starts again on every new signal connection;signalRttMskeeps its last value as before. - Added
Room.requestSignalPing(): one ping now, so a fresh round trip is known one round trip later instead of at the next periodic ping (e.g. right after a join). It sends nothing and returns false when the signal connection is not up, when the server asked for no pings, or within 1 s of the last ping.
Fixed: Android, a native crash as the microphone starts (GravixAndroidRecorder, opt-in) #
- On Android the microphone capture start pre-warms the recorder: it creates the AudioRecord and
starts its record thread, which the WebRTC engine adopts when the track is published. The adoption
is racy: on one Android phone about 1 join in 15 aborted the app natively (SIGABRT, "span index out
of range" under
WebRtcAudioRecord.nativeDataIsRecorded) when the running thread delivered a buffer before the engine had finished initialising its recording. GravixAndroidRecorder.startThreadAtCapture = false(once, before the first microphone capture): the capture start only prepares the recorder (audio processing options, room-music mixer hook, AudioRecord) and the engine starts the record thread itself, after its initialisation finished. On the phone that crashed the record order was right in 15 of 15 joins. The AudioRecord's start (about 150 ms there) is paid when the engine starts recording instead of ahead of it. When the recorder cannot be prepared that way the SDK pre-warms as before.true(default) is the previous behaviour and the rollback. Android only; iOS is unchanged.
Added: the host's microphone in the join's first offer (publishMicOnJoin, opt-in) #
GravixRoomService.connect(publishMic: true, publishMicOnJoin: true)(alsoconnectWithTokenProvider) and, in the core,Room.connect(joinMicrophone: GravixJoinMicrophone(...)). The microphone capture starts with the join, in parallel with the signalling, and the microphone is in the publisher connection's first offer: it is live as soon as that connection connects. Before, it was published afterconnect()returned, with a second offer/answer (on a real Android phone close to its region: 0.93 s after the room connected).- Join retries (
ConnectOptions.joinRetries) keep working: a failed attempt's publication and capture are undone before the next attempt. A join that fails, or is left, stops the capture beforeconnect()returns, so the OS microphone indicator does not stay on. - The first offer waits at most
GravixJoinMicrophone.offerWait(1 s) for a capture that is still starting; a later capture is published with an offer of its own. A failed capture, a participant without permission to publish a microphone, or a mute during the join (setMicEnabled(false)) publish nothing and stop the capture. - Timelines:
GravixJoinPhase.micPublishedInJoin(detail:inFirstOffer,attempt); the service timeline'smicPublishedis the join's publish;GravixJoinPhases.micLiveAtis when the microphone went live. - Pass it only when the microphone permission is already granted. Off by default (the previous
behaviour); it takes precedence over
earlyMicTrack, and is ignored besideFastConnectOptionsor the pre-connect buffer. A resume keeps the publication; a full rejoin republishes it once. No server change is needed.
Added: GravixSignalIsolate, the signalling WebSocket off the app's isolate (opt-in) #
GravixSignalIsolate.enabled = true(once, at app start, before the firstGravixStandby.openor join) runs the signal dial, its TLS and WebSocket framing, and the standby pool in a background isolate. Right after an app's launch its own work fills its isolate and delays every signalling step; with the switch on, measured on a saturated app isolate: a viewer's media ready 3.5 s -> 2.0 s, a host's 5.1 s -> 2.5 s,wsOpen~1 s -> 0.22 s. An idle app is unchanged.- The signalling protocol itself (reconnect, resume, leave, the join timeline) and the flutter_webrtc peer connections stay on the app's isolate.
false(default) is the rollback. Standby connections opened before switching on are closed. A dial stays on the app's isolate when the app installedHttpOverrides/IOOverrides(they apply per isolate) or the background isolate cannot start.GravixSignalIsolate.warmUp()starts it ahead. No-op on web.GravixSignalIsolate.ignoreHttpOverrides = true(off by default): for an app whoseHttpOverridesonly tune its own HTTP clients (e.g.HttpOverrides.globalsetting timeouts), signal dials and standbys run in the background isolate without them.IOOverridesstill keep a dial on the app's isolate.GravixJoinPhase.wsOpen(Room.watchJoinPhases) carriesdetail['signalIsolate']while the switch is on.
Added: GravixStandby.lowRttUpgradeFloor (opt-in) #
- The shortest wait for a join's upgrade over a standby connection when the app passed
standby(rttMs: ...)below 30 ms. Null (default) = 0.5 s, unchanged. On a busy app the upgrade's reply can arrive after 0.5 s and the join dials again, ~1.4 s slower; a longer floor (e.g. 1 s) lets that reply win, and delays the fresh dial by as much when the standby connection is really dead. RTTs of 30 ms and more keep 3 x RTT (0.5-1.5 s).
0.4.15 — 2026-10-11 #
Recovery after network drops and region failover, from field reports and real-device testing
(Android and iOS). Every behaviour change has its own switch in GravixRecoveryOptions
(RoomOptions(recovery: ...), GravixRoomService(recovery: ...)); each switch's off value is the
0.4.14 behaviour, and GravixRecoveryOptions.legacy turns all of them off.
Where the session is #
Room.connectedUrl/Room.connectedRegion: the url and region the session is actually on (after a failover or a rejoin to another region, not the url passed toconnect). Kept through resumes and reconnects; null only when the session has ended.RoomConnectedUrlChangedEvent(url, region, previousUrl, previousRegion, reason)on every connect and on a rejoin to another url. A server indicator in an app should read these.
Reconnect, resume and failover (GravixRecoveryOptions, all on by default) #
- Region failover goes to the nearest healthy region by the start-up measurement
(
measuredRegionFailover); a full reconnect first retries its own url once (retrySameUrlFirst); it moves only with evidence that the region failed (failoverNeedsEvidence); the region list from the last good fetch is kept for failover (lastGoodRegionList). - Signal connects are bounded and raced on slow paths (
boundedSignalConnect,signalDialCap,signalUpgradeWindow/signalDialCeiling); a host that never answers is failed over sooner (silentHostFailFast). - Resume: no 10 s wait for an idle publisher (
publisherRestartRetryAfter,subscriberSufficientWhenIdle); a resume's primary connection gets a deadline (resumePrimaryDeadline); a Leave during the wait acts at once (leaveAbortsResumeWait); the old ICE session's failure no longer forces a rejoin after a successful resume (resumeSatisfiesPeerFailure,resumePeerRestartWait). - A renegotiation (e.g. a track published or unpublished) while a resume is dialling no longer breaks
the resume ("Incompatible send direction", then a full reconnect): the offer is held until the
resume completes (
deferNegotiateDuringResume), and a restart rolls back an offer that was sent but never answered (rollbackStaleOfferOnRestart). - Faster detection: a subscribed video with no packets resumes early (
earlyStallDetection); a resumed session with no new video rejoins (noVideoWatchdog); an uplink the server stopped answering resumes (uplinkFeedbackStallFor); a full reconnect fails at once when its signal socket closes (restartAbortsOnSignalLoss); the wait for connectivity re-checks the network (networkWaitProbeInterval). - Waits that fit slow paths: a full reconnect waits like a fresh join (
fullReconnectMediaConnect), longer when the signal path is measurably slow (slowPathPeerConnectWait). - A full rejoin keeps the participants still in the room (
keepParticipantsOnRejoin,rejoinAbsentGrace) and republishes a stopped muted microphone as muted (republishStoppedTracksMuted). GravixRoomService's data saver changes the host camera in place; the camera is no longer lost after the app was in the background (hostCameraGuard).- Upstream client SDK 2.12.0 – 2.13.1 ports, including the reconnect fixes (
upstreamReconnectFixes) and a connection-level video start bitrate (startBitrateBySetupTime).
Audio #
- New: microphone state (
GravixAudioDevice.micState), one speaker / route API (setAudioRoute,clearAudioRoute,setEnableSpeakerphone,getAudioRoutes,onAudioRouteChanged). - Fixed: background microphone on Android 14+; the microphone after a phone or VoIP call; route
reporting (
speakerOffis applied only when the route is not the loudspeaker; iOS reports the new explicit route after every selection). - New, opt-in: Bluetooth "chatroom" model (Android), keep-alive against OEM app freezers (Android),
silent-capture detection, other apps' audio while the microphone is on (
otherAppAudio), media-profile echo experiments (GravixMediaEcho, experimental).
Other fixes #
- A
wss://url without a port was dialled on port 0 by the SDK's connection factory (raced dials, certificate pinning, the standby redial). - The start-up region measurement reports a run that ran out of its budget as a budget stop.
dumpVideoStatsno longer throws during a rejoin; the audio visualizer stops on dispose; log text is built only when the level is enabled.
0.4.14 — 2026-10-09 #
No SDK behaviour change.
Example: no default server url #
- The example app's server url field is empty by default. The url comes from
the token response (
{token, url}); the field is needed only with a pasted token, and a join without any url now says so instead of failing in connect.
Package metadata and README #
homepageis the Gravix Cloud docs (https://www.gravixcloud.com/docs);repositoryandissue_trackerare removed.- README quick start follows the documented flow:
flutter pub add gravix_rtc, your server mints the token (POST{room, identity, name, can_publish}→{token, url}),GravixTokenProvider.endpoint+GravixRoomService.connectWithTokenProviderconnect with the returned url. - Package text names no upstream vendor: older CHANGELOG entries, one API doc
comment and the shipped docs say "upstream client SDK" / "stock upstream
server" instead.
doc/UPSTREAM_FORK.md(a maintainers' report) is no longer in the published package.
Other #
example/ios/Podfileis committed (the example builds iOS through CocoaPods since 0.4.10).
0.4.13 — 2026-10-08 #
Fix: a join that hit a short network stall failed at once (join retry) #
Field 2026-10-06/07 (an app on 0.4.12, Android, mobile data): one join failed.
The phone's network stalled for a few seconds (ICE round trip 3.9 s), the peer
connection did not connect within 10 s, Room.connect threw
MediaConnectException on its first attempt and the join gave up. Nothing
retried it.
- Join retry.
Room.connect(and soGravixRoomService.connect) joins again when the server accepted the join (its JoinResponse came) but the peer connection did not connect (MediaConnectException). SeegravixIsJoinRetryable.ConnectOptions(joinRetries: 2, joinRetryDelays: [0.5 s, 1.5 s])are the defaults. Each retry is a complete new join with the same url and token.- Before a retry, the failed attempt sends its leave on its socket (the server may already have the participant, and the next join must not meet it as a duplicate identity), then closes its socket and peer connections and drops its remote participants. The audio session and the foreground service stay up for the retry.
- Never retried:
- any other error: a refusal at the WebSocket
(
ConnectionErrorReason.NotAllowed: 401/403, expired token), no connectivity, a certificate pinning failure, a refused or superseded WebSocket; - the JoinResponse timeout (
ConnectException, reasonTimeout): a server refuses a join it accepted at the WebSocket (room full, a failed join) with a Leave and no JoinResponse, and a lost Leave looks exactly like that timeout; - an attempt the server ended with a Leave;
- after
disconnect()/dispose(), which also cut the wait before a retry short; - a connect with
fastConnectOptions, or with a recording pre-connect audio buffer. Those publish a microphone inside the join and fail as before, so a retry never leaves a capture behind. An attempt that published any local track is not retried either.
- any other error: a refusal at the WebSocket
(
RoomJoinRetryEvent(retry, maxRetries, delay, error)before each retry.- Intermediate failures are not reported: a failed connect still emits
exactly one
RoomDisconnectedEvent(joinFailure)and throws the last attempt's error, as before. - Rollback:
ConnectOptions(joinRetries: 0)is the 0.4.12 path (one attempt, the engine reports the failure itself).
- A fresh join waits 20 s for its peer connection (
Timeouts.mediaConnect, new; it wasTimeouts.connection, 10 s).- Why 20 s: ICE + DTLS on a fresh join take about four to five round trips (offer/answer, connectivity checks and nomination, two DTLS flights). That is ~16-20 s at the field's 3.9 s RTT, so 10 s capped a join at an RTT of ~2-2.5 s. A retry repeats all of those round trips, so on a uniformly slow network only a longer wait helps.
- The SFU allows a new transport ~15 s of ICE checking, then 10-20 s for DTLS after ICE, so 20 s is not cut short by the server.
- The WebSocket dial, the JoinResponse wait, resumes and full reconnects keep
Timeouts.connection(10 s). - For the 0.4.12 wait:
Timeouts.defaultTimeouts.copyWith(mediaConnect: Duration(seconds: 10)). - Worst case for a join that never connects: 3 attempts x (JoinResponse + 20 s) + 2 s of waits, ~65 s, instead of ~12 s. Apps should show the retry.
GravixRoomService:connectOptions:(constructor) for the core's connect options.- With a region ladder, only the LAST url retries its join; earlier urls hand a failure to the next url at once, as before.
isJoining(ValueListenable<bool>): true while a connect is in flight.joinRetry(the retry in progress, null otherwise) andonJoinRetry, for a "Reconnecting…" state.disconnect()while a connect is still joining now ends that join. It stops retrying and tries no further region (nothing at all when it is still probing). A join that connects anyway is disconnected and disposed andconnectreturns false. Nothing is published: no microphone. Up to 0.4.12,disconnect()could not reach a room that was still joining, and that join went on to publish the mic.- A
connect()for another session while one is joining ends that join the same way, so its wait lasts the current attempt at most, not every retry.
Room.disconnect()on a room that is not connected (a failed or retrying join) returns at once. It used to wait 10 s and throwTimeoutException: the engine's disconnect event fired before the room listened for it.- A join again on the same
Room(join retry, the core's region fallback, the service's ladder) gives the kept local participant the new participant sid from the JoinResponse. It kept the failed attempt's sid.
No breaking API change: Timeouts gains an optional mediaConnect and
copyWith, and ConnectOptions gains joinRetries, joinRetryDelays and
copyWith.
0.4.12 — 2026-10-06 #
Fix (privacy): the microphone could stay open after leaving a room while room music played (Android) #
Field 2026-10-06 (an app on 0.4.11, Xiaomi, Android 13): the user left a room
while room music was playing. After the leave, dumpsys media.audio_policy
still listed an active AudioRecord client for the app 30+ s later. In the
foreground the microphone was really live; in the background Android silenced
it ("App op 27 missing, silencing record") about 8 s after the leave.
What happened:
- The leave stopped the music. With DTX on the microphone (the default), ending the music session gives DTX back by republishing the microphone on a new track.
- That republish raced the disconnect. Every publish attempt failed (three attempts, ~10 s each).
- Each attempt started the new track's capture. On Android that start is an
explicit pre-warm of the audio device module's recorder
(
startLocalRecording). WebRTC adopts that recorder only when the track is published and the engine starts recording. Here the engine had already stopped and terminated its recording, so nothing ever stopped the pre-warmed recorder. Stopping the unpublished track did not release it either.
Why it showed up with 0.4.11: the bug is older, but 0.4.11 made it reachable in more apps. Before 0.4.11, an app that also ran a headless Flutter engine (a foreground-task plugin) replaced flutter_webrtc's plugin singleton. The pre-warm then found no audio device module and was skipped, so there was no recorder to leak. 0.4.11 uses the app engine's own flutter_webrtc instance (the fix for room music in such apps), so the pre-warm works there now, and the same apps typically dropped the foreground-task plugin for the new built-in service. The call foreground service itself starts no capture and is not part of the cause; its 3 s stop grace only delayed Android's background silencing.
The fixes:
- A leave no longer republishes the microphone.
Room.disconnect()/dispose()andGravixRoomService.disconnect()mark the room as leaving before anything else. Ending room music on a room that is leaving or gone just stops the mixer: no DTX republish, no bitrate or voice-processing restore (the leave unpublishes and stops the microphone itself).GravixRoomService.disconnect()stops the room music this way first. - A republish never leaves a track behind (room music DTX swap and RED auto). It checks that the room is still connected and not leaving before the unpublish, before every attempt and after a successful publish. It starts the new track once (failed attempts no longer stop and restart its capture). When nothing ends up published, the new track is stopped.
- Stopping a microphone track releases the recorder its capture pre-warmed (Android). The native side stops it only while WebRTC itself is not recording, so a live published microphone is never cut. iOS is unchanged: its stop is engine-wide.
- Leaving a room releases any pre-warmed recorder after the engine is cleaned up, before the audio session stop and the foreground-service release, whatever the service's stop grace.
- RED auto stops at the start of
GravixRoomService.disconnect(), not at the end.
No API change. Apps on 0.4.11 that use room music should update.
0.4.11 — 2026-10-06 #
Android: built-in call foreground service (GravixForegroundService), disabled by default #
Field 2026-10-05 (SDK phone bench, Xiaomi, Android 13): about 5 s after the user pressed Home,
AudioPolicyService logged App op 27 missing, silencing record and the room
heard silence from the phone. Android 11+ silences microphone capture of an
app that is not visible unless it runs a foreground service of type
microphone. Room music went silent with it: it is mixed into the microphone
capture. Listeners need mediaPlayback so playback keeps going.
The SDK now ships that service. It is off by default and does nothing until the app turns it on:
GravixForegroundService.defaults = const GravixForegroundServiceOptions(
enabled: true,
notificationTitle: 'Live room',
notificationText: 'Tap to return',
showLeaveAction: true,
);
GravixForegroundService.leaveRequests.listen((_) => leaveTheRoom());
- Turned on:
Room.connect(so alsoGravixRoomService.connect) starts it asmediaPlayback. This covers every role, listeners included.- Publishing the microphone adds
microphone, but only when RECORD_AUDIO is granted. - Publishing the camera adds
camera, only withincludeCameraand the CAMERA permission. - When the last room disconnects or is disposed, the service stops after a 3 s grace. The grace means a room being replaced does not have to restart it from the background.
- Publishing the microphone adds
- Type upgrades: Android 14 refuses one asked for while the app is in the background. The SDK applies it when the app comes back.
- Per room:
RoomOptions(foregroundService: ...)andGravixRoomService.connect(foregroundService: ...). - Manual control:
GravixForegroundService.start/update/stop,isRunning,activeTypes,isSupported. - The service itself: a partial wake lock and a Wi-Fi lock while it runs.
Notification channel
gravix_call(low importance). Swiping the app from Recents stops it (no restart). Only the activity's Flutter engine can start or stop it, so a headless engine detaching does not end the call. - Lifecycle: while the service is enabled, the SDK forwards app
background/foreground to
GravixRoomService.setAppBackgrounded. It never mutes the microphone or the playout. - Manifest: the SDK manifest adds the service, the
FOREGROUND_SERVICE_MICROPHONE/_MEDIA_PLAYBACK/_CAMERA,WAKE_LOCKandPOST_NOTIFICATIONSpermissions, and a non-exported receiver for the Leave action. While the service is disabled these entries do nothing. To drop them, see the README, "Android background (foreground service)". - iOS and web: no-op.
Phone check (Xiaomi, Android 13), with the service on:
- host with mic and music, 60 s on Home: the web listener kept hearing both
voice and music, and there was no
silencing record; - listener only, 60 s on Home: playout kept running (
mediaPlaybackonly); - the types went from
mediaPlaybacktomicrophone|mediaPlaybackwhen the mic was published; - swiping from Recents stopped the service, with no restart;
- the notification's Leave reached Dart.
With it disabled: no service and no notification, and the
silencing record from the field came back.
Android: a headless Flutter engine no longer breaks the native side #
Field 2026-10-06 (an app in production, Xiaomi): every room-music start logged
voice processing not changed: PlatformException(INVALID_ARGUMENT, track is not a local audio track), so software noise suppression and AGC stayed on under
the music. Cause: the native plugin found flutter_webrtc through
FlutterWebRTCPlugin.sharedSingleton, which every new FlutterWebRTCPlugin
overwrites in its constructor. flutter_foreground_task creates a headless
FlutterEngine on every service start (with or without a task callback), the
engine auto-registers all plugins, and from then on the singleton was that
engine's instance: no local tracks, no audio device module. Affected after such
an engine starts: setAudioProcessingOptions (room music's voice-processing
relax), the audio visualizer/renderer, the engine microphone mute (Dart fell
back to disabling the track, which stops the recorder), local recording
start/stop and the peer-connection factory lookup. The plugin now uses the
flutter_webrtc instance registered on its own engine and the singleton only as
a fallback (EnginePluginLookup, JVM test); the music mixer's install looks on
its own engine first as well.
0.4.10 — 2026-10-06 #
Reconnect fixes from the 2026-10-05 field test, the gravix_rtc version on the server, an opt-in DTX switch for the microphone, and room music in the SDK.
Room music (GravixRoomMusic) #
Field 2026-10-05: room music stopped working in two apps after their move to
gravix_rtc. Cause: gravix_rtc <= 0.4.9 registered the method channel
gravity.music_mixer, the name the apps' own audio kit uses; plugins register
alphabetically and the last handler wins, so the SDK took every kit call (no
captureReady in its answer) and the kit fell back to the paid server bot.
- The SDK's channel is now
com.gravitycompile.gravix_rtc/music; it never registersgravity.music_mixeragain (a test pins it). A capture callback installed before the SDK's (an app kit) is chained, not refused. GravixRoomMusic(room)(alsoGravixRoomService.roomMusic):start(GravixMusicSource.file / contentUri / asset),pause,resume,stop,seek,setMusicVolume,setMicVolume,setDucking,setLoop;state(ValueListenable<GravixMusicState>: idle/loading/playing/paused/error, position, duration, interrupted),completed,errors(GravixMusicExceptionwith a stablecode). The music is mixed into the published microphone at the capture level: one stream, no second track, no bot.- Android engine: the file is opened and its codec created at
start()(a bad file isopenFailedat once, no silent success); the mix waits for the live capture format (captureNotReady, no guessed 48 kHz mono);content://URIs; seamless loop; 20 ms fades on start/pause/resume/stop/switch and smoothed volume (no clicks); voice level and ducking; the host monitor follows the audio mode (voice-communication vs media stream). The hook is installed right before every microphone capture start once music is used. The mixer can no longer throw on WebRTC's record thread (a fault passes the capture through). - Mute while music plays (Android): a voice-only mute: the voice is zeroed in
the audio device module, the music keeps going out, the publication stays live
on the wire (the SFU stops forwarding a track signalled muted), whatever
stopAudioCaptureOnMutesays. The app seesmuted; remote participants see the mic as on while music plays. When the music ends the mute becomes a normal one (signalled, uplink capped).start()while muted plays the music with the voice still muted.continueWhileMicMuted: falsekeeps the old behaviour. - Publish settings while music plays: mic maxBitrate 96 kbps through RTP sender parameters (no republish), restored after unless something else moved it; software noise suppression and AGC off (they run after the mixer and flattened a steady tone within ~8 s on the phone), echo cancellation kept; a mic published with DTX is republished once on a new track with DTX off, and back at stop (the default DTX-off mic is never republished). RED is left as published (switching needs a republish): music costs ~170-185 kbps on the wire with RED, measured.
- Interruptions: a phone call (audio mode RINGTONE / IN_CALL, no permission
needed) or a transient audio-focus loss pauses the music and resumes it after;
a user pause is never auto-resumed. Room disconnect,
stop()anddispose()release the decoder and the monitor. - iOS: the same API on the existing AVAudioEngine mixer (loop added); mic volume
and ducking throw
unsupported; a mic mute also silences the music. Still device-unverified. - Phone (Redmi 2201117TG, test fleet, web listener in Playwright's Chromium,
2026-10-06; 440 + 1000 Hz test tone; levels are the listener's FFT peak):
- music on a mic that was already live (apps' order), audio-room seat with DTX: the mic was republished on a new track with DTX off, stayed published, music at -42 dB, 191-196 kbps up with RED; stop: DTX back on, 118 kbps;
- the tone stayed flat for 20 s (-42.0 dB first to last: NS/AGC off), both back on after stop;
- voice-only mute: no mute signal, music unchanged (-42 dB), the Mac's speech
gone from the voice band (-101 dB vs -59 dB unmuted); no crash;
start()while muted: unmute signal, music out, voice silent; at the end of the track a mute signal and the 6 kbps cap; - ducking: music -50.8 dB while speech vs -42 dB without; mic volume 0 removed
the voice; volume 0.3: -52.5 dB; pause / resume, seek, loop and
completed(loop off) as expected; a missing file:openFailed; - a transient audio-focus grab by another requester paused the music
(
interrupted), its release resumed it; a real phone call was not tested; - 12 s in the background (HOME, no foreground service in the test app): the music kept reaching the listener;
- RED auto during music (
GravixRoomService, threshold 0): the mic was republished on a new track with RED, the music never stopped, 96 kbps re-applied to the new sender (~195 kbps with RED), voice-only mute after it; - leave: the decoder and monitor released.
Fixed (also) #
addTrackansweredQUEUED(the SFU queues a cid that is still published) is no longer taken for a refusal; the publish waits for TrackPublished.- RED auto could leave the host without a microphone. It unpublished the mic
and published the SAME track again, but
removePublishedTrackdisposes the track it unpublishes. RED auto (and the room-music DTX swap) now publish a new track on the same capture (gravixRepublishMic): the mute is carried over before the publish, a refused publish is retried with the original options, and a new track that could not be published is stopped. - iOS with flutter_webrtc 1.6.1+:
ios/gravix_rtc/Package.swiftpointed at theflutter_webrtc-1.6.0symlink and the podspec pinnedWebRTC-SDK 144.7559.09; a freshpub getresolves 1.6.2+hotfix.3 (WebRTC-SDK 150.7871.01) and both builds failed. SwiftPM now uses../flutter_webrtc(the Flutter tool maps it to the resolved version) and the podspec takes WebRTC-SDK from flutter_webrtc. Simulator builds with 1.6.2+hotfix.3: SwiftPM (an app outside the package) and CocoaPods (the example, which now opts out of SwiftPM: the tool would copy the package root, the example's build/ included, into itself on every build).
Fixed #
- Full rejoin on the first refused resume. A resume that reaches a
participant the server already closed is answered
Leave{RECONNECT}("could not restart participant") and its socket is closed. The engine then waited out the 10 s ReconnectResponse timeout (the Leave's own reconnect was dropped by the reconnect-in-progress guard) and resumed again: field 2026-10-05, 3-4 resumes and 10-17 s of extra outage each time. Now a Leave during the resume, or the resume's socket closing before the ReconnectResponse, fails that resume at once and the full rejoin (new join, tracks republished) starts with no delay. The Leave's action is respected:RESUMEresumes again (at once),DISCONNECTdisconnects. A resume socket that opens but never answers within the connect timeout also counts as refused. Phone (Android, test fleet, server-sidenodeFailure, 3 runs each): reconnected after 11.9 / 12.1 / 12.7 s on 0.4.9, 1.6 / 3.3 / 9.4 s on 0.4.10 (the last one on a slow network); one resume and one rejoin per run, the rejoin ~0.5 s after the server's refusal. - A Leave{RECONNECT} on a connected session reconnects at once (it went through the back-off's delay).
- The reconnect attempt counter is no longer reset by a resume socket that opens
and is then refused, so
maxAttemptscan be reached. - Two resumes in a row that failed without an answer (dial timeouts, ~10 s each)
now lead to a full reconnect for every session; it was three, and only with
regionReprobeOnRestart. Two of them outlast the server's 15 s disconnect grace, so a third resume could only be refused. (A refused or dead socket already went to the full reconnect at once.)
One session per identity #
Field 2026-10-05: a resume on one node raced a fresh join on another node for the same identity; the server's duplicate-identity eviction moved the room's origin and the host had ~2.5 min of instability.
- The engine never runs a resume and a fresh connect at once: a fresh
connect()or adisconnect()cancels a pending or in-flight resume first, and a socket dialled for a superseded connect is closed when it lands and delivers nothing (SignalClient connect generations). - A full reconnect sends the leave on the old socket before it rejoins (JS SDK parity), so the old session ends as CLIENT_REQUEST_LEAVE instead of a duplicate-identity eviction.
GravixRoomService.connect()runs one session per room + identity (read from the token): the same room, identity and token while a connect is in flight returns that connect's result; while connected or reconnecting it keeps the session (returns true); anything else waits for the connect in flight and then tears the previous session down before joining.disconnect()thenconnect()still forces a new session;reconnectWithToken(another token) reconnects as before.standby()is a no-op (false) while that session is joining or live.
Added #
- The gravix_rtc version on the server. The join and resume URLs carry
version=0.4.10(was the vendored core's 2.11.0, so every client looked the same in the server's logs and join analytics) andother_sdks=gravix_rtc/0.4.10(ClientInfo.other_sdks; the server needs to read it). The server gates nothing on a Flutter client's version. Verified on the test fleet: the SFU logs"sdk": "FLUTTER", "version": "0.4.10". Participant attributes are not used:gravix.*is reserved for the server and join-time attributes need CanUpdateOwnMetadata. dtxonconnect,connectWithTokenProviderandreconnectWithToken(default off, as before;GravixRoomService.dtxreads it). Opus DTX for the microphone; kept by the RED-auto republish. Phone (Android, 64 kbps cap): a silent room 2-10 kbps with DTX (RED on or off) vs ~51-58 (Opus) / ~109-117 (RED) kbps without; a muted mic (6 kbps cap) 7.2 -> 0.2 kbps; speech unchanged (~64 kbps Opus, ~130 kbps RED). With audible room noise Opus counts the noise as activity and DTX saves little (one run: 110-118 kbps either way). The 64 kbps cap holds (outbound-rtp targetBitrate 64000, Opus alone 51-64 kbps); RED doubles it, and the outbound-rtpcodecIdstill names audio/opus (PT 111) while RED is sent, so stats read alone look like "Opus at ~125 kbps". The recorder is not stopped on mute with DTX on (the 0.4.4 mute design holds). Not for music: Opus can treat quiet music as silence.
Migration (apps on 0.4.9): room music #
- Use
GravixRoomMusic(room)(orroomService.roomMusic) instead of an app mixer. Create it right afterRoom(...); drive "next track" fromcompleted. - Delete app mic gates / "keep the publication unmuted while music plays" logic
(
micGate,onMusicSessionChanged,musicPausedProbe,musicSuppressHook): a plainsetMicrophoneEnabled(false)is a voice-only mute while music plays, and phone calls pause the music in the SDK. - An app that keeps its own kit must not use the channel
com.gravitycompile.gravix_rtc/music;gravity.music_mixeris free again. Never run two mixers on one capture. GravixMusicControllermoved to the new channel; its calls are unchanged.
0.4.9 — 2026-10-05 #
Viewer first frame, standby per server, region hysteresis and the viewer's home region. Of the three viewer fast-start switches only passive subscriber DTLS is on by default; the other two are opt-in (see Migration).
Region selection #
Field test 2026-10-04: the owner's phone in Bangladesh moved between sgp1 and blr1 (a few ms apart) across four sessions in 15 minutes, and one audio room went cross-region (host sgp1, viewer blr1, 2.9-3.4 % loss on the relay leg).
Changed
- Region hysteresis (
gravixSelectRegion, gravix_region_selection.dart): the last good region is an anchor kept per network for 12 h (it used to be forgotten when the 10-minute answer went stale), and a rival must beat it by max(30 ms, 20 %) in two consecutive measurements before joins move (a second measurement follows 20 s after a first win). A failed anchor moves at once.GravixRegionMeasurementResult.selectReason/.challenger; reports carryselect_reason. - Cached answer at start-up carries every region the last run measured, not only the best, so the join lookup's keep-pinned and home-region rules can compare.
- Cellular budget: 3 s is now a floor under the gateway's
probe_budget_ms(the console sends 1500 for everyone, which replaced the cellular default). - Per-network cache key adds
+vpnwhile a VPN is up (gravixRegionNetworkKeyFrom): a Saudi tester's VPN (exit in Canada) made nyc1 the right answer through the VPN, and that answer stayed cached for the plain cellular network afterwards.
Added
- Home region for viewers:
gravixPickMeasuredRegion(..., homeRegion:)/gravixPickRegion(with aGravixRegionPickReason),connect(homeRegion:),reconnectWithToken(homeRegion:),GravixJoinCredentials.homeRegionandgravixHomeRegionFrom(the token response'shome_region). The room's home region wins when it is measured within max(25 ms, 30 %) of the fastest region; further away the viewer's own region stands (relayed). Hosts pass none.
Viewer first frame (GravixViewerFastStart) #
Measured on a 2201117TG (Android 13, Wi-Fi, ~60 ms RTT, web publisher), all three switches on, 5 joins each: subscriber ICE+DTLS 518-569 -> 384-446 ms median, warm tap -> first frame 941-1029 -> 814 ms.
passiveSubscriberDtls(default on): subscriber answers carrya=setup:passive, so the SFU sends the DTLS ClientHello when its own ICE is up instead of the phone sending hellos the SFU is not ready for (116/232/464 ms retransmits; one that just missed cost +464 ms). Native log:role=serverin every join; the phone's own server flight can still be retransmitted once (2 of 10 viewer joins on 2026-10-05, DTLS 270-301 ms, no +464 ms tail). On its own it removes the retransmit tail, not the median (DTLS writable -> complete 384 ms before, 383/407 ms after, n=2): the SFU's hello then still waits for its nomination tick. Works against the stock upstream server as well: a pion client answeringa=setup:passiveconnected and received media from stock upstream server 1.4.5, 1.6.2, 1.7.2, 1.8.4 and 1.13.7 (pion v3.2.16 to v4.2.18), renegotiation answers included.subscriberConnectPingIntervalMs(default off,null; 0.4.9 opt-in, the branch had 100): libwebrtc's stable-writable / strong-connectivity ping intervals while the subscriber connects (restored once connected), so the SFU nominates on the phone's next check instead of its 200 ms tick -- this is where the median gain is (DTLS writable -> complete 202/295 ms, n=2). Off because the pub.dev flutter_webrtc (1.6.0, 1.6.2+hotfix.3) maps neither key (a no-op there), and a plugin that maps only the first gets a configuration libwebrtc refuses -- which flutter_webrtc's AndroidcreatePeerConnectiondoes not report (the join hangs). The SDK always sends both keys.noDisableBeforeFirstView(default off; the branch had it on): nodisabled: truetrack setting at the subscription when the app's view is one frame away. No measurable gain in the traces (both messages landed before the SFU bound the track); off is exactly the 0.4.8 path.
Standby #
- Per server: a join whose token has no standby of its own takes any open
standby to the same signalling host (
standby=usedSameHost; one still opening is waited for within the join's standby wait,awaitedSameHost). Over the limit of 4, the oldest SECOND connection to a host is closed before the only one to another host. Field 2026-10-04: a live list mixing two servers kept two connections to one server while the tapped card's own server had none; the tap dialled cold (965 ms WebSocket open). - Host standby: a join with no standby for its exact url + token takes one
opened for the same host with an empty token (
GravixStandby.open(url, ''); outcomeusedHost).
Other #
- A refused subscriber answer is marked
answerFailedon the timeline hook.
Migration (apps on 0.4.8) #
- Nothing is required. Joins now answer the subscriber connection with
a=setup:passive; to go back to the 0.4.8 handshake setGravixViewerFastStart.passiveSubscriberDtls = falsebefore connecting. - Viewers: pass the room's home region to keep host and viewer on one SFU --
connect(homeRegion: credentials.homeRegion)(the token response'shome_region,gravixHomeRegionFrom(response)with your own backend). Hosts pass none. - Optional, only with a flutter_webrtc that maps BOTH
stableWritableConnectionPingIntervalMsandiceCheckIntervalStrongConnectivityMs:GravixViewerFastStart.subscriberConnectPingIntervalMs = 100. - Optional:
GravixViewerFastStart.noDisableBeforeFirstView = true.
0.4.8 — 2026-10-03 #
Added #
GravixStandby(staticopen(url, token),state,reopenAll,closeAll) and theRoomextensionroom.standby(url, token)/room.standbyState(...): the standby signalling pre-connect without aGravixRoomService.Room.connecttakes the standby for the same url + token, as before.GravixRoomService.standbynow delegates here (unchanged behaviour).- Public join-phase hooks for apps that drive
Roomdirectly:GravixJoinPhases.attach(room, onPhase:)/room.watchJoinPhases()(attach beforeconnect) report, once per join,wsConnecting,wsOpen(+ standby outcome),joinResponse(+ server region, fastPublish, subscriberPrimary),peerConnected(ICE + DTLS of the primary PC),publisherConnected,subscriberConnected, first local audio / video published and first remote audio / video subscribed, as a stream and a callback;cameraLiveAt/micLiveAt.room.signalRttMsreplaces reading@internalengine.signalClient.rtt.GravixRoomService.onJoinPhaseattaches the same hooks to the room it creates. Observation only. GravixRtcClient.initialize(enableWARP:): passed to flutter_webrtc'sinitializeoptions (read by flutter_webrtc 1.6.2+, ignored by 1.6.0 / 1.6.1); the key is only sent whentrue. Defaultfalse.GravixFastJoin.enabled(defaultfalse, opt in) — turns on the faster publish order below;falsekeeps exactly the 0.4.7 order. Off by default because one device join stalled once with it on (cause not yet known).
Changed #
- Camera publishes beside the microphone (only with
GravixFastJoin.enabled = true; off by default). The camera has its own publish lock inLocalParticipant; up to 0.4.7 one serial runner madeFuture.wait([setMicrophoneEnabled(true), setCameraEnabled(true)])still run mic, then camera.FastConnectOptionspublishes run side by side and no longer hold the JoinResponse handling (remote participants,RoomConnectedEvent), andGravixRoomService.connectruns its initial mic and camera steps side by side. - audio_session
>=0.1.21 <0.3.0(was^0.1.21). 0.2'sgetCommunicationDevice()returns a nullable device, which 0.4.7'sdevice.type.namedid not compile against; it is now read null-safely, and works on both 0.1.x and 0.2.x. - flutter_webrtc
>=1.6.0 <1.7.0(was exactly1.6.0), so apps can take 1.6.2+hotfix.3 (the SIGABRT-on-room-entry fix) without adependency_overrides. - CI: a
test (lower bounds)job pins audio_session 0.1.25 + flutter_webrtc 1.6.0.
Migration (apps on 0.4.7) #
- Remove any
dependency_overridesforaudio_session(0.2.x now resolves) and forflutter_webrtc(1.6.2+hotfix.3 now resolves). - Replace a throwaway
GravixRoomServiceused only forstandby()withGravixStandby.open(url, token)orroom.standby(url, token). - Replace
@internalroom.engine.signalClientlisteners withroom.watchJoinPhases(onPhase: ...)androom.signalRttMs.
0.4.7 — 2026-10-03 #
Added #
- 480p plan cap.
gravix.max_video_heightmay now be 480 (the console's caps are 360/480/540/720/1080/1440,GravixVideoCap.allowedCaps). NewVideoParametersPresets.h480_169/VideoDimensionsPresets.h480_169= 854x480 (600 kbps, 25 fps), also inall169. At cap 480 the default 540p capture is asked for 854x480 and theGravixRoomServiceladder is two layers, 180p + 480p, both at 24 fps (the low rung declares 179 px high from an 854-wide frame: it is scaled on the long edge). gravixEffectiveQuality(server, {uplinkLossPct, downlinkLossPct, rttMs, reconnecting})(+gravixLocalQuality,GravixQualityThresholds): the connection-quality label to SHOW -- the server's score, made worse when the device's own stats are clearly bad (loss >= 3/10/30 % or RTT >= 400/1000/3000 ms -> good/poor/lost; reconnecting -> at best poor). Field 2026-10-02 (doh1): the label read "excellent" with 43 % downlink loss and 7.9 s RTT, becauseconnectionQualityis only the SFU's last ConnectionQualityUpdate, which cannot arrive over a broken signalling path (and a muted track is not scored at all).Participant.connectionQualityis unchanged (the audio-only fallback still gates on the server's value).
Changed #
GravixVideoCap.clampDimensionsrounds the LONG edge to the nearest even pixel (was: down to even): 960x540 at cap 480 is 854x480, not 852x480 — the same size the JS SDK uses. The short edge (what the SFU checks) is still rounded down, so it never exceeds the cap; the 360/540/720/1080/1440 sizes are unchanged.
Note (server side, no SDK change) #
- Gravix billing v2 rates each participant's minute by the aggregate resolution of the video it RECEIVES (Agora-style: Audio / HD / Full HD / 2K / 2K+); a lower cap therefore also caps the tier.
0.4.6 — 2026-10-02 #
Region measurement on Android/iOS measured handshakes, not round trips. Field 2026-10-01 (Bangladesh, one phone, one Wi-Fi): Gravix Tester Android 0.3.4 measured sgp1 190-265 / blr1 200-333 ms, flip-flopped between them and joined blr1, while the web tester on the same phone measured sgp1 61 / blr1 116-122 every time.
Fixed #
- Warm samples are warm. Every sample of
gravixMeasureRegionswent throughsdkHttpGet, which opened a new HTTP client per request and closed it, so all four samples paid DNS + TCP + TLS + HTTP (3-4 round trips) and dropping the first one dropped nothing. Each region is now sampled on ONE keep-alive connection (GravixRegionProbeClient, idle timeout 30 s, closed when the region is done): the first sample opens it and is dropped, samples 2..N measure one round trip. - Regions are measured in parallel, each on its own connection, samples sequential within a region. The measurement took 13-15 s on Wi-Fi (5 regions x 4 cold samples, one after another); it now takes about the slowest region's cold sample plus three round trips.
- Unchanged: the verified probe (a region whose probe endpoint names another region never wins), the 1.5 s per-sample timeout, stop-at-first-failure, the switch margins (held region and pinned region: > 30 ms and > 20 %).
Added #
GravixRegionProbeClient(probe,verifiedProbe,connections,close).gravixMeasureRegions(samplerFor:)/gravixStartRegionMeasurement(samplerFor:): aGravixRegionSamplerFactorygets the region's client, so an app can change the timeout or retries and keep the connection reuse.gravixDefaultRegionSampler. The statelesssampler:hook still works as before (no reuse unless it keeps its own connection).GravixRegionMeasurement.connections(sockets the region's client opened; 1 = every warm sample reused) andGravixRegionMeasurementResult.elapsed.GravixRegionProber.defaultProbe/defaultVerifiedProbetake an optionalclient:.
Region measurement that scales (owner 2026-10-02: 20-50 servers / 10-20 regions) #
Parity with React 0.6.4; both SDKs run the same fixture
(test/fixtures/region_shortlist_cases.json) and must make the same choice.
- Which regions (
gravixPlanRegionCandidates):GET /v1/regionsmay now carryshortlist,probe_budget_ms,shortlist_ttl_sand per-regionest_rtt_ms(GravixRegionUrl.estRttMs). Shortlist -> only it, plus the region in use when the shortlist leaves it out. No shortlist (old gateways): <= 6 regions -> all, as before; more -> the 3 lowestest_rtt_ms; else the 2 last-known-best for this network + 2 from a rotating cursor; else all. - Bounded time: at most 6 regions at a time; 1 cold + up to 2 warm samples (was 4
samples); early exit once every region has a warm sample and the best wins by the
keep-rule margin; a TOTAL budget (
probe_budget_ms, default 1.5 s Wi-Fi / 3 s cellular, orbudget:). At the budget the best so far is the answer; regions that had not answered areunmeasured, not failed. A failed first request is retried once, budget permitting. - Nothing measured:
shortlist[0], else the lowestest_rtt_ms, else nothing -- a guess (bestSourceshortlist/est) that never moves a join and never replaces a fresh measured answer. - Per-network cache: answers kept per network key (connectivity type; add a hashed
SSID / carrier with
networkKey:+gravixRegionNetworkKey) forshortlist_ttl_s(default 10 min), in the list store (shared preferences by default). A start-up or network change on a known network uses its answer at once. - Reports:
regions_measuredin the analytics join body andregionsMeasuredonGravixRegionReport(per probed regionsamples_ms,conns,source,status,budget_hit; plusmode,budget_ms,budget_hit,early_exit,measured_at) --gravixRegionsMeasuredReport. - New:
GravixRegionDirectory,gravixMeasureRegions(fetchDirectory:, shortlist:, budget:, currentRegion:, networkKey:, networkType:)(also ongravixStartRegionMeasurement),GravixRegionMeasurement.status/source/budgetHit/ retried,GravixRegionMeasurementResult.bestSource/mode/budget/budgetHit/earlyExit/ networkKey/ttl.
Tests #
- The mute reaches the server.
test/track/mute_signal_test.dartpins the hop the SFU's "stop forwarding muted tracks" depends on:setMicrophoneEnabled(false)withstopAudioCaptureOnMute: false(the room service's setting) takes the 0.4.4 engine-level mute (track enabled, PCM zeroed) AND writesMuteTrackRequest{sid, muted: true}to the signal socket; unmute writesmuted: false; one request per transition over 10 toggles; the disable fallback sends the same request. Test only, no code change (field review 2026-10-01, item 4).
Behaviour fix: participants across a full reconnect #
- A full restart (new peer connections; a resume that fails escalates to one) drops
every remote participant with a
ParticipantDisconnectedEventand re-creates the ones still in the room from the new JoinResponse. The re-created participants were never announced, soGravixRoomService.onUserOfflinefired for everyone andonUserJoinedfor no one: an app keeping its user list from the callbacks (or the room events) was left empty while everyone was still there (tester proof 2026-10-02, airplane mode 4 s on the emulator). - Room events: every participant present after the restart now gets a
ParticipantConnectedEventbeforeRoomReconnectedEvent(symmetric to the disconnects; they are NEWRemoteParticipantobjects, so code holding the old ones must take these). GravixRoomServiceidentity callbacks report only the real changes:onUserOfflinefor who left during the outage (atRoomReconnectedEvent, or at a disconnect if the restart fails),onUserJoinedfor who joined, nothing for the identities still present.test/room/full_restart_participants_test.dart.
Muted microphone: uplink capped, recorder untouched (owner 2026-10-02) #
- While muted the mic sender's bitrate is capped at 6 kbps (
encodings[0].maxBitrateviasetParameters, the audio-first cap's path) and restored on unmute, BEFORE the engine mute is released. The 0.4.4 engine-level mute is unchanged (the Android recorder keeps running, the module zeroes the PCM,MuteTrackRequeststill goes out), but the encoder kept sending those zeros at the publish rate: 46-75 kbps of uplink for silence (DTX off + RED). Phone proof (Xiaomi 2201117TG, tester 0.3.7+11, blr1): muted uplink 7 kbps (tester outbound-rtp, was ~110 unmuted), downlink steady through a 40 s mute and 10 rapid toggles, no AudioRecord stop/start, unmute heard by the other side 121-187 ms after the touch (6 unmutes, 3 runs). - Not
encodings[0].active = false, although it sends nothing at all: measured on the same phone, an inactive encoding stops the audio send stream and the engine then STOPS the AudioRecord on mute and re-creates it on unmute -- the 0.4.4 field bug (re-opened voice input interrupting the others' playout) all over again. - A refused cap only costs uplink (the cached parameters are reverted, the mute still happens); a refused restore is retried once and logged, and the unmute goes ahead. A republish while muted (RED auto, full reconnect) caps the new sender. Applies to every mute path, also the disable path (iOS, Android with the native mute refused); iOS has no device proof yet.
- The audio-first policy no longer caps a MUTED mic: it would have recorded the 6 kbps as the rate to restore and left the unmuted mic there.
- Toggles still coalesce (last wins, at most 2 transitions per burst); RED auto does
not count muted windows (too few or loss-free packets) as loss.
test/track/mic_uplink_pause_test.dart.
0.4.5 — 2026-09-30 #
Includes 0.4.4, which was never published: the Android mute fix for "after mute + unmute the other participants' audio goes silent for a moment" (Gravix Tester Android 0.3.2+5). Also the field review of Gravix Tester 0.3.2 (Android) / web 0.3.2, and the per-app maximum video resolution (Gravix plan cap).
Added #
- Plan resolution cap. The Gravix SFU sets the server-owned attribute
gravix.max_video_height(short edge, e.g."540") on the local participant after join and may change it mid-session. The SDK honours it automatically so a compliant app is never rejected: camera capture and every published layer are clamped somin(width, height) <= cap(540x960 portrait = 540p); simulcast rungs above the cap are dropped, the top layer becomes the cap; app-supplied presets / layers andGravixPublishPresetsare clamped the same way; the size announced at addTrack is the clamped one. Screen share usesmax(cap, 1080). No attribute = no cap known, behaviour unchanged. GravixVideoCap: the pure clamp rules (attributeKey,parse,effective,clampDimensions,clampParameters,clampLayers,clampPublishOptions,scaleDownBy).- A cap LOWERED while video is already published is applied best-effort by raising
scaleResolutionDownByon the live sender (capture is not restarted, and the layer sizes the SFU was told at addTrack are not re-announced). A RAISED cap takes effect on the next publish. connect(red: GravixRedMode.on | off | auto, redLossThresholdPct: 3.0): RED on (default, unchanged), off, or auto = plain Opus until the mic's uplink loss stays= the threshold for four 5 s windows (20 s; an RTT above 1.5 s blocks it), then the mic is republished with RED once (same policy as the JS SDK's
red: 'auto'). Trade-off: RED roughly doubles the audio upload (field: ~125 vs ~50 kbps) and recovers lost packets instead of concealing them (gravix_red_mode.dart).connect(earlyMicTrack: true)(opt-in): the mic track is created right after the audio session, in parallel with the signalling; the mic step only publishes it. Only with the permission already granted; never before the tap (privacy indicator, audio mode taken from other apps).- Join timeline: the ICE poll runs on until the stats show DTLS connected:
ms.dtlsStats(ICE up -> DTLS up, stats time),ms.dtlsStatsToPcConnected(stats DTLS up -> theconnectedcallback in Dart),ms.iceToNominated/ms.nominatedToPcConnected(when the SFU nominated the pair),ice.dtlsRole,ice.pairChanges, and the Dart receipt of each peer connection's ICE / connection-state callbacks in the path log (pub:ice,sub:ice,pub:pc,sub:pc).
Changed #
CameraCaptureOptionsdefaults to 540p (960x540), was 720p. It is whatGravixRoomServicealready captured, and 720p is ~2x the encoder pixels. Passparams:for more (still clamped to the plan).- One framerate across every default simulcast ladder. Every layer of a ladder
the SDK builds now runs at the TOP layer's
maxFramerate.GravixRoomServiceandGravixPublishPresets.hostpublish [180p, 540p] both at 24 fps (the 180p rung was the stock 15 fps preset) through the newGravixPublishPresets.lowLayer(320x180, 160 kbps, 24 fps); the stock ladder with no app layers is 25 fps on every layer at 540p (was 15/20/25), 30 at 720p and 1080p, 20 at 360p, with or without a plan cap; screen share already matched. Why: a server relay bug kept cross-region viewers on the lowest layer when a track's layers had differentmaxFramerate; the server fix ships separately, this is defence in depth. The top layer keeps its fps because it is what most viewers watch; bitrates are unchanged (160 kbps at 320x180 is still ~0.12 bit/pixel/frame at 24 fps). App-suppliedvideoSimulcastLayerskeep the fps they set (capped at the top layer's, as before); layers that differ log a one-time warning in debug builds. Same rule as the JS SDK 0.6.3.
Fixed #
- Android: muting the microphone no longer stops and re-creates the recorder.
A mute disabled the mic track, and the WebRTC engine's stop-on-mute audio device
module then STOPPED the AudioRecord and opened a new one on every unmute (emulator:
one new record stream per unmute, 0 bytes sent while muted). Re-opening a
VOICE_COMMUNICATION input under a live call re-routes the voice path on OEM audio
HALs, which interrupted the playout of the other participants. With
stopAudioCaptureOnMute: false(GravixRoomService's default) a mute now keeps the track enabled and the recorder running, and zeroes the captured audio inside the audio device module (JavaAudioDeviceModule.setMicrophoneMute, new native methodsetMicrophoneMuteon thegravix_clientchannel); the music mixer is held while muted, so only silence is sent (music pauses, as before). The mute signal to the room is unchanged. Falls back to disabling the track when the module is not available. The module mute is engine-wide: released when the muted track stops, on disconnect, and before any new microphone capture. iOS / web unchanged. setMicEnabled/muteLocalAudiocoalesce rapid toggles. Each tap used to queue one full transition behind the previous one; now the last requested state wins, at most one transition runs at a time (20 rapid taps: at most 2), andisMicMutedfollows the tap at once (corrected to the published state if the transition fails).- A refused addTrack (SFU
RequestResponse, e.g.LIMIT_EXCEEDED: video resolution above plan limit (540p)) now fails the publish at once with aTrackPublishExceptioncarrying the server's reason, instead of waiting for the publish timeout. The sender attached during parallel negotiation is removed on failure. - Standby handoff stall. A Bangladesh vivo join took 7.7 s (wsOpen 6750 ms,
standby.outcome=used): the upgrade over the standby connection reached the SFU (session started at the tap) and no answer came back; 0.4.3 waited 2.5 s, then dialled cold, and the SFU dropped the first session as DUPLICATE_IDENTITY 6.5 s after the tap. The upgrade over a standby connection now gets 3 x RTT (0.5-1.5 s; 1.5 s without an RTT,standby(url, token, rttMs:)), then a fresh dial races it. The race is decided when the fresh socket is connected and before its upgrade is written: the warm connection is force-closed first (its server session sees the signal connection drop), so the server never gets the fresh join followed by a late warm one. A warm upgrade that still answers after it was abandoned is closed. Timeline:standby.outcome: stalled_redialed,standby.path(standby | standby_late | redial | cold_after_error),boundMs,dialMs;reusedis now false for a redial (it could not tell before). A join waits at most 1.5 s (was 5 s) for a standby still opening. A dial that answers after the join's connection timeout is closed instead of left as a ghost session. - Standby after an app resume:
reopenStandby()(closes every standby connection at once and opens it again) andcloseStandby()for the app's lifecycle; a connection opened while the app was paused is not trusted. - Leave on dispose / app detach. A signal client, engine or room disposed
while connected now writes the leave before closing the socket (once per socket);
GravixRoomService.dispose()writes it before its teardown, and the newleaveNow()(appdetached, process termination) writes it first and bounds the teardown. The WebSocket close is bounded (1 s). Field: six mid-call restarts left ghost participants for 10-20 s each. - Mic serializer. The join's own first mic enable and the audio-interruption
recovery go through the same worker as setMicEnabled (the coalescing change
above first left both outside it). A mute while nothing is live (the first
enable still blocked, e.g. on a permission dialog) returns at once and is
applied when the pending step returns (0.4.3: it never returned). A tap during the join wins over
publishMic. - Region: the measured pick keeps the token's region unless another region is
clearly faster in the same measurement (> 30 ms and > 20 %, the decision cache's
rule). Emulator 2026-09-30: the app kept sgp1 (180 ms) over blr1 (167 ms) and
opened its standby for sgp1;
gravixPickMeasuredRegionmoved 3 of 4 joins to blr1 anyway -- a cold dial (wsOpen 200-1314 ms instead of ~70) and the region flip-flop the app avoided.
0.4.3 — 2026-09-30 #
From the Gravix Tester field logs (Kuwait, OnePlus Android 15, cellular -> doh1).
Added #
GravixRoomService.standby(url, token)/standbyState(url, token): pre-connects the signalling host (TCP + TLS) ahead of the tap; the join's WebSocket upgrade reuses that connection, so the tap pays one round trip instead of TCP + TLS + upgrade (field: tap -> wsOpen 420-630 ms cold at ~50 ms RTT). Bookkeeping as the JS SDK'sroom.standby(exact url + token, 110 s, rotate at 45 s, at most 4, the join waits up to 5 s for one still opening). NOT the JS protocol-level standby socket: that is a single-peer-connection (v1) join on the server, which this SDK does not speak. Works against any server. Join timeline:standby {outcome, ageMs, waitedMs, reused, mechanism: "preconnect"}.connect(publishInBackground: true)(opt-in): connect() returns once the peer connection is up; the mic/camera publication runs behind it (field: ~0.5 s between pcConnected and connect() returning). New timeline markmicPublished, deltaspcToConnectReturned,pcToMicPublished,tapToMicPublished. setMicEnabled / setCameraEnabled / disconnect wait for the initial publication.selectedPairRtt()andgravixSelectedPairRttMs(stats): RTT of the selected ICE candidate pair per peer connection.
Fixed #
LocalAudioTrack.getSenderStats: packetsLost / roundTripTime / jitter are read fromremote-inbound-rtp(they were read fromoutbound-rtp, which has none: uplink loss showed 0 % and RTT null while the SFU measured ~15 % uplink loss).- Audio publish: RED (redundant audio) was DISABLED by default and whenever asked for
(
disableRed: red ?? true). Now on by default, off withred: falseor E2EE. - The device info in the join URL is read once per process, not on every join.
0.4.2 — 2026-09-28 #
- Upstream copyright notices restored in the Apache-2.0 third-party files; no code changes.
0.4.1 — 2026-09-28 #
- License: gravix_rtc is now MIT (Gravity Compile). The vendored RTC client, its protocol
definitions and the ported native plugin code stay under Apache-2.0 as third-party
components (
LICENSES/Apache-2.0.txt,NOTICE). No code changes.
0.4.0 — 2026-09-27 #
First release as gravix_rtc on pub.dev (renamed from gravix_cloud; import package:gravix_rtc/gravix_rtc.dart). iOS privacy manifest bundled.
GravixCloudBackend(deprecated since 2026-09-19) is not part of gravix_rtc; useGravixTokenProvider.
Native plugin (Android + iOS) #
Native plugin for Android and iOS. Until now nothing answered the RTC core's
gravix_client channel on phones: speaker/earpiece switching was a silent
no-op, iOS setDirectAudioOutput reported success without doing anything, and
the music mixer was Android-only.
Added #
- Android
gravix_clientplugin: SDK-owned audio session (communication mode, focus,setCommunicationDeviceon API 31+, speakerphone/Bluetooth SCO below),setAndroidSpeakerphoneOn, explicit recording start with audio-processing options,setAudioProcessingOptions/getAudioProcessingState, visualizer and PCM renderer event channels,osVersionString. - iOS
gravix_clientplugin: engine-drivenAVAudioSessionconfiguration (playAndRecord, voiceChat/videoChat, Bluetooth/A2DP/AirPlay, forced speaker), automatic / manual / CallKit (externalCallSystem) management,setEngineAvailability(also as a native static for killed-state CallKit wakes), microphone mute modes, explicit recording start, audio processing, visualizer/renderer, route-change reporting,osVersionString. - Screen share: Android consent +
mediaProjectionforeground service run bysetScreenShareEnabled(AndroidScreenCapture, opt-out withAndroidScreenCapture.enabled = false); iOS broadcast picker + state and a Broadcast Upload Extension template inios/BroadcastExtension/. - iOS music mixer on
gravity.music_mixer, same methods as Android: music mixed into WebRTC's input mixer; compile-verified only. BroadcastManagerandAndroidScreenCaptureare exported.
Changed #
- flutter_webrtc's own native audio-session management is switched off when
the plugin registers; the SDK is the single owner (on Android the session now
starts when a room connects or the app starts it, not on the first
getUserMedia). - Android
pluginClassis nowcom.gravitycompile.gravix_cloud.GravixCloudPlugin(it ownsMusicMixerPlugin;MusicMixerPlugin.flutterEnginestill works). - iOS depends on flutter_webrtc from both
Package.swiftand the podspec.
Fixed #
- iOS
setDirectAudioOutput/GravixAudioRouting.setAudioOutputreturned true for a switch nothing performed; it now returns what the native route became (true when applied, or deferred until the call's audio session exists). setScreenShareEnabled(true)on iOS withoutRTCScreenSharingExtensionin Info.plist now throwsbroadcastExtensionNotConfiguredinstead of silently doing nothing; with it, frames come from the extension.- Explicit recording start on iOS: audio-session errors fall back to the
publish-time start; a missing microphone permission fails the track as
TrackCreateException.
Dart API #
Additive, except that the service no longer calls the gravity.beauty_filter
channel by default (see "Changed").
Added — GravixVideoEffect, the local-camera effect hook #
GravixRoomService(videoEffect: …)takes an effect from an effect package (beauty, blur, filters). None by default: no effect code runs andvideoEffectActivestays false.- Contract:
attach(GravixVideoEffectTarget)→Future<bool>(true only when frames really go through the effect),detach(),setEnabled(bool),setParams(Map), and aminFpshint the capture rate honours. The service attaches on camera start and flip, detaches on camera stop / disconnect / dispose, and replayssetVideoEffectEnabled/setVideoEffectParamsafter every attach. How a native effect registers a flutter_webrtc frame processor (AndroidLocalVideoTrack.addProcessor, iOSaddProcessing:) is documented onGravixVideoEffectand in the README.
Added — join analytics upload (opt-in, ANALYTICS_CONTRACT §2) #
GravixRoomService(analytics: GravixAnalytics(url: …)), orconnect(analyticsUrl: …)/connectWithTokenProvider(analyticsUrl: …): after each connect,POST {url}/v1/ingest/clientwith the join token as Bearer andjoin_ms,success/error,region,url,participant_sid,sdk: FLUTTER+ version,network(connectivity_plus) and, when the join records one, the join timeline (the report then waits for that timeline to end: first audio, 30 s, or disconnect).- Fire-and-forget with a 5 s timeout: never awaited on the join path, never throws, never fails a join. Errors are redacted before upload; a timeline that would push the body over 64 KB is dropped.
Added — GravixSharedPrefsRegionListStore #
- The region list fetched by
gravixStartRegionMeasurementis kept in shared_preferences by default, so a cold start with the gateway unreachable still measures (new dependency:shared_preferences). Falls back to the in-process store when the plugin does not answer.gravixStartRegionMeasurementalso takessampler:/fetchList:.
Added — ReconnectPolicy (React parity) #
RoomOptions(reconnectPolicy: …)/GravixRoomService(reconnectPolicy: …);ReconnectPolicy,ReconnectContext,DefaultReconnectPolicymirror the React SDK.nullfrom the policy (or a policy that throws) stops reconnecting withreconnectAttemptsExceeded. Without a policy the behaviour is unchanged (the default policy is the old delay table with the same jitter).RoomAttemptReconnectEvent.maxAttemptsRetryis -1 under a custom policy.
Changed #
DefaultGravixBeautyFilteris no longer the default. It called agravity.beauty_filterchannel that no platform of this SDK implements and swallowed the "missing plugin" — a silent no-op that looked like beauty was on. An app that registered its OWN nativegravity.beauty_filterplugin and relied on the implicit default must now passbeauty: DefaultGravixBeautyFilter()(deprecated) or implementGravixVideoEffect.- Deprecated (removal no earlier than 2027-09):
GravixRoomService(beauty:),GravixBeautyFilter,DefaultGravixBeautyFilter. A filter passed asbeauty:still runs, wrapped inGravixBeautyFilterEffect; withDefaultGravixBeautyFiltera missing native plugin is now reported (videoEffectActive == false). connect(enableVideo: true)no longer waits 500 ms after opening the camera; that wait existed for the beauty attach, which now runs as soon as the camera track is published.- pubspec: pub.dev metadata (description, repository, issue tracker, topics); README rewritten for pub.dev.
Not yet in Flutter #
- Standby connect (React
Room.standby): the server accepts a standby socket only on the v1 path, which always runs single-peer-connection; the vendored core is v0 / two peer connections.
0.3.1 — 2026-09-27 #
Found by the new example app (examples/gravix_flutter_demo) on an Android emulator.
- Fix: the microphone never published on Android/iOS.
LocalAudioTrack.startCaptureasked the native side for an explicit recording start (startLocalRecordingon thegravix_clientchannel), which no Gravix plugin implements, and treated "not implemented" as a failure -- sosetMicEnabled(true)/connect(publishMic: true)failed with "Audio processing options are unavailable on this platform" and the far side heard nothing. An unavailable explicit start is now skipped (the audio device starts when the track is published); real processing failures still throw. - Fix:
setCameraEnabled(true)reported the camera off when the token could not update its own metadata. The camera published, then publishing thecameraFacingattribute was refused and the whole call failed. The attribute is now best effort. (Tokens from the Gravix token libraries 0.1.1 grantcanUpdateOwnMetadataby default.)
0.3.0 — 2026-09-27 #
Everything below "Unreleased" as of 2026-09-20 (fast connect, prewarm, token provider, M4 client, region failover, earpiece parity), released, plus parity with the React SDK 0.4.0/0.5.0 for joins with no token server. Additive: an app that changes nothing behaves as before.
Added — region choice at start-up (gravixStartRegionMeasurement) #
- Call it once when the app starts (e.g. with the console's
/v1/regions). Each region is measured one after another, 4 samples, the first dropped, the minimum kept; re-measured every 5 min and on a connectivity change; 10 min TTL; the held region stays unless a rival is 30 ms / 20 % faster. Never throws. connect()then goes straight to the measured region: no probe on the join path.- A token minted by the app's own backend (
gravix-rtc-token,gravix-token-go) carries no region list: the measured regions are the list then, as long as the connect url is one of them (a url outside them is never redirected). - When the list cannot be fetched, the last one is used (
GravixRegionListStore; in memory by default, back it with shared_preferences to keep it across launches).
Added — audio-first mode (GravixAudioFirst, opt-in) #
Port of the React SDK's audio-first mode: when the RTT stays above 1.5 s for 6 s (2G/EDGE) it turns the camera off for others (muted, not stopped), caps the microphone at 16 kbit/s (no renegotiation) and stops receiving remote video; restores exactly that after 30 s of a 4G-class RTT, with a restore hold that doubles after a restore that did not hold (60 s up to 16 min).
Added — receiver stats totalSamplesReceived (audio) and freezeCount (video) #
Not yet in Flutter #
Standby connect (the React SDK's room.standby) needs the single-peer-connection
signalling path this SDK does not use yet.
Tests: flutter test 335 (22 new), analyzer: only the 2 baselined issues.
Unreleased #
Added — connect(fastAnswer:) and connect(earlyCallAudio:), both opt-in (2026-09-20) #
Two experimental switches against the Android "second-offer hold" (the phone sits
on the SFU's audio offer ~600 ms before answering; nothing is forwarded until it
does). fastAnswer sends the subscriber answer before setLocalDescription
(vendored offer handler, // GRAVIX, ordering in gravixAnswerSubscriberOffer).
earlyCallAudio activates Android call audio at the WebSocket dial through this
package's own plugin, without opening the microphone. Default OFF; risks, the
measured side effects and the LAN numbers are in
doc/FAST_CONNECT_INTEGRATION.md. Also GravixRoomService.inboundAudioCounters().
Single peer connection is NOT available in the vendored core (same document).
Added — gravixLogJoinTimeline / GravixRoomService.logJoinTimelines (production-app measurement) #
One switch for an app to write every join timeline to the device log under the
fixed tag GRAVIX_JOIN_TIMELINE — one line of JSON, RELEASE builds included, via
the package's own Android plugin (Log.i); long lines are chunked PART i/n and
reassembled by the maintainers' collection script. The signalling url
is logged without its query string or userinfo. See doc/JOIN_TIMELINE.md.
Added — GravixRegionProber(stagger: …), default zero (unchanged race) #
Optional delay between probe starts, for the N >= 3 self-bias (simultaneous TLS
handshakes contending on a constrained uplink reorder close regions). A staggered
race is decided on each probe's own RTT instead of arrival order. Cost: the
decision comes up to (N-1) * stagger later and the worst-case window grows by
the same amount; every recorded RTT changes. See doc/PROBE_RACE_PARITY.md §5.
Changed — GravixCloudBackend requests now time out (default 8 s) #
GravixCloudBackend(timeout: …), default 8 s, on getJoinRoomToken and
grantPublish: a stalled gateway becomes a DioException (receiveTimeout)
instead of a join that never ends. This one is on by default — a hang is never the behaviour
anyone wanted; pass timeout: null for the old unbounded wait.
(GravixTokenProvider has had the same 8 s default since it was added.)
Added — connectWithTokenProvider(parallelTokenAndProbe: true), opt-in #
Starts the probe race while the token is being fetched — only when the region list is already known (this provider has seen a response for the request before, now expired) and no region decision is remembered. The early result is used only if the fresh response names the same pinned url and region entries; otherwise it is discarded and the join races normally. A cold start cannot do this: the region list arrives in the token response, so the first join is token-then-probe whatever the flag says.
Added — connect(parallelAudioSession: true), opt-in #
connect() used to await the audio-session bring-up before any network work.
With the flag it runs alongside: under v1 routing next to the probe race and the
transport, under v2 next to the probe race only (v2 must own the session before a
Room exists). The session is always ready before the mic or the route is touched.
Off = the old order, pinned by a test.
Added — GravixRoomService.prewarm(...) (explicit call; nothing changes unless you call it) #
Call it when the room list opens: token into the provider's cache, the region
decision into the decision cache (with regionProbe), one HEAD to the chosen
signalling host, and the audio-session plugin instantiated — so the tap pays
only for WebSocket + ICE + DTLS. Never throws; returns a GravixPrewarmReport.
configureAudioSession and requestMicPermission are off by default (the first
interrupts other apps' audio on iOS, the second lights the mic indicator).
Honest limit: on a phone the HEAD is known to warm DNS; TLS session reuse
between it and the WebSocket is unverified — measure ms.wsOpen.
Added — join timeline: subscriberPath and firstPacketEstimate (2026-09-20) #
An event log of the subscriber path between PC-connected and the first audio
packet (offer arrival, handler start, setRemoteDescription / createAnswer /
setLocalDescription, answer sent, onTrack, track events, this service's own
post-connect calls), and the first packet's arrival on the receiver's clock. One
observation hook was added to the vendored core for it
(Engine.gravixTimelineHook, null by default). See doc/JOIN_TIMELINE.md.
Fixed — join timeline, after the first real phone joins (2026-09-20) #
iceConnectedcame from theonIceConnectionStatecallback, which on libwebrtc/flutter_webrtc is the legacy state and fires when DTLS is writable:ms.dtlswas negative on 10 of 10 phone joins. It is now read fromgetStats()(transport.iceState), stamped with the snapshot's timestamp, and isnull(withms.ice/ms.dtls) when the boundary fell between two 50 ms stats snapshots. Newms.iceAndDtlsis always exact; newiceobject gives the observed bounds.- The playout proxy accepted
totalSamplesReceived > 0, which counts concealed samples (seen: 4320 total / 1744 concealed). NowjitterBufferEmittedCount > 0, elsetotalSamplesReceived − concealedSamples > 0. - The selected-pair read could be served libwebrtc's cached pre-connect snapshot
(
pair: nullon a connected join); it re-reads until the snapshot catches up. - Stats-derived marks are moved back by the snapshot's age (getStats took up to
300 ms during connection setup). New report fields:
ice,stats,firstAudioEvidence,offersMs.
Added — join timeline (connect(joinTimeline: …)), opt-in #
One GravixJoinTimeline per join (GravixRoomService.joinTimeline /
onJoinTimeline, toJsonLine()): tap, app spans, token, region probe, audio
session, WebSocket open, JoinResponse, ICE, DTLS, mic, and first audio — with the
selected candidate pair and a fallbackDetected flag for TCP/TURN. Adds an
honest first-audio proxy (firstAudioPlayoutProxy: samples left the jitter
buffer) next to the two existing meanings (firstAudioSubscribed,
firstAudioPacket); GravixFirstAudioReport is unchanged. Local observation
only — no wire change, zero work when off (one observation hook,
Engine.gravixTimelineHook, was later added under rtc_core/ for subscriberPath). See
doc/JOIN_TIMELINE.md.
Added — GravixTokenProvider: the api_secret leaves the app (2026-09-19) #
final provider = GravixTokenProvider.endpoint(Uri.parse('https://api.example.com/rtc/token'),
headers: {'Authorization': 'Bearer $session'}); // or .literal(...) / .callback(...)
await room.connectWithTokenProvider(
tokenProvider: provider,
request: GravixTokenRequest(room: roomId, identity: uid, name: name, canPublish: isHost),
);
GravixTokenProvider(literal/callback/endpoint/fromTokenSource),GravixTokenRequest,GravixJoinCredentials,GravixTokenException+GravixTokenErrorReason, andGravixRoomService.connectWithTokenProvider/lastTokenError.- The tenant's backend holds the secret and returns the gateway's
/v1/tokenresponse unchanged; the SDK POSTs only{room, identity, name, can_publish}. A Parse Cloud Function{result: …}envelope is unwrapped. region_urls(slug +probe_url) flow from the provider into the probe race and the connect ladder. The vendored upstreamTokenSourceResponsehas no field for them, which is why the credentials type is Gravix-owned; the provider still implements the upstreamTokenSourceConfigurableand adapts any upstream source viafromTokenSource.- Expiry-aware cache (JWT
exp/expires_in, 60 s safety margin, LRU 16), in-flight de-duplication, and an 8 s request timeout. The old backend had neither a cache nor a timeout. - No added round trip: a cached token makes
connectWithTokenProviderissue no token request at all, so fetching when the room list opens moves the token off the tap. doc/MIGRATION_TOKEN_PROVIDER.md, with a Parse Server cloud function for the server side.
Deprecated — GravixCloudBackend(apiKey, apiSecret) #
Still works, byte-for-byte the same requests, so upgrading breaks nothing.
@Deprecated, and the first construction in a process prints a loud warning
(once). New optional tokenEndpoint / upgradeEndpoint constructor overrides,
default unchanged. Rotate the secret after migrating — every build that
shipped it is still out there.
Additive and opt-in. An app that upgrades and changes no code gets the same behaviour it had on 0.2.0 — the default audio route is unchanged, and the new telemetry events are emitted only on the probe path that was already opt-in.
Added — explicit audio output (GravixAudioRouting.setAudioOutput) #
GravixAudioRouting.v2 = true; // same opt-in flag
await GravixAudioRouting.setAudioOutput(GravixAudioOutput.earpiece);
await GravixAudioRouting.clearAudioOutput(); // back to automatic
GravixAudioOutput(speaker|earpiece),GravixAudioRouting.setAudioOutput/clearAudioOutput/audioOutput, andGravixAudioRouteManager.audioOutputListenablefor binding a toggle.- Backed by a new
GravixAudioPlatform.setDirectAudioOutputseam method:AudioManager.setCommunicationDeviceon API 31+, a legacysetSpeakerphoneOnwrite below it, and the Apple session config on iOS. Returnsfalse— rather than silently doing nothing — when the handset has no such output. - Returns
falseand does nothing whileGravixAudioRouting.v2is off.
The automatic earpiece ranking is still gone, and stays gone. v1 called
setSpeakerOutputPreferred(false) whenever an external output was present,
which does not pick a device — it installs the preferred-device list
[BT, Wired, Earpiece, Speaker]. That list is sticky: the native switch
re-resolves it on every hot-plug, so when the headset that justified it
disconnects the earpiece wins, nobody asked for it, and a re-apply gated on
device enumeration cannot recover because enumeration keeps reporting the
headset for about a second after it is gone. That is §1 of
the internal audio-routing design notes and it is deliberately, permanently removed.
setAudioOutput is the deliberate replacement, and it is a different
mechanism, not a reinstatement. It names a device instead of ordering a list,
so nothing is left behind for a later hot-plug to re-resolve into a surprise;
it only ever happens because the app asked; and setAudioOutput(speaker) undoes
it completely. The property that made the old behaviour unrecoverable — a
decision that depended on device enumeration telling the truth — is pinned
absent by a test that re-asserts an explicit earpiece while enumeration still
falsely reports a headset.
Default behaviour is unchanged. Routing stays automatic until an app calls
setAudioOutput: the speaker is preferred unconditionally and a connected
headset wins by ranking, exactly as before. 12 tests through the
GravixAudioPlatform seam cover the new API, including that preferred: false
is never emitted on any path.
Changed — probe-race telemetry is now two events #
GravixConnectionReport carries joinToFirstAudio, so a complete record could
not exist until first audio arrived or its 30 s timeout elapsed — which loses
exactly the sessions where region selection went wrong, because a user who
lands on a far edge, hears nothing and leaves is inside that window by
definition.
Split, matching the JS SDK:
GravixRegionReport— the region decision, emitted atconnectedAt, waiting on nothing.onRegionReport/service.regionReport.GravixFirstAudioReport— first-audio timing, emitted later on first remote audio, at 30 s, or ondisconnect(), whichever is first — so it always fires exactly once.onFirstAudioReport/service.firstAudioReport.- Correlated by
connectionId(UUID v4, perconnect()).
Field names, enum wire values and the UTC ISO-8601-with-milliseconds timestamp
format match the JS SDK byte for byte, and that is asserted by test rather than
by comment. connect() gains an optional regionEntries: parameter taking
GravixRegionUrl pairs (from the new gravixRegionEntriesFrom(tokenResponse))
so region slugs survive into the report; callers passing plain regionUrls:
still work and get unknown slugs.
GravixConnectionReport is unchanged and still works. Its field names never
matched the JS SDK's — see doc/PROBE_RACE_PARITY.md for the field-by-field
audit and for the race-semantics differences that are reported but not
changed.
HEAD remains the probe method; the JS SDK has moved to HEAD to match.
Added — full reconnect re-probes (connect(regionReprobeOnRestart: true), default false) #
With regionProbe on, an in-engine FULL reconnect races the regions again
(GravixProbeRestartStrategy) and joins the fresh winner, then its ladder,
instead of re-joining the region the session was on. Same rules as JS. The
vendored engine gains Engine.restartRegionStrategy, consulted before the
upstream region provider.
Fixed — reconnectWithToken dropped the region entries #
It forwarded only the bare url strings, so a reconnect lost the region slugs
(the report said unknown) and every probe_url. It now takes and forwards
regionEntries, regionDecisionCache and regionReprobeOnRestart.
Fixed — regionReprobeOnRestart did not fire when a region was unreachable #
A resume against an unreachable region times out (ConnectException) instead of
being refused, and that never escalated to the full reconnect where the re-probe
runs. The JS SDK's live sim test exposed the same gap there. With the re-probe
installed, 3 consecutive such failures now escalate. Default behaviour is
unchanged. Unit-tested only: Flutter has no macOS target for the live test.
Changed — the region decision cache is ON by default #
With regionProbe on, a repeat join within 10 minutes now connects straight to
the remembered region. Measured in real Chrome against the sim, this removed the
probe's ~2 round trips from repeat joins (532 → 466 ms p50 at +60 ms) and changed
nothing else. A remembered region that refuses is forgotten, and the ladder takes
over. Pass regionDecisionCache: false to race on every join (one-flag rollback).
Added — region decision cache (connect(regionDecisionCache: true), default false) #
With regionProbe: true, a repeat join connects straight to the region the last
race picked, and the race runs in the background to keep that memory fresh. This
is a port of the JS SDK's regionDecisionCache with the same rules:
- 10 min TTL;
- hysteresis, so a rival must beat the remembered region by the larger of 30 ms and 20 %;
- a region that refused a WebSocket is kept out for 2 min;
- after a ladder rescue, the region the join actually landed on is remembered.
The cache is in memory only: GravixRegionDecisionCache.shared, or inject one
through GravixRoomService(regionDecisionCache: …). A cache hit reports reason
cached (new GravixRegionChoiceReason.cached, same wire value as JS) with no
probe rows.
Changed — the pinned region keeps the join within 15 ms (JS parity) #
With connect(regionProbe: true) the race still resolves on the first responder,
but when that is not the pinned url it waits up to 15 ms
(GravixRegionProber.tieBreak) for the pinned region, which then keeps the join.
This is the same winner the JS SDK picks. race / raceEntries gained an
optional {String? pinnedUrl}, so a subclass overriding race must add it.
Fixed — a region answering 503 no longer wins the probe race #
The default HEAD probe counted any HTTP answer as a responder. sdkHttpHead does
not throw on a non-2xx, so an edge answering 503 (draining) or 404 (misrouted)
won the race by answering first, and the join went to the region that had just
refused. It now counts 2xx only, and retries once with GET on 405/501, as the JS
SDK does. Only affects connect(regionProbe: true).
Added — CI #
.github/workflows/ci.yml: gitleaks over full history, flutter test, and
flutter analyze via tool/check_analyze.sh. The analyze job is baselined
against the two pre-existing unawaited_return_in_try_block warnings in the
vendored upstream code (tool/analyze_baseline.txt), so it is green on current
main and red on any new issue. The baseline stores no line numbers, so
unrelated edits cannot flip the job.
Docs #
- The internal on-device audio-routing checklist — triaged. Every row is now either verified here with cited evidence, or explicitly marked blocked on device testing with the question only hardware can answer. Includes the minimum device set (Xiaomi/Redmi, Realme, Infinix, Tecno, Samsung A-series, one API < 31).
doc/PROBE_RACE_PARITY.md— new. Flutter ↔ JS field-name audit and the behavioural divergences that remain.doc/AUDIO_SESSION_MIGRATION.md— new. What breaks upgradingaudio_session0.1.25 → 0.2.4, measured rather than guessed. Not upgraded.doc/UPSTREAM_FORK.md— new. Which upstream client SDK versionlib/src/rtc_core/is forked from (2.11.0) and what a resync costs.- Corrected a false claim in
gravix_audio_platform.dart:AndroidAudioHardwareModeis a const-class in bothaudio_session0.1.25 and 0.2.4, not an enum in 0.2.4.
0.2.0 #
Minor bump: everything in this release is additive and opt-in. An app that upgrades and changes no code gets the same behaviour it had on 0.1.0.
Added — audio routing v2 (GravixAudioRouting.v2, default false) #
A port of the routing implementation from a production app's performance upgrade:
GravixAudioRouteManager— one serialized owner of the output route, with a re-assert ladder (0 / 400 / 1600 ms) after anything that restarts playout, and debounced handling of device connect/disconnect.GravixAndroidAudioSessionOwner— takes the Android audio session offRoomlifecycle so a stale room's teardown cannot kill a live room's session.GravixAndroidAudioSessionGuard— detects a session reset under a live room and rebuilds it once, with six documented stand-down conditions and a loop cap.GravixForeignCallDetector— six-signal detection of another app holding a voice call, with hysteresis in both directions.GravixAudioRouteLog— bounded breadcrumb trail of every routing decision;GravixAudioRouting.log.dump()for a bug report.GravixRoomService.setAppBackgrounded(bool)— tells the guard to stand down while the app has deliberately released the session.
This is unvalidated on hardware. The internal audio-routing design notes record
what changed and what each change claims to fix;
The internal on-device audio-routing checklist is the before/after checklist that has to be
filled in before any of it is described as an improvement. The flag defaults to
false for that reason.
Note one deliberate capability removal in v2: it has no earpiece mode. The speaker is preferred unconditionally and a connected headset wins by ranking. An app that needs an earpiece mode should stay on v1.
Added — probe-race connect (connect(regionProbe: true), default false) #
- When the token response carries
region_urls, race a lightweight HTTP HEAD at each candidate and connect to the first responder; fall back to the pinned URL on timeout or when every probe fails. GravixConnectionReporton the service (lastConnectionReport, andconnectionReportas aValueNotifier) records which region won, every probe's outcome, join-to-connected and join-to-first-audio.gravixRegionUrlsFrom(tokenResponse)extracts the field, treating absent, null, or wrongly-typed values as "no regions".
The probe creates no server-side state and does not consume the join token.
Signalling and SDP are unchanged. With regionProbe off, or with no
region_urls, the race is not entered, no probe is sent, and no await is added.
Added — large-room client helpers (all opt-in) #
None of these are applied by GravixRoomService; importing them changes nothing.
GravixParticipantInfo— typed reads ofgravix.role,gravix.mixed,gravix.linked_from,gravix.broadcast_url.GravixRoomView— filters mixers and linked participants out of UI lists. A view, not a policy: those participants stay subscribed.GravixAudioOnlyFallback— drops remote video subscriptions after sustained poor quality and restores them after sustained excellent, with a 60s floor between switches. Audio is never touched, and it restores only the subscriptions it dropped.GravixPublishPresets.host— two simulcast layers, H.264, for a large-room host. Not a default: changing the codec changes the offer, which is a deployment decision.GravixPublishPresets.hostLowDatais the single-layer variant.
Changed #
GravixRoomService.connectandreconnectWithTokentakeregionUrlsandregionProbe. Both default to the previous behaviour.GravixRoomServiceimplementsGravixAudioHost, exposingisRoomConnected,appBackgrounded,audioFlowingandrecordableRoomAudiofor the v2 guard.- Example app: lobby toggles for each opt-in feature and an in-call diagnostics overlay showing the connection report and the routing state.
fake_asyncadded as a dev dependency for the routing timer tests.
Tests #
128 tests, covering the ported routing logic against a fake platform, the probe
race and its fallbacks, the "absent region_urls behaves identically" contract
at both the parser and connect(), and the large-room helpers.
0.1.0 #
- Initial white-label RTC package.
- Vendored + purged RTC core (
lib/src/rtc_core/) with regenerated protobuf output (package gravixcloud— no upstream brand tokens inlib/). GravixRoomService: GetX-free port of the app's room service (ValueNotifierobservables, auto data-saver quality monitor, audio-session/interruption handling, camera-facing sync, remote mute).GravixMusicController+ native Android music mixer (gravity.music_mixerchannel, reflection-installedAudioBufferCallback).GravixBeautyFilterinterface + default platform bridge.GravixCloudBackend: opt-in token-gateway client (fixed gateway endpoints, API credentials passed in the constructor).