diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e96ef74..8623c53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,8 +37,8 @@ android { minSdk = 33 targetSdk = 35 // Remain newer than the 0.6.3 rendezvous candidate (code 26). - versionCode = 33 - versionName = "0.6.10" + versionCode = 34 + versionName = "0.6.11" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/LocalMedia.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/LocalMedia.kt index 6aef1a7..9f1d6e8 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/LocalMedia.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/LocalMedia.kt @@ -79,6 +79,9 @@ class LocalMedia( private var cameraCapturer: CameraVideoCapturer? = null private var cameraSource: VideoSource? = null + + /** What the camera source hands the encoders; see [VideoLadder]. */ + @Volatile private var cameraRung: VideoRung = VideoLadder.FULL private var cameraTrack: VideoTrack? = null private var cameraHelper: SurfaceTextureHelper? = null private var frontFacing = true @@ -191,6 +194,9 @@ class LocalMedia( // frame reaches the encoder without having been through the rule in // `routeFor`. With no scene chosen it forwards frames untouched. source.setVideoProcessor(newBackgroundProcessor()) + // The sensor runs at the full format; the source scales and drops to + // the rung before the processor and every encoder see a frame. + source.adaptOutputFormat(cameraRung.width, cameraRung.height, cameraRung.fps) capturer.initialize(helper, context, source.capturerObserver) capturer.startCapture(CAMERA_WIDTH, CAMERA_HEIGHT, CAMERA_FPS) val track = factory.createVideoTrack(trackId(Roles.CAMERA), source) @@ -203,6 +209,19 @@ class LocalMedia( return track } + /** + * Fit the camera to a call of this size. + * + * One downscale at the source, so a phone on a four-way call encodes + * 640 by 360 three times rather than 720p three times. Takes effect on + * the next frame, or when the camera next starts. + */ + @Synchronized + fun adaptCamera(rung: VideoRung) { + cameraRung = rung + runCatching { cameraSource?.adaptOutputFormat(rung.width, rung.height, rung.fps) } + } + @Synchronized fun stopCamera() { runCatching { cameraCapturer?.stopCapture() } diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/VideoLadder.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/VideoLadder.kt new file mode 100644 index 0000000..8e8afcb --- /dev/null +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/VideoLadder.kt @@ -0,0 +1,62 @@ +package dev.forgesworn.kithmoot.media + +import dev.forgesworn.kithmoot.session.Roles +import org.webrtc.RtpSender + +/** + * What the camera sends for a call of this size. + * + * Every remote device on a call has its own peer connection and so its own + * encoder for this device's camera. A phone on a four-way call runs three + * encoders and three decoders at once, and with nothing to bound them each + * encodes the full capture (1280 by 720 at 30) at libwebrtc's own ceiling of + * about 2.5 Mbps. That is the heat, and the uplink. + * + * A rung is one downscale at the source, done once and fed to every encoder, + * and one bitrate ceiling per sender, which is where libwebrtc takes it. + * Nothing on the wire changes: resolution and bitrate are a sender's own + * choice, and the far end plays what arrives. + */ +data class VideoRung(val width: Int, val height: Int, val fps: Int, val maxBitrateBps: Int) + +object VideoLadder { + /** One far end: the camera as captured. */ + val FULL: VideoRung = VideoRung(1280, 720, 30, 1_200_000) + + /** Two or three far ends. */ + val MEDIUM: VideoRung = VideoRung(960, 540, 24, 800_000) + + /** Four or more. */ + val SMALL: VideoRung = VideoRung(640, 360, 15, 500_000) + + /** The rung for a call where this device's camera goes to [peers] devices. */ + fun rungFor(peers: Int): VideoRung = when { + peers <= 1 -> FULL + peers <= 3 -> MEDIUM + else -> SMALL + } +} + +/** + * Whether a track id names this device's camera. + * + * Track ids are `role-uuid` (see `LocalMedia.trackId`), so the role is + * readable off the id alone, which is all a sender has. Screen shares are + * left at libwebrtc's defaults: text needs the bits, and they already run at + * 15 frames a second. + */ +internal fun isCameraTrackId(trackId: String): Boolean = trackId.startsWith("${Roles.CAMERA}-") + +/** + * Put a bitrate ceiling on one sender. False when nothing was set: no + * ceiling asked for, no encodings to set it on yet, or libwebrtc refused. + */ +internal fun capSender(sender: RtpSender, maxBitrateBps: Int): Boolean { + if (maxBitrateBps <= 0) return false + return runCatching { + val parameters = sender.parameters + if (parameters.encodings.isEmpty()) return@runCatching false + for (encoding in parameters.encodings) encoding.maxBitrateBps = maxBitrateBps + sender.setParameters(parameters) + }.getOrDefault(false) +} diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcEngine.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcEngine.kt index d6afd96..13eb1e2 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcEngine.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcEngine.kt @@ -6,10 +6,14 @@ import dev.forgesworn.kithmoot.protocol.SignalBody import dev.forgesworn.kithmoot.protocol.TrackRef import dev.forgesworn.kithmoot.session.CALL_PROFILE_2 import dev.forgesworn.kithmoot.session.CALL_PROFILE_2_ENABLED +import dev.forgesworn.kithmoot.session.Roles import dev.forgesworn.kithmoot.session.RoomSession import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -81,6 +85,21 @@ class WebRtcEngine( private val iceServers: List, ) { + /** + * Where the engine's jobs run. + * + * A supervisor under the caller's scope, with a handler: a job that throws + * is a logged failure, not the end of the process, and does not take the + * other jobs with it. Cancelling the caller's scope still cancels all of + * it. The caller's dispatcher is kept; the collectors pick + * [MediaDispatcher] themselves. + */ + private val engineScope = CoroutineScope( + scope.coroutineContext + + SupervisorJob(scope.coroutineContext[Job]) + + CoroutineExceptionHandler { _, failure -> Log.e("KithMootMedia", "engine job failed", failure) }, + ) + val eglBase: EglBase = EglBase.create() private val factory: PeerConnectionFactory @@ -161,7 +180,7 @@ class WebRtcEngine( fun setCallActive(active: Boolean) { synchronized(lock) { callActive = active } - if (active) scope.launch(MediaDispatcher) { reconcile(session.remoteDevices.value) } else stop() + if (active) engineScope.launch(MediaDispatcher) { reconcile(session.remoteDevices.value) } else stop() } /** @@ -174,8 +193,8 @@ class WebRtcEngine( // The set of devices to connect to is derived from the roster, so a // device that joins, leaves or lapses is reconciled here rather than // being handled as an event. - scope.launch(MediaDispatcher) { session.remoteDevices.collect { reconcile(it) } } - scope.launch(MediaDispatcher) { + engineScope.launch(MediaDispatcher) { session.remoteDevices.collect { reconcile(it) } } + engineScope.launch(MediaDispatcher) { session.signals.collect { signal -> val target = managedLinkFor(signal.from) ?: return@collect try { @@ -193,8 +212,8 @@ class WebRtcEngine( } } } - scope.launch(MediaDispatcher) { localMedia.tracks.collect { onLocalTracksChanged(it) } } - scope.launch(MediaDispatcher) { + engineScope.launch(MediaDispatcher) { localMedia.tracks.collect { onLocalTracksChanged(it) } } + engineScope.launch(MediaDispatcher) { while (isActive) { delay(10_000) synchronized(lock) { links.values().forEach { it.reportMediaProgress() } } @@ -205,7 +224,7 @@ class WebRtcEngine( // the only honest evidence that a direction is alive: a connection can // be `connected`, the signalling quiet, every object healthy, and one // direction carrying nothing at all. - scope.launch(MediaDispatcher) { + engineScope.launch(MediaDispatcher) { while (isActive) { delay(HEALTH_SAMPLE_MS) val sampling = synchronized(lock) { links.values().filter { it.profileTwo } } @@ -260,6 +279,7 @@ class WebRtcEngine( _connections.update { it - device } _remoteTracks.update { current -> current.filterNot { it.device == device } } } + applyRung() } private fun managedLinkFor(device: String): ManagedLink? = links[device] @@ -381,6 +401,28 @@ class WebRtcEngine( synchronized(lock) { for ((device, link) in links.snapshot()) link.syncLocalTracks(tracksFor(device, tracks)) } + applyRung() + } + + /** What the camera sends right now; see [VideoLadder]. */ + @Volatile private var rung: VideoRung = VideoLadder.FULL + + /** + * Fit the camera to the number of devices it goes to. + * + * Called after the links or the audience move, and outside the engine + * lock: adapting the source and setting sender parameters both wait on + * libwebrtc's threads, and nothing that waits on them may hold the lock + * (see [reconcile]). A sender added later is capped as it is added. + */ + private fun applyRung() { + val peers = links.devices.count { runCatching { audience(it) }.getOrDefault(false) } + val next = VideoLadder.rungFor(peers) + if (next == rung) return + rung = next + Log.i("KithMootMedia", "camera rung peers=$peers ${next.width}x${next.height}@${next.fps} maxBitrateBps=${next.maxBitrateBps}") + localMedia.adaptCamera(next) + for (link in links.values()) link.capCamera(next.maxBitrateBps) } private fun tracksFor(device: String, tracks: List = localMedia.tracks.value): List = @@ -460,8 +502,10 @@ class WebRtcEngine( @Volatile private var closed = false private var connection: PeerConnection? = null - private val senders = mutableMapOf() + // Read outside the engine lock by `capCamera`; written under it. + private val senders = java.util.concurrent.ConcurrentHashMap() private val received = java.util.concurrent.ConcurrentHashMap() + private var handle: WebRtcPeerConnection? = null lateinit var link: PeerLink private set @@ -499,7 +543,7 @@ class WebRtcEngine( override fun onIceCandidate(candidate: IceCandidate?) { val value = candidate ?: return - scope.launch { link.onLocalCandidate(IceCandidateData(value.sdp, value.sdpMid, value.sdpMLineIndex)) } + engineScope.launch { link.onLocalCandidate(IceCandidateData(value.sdp, value.sdpMid, value.sdpMLineIndex)) } } override fun onRenegotiationNeeded() { @@ -538,21 +582,24 @@ class WebRtcEngine( fun attach(connection: PeerConnection) { this.connection = connection + val handle = WebRtcPeerConnection( + connection, + ::refreshRemoteTracks, + // Profile 1 adds and removes senders on the connection + // directly, so this map is the only thing that can say + // whether a repeated offer would be answered the same way. + localMedia = { runCatching { senders.keys.toSet() }.getOrNull() }, + cameraBitrate = { rung.maxBitrateBps }, + ) + this.handle = handle link = PeerLink( localDevice = session.identity.devicePubkey, remoteDevice = device, - connection = WebRtcPeerConnection( - connection, - ::refreshRemoteTracks, - // Profile 1 adds and removes senders on the connection - // directly, so this map is the only thing that can say - // whether a repeated offer would be answered the same way. - localMedia = { runCatching { senders.keys.toSet() }.getOrNull() }, - ), + connection = handle, roomId = session.room.roomId, send = ::sendEnvelope, callProfile = if (profileTwo) CALL_PROFILE_2 else 1, - scope = scope, + scope = engineScope, onRebuild = { generation, open -> rebuildLink(device, generation, open) }, onDowngrade = { // The far end reloaded into a build that does not speak @@ -689,7 +736,25 @@ class WebRtcEngine( // catches up. runCatching { connection.addTrack(track.track, listOf(STREAM_ID)) } .getOrNull() - ?.let { senders[track.trackId] = it } + ?.let { sender -> + senders[track.trackId] = sender + if (track.role == Roles.CAMERA) capSender(sender, rung.maxBitrateBps) + } + } + + /** + * Bound what this pair's camera sender may spend; see [VideoLadder]. + * + * A profile-1 pair's senders are the ones `addLocalTrack` kept; a + * profile-2 pair's live in its slots, which the handle reads. + */ + fun capCamera(maxBitrateBps: Int) { + if (closed) return + if (profileTwo) { + handle?.capCameraSenders(maxBitrateBps) + return + } + for ((id, sender) in senders) if (isCameraTrackId(id)) capSender(sender, maxBitrateBps) } /** @@ -707,7 +772,7 @@ class WebRtcEngine( fun syncLocalTracks(tracks: List) { if (profileTwo) { if (!::link.isInitialized) return - scope.launch { runCatching { link.applyTracks(tracks.map { it.slot() }) } } + engineScope.launch { runCatching { link.applyTracks(tracks.map { it.slot() }) } } return } val wanted = tracks.associateBy { it.trackId } diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcPeerConnection.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcPeerConnection.kt index c35070e..668f011 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcPeerConnection.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/media/WebRtcPeerConnection.kt @@ -7,6 +7,7 @@ import org.webrtc.PeerConnection import org.webrtc.RtpTransceiver import org.webrtc.SdpObserver import org.webrtc.SessionDescription +import org.webrtc.VideoTrack import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -32,6 +33,8 @@ class WebRtcPeerConnection( * being told again, expensively. */ private val localMedia: () -> Set? = { null }, + /** The camera sender's bitrate ceiling at the moment a slot binds it; 0 for none. See [VideoLadder]. */ + private val cameraBitrate: () -> Int = { 0 }, ) : PeerConnectionHandle { override fun localMedia(): Set? = runCatching { localMedia.invoke() }.getOrNull() @@ -98,7 +101,26 @@ class WebRtcPeerConnection( if (media != null && media !is MediaStreamTrack) return false // `takeOwnership = false`: the track belongs to LocalMedia and outlives // any one connection, so the sender must not dispose it. - return withTransceiver(mid) { it.sender.setTrack(media as MediaStreamTrack?, false) } + return withTransceiver(mid) { + val bound = it.sender.setTrack(media as MediaStreamTrack?, false) + if (bound && media is VideoTrack && isCameraTrackId(media.id())) capSender(it.sender, cameraBitrate()) + bound + } + } + + /** + * Bound every camera sender on this connection; see [VideoLadder]. + * + * The transceivers are read once and never held, for the reason + * [withTransceiver] gives. + */ + fun capCameraSenders(maxBitrateBps: Int) { + runCatching { + for (transceiver in connection.transceivers) { + val track = transceiver.sender.track() ?: continue + if (track is VideoTrack && isCameraTrackId(track.id())) capSender(transceiver.sender, maxBitrateBps) + } + } } /** diff --git a/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt b/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt index a76915d..ab4b9e9 100644 --- a/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt +++ b/app/src/main/kotlin/dev/forgesworn/kithmoot/session/RoomSession.kt @@ -483,9 +483,18 @@ class RoomSession( */ fun calls(): List = callsOf(_participants.value) + /** + * What this device is publishing, for the roster. + * + * Best-effort like a heartbeat, not fail-closed like a chat message: the + * engine calls this on every local track change, and a camera toggle + * does not stop for a secure update. While the gate is shut the set is + * kept and nothing is published; the successor epoch's first + * announcement (`applyEpoch`) and every heartbeat after it carry it. + */ fun setTracks(tracks: List) { synchronized(lock) { this.tracks = tracks } - announce() + announceIfPublishing() } /** diff --git a/app/src/test/kotlin/dev/forgesworn/kithmoot/media/VideoLadderTest.kt b/app/src/test/kotlin/dev/forgesworn/kithmoot/media/VideoLadderTest.kt new file mode 100644 index 0000000..ff1bdc1 --- /dev/null +++ b/app/src/test/kotlin/dev/forgesworn/kithmoot/media/VideoLadderTest.kt @@ -0,0 +1,37 @@ +package dev.forgesworn.kithmoot.media + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VideoLadderTest { + @Test fun `a one-to-one call keeps the camera as captured`() { + assertEquals(VideoLadder.FULL, VideoLadder.rungFor(0)) + assertEquals(VideoLadder.FULL, VideoLadder.rungFor(1)) + assertEquals(VideoRung(1280, 720, 30, 1_200_000), VideoLadder.FULL) + } + + @Test fun `a small group steps down once and a larger one again`() { + assertEquals(VideoLadder.MEDIUM, VideoLadder.rungFor(2)) + assertEquals(VideoLadder.MEDIUM, VideoLadder.rungFor(3)) + assertEquals(VideoLadder.SMALL, VideoLadder.rungFor(4)) + assertEquals(VideoLadder.SMALL, VideoLadder.rungFor(12)) + } + + @Test fun `every rung costs less than the one above it`() { + val rungs = listOf(VideoLadder.FULL, VideoLadder.MEDIUM, VideoLadder.SMALL) + for ((above, below) in rungs.zipWithNext()) { + assertTrue(below.width * below.height < above.width * above.height) + assertTrue(below.fps <= above.fps) + assertTrue(below.maxBitrateBps < above.maxBitrateBps) + } + } + + @Test fun `only the camera is capped`() { + assertTrue(isCameraTrackId("camera-1f2e3d")) + assertFalse(isCameraTrackId("screen-1f2e3d")) + assertFalse(isCameraTrackId("mic-1f2e3d")) + assertFalse(isCameraTrackId("camera")) + } +} diff --git a/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt b/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt index 8dfa7a6..a78b531 100644 --- a/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt +++ b/app/src/test/kotlin/dev/forgesworn/kithmoot/session/RoomEpochTransitionTest.kt @@ -7,6 +7,8 @@ import dev.forgesworn.kithmoot.protocol.decodeRekeyEvent import dev.forgesworn.kithmoot.protocol.deriveEpoch import dev.forgesworn.kithmoot.protocol.encodeRekeyEvent import dev.forgesworn.kithmoot.protocol.encodeRosterEvent +import dev.forgesworn.kithmoot.protocol.TrackRef +import dev.forgesworn.kithmoot.protocol.decodeRosterEvent import dev.forgesworn.kithmoot.protocol.RosterEntry import dev.forgesworn.kithmoot.protocol.decodeEpochRequest import dev.forgesworn.kithmoot.protocol.encodeEpochGrant @@ -16,11 +18,13 @@ import dev.forgesworn.kithmoot.support.FakeRelay import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -128,6 +132,71 @@ class RoomEpochTransitionTest { assertEquals(before, relay.countFrom(identity.devicePubkey, KIND_ROSTER)) } + @Test fun `a track change while a secure update blocks traffic is kept and not fatal`() = runTest { + val stable = Fixtures.room() + val identity = Fixtures.primary(stable, 1, 2) + val relay = FakeRelay() + val live = session( + stable, identity, relay, authority = authority, + epochGate = { _, _ -> EpochGateResult.PENDING }, + ) + live.join() + runCurrent() + val before = relay.countFrom(identity.devicePubkey, KIND_ROSTER) + val current = deriveEpoch(RoomEpoch(0, ByteArray(32) { 7 })) + relay.publish( + encodeRekeyEvent( + stable.roomId, authoritySecret, current, RoomEpoch(1, ByteArray(32) { 45 }), + listOf(identity.devicePubkey), emptyList(), 1, + ), + ) + runCurrent() + assertTrue(relay.publicationBlocked) + // The engine calls this on every local track change, gate or no gate: + // a camera toggle during a secure update is a kept fact, not a crash. + live.setTracks(listOf(TrackRef("camera-1", Roles.CAMERA))) + assertEquals(before, relay.countFrom(identity.devicePubkey, KIND_ROSTER)) + } + + @Test fun `a track change during a committed rekey is announced under the successor`() = runTest { + val stable = Fixtures.room() + val identity = Fixtures.primary(stable, 1, 2) + val relay = FakeRelay() + val tracks = listOf(TrackRef("camera-1", Roles.CAMERA)) + lateinit var live: RoomSession + live = session( + stable, identity, relay, authority = authority, + epochGate = { _, _ -> EpochGateResult.COMMITTED }, + onEpochApplied = { _, _ -> + // Between the old epoch and the new: the gate is shut. + assertTrue(relay.publicationBlocked) + live.setTracks(tracks) + }, + ) + live.join() + runCurrent() + val current = deriveEpoch(RoomEpoch(0, ByteArray(32) { 7 })) + val next = RoomEpoch(1, ByteArray(32) { 44 }) + relay.publish( + encodeRekeyEvent( + stable.roomId, authoritySecret, current, next, listOf(identity.devicePubkey), emptyList(), 1, + recipientNonces = mapOf(identity.devicePubkey to ByteArray(32) { 3 }), + bodyNonce = ByteArray(32) { 4 }, auxRand = ByteArray(32) { 5 }, + ), + ) + runCurrent() + + val successor = deriveEpoch(next) + assertEquals(successor.id, live.epochKeys().id) + val announced = relay.published.last { it.kind == KIND_ROSTER } + assertEquals(successor.id, announced.tagValue("d")) + // Successor traffic is keyed by the epoch id; the credential still names the room. + val entry = assertNotNull( + decodeRosterEvent(announced, successor.id, successor.key, currentTime / 1000, credentialRoomId = stable.roomId), + ) + assertEquals(tracks, entry.tracks) + } + @Test fun `a committed removal is terminal and never reveals or enters the successor`() = runTest { val stable = Fixtures.room() val identity = Fixtures.primary(stable, 1, 2) diff --git a/docs/android-release.md b/docs/android-release.md index ac2027e..7de9230 100644 --- a/docs/android-release.md +++ b/docs/android-release.md @@ -2,6 +2,37 @@ The public website currently offers production-signed 0.6.7 (30), Android 13 or later. Signing, publication and physical acceptance are recorded separately. +## 0.6.11 the secure-update crash and the camera ladder + +Version code 34 fixes a process crash and puts a ceiling on what the camera +costs in a group call. No wire change: web and desktop peers need nothing. + +The crash: a local track change (a camera or microphone toggle, a share +starting or stopping, or the engine's first read of them) while the room was +between epochs, which is every link rotation, every removal and every rejoin, +called the fail-closed `announce()` and the `IllegalStateException` ("Room +publication is blocked during a secure update") ended the process. CI's +`recovery-emulator` lane hit it on `main` after 0.6.10 in +`PersistentGroupUiTest.a_create_and_join_web_group`. `RoomSession.setTracks` +is now best-effort like a heartbeat: the set is kept while the gate is shut +and goes out in the successor epoch's first announcement, which +`RoomEpochTransitionTest` pins both ways. The engine's jobs also now run +under a supervisor with a handler, so a media job that throws is a +`KithMootMedia` log line and not the end of the app. + +The heat: every remote device on a call has its own peer connection and its +own encoder, and each encoded the full 1280 by 720 at 30 with no bitrate +ceiling. `VideoLadder` fits the one camera source to the number of devices +it goes to (one: 720p at 30; two or three: 540p at 24; four or more: 360p at +15) with one downscale at the source, and caps each camera sender's bitrate +(1.2 Mbps, 800 kbps, 500 kbps). Screen shares are untouched. The rung moves as +links open and close and as the audience rule changes; a sender added later is +capped as it is added, on both the add-a-track path and the profile-2 slots. +`VideoLadderTest` pins the steps. Not measured on a handset yet: the check is +a ten-minute three-way video call on the Pixel with `dumpsys thermalservice` +sampled every two minutes, and the far end's received size in +`chrome://webrtc-internals`. + ## 0.6.10 the freeze on calls Published on 22 September 2026 from `f4a1efa`: owner-signed on the M4 with the production key and lineage (v3 only), APK SHA-256 `725266089a7b32b87870e724e41afc24591c3dfe981adf80688f694aa50c3ad8`, certificate `135bcabf…`, lineage `0ccf5ece…`. Passed the web repository's publication verifier, installed in place over 0.6.9 on the owner's Pixel 10 Pro XL (version 33 reported, first-install time preserved, launched in about a second, no crash), offered as GitHub pre-release `v0.6.10`, and on the website once kithmoot's release PR merges. A live multi-person call on this build is the check that remains.