# Experimental, alternative and optional behaviour: the switch register Reference list of every switch that is off by default, exists for comparison, or can be changed from Settings or a DEBUG launch extra; [architecture.md](architecture.md#switches-and-experiments) explains how they fit together, and [decisions](decisions/README.md) holds the evidence. This is the single register of switches that are off by default or exist for comparison. The rule behind it (design rule, 2026-10-08/09): an experiment, or the losing arm of an A/B comparison, is kept, off by default and labelled Experimental, unless it is impossible by first principles. [decisions](decisions/README.md) records the evidence for each. How to read it: - DEBUG switches are launch extras named `test_*`; benchmark tunables are in square brackets. Release builds ignore them unless an entry names a Settings switch: a release launch is never a benchmark launch, whatever extras another app sends (fixed 2026-10-27; before, any `test_` extra skipped onboarding, the go-live disclosure and the End-live confirmation in a release build, and three GATT PHY arms below were release-reachable). - "Control arm" means the default is the new behaviour and the switch restores the old one exactly, so an A/B can run in either direction. - Phones are named by alias: S24, S22, M31, ASUS, Xiaomi, A55 (the reference set) and A15s. "Untested on phones" means the entry has unit tests but no measured phone run yet; an entry that cites numbers was measured. - Entries marked with a Settings name appear in Settings; the row gives the path (several sit under Experiments, Privacy > Advanced privacy or Advanced > Connection). Promoted features leave the list of experiments. Each has one default constant (`CallFeatureDefaults`, `ConnectivityFeatureDefaults`; privacy features are on by default for user sessions since 0.2.0-beta) and, where it still has one, a switch in Settings: the connectivity rows sit under Advanced > Connection, "Hide my ID from strangers" and "New identity for each stranger" under Privacy > Advanced privacy, Messages and automatic sync under Experiments. Voice calls, ringback, camera pause at the ear, lock-screen calls, call notifications, names and titles and audio-only live are simply on (no switch since 2026-10-17). `FeatureCatalog` (pure Kotlin) holds each feature's id, preference key, default, benchmark extra and texts, and the Settings cards are generated from it. An untouched switch follows the constant, an explicit choice sticks, and a DEBUG benchmark launch (any `test_*` extra) reads only its extras, default off, so case IDs stay valid and every release-ON feature runs OFF there unless its extra is passed. **Requires / masked by.** An arm can be inert because a parent switch is off or because another switch already decides the same thing; rows of such arms carry a "Requires / masked by" note, so an A/B is not read from an arm that cannot act. Rows marked **Not wired** name a switch or helper that nothing in the app calls yet; they stay listed (no experiment is deleted) and do nothing. ## Streaming: rate control, queues, playout and bounded age | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Temporal layering (`test_temporal_layers`; Advanced setting) [temporal_layers] | Off | Drops discardable enhancement frames under congestion. Superseded by the no-loss default (2026-10-07); it cost about 2.5 VMAF on a calm link. | | Relay child thinning (DEBUG `test_relay_child_thinning`) [relay_child_thinning] | Off (Experimental) | From the adaptive-scaling review (P2). The source encodes two temporal layers (libaom always; MediaCodec AV1; MediaCodec VP9 only with `test_hardware_temporal_layers`), and every frame of the second layer is marked discardable only when its bitstream proves nothing references it. Each relay then decides per direct child, from that child's queue age, delivery rate and late-media report, between all frames, only the base layer (half the frame rate, no re-encode, no keyframe) and audio only (video paused for that child, audio, configuration and lifecycle records still flow; the child shows "Video paused: weak link"). It steps down at once on a severe backlog and otherwise after a hold, and probes one level back up only after a calm hold that doubles after each failed probe (8 s to 60 s). Resuming from audio only waits for a keyframe, requested with the bounded-age back-off. Hop-local; no wire change (whole records are dropped and the end-to-end sequence check already accepts gaps). The source pays the layered encoding's cost (about 2.5 VMAF at equal bitrate) for the whole stream, because it cannot know about a weak link two hops away. Off: no layer control reaches the encoder, nothing is dropped for a layer and no notice is shown. Phone-measured on relay lanes: with thinning on the frozen share halved, but the layer controller never left level=full, so the stepping itself has not run on a phone (cause unclear; not functional there). Needs a relay phone test in which thinning engages (DECISIONS "Design RT"). **Requires / masked by:** the audio-only level can trip the child's own parent-link trickle close (that close is gated by `relay_child_reanchor`, and the weak-link pause is not passed to it), which would loop; latent, because thinning never left level=full on phones (DECISIONS "Known interactions", B10). | | Bounded media age (`test_bounded_media_age`) [bounded_media_age] | Off | Calls keep speech within about 400 ms by degrading video (rate, AV1 non-reference drops, skip to keyframe, audio-only pause). Streams shed beyond about 2 s. It is under A/B against no-loss; the loser stays here. | | Relay-child media age (Advanced "Relay: slow viewers skip to keyframes"; `test_relay_child_media_age=false` to disable) [relay_child_media_age] | On (design decision 2026-10-09) | Applies the bounded-age shedding to a forwarding viewer's child queue only. A child slower than the stream gets skip-to-keyframe video (audio kept) on its own branch instead of being withdrawn and rerouting to the source, which had halved the source's other child in a three-phone test. Its recovery keyframe requests back off from 1.5 s to one per 15 s while shedding recurs (reset after a calm minute), because each one adds a keyframe on every branch. Off: no-loss with withdrawal. Calls and direct senders stay no-loss. Needs a relay A/B. **Requires / masked by:** `=false` has no effect while `bounded_media_age` is on, which already sheds the relay child queue. In user launches the stored choice counts with or without Manual video settings (until the 2026-10-27 switch audit, B14, a stored OFF reverted to ON when Manual video settings was turned off). | | Link pacing off (`test_link_pacing=false`) [link_pacing] | Pacing on | Control arm for the write pacer (a GATT double-charge in the pacer was fixed 2026-10-09). **Requires / masked by:** this is the parent of the pacer arms: with pacing off the pacer seed, per-peer seed, join probe, seed raise, drain guard, relay head-only exemption and lead cap all do nothing. | | Link pacer seed off (`test_link_pacer_seed=false`) [link_pacer_seed] | Seed on | Control arm. A child link is paced from join at the learned single-link capacity (else 200-400 kbps by transport); the seed halves every 2 s after 4 s without an arrival report (floor 150 kbps), so a link whose reports never arrive shows its backlog in the app queue rather than the Android stack. Needs link pacing. **Requires / masked by:** `=false` also disables the per-peer seed, join probe, seed raise and drain guard. | | Per-peer pacer seed (`test_link_pacer_peer_seed`) [link_pacer_peer_seed] | Off | A child link's pacer starts from that child's own last arrival report for this link (24 h, per transport and radio state) instead of this phone's best single-link probe, which seeded a ~65 kbps ASUS -> A15s link at about 1 Mbps. Phone-measured with no effect (an overnight A55 <-> M31 A/B: equal to the control), so it stays a non-default arm. **Requires / masked by:** link pacing and the pacer seed (`=false` of either disables it). | | Early fan-out release off (`test_fanout_early_release=false`) [fanout_early_release] | Early release on | Control arm. A fan-out child whose backlog grew by 500 ms over 10 consecutive windows (at least 1.5 s behind, receiving less than offered, no keyframe drain) while the encoder is at its rate floor and a sibling is healthy is released, instead of after 24 of 30 s. | | Delivery reports off (`test_delivery_reports=false`) [delivery_reports] | Reports on | Control arm restoring the behaviour from before 2026-10-09, except that it also forces the learned call start (`start_rate_guard`) off, so it is no longer an exact control: no call hello or version 3 report, cuts use the old references (0.85 x min(bytes the stack accepted, arrival trains)), and every cut is remembered as the peer's start rate again. A/B arms also pass `test_reset_learned_rates` [reset_learned_rates], which clears the 24 h learned start rates at launch; every launch logs them (`BENCHMARK learned_rates`). | | Delivery-based probing (`test_delivery_rate_control`) [delivery_rate_control] | Off | On links whose peer reports delivery (link report version 3): the climb holds at 95% of what the link delivered at its last overuse and probes 10% past it, undoing a probe as soon as the measured queue grows; failed probes back off (10 to 30 s); a measured queue of 300 ms that is not draining is overuse; decreases may be 1 s apart. Off keeps the default: calls exchange version 3 and cut to 0.85 x the delivered rate without cascading. **Requires / masked by:** no effect on streams without `stream_delivery_reports` at both ends. | | On-time reports off (`test_on_time_reports=false`) [on_time_reports] | Reports on | Control arm (2026-10-16). A stream viewer that has not measured its parent link yet (no record large enough to time) reports every 2 s that media still arrives on time, and the parent or relay keeps its starting pace while those reports show no lateness. Off restores the earlier behaviour: the viewer stays silent until its delay moves and the parent halves its pace after 4 s of silence down to 150 kbps, which starved low-rate streams (640x360 at 500 kbps or less fell to about 157 kbps). The reports use the existing link report format, so phones on older builds ignore them. | | On-time seed raise off (`test_on_time_seed_raise=false`) [on_time_seed_raise] | Raise on | Control arm (promoted 2026-10-16, design decision). While a viewer reports media on time but has not measured its link yet, and the sender's own queue is at least 0.5 s old, the sender (or relay) raises its starting pace by 25% per report, up to 1500 kbps, instead of holding it. Reason: a phone with no measured link starts from a cautious 350 kbps over GATT, below a 400 kbps stream plus audio, so holding that pace grew the queue for the whole stream. A link that cannot carry the higher pace makes the viewer report late, which stops the raise and lets the pace fall as before. Off restores the held seed. Planned comparison: a join-time link measurement that seeds fresh links directly (PLAN). | | Join probe seed (`test_join_probe_seed=true`) [join_probe_seed] | Off | Experimental (2026-10-17, option C of the starting-pace decision). Instead of starting a fresh stream link from the cautious prior and climbing with the on-time raise, the sender (and a relay's child link) lets its writer run unpaced for a bounded window at link start (1.5 s or 32 KiB, audio included), the viewer times the records that arrive back to back in its first 3 s as one burst (even when each record is too small to be a train) and reports it in the ordinary link report, and the first rate that arrives within 3 s of link start seeds the pace. A sender whose own queue held at least 0.5 s took the burst as the link's rate and measures it like any report; a sender that kept up only raises its seed to the burst rate when that is higher (never lower), so the on-time raise and the silence decay still apply, and the burst is not stored as a capacity. With no report inside 3 s the prior stands (`fallback=true`). Hop-local, no wire change: an older or switched-off viewer never reports a burst, so the prior stands; both ends should run the switch for a seed to arrive. Logs `BENCHMARK join_probe_seed: role=stream_tx\|relay_tx \| peer \| measuredKbps \| seedKbps \| probeMs \| fallback \| linkBound`. | | Seed raise drain guard (`test_seed_raise_drain_guard=true`) [seed_raise_drain_guard] | Off | Experimental (2026-10-17). The on-time seed raise only helps while the pace, not the link, is the limit. With this on, a raise is skipped when the sender's queue age has not fallen by 100 ms since the previous report and its writer spent at least 40% of that time inside blocking write calls, more than the pacer held it (a link-limited writer; a pacer-limited writer sleeps in the pacer, so the raise continues). The seed is still held, not decayed. Only GATT writes block visibly, so a CoC or Classic link keeps the raise as it was. Logs `BENCHMARK pacer_seed_raise_blocked`. Off is the raise exactly as before. **Requires / masked by:** `on_time_seed_raise`, `on_time_reports`, link pacing and the pacer seed. Shed-aware since F4 (DECISIONS "Known interactions", B3): a fall in queue age across two reports that also saw the queue drop media (a bounded-age or relay-child shed, video dropped while its recovery keyframe is awaited, thinning) is not counted as draining; `pacer_seed_raise_blocked` gains `shed=true|false`. One seed rule for the sender and the relay (PacerSeedPolicy, `BENCHMARK pacer_queue_config`). | | Relay establishment keeps 2M (`test_relay_establish_keep_2m=true`) [relay_establish_keep_2m] | Off | Experimental (2026-10-16). Today one failed 2M connection to a child pins that child to 1M for the rest of the stream, even when the connection never came up (the stack reported a connect failure, no link existed). Connection setup runs on the advertising PHY, so 1M cannot fix that failure, and 1M halves what the relay can send to the child. With this on, a relay child setup that failed before any link to the child existed keeps asking for 2M on the next try (`BENCHMARK link_phy: ... reason=establishment_failed \| next=2M`); a failure after the link existed, while the 2M PHY update was requested or in flight, still pins 1M as before. Off is the old rule exactly. Calls are not changed. | | Relay child guard against own baseline (`test_receiver_node_baseline_p95=true`) [receiver_node_baseline_p95] | Off | Experimental (2026-10-17). A viewer that forwards a stream detaches its child when its own playback becomes unstable. Today the limit is 1.75 frame periods or 1.35x the viewer's typical frame time before the child joined; with this switch it also allows 1.35x the viewer's own pre-child worst-case (p95) frame time, so a relay that was already a little uneven is not mistaken for one the child overloaded. A real regression still detaches the child. | | Stream delivery reports (`test_stream_delivery_reports`) [stream_delivery_reports] | Off | Stream viewers report delivery (version 3) every second and the stream sender uses it under its own switch. Every phone in the stream must run the same build (an older parent closes the link on version 3). Reports sent only for delivery carry the delivery-only flag, so the parent's lateness trend sees full reports exactly as before. | | Learned-limit recovery (`test_ceiling_probe_recovery=true`) [ceiling_probe_recovery] | Off | Experimental. A fresh relay or fan-out ceiling may admit one link beyond it after a 2 min cool-down (doubling per failed probe, at most 64 min); healthy traffic above it clears it, a new saturation re-records it. Bad-2M marks age out after 24 h unless re-confirmed (default 7 days). Reason: learned limits outlive the runs that taught them; stale M31 relay (450) and GATT fan-out (315) ceilings and a bad-2M mark refused four viewers for up to 6 h. `test_reset_learned_state=true` (DEBUG) clears ceilings, sustained rates and bad-2M marks; every launch logs `BENCHMARK learned_state`. | | Encoder bitrate adjuster off (`test_encoder_bitrate_adjuster=false`) [encoder_bitrate_adjuster] | Adjuster on | Control arm. On: a port of WebRTC's DynamicBitrateAdjuster (`EncoderBitrateAdjuster`) scales the bitrate requested from a MediaCodec encoder under Automatic by 0.25-1.0x from its measured output (judged every 3 s against one second of target bytes; never above the rate controller's target; no reduction while most frames sit at the VP9 maximum quantizer), because the M31 OMX.Exynos.VP9 produced 720 kbps for a 401 kbps target. No model matching. Logs `BENCHMARK bitrate_adjuster` at most once per second. Replaces the output-rate trim for MediaCodec encoders while on. Untested on phones. | | Encoder frame-rate adjuster (`test_encoder_framerate_adjuster=true`) [encoder_framerate_adjuster] | Off (Experimental) | A/B arm. Applies WebRTC's FramerateBitrateAdjuster within WebRTC's own scope (OMX.Exynos. VP9/H.264). The codec keeps its configured FPS and is asked for target x configured / delivered FPS, where delivered FPS is the number of encoded frames in the last second of output timestamps; re-applied at most once per second; the measured adjuster is skipped, which silently re-enables the output-rate trim (the trim is normally off while the bitrate adjuster runs), so this arm is "frame-rate adjuster plus trim". Kept as an arm, not the default, because it corrects undershoot after FPS drops while the M31 OMX.Exynos.VP9 overshoots at a fixed, capped FPS. Untested on phones. | | Encoder rate trim off (`test_encoder_rate_trim=false`) [encoder_rate_trim] | Trim on | Control arm. While the bitrate adjuster is on, the trim acts only for native libaom. It cannot act at the maximum quantizer. | | Keyframe shaping (`test_keyframe_shaping`) [keyframe_shaping] | Off | Lowers a hardware encoder's target for one app-requested keyframe. Not promoted: scene-cut bursts are not keyframes. | | Forced bitrate mode (`test_bitrate_mode` 0/1/2) [bitrate_mode] | Encoder's resolved mode | An M31 CBR test (-4 to 9% burst). CQ is rejected by the OMX encoder. | | Hardware QP bounds (`test_hw_qp_min/max`) [hw_qp_min/max] | None | Ignored by the ACodec/OMX path (M31). Kept for Codec2 encoders. | | Learned call start off (`test_start_rate_guard=false`) [start_rate_guard] | Guard on | Control arm. A call with no learned rate starts at a share of the measured link or 400 kbps instead of the request; learned sustained rates survive app updates (format 2, 24 h) and the start applies before the first frame. Off: build-keyed rates, and an unmeasured call starts at the request. Read when a call or stream starts. **Requires / masked by:** `test_delivery_reports=false` also turns it off, so that control arm is not exact. | | Burst-aware drain (`test_burst_drain`) [burst_drain] | Off (Experimental) | A child's lateness that follows a large frame while the link now carries everything offered is attributed to that one burst instead of congestion, and large-frame grace lasts while the backlog keeps shrinking. Reason: a single 72 KB scene cut cut M31 -> ASUS GATT 300 to 206 kbps for 12 s with an empty sender queue. DEBUG only. | | One-step playout growth (`test_playout_burst_growth=<1.0..4.0>`) [playout_burst_growth] | Off (Experimental; unset keeps the staircase) | Media later than the burst-lateness threshold grows the adaptive playout delay by this multiple of its lateness at once, instead of lateness + 50 ms per late frame (a staircase of separate holds during one burst). Costs a longer first hold and more held latency. Streams only: call clocks keep the staircase (since 2026-10-27; before, the arm also changed calls). DEBUG only. | | Scene-cut shaping (`test_scene_cut_shaping=0\|1\|2`) [scene_cut_shaping] | Off (Experimental) | A raw-frame luma detector finds a hard cut before the frame reaches a MediaCodec encoder; 1 requests that frame as a sync frame at a lowered target, 2 only lowers the target, and the target is restored once the frame is encoded. Only raw-fixture and ByteBuffer camera input pass the app; live Camera2 surface input does not. Off requests nothing; benchmark traces still log the detector's decisions (`BENCHMARK scene_cut`). DEBUG only. | | VP9 quantizer headroom (`test_vp9_q_floor=<1..254>`) [vp9_q_floor] | Off (Experimental) | While the parsed `base_q_idx` stays below the floor the encoder target is lowered, so a scene cut is not coded at a stale low quantizer (`QuantizerHeadroomGovernor`). Unset leaves the target alone. DEBUG only. | | Encoder ranking by measured behaviour (`test_encoder_ranking`) [encoder_ranking] | Off (Experimental; design decision Q3) | An encoder whose recorded sessions kept scene-cut frames within about 0.5 s of its own stream (and, for software, encode time within budget) ranks above one measured to burst, for example the M31's libaom AV1 (26 KB cuts) above its OMX VP9 (135 KB). Off: the static hardware-first order. Measurements are recorded in both arms. DEBUG only. | | Prefer hardware AV1 (`test_prefer_hw_av1=false` is the control arm) [prefer_hw_av1] | On (design decision 2026-10-27, release 0.4.1; no A/B required) | Automatic sends with a MediaCodec AV1 encoder that reports `isHardwareAccelerated` whenever the phone has one: first for streams (a viewer that cannot decode AV1 is refused at join, as for any stream codec) and for calls when the peer advertises AV1 decode without the legacy VP9 preference (otherwise VP9). The bundled libaom and Android software AV1 are never promoted by this rule; a component named by a `NoAutomaticHardwareAv1` policy entry (none yet) is skipped. Manual and `test_codec` choices, family VP9 call profiles and thermal policy are unchanged. Off: hardware AV1 ranks after the other hardware encoders (hardware VP9 first). Each call/stream logs `BENCHMARK encoder_choice` with its reason. DEBUG only; release always prefers it. | | HEVC and hardware H.264 codecs, Design HC (masters `test_hevc_codec`, `test_h264_codec`; shipped constants `ConnectivityFeatureDefaults.HEVC_CODEC`, `H264_CODEC`) [hevc_codec, h264_codec] | On (design decision 2026-10-28, release 0.4.2, after the phone codec smoke and heat runs; a benchmark launch without the extra keeps them off) | Adds `BTV3` codecs 5 (HEVC) and 4 (H.264) with their hello and announcement bits (PROTOCOL "Draft: HEVC and H.264 video codecs"). Platform encoders and decoders only; Automatic uses hardware encoders only (software HEVC/H.264 appear only in the manual picker). The receiver checks every access unit with a bounded structural gate before its decoder; the sender prepends the parameter sets to each IDR and sends no config packet. Off: no HEVC or H.264 bit, picker entry or log; every byte as today. DEBUG `test_hc_vectors=true` saves short access-unit sequences from the phone's HC encoder for the Windows and Apple fixtures. | | Prefer hardware HEVC / H.264 (`test_prefer_hevc=false`, `test_prefer_hw_avc=false` are the control arms) [prefer_hevc, prefer_hw_avc] | On whenever a master is on (masters on since 0.4.2) | Automatic order hardware AV1, HEVC, VP9, then H.264, before any software encoder; streams follow open question Q-HC1 (DECISIONS "Design HC"): VP9 ahead of HEVC. Off: that codec stays advertised and selectable by hand, but Automatic never picks it (the old order). `test_av1_soft_decode_bit=false` stops end-to-end decode-mask bit 4 ("my AV1 decoding is software"). A family `CallVp9Compatibility` entry (ASUS `qcom-msmnile-call`) keeps the ASUS's own calls on VP9. A peer's "prefers VP9" bit 7 puts VP9 first except below a hardware HEVC encoder the peer decodes; `test_bit7_hw_hevc=false` is the control arm (VP9 first even over hardware HEVC, the first draft's rule). HEVC and H.264 encoders request the lowest standard level the session needs (never above what they list); `test_hc_session_level=false` is the control arm (the highest listed level, which made Exynos and Qualcomm HEVC streams signal level 6.0). DEBUG only; Apple `-NearbyPreferHEVC 0` / `-NearbyPreferAVC 0`. | | Bundled libvpx VP9 encoder in Automatic, Design VX (`test_prefer_libvpx=true|false`; shipped constant `ConnectivityFeatureDefaults.LIBVPX_AUTOMATIC`) [prefer_libvpx] | On (design decision 2026-10-28, release 0.4.2; a benchmark launch without the extra keeps it off) | The app carries its own VP9 encoder (libvpx, built from a pinned source) for phones without a hardware AV1, HEVC or VP9 encoder. Off: it is a manual choice only ("VP9 - optimized software", last in the picker); no Automatic, call-fallback or family-policy path picks it, and every other order is unchanged. On: calls use hardware AV1, HEVC and VP9, then libvpx VP9, then hardware H.264, then libaom AV1 and Android's software encoders; streams (Q-HC1 A) use hardware AV1, VP9 and HEVC, then libvpx VP9, then hardware H.264, then the rest: libvpx stays below every hardware AV1, VP9 and HEVC encoder because it costs about 4x their CPU. libaom stays the last resort, used only when nothing above it starts. Measured before turning it on: about the same quality per bit as hardware HEVC and better than hardware H.264, at several times the CPU of a hardware encoder (DECISIONS "Automatic codec order after the 2026-10-28 phone measurements"); see `codec_power_aware` for battery saver and warm phones. A phone with it advertises VP9 encoding in both arms, so a hand-picked libvpx stream is accepted. Its experimental temporal layering is not available (AV1 only). | | Power-aware codec start (`test_codec_power_aware=false` is the control arm; shipped constant `ConnectivityFeatureDefaults.CODEC_POWER_AWARE`) [codec_power_aware] | On (design decision 2026-10-28, release 0.4.2; a benchmark launch without the extra keeps it off) | When battery saver is on (`PowerManager.isPowerSaveMode`) or the phone is already warm (thermal status LIGHT or above, read with the same battery fallback and forecast inputs as the thermal adaptation) as a call or stream starts, Automatic moves the phone's usable hardware encoders ahead of a software first choice, for example hardware H.264 instead of libvpx VP9 on a phone without hardware VP9 or HEVC. Only phones whose Automatic choice would otherwise be a software encoder change; it is decided once at session start, never during a call or stream; a manual choice, a family VP9 rule and a peer's "prefers VP9" bit are never overridden. Off: the plain Automatic order. Reason: libvpx costs several times the CPU of a hardware encoder (DECISIONS "Automatic codec order after the 2026-10-28 phone measurements"). DEBUG only; release always on. | | Software AV1 decoding where the platform has no AV1 decoder, Design AD (Apple launch argument `-NearbyDav1d 0` is the control; Windows: its debug-switch mechanism) | Not built (approved 2026-10-27, Windows too by Q-AD1; on when built) | The Apple app decodes AV1 with the pinned dav1d library when VideoToolbox has no AV1 decoder (before A17 Pro and M3); the Windows app does so when no Media Foundation AV1 decoder starts. Either then advertises AV1 decode and end-to-end bit 4 ("my AV1 decoding is software"). Off: AV1 decode only where the platform decodes it, as today. No new wire format. | | Hardware intra refresh (`test_intra_refresh_frames=<2..600>`) [intra_refresh_frames] | Off (Experimental) | Sets the MediaFormat intra-refresh period in frames at configure. Whether the encoder applied it is read from its output format and logged (`BENCHMARK encoder_capabilities`). Unset leaves periodic keyframes. Also a setting of the encoder bench. DEBUG only. | | Hardware temporal layers (`test_hardware_temporal_layers`) | Off (Experimental, DEBUG) | With temporal layering on, also asks MediaCodec encoders for two temporal layers. Off: on the M31 OMX VP9 encoder the error-resilient layered stream lost about 11 VMAF at the same rate and burst harder; frames an encoder makes droppable on its own are still verified and used. **Requires / masked by:** `test_temporal_layers` (the layering switch). | | Stream buffer/flush, playback delay override, decoder low latency, release at playout, decoder choice (`test_stream_buffer_kib`, `test_stream_buffer_bytes` (exact bytes, wins over the KiB value), `test_stream_flush`, `test_playback_delay_ms`, `test_decoder_low_latency`, `test_release_video_at_playout`, `test_decoder`) | Off / defaults | I/O and playout experiments. Output holding worsened losses and freezes. | | GATT full-slot source-scan pause, output holding | Off | Unpromoted; kept for comparison (see DECISIONS). | | Viewer decoder size-change handling (`test_decoder_size_rebuild=false` to disable) [decoder_size_rebuild] | On (2026-10-13) | Control arm. On: a keyframe whose coded size (read from the VP9 keyframe header or the AV1 sequence header) or codec configuration differs from the viewer decoder's makes the viewer release its MediaCodec and create one for it (a rung change of the Auto stream ladder or a call's resolution ladder, an encoder restart with new csd); new configuration is held for that keyframe instead of being queued as a separate codec-config buffer; recovery re-queues the newest csd; `KEY_MAX_WIDTH/HEIGHT` is set where the decoder reports adaptive playback; the display follows the new size. The first decoder is configured at the size of the first keyframe, not the header's maximum. Why: the decoder was configured once at the header size, a size change was only logged, and recovery re-queued the stream-start csd (a stale AV1 sequence header after a rung change); Android documents mid-stream format changes only for adaptive-playback VP8/VP9/H.264/H.265 and not for AV1. Off restores one decoder at the header size. The sender-side keyframe marker (`FLAG_DISCONTINUITY` on the first keyframe after a size-changing restart) and the late-joiner config cache that keeps only the current encoder's csd are not switched: both only act when an encoder restarts at another size. Not yet measured on phones. | ## Viewer and Watch screen Display switches of the viewer and of a live broadcaster. Local UI unless a row says it adds a wire record. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Viewer frame age: stale-picture notice and "Last update" label (Settings > Video and calls "Warn when video freezes"; DEBUG `test_viewer_frame_age` is the benchmark switch) | Promoted 2026-10-11 (default OFF until its phone test passes; one constant, `ViewerFeatureDefaults.FRAME_AGE`) | From an external review of multi-hop media (2026-10-11): with A -> relay B -> viewer C, when B leaves and returns while A keeps sending, C must show an interruption instead of a frozen frame presented as live. Local UI only, no wire change; off, no monitor exists and the Watch screen is unchanged. A picture is stale when no new picture was presented for more than max(1.5 s, 3 frame intervals), or when the parent link was lost and no picture of a newer link has been shown yet. The 1.5 s floor sits just above the largest single Bluetooth burst the adaptive playout delay is built to absorb (0.65-1.3 s scene-cut frames on GATT), so ordinary bursts never flash a warning; three intervals only matter below 2 FPS. Stale shows a light dim over the frozen picture and a badge: "Signal lost - reconnecting" when the link was lost, "Picture stalled - waiting" when the link is up and nothing arrives, plus "Last picture N s ago"; the older centre overlay is not drawn meanwhile and its message becomes the badge's last line. The Watch status chip says "Signal lost" or "Picture stalled" instead of "Watching ...". The first picture of a newer link, or of the same link after a stall, clears everything at once. A broadcaster pause, an ended stream and the app being in the background are never stale (the stall count restarts at the resume). A small label "Last update <1 s ago" (bottom left) shows the age of the last picture. It is counted on this phone and is NOT the camera-to-screen delay: the stream carries only the sender's MediaCodec presentation timestamps (no wall-clock capture time) and no clock relation to this phone exists, so end-to-end age is unknowable here; adding one would need a clock-sync design in PROTOCOL. The clock-free part is logged: both timestamps come from the sender, so newest-received minus presented is how far the picture trails the newest video held (`behindReceivedMs`), and adding the time since that video arrived gives `frameAgeMs`, a lower bound of the real age that excludes everything the sender and relays still hold. Lines (`BENCHMARK ` prefix in DEBUG): once a second `viewer_frame_age: state=live|stale|waiting|paused|ended | cause=none|no_frames|link_lost | sinceFrameMs | frameAgeMs | behindReceivedMs | playoutDelayMs | maxGapMs | thresholdMs | staleEpisodes | recoveries | linkEpoch`; transitions `viewer_stale: event=enter|cause|clear` (clear: `reason=frame|paused|ended|reset`, `staleForMs`, `gapMs` = the whole freeze) and `viewer_link: event=start|playout_base|lost|restored`. Audio and delay on recovery were verified by reading the code, not changed: every link builds a new playout clock at the profile's base delay (`newViewerLinkPlayoutClock`), audio decoder, AudioTrack and video decoder, and closes them when the link ends, so nothing queued or learned on a lost link reaches its replacement (a source or relay also builds a new per-viewer queue for the rejoining viewer, `BluetoothVideoSender` or `RelayChildQueue`, which holds only media offered after the join); `ViewerLinkPlayoutTest` keeps that, and `viewer_link: event=playout_base` shows it per link on phones. Within one link the adaptive delay still grows on late media and shrinks after 60 s calm, by the no-loss design; a stall that keeps the link up therefore replays its backlog late, and `playoutDelayMs`/`behindReceivedMs` measure how much (the bounded-age experiment above is the separate answer). **Phone test (fold into the relay loss/rejoin lane: A -> relay B -> viewer C on CoC and GATT, plus a direct A -> C control; B leaves for 10-30 s by killing its app or switching Bluetooth off, 10 cycles; switch on at C, repeat once with it off), pass when:** (1) `viewer_stale: event=enter` logs at C within about 2 s of the last picture (`sinceFrameMs` at most threshold + 0.5 s), the badge and chip show it, and the frozen frame is never labelled Watching; (2) the cause becomes `link_lost` when C notices the loss (no later than the existing 5-8 s detection) and the badge text matches the cause; (3) the first picture after B returns, or after C re-routes to A, ends it with `event=clear | reason=frame` and a `gapMs` close to the real outage, with no stale state left while pictures keep arriving; (4) over 10 minutes of undisturbed playback on a healthy link no `event=enter` appears except where `viewer_frame_age maxGapMs` really exceeded 1,500 ms; (5) across the 10 cycles every `playout_base` equals the base delay (AUTO 250 ms, a relaying viewer at most its fixed reserve) and `playoutDelayMs` at cycle 10 is not above cycle 1's bound, with `recoveries` equal to the cycles survived; (6) the receiver audio trace shows no audio timestamp older than the first one after the rejoin and none repeated; (7) a 30 s broadcaster pause never shows stale and a stall after the resume counts from the resume; (8) with the switch off the cycle draws nothing and logs no `viewer_*` line. Not yet tested on phones; JVM tests only. | | Live media state: Muted / Camera off for viewers (Settings > Video and calls "Show Muted and Camera off to viewers"; DEBUG `test_live_media_state` is the benchmark switch) [live_media_state] | Off (until phone test T-LM1 and every phone/relay has this build) | A video broadcaster's Mute and Camera buttons always work locally (audio dropped at the microphone encoder output with the codec configuration kept; camera and encoder released, keyframe-first restart). With the switch on, each change also sends one authenticated stream-state record (values 4 to 6 of `StreamLifecycleState`, PROTOCOL "Experimental live media state") so viewers show "Muted" / "Camera off". Builds without the record drop the link on an unknown state value, which is why it ships off. Off: viewers just hear silence or see a still picture; nothing is added to the wire. | | Receiver UI stability off (`test_receiver_ui_stability=false`) [receiver_ui_stability] | Stability on | Control arm (2026-10-11, `ReceiverRecoveryNoticePolicy`, `receiverBrowserEmptyStatus`). A recovery that begins within 3 s of the previous "Recovering video" cover's release keeps that cover and label reveal through gaps under 1 s instead of switching the 70% black cover off and on (saved S22 viewer: about a dozen flashes in 30 s; 45% of gaps between recoveries are under 1 s); an isolated recovery clears the moment it ends, and the cover is never released while the decoder reports recovery. The browser line says "A stream is in range. Trying it again in about N s." while the only stream is cooling down after a failed join, instead of "No nearby streams yet". Off: the cover follows the decoder flag one to one and the old line. Lines `Receiver recovery UI: repeating episode, cover held` / `held cover released` and `BENCHMARK le_link_policy ... receiverUiStability=`. Control arm, not yet tested on phones: a viewer with induced repeated recoveries (relay loss/rejoin, relay lane) and a failed join; pass = no cover off/on pair under 1 s apart in the log, the label reveals once per burst, and no corrupted frame is visible while the cover is held. | | Watch list grouping off (`test_stream_row_grouping=false`) [stream_row_grouping] | Grouping on | Control arm (2026-10-12, `groupStreamRoutes`). Every viewer that relays a broadcast advertises it again under the same logical stream token, so a phone near the source and three relaying viewers listed one broadcast four times, each row named after the advertiser. On: one row per broadcast (logical token and codec), named from the source's own offer or its title, or "Live from a phone further away" when only relays are in range; a tap joins the best route, ordered as reconnection orders them (joinable first, then routes whose establishment is not failing, then fewest relays). The other routes stay available for reconnection. UI only, no wire change. Off: one row per advertiser, as before. Needs a three-phone check (source, relaying viewer, new viewer near both). | ## Relay and fan-out admission | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Relay child re-anchor (`test_relay_child_reanchor=false` to disable) [relay_child_reanchor] | On | A relay whose child link carries under 50 kbps for 2 s with media waiting closes that child's connection so it reconnects on a new anchor (Bluetooth HCI logs showed a child anchor inside the parent's multi-packet window carrying 4-37 kbps for the whole connection, otherwise 349-378). At most 2 per child per minute, then the withdrawal policy decides. Stream relays only; the private-call relay is unchanged. **Requires / masked by:** this switch is the relay-side close. The child's watch of its parent link (silence and trickle close) has its own switch, `relay_parent_watch`, which follows this one when it is not given, so `test_relay_child_reanchor=false` alone still turns it off and the trickle can be A/B-tested with or without the relay-side close; the same-parent preference (`relay_prefer_parent`, with the grace) is a separate switch, off by default since build 4 (one relay-child recovery policy, logged as `BENCHMARK relay_recovery_config`; DECISIONS "Known interactions", B2). It also masks `relay_reanchor` (the Experimental child-link re-anchor): the close reconnects the child before that arm fires. | | Relay recovery grace (`test_relay_recovery_grace=false` to disable) [relay_recovery_grace] | On | After a link loss that makes the child prefer its previous relay (the re-anchor above), reconnection waits up to 2 s for that relay's offer instead of taking the first offer heard. Manual test 2026-10-10: the A15s heard the full S24 source 147 ms after the ASUS re-anchor, chose it and waited 15 s without recovering. After 2 s any route is allowed. Stream relays only. **Requires / masked by:** `relay_prefer_parent` (the grace applies only after its link-loss preference), which is off by default since build 4, so the grace is inert unless `test_relay_prefer_parent=true`. | | Relay cold start (`test_relay_cold_start=false` to disable) [relay_cold_start] | On | A viewer with no relay evidence (relay proven 0, no fresh relay ceiling) counts the incoming link it already carries and may forward to one child. Without it a fresh install (`probe none, prior 700 kbps`) never relays a 300 kbps stream (it would need 807) until it has been a source. The receiver-node health detach and congestion withdrawal still roll it back and record a relay ceiling. Line `BENCHMARK relay_admission`. | | Fan-out growth in planned-link units (`test_fanout_proven_links=false` to disable) [fanout_proven_links] | On | Without a fresh ceiling a source may add one link beyond its proven load; proven is measured wire kbps while a planned link carries a 20% margin, so proven is scaled by 1.2 before the comparison. Without it a 300 kbps source could never pass 2 children (ASUS proven 737 + 403.5 < 3 x 403.5). Fresh ceilings and the fan-out rollback are unchanged. Line `BENCHMARK fanout_admission`. | | Relay parent trickle close (`test_relay_parent_trickle=false` to disable) [relay_parent_trickle] | On | A relayed child also closes a parent link that delivers under 1/8 of the stream bitrate for a whole 5 s window, not only a silent one, and reconnects through link-loss recovery. In one run M31 -> A15s trickled about 12 kbps for 17 s; the relay's re-anchor closed its socket but the L2CAP channel stayed up while the stack drained, so the child never reconnected. The 4-37 kbps collision band closes; the ~120 kbps 2M one-PDU pair does not. **Requires / masked by:** `relay_parent_watch` (the trickle is judged only inside the child's parent-link watch, which follows `relay_child_reanchor` when not given). A window in which the relay paused this child's video for a weak link (Experimental relay child thinning at audio only) counts as a pause and is not judged, because audio alone is far below 1/8 of the video bitrate (DECISIONS "Known interactions", B10). | | Relay parent-link watch (DEBUG `test_relay_parent_watch=true\|false`) [relay_parent_watch] | Follows `relay_child_reanchor` (on) | Child side of relay recovery, split out of `relay_child_reanchor`: a relayed child closes a parent link that delivers no byte for 5 s (unless the parent said the stream is paused) and, with `relay_parent_trickle`, one that trickles; it then reconnects through link-loss recovery. Not given (and always in release) it follows `relay_child_reanchor`, so the earlier control arm keeps its meaning; given, it lets the child-side close be A/B-tested apart from the relay-side close. Why: a starved relayed link can stay up with nothing arriving, and the relay's close may never reach the child. Logged in `BENCHMARK relay_recovery_config` (parentWatch). | | Viewer link silence watch (DEBUG `test_viewer_link_silence=false` to disable) [viewer_link_silence] | On | A viewer link that the relay parent-link watch does not cover (every direct link, and GATT links) is closed when a started, live video stream with its camera on delivered no byte for 8 s; link-loss recovery then reconnects, and the close is also what tells the source to drop the dead viewer. Why: the viewer's read has no timeout, the viewer's heartbeats keep reaching the source and the source's writes keep returning, so a parent link that stays up but delivers nothing would otherwise show a frozen picture until the user leaves. A healthy source sends a frame every 1/FPS, so 8 s of total silence (the source's own child-heartbeat timeout) is not jitter. A paused stream, a camera-off media state and audio-only live (whose Experimental DTX may send nothing) are never judged. Default on because it acts only in a state that otherwise never recovers; no such stall was found in the saved runs, so it is a safety net, not a measured fix. **Requires / masked by:** runs only where `relay_parent_watch` is not watching the link (one silence watch per link). | | Relay same-parent preference (DEBUG `test_relay_prefer_parent=true` brings it back) [relay_prefer_parent] | Off (since build 4, 2026-10-27; followed `relay_child_reanchor`, on, before) | Child side of relay recovery, split out of `relay_child_reanchor`: after a plain link loss of an active relayed route the child tries the same relay first (and, with `relay_recovery_grace`, waits up to 2 s for its offer), so a relay's re-anchor reconnects the same child instead of rerouting it. Off by default and always in release: on a stream chain S22 > M31 > ASUS whose middle phone was killed and relaunched (valid kills only), the child re-anchored 5/5 without the preference (freeze median 2.9 s) vs 4/5 with it (about 4 s, one never recovered): waiting for a parent that has gone costs more than it saves (DECISIONS "Relay same-parent preference off"). Logged in `BENCHMARK relay_recovery_config` (preferParent, recoveryGrace with its effective value). | | Relay child stall guard (`test_relay_child_stall_guard=false` to disable) [relay_child_stall_guard] | On (2026-10-11) | Control arm. On: an LE child link (GATT or CoC, not Classic) that delivers no inbound byte for 2 s between link-up and the end of the end-to-end handshake is closed by the relay (`BENCHMARK relay_child_stall: ... action=close`), and a stall before hop authentication is counted apart from refusals (3 stalls within 2 minutes mark the child for 30 s, then 60, 120 and 300 s; see "Relay stalls counted apart"); `... result=ok | handshakeMs | maxSilentMs` records the margin on every healthy join. Why: a relay is peripheral to its parent and central to its child, both at 15 ms with a fixed anchor phase; on the M31 (Exynos 9611) a child anchor 11.3-13.7 ms after the parent anchor gave the child link no completed exchange at all: all 9 deaths in the saved relay runs were `0x08` 5.0-5.1 s after the last completed packet (77-92 ms after the update to 15 ms in the four diag cases), and the stack's status 8 never counted as an establishment failure, so the relay held its only child slot for 5.9 s per attempt and looped. Healthy hello replies took 59-214 ms (30 links; one 1.24 s reply came over a link starving at 6 kbps). It does not change which anchor a connection draws; it shortens a dead attempt to about 3 s and makes it visible to the fallback. Off restores the 5.9 s wait exactly. Evidence and the full cause: DECISIONS "Relay dual-role scheduling (2026-10-11)"; A/B in PLAN. | | Relay child pacing: audio exemption on the head record (`test_relay_pace_head=false` to disable) [relay_pace_head] | On (2026-10-11) | Control arm. On: a relay's child writer skips its link pacer only when the next record is audio. The child queue is strictly ordered (the child rejects a sequence that goes backwards), so audio queued behind a video frame cannot be written first; the earlier test skipped the pacer whenever audio was queued anywhere, and a backlog always holds audio (50 records/s), so a standing backlog was written unpaced and the Android socket buffer took the excess instead of the app queue. A saved relay run (S24 -> M31 -> ASUS over LE CoC, 200 kbps, about 281 kbps on the wire): the M31 -> ASUS link carried a flat 144 kbps (HCI snoop, one PDU per 15 ms event); the relay's app queue read 1.3-1.9 s behind, but bytes the app had written minus bytes the controller had sent were 57-80 KB (3.2-4.4 s, writes blocked up to 2.0 s once the buffer was full) and the ASUS saw 5.3-6.2 s of lag. Off restores the any-audio-queued test exactly. Audio at the head is never held, as before. Expected effect: the backlog stays in the app queue, where the relay-child media-age budget (400 ms once the child reports a pinned delay) and the withdrawal rules (2.5 s for 3 s, 6 s) read it, so a link slower than the stream sheds or withdraws sooner instead of hiding seconds below the app. Lines: `BENCHMARK relay_pacing` (headOnly, heldMs, exemptWrites, exemptWaitMs, paceLeadMs). Not yet measured on phones; A/B in PLAN "Relayed viewers hold seconds of media". | | Relay pace lead cap - Experimental (`test_relay_pace_lead_cap=true`) [relay_pace_lead_cap] | Off | While the child's last report says its playout is pinned at the maximum and media is still late, the relay writes at most 250 ms of measured-rate link time ahead of the link (`LinkPacer.setLeadCapNs`) instead of letting the 20% pace gain run ahead without bound. Why: the gain lets the link be probed upward, but with a standing backlog each second adds 0.2 s that the stack holds below the app; an offline model on the diagM1 timeline (not a measurement) fills the 70 KB socket buffer in about 15 s even with the head fix. Tradeoff: if the child's train measurement reads low, the capped pace is below the gained one while the child is late; fewer stale frames reach the child and its shed starts sooner. The same model gives median lag 5.2 -> 2.7 s and 214 -> 158 video frames over 45 s. Hop-local (the child's own report about this link), no wire change; needs link pacing. Same `BENCHMARK relay_pacing` line (capMs, modelLeadMs, childLate). Not yet measured on phones. | | Relay time-based queue - Experimental (`test_relay_time_queue=true`; `test_time_queue_budget_ms`, `test_time_queue_floor_ms`) [relay_time_queue, time_queue_budget_ms, time_queue_floor_ms] | Off | A relay's child queue sheds to the next keyframe at a target sized in milliseconds of media instead of the fixed "stream budget (2 s) minus the child's playout": target = clamp(time the last keyframe needs at the child's measured drain rate + two frame intervals + the worst drain stall of the last minute, floor, min(latency budget, fixed budget)). Defaults: latency budget 1 500 ms (300-5 000), floor 400 ms (100-1 000, the earlier fixed minimum). A stall is how long a record waited for the link beyond its own arrival and the airtime the measured rate predicts for the record before it (10% tolerance). In a sustained overload (offered above 1.1 times the drain) the jitter term is dropped, because a queue that cannot catch up only gains delay from a cushion. With no drain measurement yet, or at the latency budget, the fixed budget applies. Why: the fixed budget lets a link that is only slightly slower than the stream hold up to 1.75 s before it sheds, and cannot see a link whose keyframe alone takes longer than the queue. Tradeoff: the first stall is unmeasured and may cost one shed (a keyframe request) that the fixed budget would have ridden out. Hop-local (this relay's queue and its direct child's arrival report), no wire change; the shed itself, keyframe recovery, cooldown and keyframe grace are unchanged; audio, configuration and lifecycle records are never shed. Applies only while relay-child skip-to-keyframe is on (its default). Line `BENCHMARK relay_time_queue` (both arms: arm, queueAgeMs, targetMs, legacyBudgetMs, drainKbps, offeredKbps, jitterMs, keyFrameDrainMs, overload, shedEvents, shedFrames) and `BENCHMARK time_queue_arms`. JVM-tested with injected clocks. Phone-measured and inert: with the child reporting no arrival train the drain rate was 0 kbps, so the arm never saw an overload (not functional on phones until a sustained drain estimate exists; see the next row). A/B plan in DECISIONS "Time-based relay queues and viewer buffers". **Requires / masked by:** relay-child shedding or the global bounded media age must be on (the queue it sizes does not exist otherwise). | | Relay sustained drain - Experimental (`test_relay_drain_sustained=true`) [relay_drain_sustained] | Off | The time-based relay queue's drain rate for a child is the relay's own sustained delivery to it, instead of only the child's arrival-train report. The train report needs one large record to cross the link as a back-to-back train (at least 8 000 bytes over at least 60 ms); on a slow GATT hop (about 100-270 kbps) records are paced by the connection events and never form one, so the child reports 0 kbps and the queue had no drain, never saw an overload and stayed at the fixed budget. The relay counts the wire bytes its writer hands the link over the seconds media waited for it (queue non-empty before and after the write, the pacer holding the writer for at most a quarter of the cycle, the first 1.5 s of each run discarded because the stack accepts tens of kB ahead of the radio, at least 3 s of qualifying time inside a 10 s window); the estimate is kept 30 s after the queue clears and then dropped, and an idle or brief-burst queue produces none. The train report stays the fallback when no sustained estimate exists, and with the switch off it is the only estimator (the control); the sustained value is then still measured and logged, never used. Why: without a drain rate the Q31 arms are inert (overload is never true). Tradeoff: the first seconds of a backlog can read high (stack buffering), and a writer that the pacer holds more than a quarter of the time yields no estimate. While the Experimental lead cap holds the writer (the child reports late) and media still waits, the last estimate is kept instead of expiring after 30 s, so the time queue no longer falls back to the fixed budget exactly while the child is late (B3); the capped cycles themselves measure nothing. Hop-local (this relay's writes to its direct child), no wire change; it does not feed the link pacer. **Requires / masked by:** `relay_time_queue` (the estimate feeds only the time queue), and below it relay-child shedding or bounded media age. | | Viewer time-based playout target - Experimental (`test_playout_time_target=true`; shares `test_time_queue_budget_ms`) [playout_time_target] | Off | A stream viewer's playout measures how much later than the luckiest arrival of the last minute each arrival came, relative to the media's own timestamps, and grows its delay in one step to the 99th percentile of that spread plus 30 ms, up to the latency budget (default 1 500 ms), before a frame is late; calm shrinking stops at that target. The earlier rule (grow by the late frame's lateness plus 50 ms, shrink after a calm minute) still applies and still reaches the profile's maximum, so the arm only adds advance growth and a shrink floor. Why: with a repeating jitter the earlier rule forgets the burst after a calm minute, shrinks, and is late again every cycle (the JVM model shows this; a phone has not). Shape follows WebRTC NetEq's delay manager (a high quantile of relative arrival delay sets the target level). Tradeoff: it holds more delay while the jitter lasts, and a join backlog in the window can raise it for a minute. Streams only; calls and audio-only live keep their own profiles. `test_time_queue_budget_ms` still sets both its budget and the relay time-based queue's; `test_playout_time_budget_ms` (benchmark tunable `playout_time_budget_ms`, 300-5 000 ms, masked while this arm is off) overrides the viewer's alone, so an A/B can vary the two separately (DECISIONS "Known interactions", B12). Local to the viewer, no wire change. Line `BENCHMARK playout_time_target` (neededMs, budgetMs, grows, with the playout and added delay) beside `media_age: role=stream_rx`. JVM-tested; not yet measured on phones. | | Relay parent fallback on establishment failure (`test_relay_establish_fallback=false` to disable) [relay_establish_fallback] | On | Two link-establishment failures within 2 min (child: the parent never connected before the CoC accept timeout; relay: status 133/62 before hop authentication) mark that peer. On the relay side, with refusal escalation (the default) the marks escalate 30, 60, 120, then 300 s on consecutive marks without a success; the flat 5 minutes applies on the child side and when refusal escalation is off. The child then reconnects to the next-best route (another relay or the source, through normal admission) and ranks the marked parent last; the relay stops connecting to the marked child. Why: in a four-phone mesh A/B (2026-10-10) the M31 relay's HCI snoop showed ten LE connections to its child, each ending 0x3E after 6 events. Line: `BENCHMARK relay_parent_fallback`. | | Relay establishment budget - Experimental (`test_relay_establish_budget=true`) [relay_establish_budget] | Off | A relay's child connect (GATT video, and the GATT control connect of a CoC child) retries a fast establishment failure (GATT 133/62 within 1.5 s, the stack's report of HCI 0x3E) in place, 300-900 ms apart, while the next attempt still fits 12 s from the first connect; the stack's own 5 s create-connection timeout is retried once. An exhausted budget is ONE failure for `relay_establish_fallback` (the control arm counts one per 2-attempt cycle), the child mark escalates 30, 60, 120, then 300 s (with refusal escalation on, the default, that escalation already applies, so this switch changes only the in-place retries and the 2M -> 1M skip; the flat 300 s control exists only with refusal escalation off too) and the 2M -> 1M swap is skipped. Why: an M31 relay (peripheral to its parent, central to its child) lost 11 of 23 child connects to 0x3E; with the A15s as child it gave up after 4 attempts in 3.5 s, marked the child for 5 minutes and so refused the child's own retry although the child advertised for 15 s. With about 0.5 per attempt, 4 attempts fail 6 %, 10 attempts 0.1 %. Line: `BENCHMARK relay_child_connect: link= | attempt= | durationMs= | status= | kind=fast_establishment/target_absent/other | result=ok/retry/exhausted/legacy | delayMs= | elapsedMs=`. Untested on phones. **Requires / masked by:** it shares the escalating marks with `relay_refusal_escalation` (either turns them on; `BENCHMARK relay_recovery_config` logs `escalatingRefusalMarks`). The PHY after a failure before any link existed is one rule shared with `relay_establish_keep_2m` (RelayChildRecoveryPolicy.setupPhyAfterFailure): such a failure keeps 2M, because establishment runs on the advertising PHY; an exhausted budget now also logs that decision (`link_phy ... reason=establishment_failed | next=2M`) instead of skipping it silently, with the same PHY as before. The four combinations are not phone-tested together (DECISIONS "Known interactions", B1). | | Relay hold in place off (`test_relay_hold_in_place=false`; DEBUG only) [relay_hold_in_place] | On (since the connection review B1, 2026-10-27; release too) | Control arm. A relay drops a sighting of a child it will not dial (marked by the establishment tracker, or inside the stall/refusal hold-off) in the discovery callback, before the presence is stopped, instead of stopping and restarting its offer advertising set and its scan for every sighting. The mark, the hold-off and what counts as a failure are unchanged; the first sighting after they end dials at once. Why: a child that keeps advertising is rediscovered about every 120 ms, so a skipped sighting spun the restart loop until Android's scan quota (5 starts per 30 s) stopped it and left the relay blind for 29 s. Lines: the existing `relay_parent_fallback ... action=skip_connect` and `relay_child_stall ... action=holdoff`, at most one per child per 5 s with `| held_in_place=true | sightings=N`; control (`=false`): the old restart per sighting, one line per sighting. Promoted because the restart loop is a P1 outage (the relay goes blind for 29 s) and holding in place changes no dial decision. **Requires / masked by:** the relay child stall guard, the establishment fallback or the stall hold-off (it only skips sightings that those mark). | | Relay PHY guard off (`test_relay_phy_guard=false`) [relay_phy_guard] | Guard on | Control arm. On: relays, and call endpoints whose hop is a relay, never write bad-2M memory from that hop's one-PDU windows or early losses (`bad2m=not_recorded \| hop_relays=true`). A relay's one radio carries both adjacent links, so those losses are not evidence about the pair's 2M (entries written against healthy pairs made a mesh test refuse viewers). Existing "via" entries are purged once. Off restores the old recording exactly. | | Direct-call bad-2M memory despite a helper's relay bit (`test_direct_call_hop_memory=false` is the control arm) [direct_call_hop_memory] | On | A call endpoint whose hop is known to be the call's own peer (the hop authenticated as the end-to-end peer, or this caller dialed the callee's own offer) ignores the hop's call-relay capability bit when it decides whether to remember the peer as bad on 2M after an early link loss or a 2M setup retry. Every phone with relayed calls on and its helper running signs that bit, so before this a direct call to such a phone never remembered a bad 2M pair and every later call started on 2M again. Relayed offers, relayed invitations and a callee whose hop is a relay still skip the memory (`relay_phy_guard`). Line unchanged: `adaptive_phy: link=memory ... bad2m=recorded` instead of `bad2m=not_recorded | hop_relays=true`; the switch is logged in `BENCHMARK le_link_policy` as `directCallHopMemory`. **Requires / masked by:** `relay_phy_guard` (off records every loss anyway), `adaptive_phy_memory`. | | Relay one-PDU PHY move (`test_relay_one_pdu_move=true` to enable) [relay_one_pdu_move] | Off | Experimental. In one run with the move, the ASUS relay's own receive fell from 283 to 119-210 kbps (ASUS 8.7 FPS, A15s 4.7) versus re-anchor alone (ASUS 15.0, A15s 5.4); one run each, A/B pending. Under the relay PHY guard, a relay's child link measured at 0.5-1.25 PDUs per event on 2M still moves to 1M (no bad-2M memory is recorded). In another run ASUS relay -> A15s carried 121 kbps against 131 kbps for one PDU per 15 ms event and the guard kept it on 2M (A15s 1.0 FPS). The collision band (4-37 kbps) still stays on 2M. **Requires / masked by:** the relay PHY guard and `adaptive_phy`. | | Relay child-link re-anchor, earlier arm (`test_relay_reanchor=true`) [relay_reanchor] | Off (Experimental) | The child-link re-anchor of the adaptive PHY (`RelayReanchor`). Not proven: in relaylink-arms3 it never fired before the relay's stall release reconnected. **Requires / masked by:** masked by `relay_child_reanchor` (default on), which closes the starved child link first; it is also the only arm touched by the adaptive PHY's process-wide in-progress flag. | | Relay child-link interval (`test_forwarder_child_interval_units=<6..40>`) [forwarder_child_interval_units] | Off (DEBUG experiment) | Requests this connection interval (1.25 ms units) on a relay's child link (`LeChildInterval`), to test interleaving the relay's peripheral (parent) and central (child) connections. Unset keeps the priority-based interval. | | Relay CoC child priority (`test_forwarder_coc_priority=balanced\|low\|dck\|high`) [forwarder_coc_priority] | Off (DEBUG experiment; unset = HIGH) | The connection priority a relay requests on its CoC child link, for the same interleaving test. | | Relay upstream DCK (`test_relay_upstream_dck=true` to enable) [relay_upstream_dck] | Off (Experimental; promoted on 2026-10-15 per Q48, then held off the same day: the full-chain run with a child, lane 11 block E, gave parent 274 vs 389 kbps and child frozen 83% vs 67% with DCK, n=3 per arm on a 15-17% S24; earlier evidence: parent-only video A/B 105%/101%, audio relay formed 8/8 vs 5/8). Re-test with a charged S24, n>=6 per arm, before promoting | A central whose just-authenticated child can relay requests DCK (Android 14+ centrals only; 30 ms by the AOSP default `gatt_dck_priority_*` = 24 units, OEM overlays may differ) instead of HIGH on that link, on GATT after the handshake and on CoC through its control connection (`BleL2capControlConnection.requestConnectionPriority`); the relay's own HIGH child link then has two events per parent event, so one of them always lies outside any parent window shorter than 15 ms minus one child exchange. Hypothesis from the M31 snoops (every dead or starved stage had equal 15 ms intervals; the 7.5 ms stage never died), not a measured fix; an explicit `test_source_gatt_priority` still wins. Line `BENCHMARK relay_upstream_priority: role=source|relay | transport | childCanRelay | priority`. Tradeoff: the parent hop adds up to 15 ms. Promotion needs the PLAN A/B (parent interval observed at 30 ms, no 0x08, child rate at least the stream rate). | | Inbound-child probe (`test_inbound_probe=server` or `dial`, DEBUG launch extra only) [inbound_probe] | Off (Experimental, DEBUG builds only, 2026-10-14) | Measurement 7 of the stream link role-reversal design: a phone that is LE central on its parent link under media load also hosts a connectable GATT sink (`server`), and a second phone redials it (`dial`; `test_inbound_probe_dials` 15, `_hold_ms` 20000, `_gap_ms` 2000, `_kbps` 60) to show whether each dial draws a new phase, how many dials stay alive, and whether the parent link keeps completing. It writes only `inbound_probe:` log lines: no authentication, no media, no hop records, nothing on air unless the extra is set, and release builds ignore it. Pass and fail criteria are in the design record; a fail on the parent side ends the design. | | Source fan-out count (`test_max_children=<1..8>`) | Off (DEBUG; unset = the bandwidth-derived count) | Overrides the number of direct children a stream source accepts (clamped to 8). Release builds ignore it. | | Minimum route cost (`test_min_route_cost=`) | 0 (DEBUG) | A DEBUG auto-selecting viewer joins only a stream offer whose route cost is at least n, to force a relayed placement. | | Require a relay-capable offer (`test_require_relay`) | Off (DEBUG) | A DEBUG receiver or relay accepts only announcements that are relay-capable, to force a chain. | | Relay child count (`test_relay_children=<1..3>`) | 1 (DEBUG) | How many BLE children a DEBUG relay waits for before it starts; the status line reads "Next N BLE receivers". | | Receiver packet delay (`test_receiver_delay_ms=<0..1000>`) | 0 = off (DEBUG) | The stream receiver sleeps this many milliseconds after each media packet, to emulate a slow receiver. | | Relay chain control arm (`test_call_relay_chain=false`) [call_relay_chain] | Chains on | The DEBUG call relay may take another relay's offer as its child, so a call crosses up to four opaque relays (PROTOCOL "Private-call relay chains": hop-local admission by route cost and signed relay capacity, loop-free, endpoint-authenticated end to end; a malicious relay can drop or delay, never impersonate or read). A relayed child must sign the call-relay link capability, Classic reaches only the advertising device (no bonded fallback or inquiry), a relay waits at most 180 s for its parent, and a caller refuses offers above four relays. Off restores the one-relay rule exactly. Benchmark relays use `test_relay_callee` and `test_relay_route_cost` so each takes only its requested child's offer. Untested on phones. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | Measured relayed call cap (`test_relayed_call_measured_cap`) [relayed_call_measured_cap] | Off | A relayed call's video bitrate cap becomes this phone's fresh measured link x 0.2125 less call audio (a relay's one radio carries both adjacent links in both directions, half the direct 0.425 share), never below the legacy 110 kbps; FPS stays at most 15. The legacy 15 FPS / 110 kbps cap has no measured basis as a ceiling (it was a starting budget), while a one-way stream relay carried 433-458 kbps in and out. Example: a 900 kbps link gives 159 kbps. Line `BENCHMARK call relayed_cap`. Needs a relayed A/B before promotion. Untested on phones. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | Relayed first hop as a direct link (`test_relayed_first_hop_direct=false` is the control arm) [relayed_first_hop_direct] | On | Before SETUP, the caller's link to the helper it selected for an on-demand relayed route is an ordinary link to an idle phone, so it gets the direct-call 2M protections for that helper: the helper's bad-2M memory is read at the dial and written at a 2M setup stall (under the helper's recognised contact node, as for a rotating direct offer), and the 1M retry rescans for the helper's own current offer (same helper ID; the hop opening still proves the selected helper, so another relay is never swapped in) instead of redialing the old address. Reason: an A15s caller via an ASUS helper failed 4 of 4 (CoC and GATT): the first hop stalled on 2M, the helper re-advertised under a new address after its dead link timed out, and the 1M retry redialed the old address until "BLE control did not respond" / "Timed out connecting GATT video". Lines `relayed_route`, `adaptive_phy: link=call ... hop=helper:...`. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | Helper next-hop connect retry (`test_relayed_hop_retry=false` is the control arm) [relayed_hop_retry] | On | A relayed-call helper whose connect to its next hop fails within 2 s (no link formed) repeats that same connect once after 0.5 s, logged (`relayed_hop: ... event=connect_retry`), while at least 6 s of the SETUP deadline is left. A slow failure (a link that formed and then died) is not repeated. Reason: an M31 helper's CoC connect on its fresh ACL failed after 341 ms and the call failed with 14 s of the deadline unused. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | Helper next hop on LE 1M - Experimental (`test_relayed_hop_1m=true`) [relayed_hop_1m] | Off | A relayed-call helper opens its next hop on LE 1M instead of the 2M default; the difference is logged as `link_phy requested=2M effective=1M reason=relayed_hop_1m`. Hypothesis under test: the A15s -> ASUS child hop (A15s helper, ASUS callee) goes silent right after the 2M PHY update, the same signature as the direct A15s -> ASUS 2M setup stall; A15s helpers reached an M31 callee. The helper cannot use the direct calls' stall-and-retry because the ASUS keeps the dead link for 7-9 s, longer than the SETUP deadline leaves. A/B on m31 -> a15s -> asus (CoC, build b5bf8aad): off 0 of 4 set up ("read failed", ASUS EOFException), on 3 of 4. It stays the forcing switch; the measured per-link fallback below is the default. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | Helper next hop: per-link 2M -> 1M retry (`test_relayed_hop_phy_retry=false` is the control arm) [relayed_hop_phy_retry] | On | A relayed-call helper's next hop gets the call's measured 2M protection, per link and not per device family: a first attempt on 2M whose connect fails or whose hop authentication has not finished 3 s after the link opened is closed and retried once on 1M (to the next hop's freshest advert in the helper's reach table, waited for up to 5 s) while 6 s of the SETUP deadline is left; a 1M retry that authenticates records that hop peer bad on 2M (`adaptive_phy ... reason=relay_hop_setup_retry`), so the next dial of the pair starts on 1M. Only a pair that failed on 2M and then set up on 1M inside one attempt is recorded, which separates it from the relay's dual-role scheduling losses the relay PHY guard keeps out of the memory. Lines `adaptive_phy: link=call-relay ... from=2M to=1M`, `relayed_hop: event=rediscover`, `link_phy requested=2M effective=1M reason=setup_retry`. Evidence for default on: the `relayed_hop_1m` A/B (0 of 4 on 2M, 3 of 4 on 1M) and the same 2M -> 1M setup retry that direct calls already use. Stream chain hops are not changed: the two failing chain runs show a harness raw-input failure and a mid-stream stall, not a 2M setup stall. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | Relayed-call candidate iteration (`test_relayed_candidates=false` is the control arm) [relayed_candidates] | On | A relayed call whose route fails before READY (first-hop open or handshake, setup stall, a helper's FAIL, the helper's pre-setup timeout) tries the reach table's next-best route through a helper this attempt has not tried, inside the same call attempt: at most 3 routes, each started while a full 20 s SETUP deadline still fits in 60 s, the exact relay count under `require`, every hop authenticated and checked as before (selected-helper opening, consent and the helpers' admission unchanged). A failure after READY, a cancelled call or an answered one never moves to another route. Line `relayed_route: role=caller \| attempt=N \| helper=...`. Design C listed this as deferred; reason: one bad pair (A15s -> ASUS on 2M) failed the whole call while another helper was in range. **Requires / masked by:** relayed calls (Settings > Experiments "Relayed calls (beta)", honoured only while Manual video settings is on; benchmark launches use `test_relayed_calls`): the whole `relayed*` and call-relay-chain family is inert without it. | | MediaTek helpers over LE CoC only (`test_device_policy_off=mediatek-call-relay-gatt-helper-off` turns it off) | On for MediaTek phones (2026-10-27, build 4) | A DeviceFamilyPolicy entry: a MediaTek phone (SoC maker MediaTek on Android 12+, else a `mt` hardware name) whose call endpoint is GATT does not help with other people's calls: its offer carries no helper flag, its link hello signs no relay mark, and a SETUP or route search arriving over GATT is refused, so callers pick another helper. Over LE CoC it helps as before, and its own GATT calls are unchanged. Settings shows "Helping: paused (CoC only on this phone)". Reason: as a GATT middle the A15s (MT6765, Android 10) held 0 of 8 relayed calls in every arm (control, `test_gatt_byte_window=3072` with 120 kbps, `test_gatt_payload=244`), with single bytes changed inside queued notifications at the next phone's HCI log; never on single-link calls (228 A15s direct GATT calls, 0 desyncs); the Xiaomi Redmi 12 (also MediaTek) 3 of 10 as GATT middle; non-MediaTek middles 0 of 72; the A15s as a CoC middle held 7 of 8. Low confidence: two local phones and no MediaTek document. The protocol fix (per-chunk integrity with a GR1 re-request) is in PLAN. **Requires / masked by:** relayed calls (the whole helper family is inert without it). | | Relay join keyframe (`test_relay_join_key_frame`) [relay_join_key_frame] | Off (Experimental) | A source honours a keyframe request from a relay-capable direct child (one that may have children) at once, bypassing the shared recovery back-off (1 s doubling to 8 s), when that child sent no request for 2 s (`RelayJoinKeyFrame`); the window starts at the child's authentication, whose keyframe is already forced. At most one forced keyframe per relay child per 2 s; a child that keeps asking more often never forces one; plain viewers are unchanged. Reason (2026-10-26, saved runs): authenticated-to-stream-header was a median 402 ms on direct joins but 1127 ms through one relay (p90 3484 ms) and 2248 ms through two, because the forwarder's regenerated request for its new child looks like a loss recovery to the source. No wire change (DECISIONS "Design JK" is the tagged alternative). A/B: a chain join (source, forwarder, child), child selection to first surface and authenticated to header; source keyframe count per minute. Lines `relay_join_key_frame: childRelays= | forced=`, `key_frame_request: honored=`, `relay_child_join: event=first_media | waitMs=`. | ## Calls and intercom | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Voice calls (formerly Settings > Features "Voice calls"; DEBUG `test_voice_calls` is the benchmark switch) | On since 2026-10-17, no Settings switch (design decision; a constant with no stored choice). One constant, `CallFeatureDefaults.VOICE_CALLS` (true) | Intercom pillar (2026-10-09). Tapping a person offers Voice call and Video call. A voice call starts no camera, encoder or decoder and sends no video packets; it starts on the earpiece (video calls start on the loudspeaker; the Speaker/Earpiece button overrides, and until you press it the route follows the layout) and shows the peer, a timer, mute, speaker/earpiece and End. Either person can add or remove their own video during the call; adding video widens the foreground-service type to camera before the camera starts, and removing it narrows it again. It uses no new wire value (PROTOCOL "Voice calls"). A peer without it (an older build, or the switch off) sees a video call with the caller's video off and answers with its own camera; the caller then sees one notice that the other app has no voice calls. Changing a call or Messages switch rebuilds an idle call listener (about 1 s) so the next call, in either direction, follows it. Benchmark launches (any `test_` extra) read only the extra. DEBUG controls `invite_voice` / `voice_call:PREFIX`; lines `BENCHMARK call_type`, `call_type offer`, `call_mode`, `call_audio_route`. Not yet tested on phones. | | Push-to-talk button, PTT mode (`test_push_to_talk`, `test_ptt_mode`; launch extras only, never a setting: push-to-talk is not a mode) | Off | Hold Mute to talk is part of every call and has no switch: while muted, press and hold Mute (350 ms) to talk and let go to mute again, on the existing mute path with haptic feedback. Screen readers get a "Talk" custom action that latches talking on and off, and the Mute button's description explains it. A cellular call that holds the microphone (audio focus) keeps the call muted, and the screen says so. The extras add a hold button with tone and colour cues; PTT mode starts calls muted. DEBUG controls `ptt_press` / `ptt_release`; line `BENCHMARK call_ptt`. Group push-to-talk is not built. The stored `experimental_push_to_talk` and `experimental_ptt_mode` preferences have no reader outside tests (**Not wired**). Not yet tested on phones. | | Call FPS ladder from 0.4 x request (`test_call_ladder_from_request`) [call_ladder_from_request] | Off | The call frame-rate floor used before 2026-10-09. The default floor is 0.1 bit/pixel of the encoded geometry. **Requires / masked by:** masked by the measured Auto ladder (`test_auto_ladder`) when that is on. | | Call 2M setup-stall guard off (`test_phy_setup_stall=false`) [phy_setup_stall] | Guard on | Control arm. On: the first 2M LE call attempt that may still retry on 1M closes its link when hop authentication has not finished 3 s after it opened (`BENCHMARK adaptive_phy: ... reason=setup_stall \| action=close`), and the 1M retry of a direct offer rescans up to 8 s for the callee's current advertisement by node prefix and route cost (the end-to-end handshake verifies the identity); a relayed offer keeps its original relay so the requested parent never changes. The authenticated 1M retry logs `BENCHMARK link_phy requested=2M effective=1M reason=setup_stall\|setup_retry source=adaptive` and records bad-2M memory except for a relaying hop. Every LE call attempt logs `BENCHMARK call_setup: ... hopAuthMs=`. Why: A15s central -> ASUS on 2M delivered nothing ASUS -> A15s after PHY Update Complete, and the old retry reconnected to the ASUS's stale random address; 1M went active in 2.5 s. 353 saved outgoing GATT setups authenticated within 1.05 s on healthy pairs. Off restores the retry only after the link dies, to the old address. | | Private-call close waits for the peer (`test_call_close_wait_peer=false` is the fixed-400 ms control arm) | On (socket transports) | After HANG_UP/DECLINE/CANCEL the writer waits until the peer closes the link (its reader ends), capped at 3 s, instead of closing after 400 ms; the fixed drain lost the hang-up and about 1.3 s of media behind a 542 ms CoC queue. Line `BENCHMARK call close_drain`. Untested on phones. | | Call audio headroom off (`test_call_audio_headroom=false`) [call_audio_headroom] | Headroom on | Control arm. The call send queue was 33 records for 500 ms of audio plus video. During one long GATT video write, audio alone filled it, so every queued video frame was dropped and a keyframe was forced; the keyframe's own write repeated the cycle (104 recoveries in 46 no-loss calls). Audio now has its own 3 s (150-packet) room and video counts only video. Untested on phones. **Requires / masked by:** a no-op under `bounded_media_age`. | | Call codec fallback off (`test_call_codec_fallback=false`) [call_codec_fallback] | Fallback on | Control arm. A legacy Qualcomm phone (API 32 or older) used to advertise VP9-only call decoding. Its end-to-end hello now lists its real decoders plus a signed "prefers VP9" bit (decode-mask bit 7). Senders keep VP9 whenever they can encode it, so VP9-capable peers are unchanged, and AV1-only phones (A15s, Xiaomi) send AV1 instead of failing with "Peer cannot decode any locally available call codec". The decision uses only the end-to-end hello, which relays copy opaquely, so direct and relayed calls agree. The Android CDD requires VP9 decoding, not VP9 encoding, on handhelds. The ASUS decoded AV1 640x480 with 0 freezes in 12 stream cases. Direct A15s <-> ASUS calls worked with the first version; this end-to-end form is not yet tested on phones. | | Call video fragments (`test_call_video_fragments`) [call_video_fragments] | On for direct BLE GATT calls in user launches (promoted 2026-10-11) | Direct calls with the bit in both signed endpoint hellos. Promoted with speech-led playout for GATT (DECISIONS "Call head-of-line A/B": GATT audio queue age p95 -38..-69%, about 4% framing overhead; CoC/Classic unchanged; benchmark launches keep the extras-only arms). Video frames over 3 KB travel as authenticated 2 KB fragments, and queued call audio is written between them. GATT audio waited up to 1.19 s behind a frame. PROTOCOL "Experimental call video fragments". Phone-measured in the call head-of-line A/B (the numbers above). **Requires / masked by:** a direct call and the fragments bit in both signed endpoint hellos; a relayed call, or a peer without the bit, negotiates no fragments. A call link renewal drops a frame its old link cut and the new link starts at a whole frame (fixed 2026-10-27: the partial frame used to end the call as "Call link lost" right after a successful renewal; not yet observed on phones). | | Airtime-sized call fragments (`test_call_video_fragments` + `test_call_fragment_airtime_ms=40`) [call_fragment_airtime_ms] | Off (0) | Fourth arm of the call head-of-line A/B. Instead of a fixed 2 KB, each fragment carries link rate x target airtime (40 ms) payload bytes, clamped to 675-4096 bytes, so call audio waits about one short write at any link rate; a fixed 2 KB is still 80-170 ms of a slow GATT link. The rate is this call link's peak delivery goodput (last 10 s, from the peer's version 3 reports, which count bytes and survive small fragments), else the peer's arrival-train estimate, else the learned probe (which may only shrink fragments); unknown keeps 2 KB. Each fragment adds 75 bytes of framing and tags (hop record 33, endpoint record 34, fragment header 8), so the 675-byte minimum holds that at or below 10% (2 KB costs 3.5%, 4 KB 1.8%); a very slow link gets fragments longer than the target rather than more overhead. Queued audio is written before every fragment, so none starts while audio waits. Frames up to 1.5 x the chunk go whole. No wire change (the receiver accepts any chunk length). Needs `test_call_video_fragments`; 0 or absent is today's fixed size. One `BENCHMARK call_fragment_airtime` line per second (both fragment arms) with fragment sizes, fragments/s, overhead bytes and the longest audio wait behind video. PROTOCOL "Experimental call video fragments". Not yet tested on phones. **Requires / masked by:** masked by the fragments negotiation: with no fragments negotiated (the peer lacks the bit, or a relayed call) the size is never used. | | Speech-led call playout (`test_call_speech_led_playout`) [call_speech_led_playout] | On for direct BLE GATT calls in user launches (promoted 2026-10-11) | Late call video may grow the shared call delay by at most 150 ms; late audio keeps the full 250-600 ms range. Later video is shown late (nothing is dropped) and is still reported to the sender's rate control. Every GATT call pinned the shared delay at 600 ms. Pair it with fragments, or audio itself still arrives late on GATT. Phone-measured only together with fragments (call head-of-line A/B: playout delay p50 about 100 ms lower on GATT); speech-led alone is unmeasured. It is applied to every direct GATT user call, also one where no fragments were negotiated (the peer lacks the bit, or the call is relayed): that combination is unmeasured. | | Legacy call low delay (`test_call_legacy_low_delay`) [call_legacy_low_delay] | Off | Direct calls on a legacy-constrained route (a legacy Qualcomm phone, or a peer declaring a constrained CoC/Classic limit) send audio in 40 ms pairs instead of 400 ms batches and play with the normal call delay instead of 500-800 ms. Measured protection cost: about 0.9-1.2 s one way on M31/ASUS CoC, including voice calls. The batch's own evidence was a relayed route, where pairs sufficed. Relayed calls keep the batch. Untested on phones. | | Call audio coalescing (`test_call_audio_coalesce=false` is the control arm) [call_audio_coalesce] | On (promoted 2026-10-27, build 4) | Overhead audit. When the call writer takes one ordinary 20 ms Opus unit while more ordinary units already wait in its queue (audio that queued during a blocking video write on a slow link), it sends them as one existing `BTA1` pair or `BTA2` batch record (at most 20 units, 1 s span; never codec configuration or an already-combined record). Nothing waits for audio not yet captured, so the added delay is zero, and with an empty queue every record is exactly as before. No wire change: every Android, iOS and Windows call receiver already splits pairs and batches (legacy routes send them today). Basis (computed from the record formats): each merged unit saves the 80 bytes of per-record framing (`BMS1` 17 + 16, `BME1` 18 + 16, packet 13) minus a 6-byte batch item header, plus one ATT value or CoC SDU and two AES-GCM operations; for example 8 units queued behind one video write at 131 kbps (15 ms interval, one PDU per event) go as one 773-byte record instead of eight records of 1,280 bytes in total. The saving appears only under backlog, which is when it matters. `BENCHMARK call tx` adds `coalescedAudioRecords` and `coalescedAudioUnits`; `audioWrites` then counts records, not units. It does nothing when audio pairs are on (a pair record is not a plain audio unit) and on relayed or legacy routes, which already pair or batch. Measured (direct GATT video calls, 25 s, ABBA x3): M31 <-> S22 control 747 sender queue drops and call audio queue age up to 3.7 s, coalescing 0 drops and at most 20 ms; ASUS <-> A15s video 13.3 -> 15.0 fps, drops 136 -> 102 (DECISIONS "Call audio coalescing promoted"). | | Call audio pairs (`test_call_audio_pairs`) [call_audio_pairs] | Off (Experimental, 2026-10-26) | Overhead audit. Routes that send single 20 ms units (every direct call that is not legacy-constrained, and relayed GATT and Classic calls) send 40 ms `BTA1` pairs instead; routes that already pair or batch are unchanged. Cost: every other unit waits 20 ms for its partner (+10 ms mean one-way delay). Basis (computed, 80-byte Opus units at 32 kbps): call audio drops from 64 to about 50 kbps of records (-34 bytes per unit) and from 50 to 25 records per second, so half the AES-GCM operations on both layers. A pair of 80-byte units (264-byte L2CAP PDU) still needs two 251-octet LE data PDUs, so on a link that moves one data PDU per connection event the event count does not fall; that needs units of 73 bytes or less. No wire change (same receivers as coalescing). Untested on phones. | | AEAD cipher reuse (`test_aead_cipher_reuse`) [aead_cipher_reuse] | Off (Experimental, 2026-10-26) | Overhead audit, CPU and one write per record. Hop (`BMS1`) and endpoint (`BME1`) record writers, readers, sealers and openers keep one AES-256-GCM `Cipher` for their record stream instead of a security-provider lookup for every record (about four per media record on a call endpoint: hop and endpoint, seal and open), seal the header and ciphertext into one array, and open endpoint records in place instead of copying header and body out first. A hop record then reaches the link stream as one write: on LE CoC (4 KiB send buffer) a record of 4 KiB or more no longer sends its 17-byte header as a separate SDU. Records are byte-for-byte identical; each record re-initialises the cipher with its own key and nonce, and the cipher is dropped with the key on close. Basis: host JVM 1.45 to 0.29 microseconds per 160-byte record and 2.08 to 0.95 per 2,600-byte record (Android Conscrypt cost unmeasured). Untested on phones. | | Stream audio batches (`test_stream_audio_batch_frames=N`) [stream_audio_batch_frames] | 2 = 40 ms pairs (Experimental, 2026-10-26) | Overhead audit. A video stream source sends its 20 ms Opus units to every child as one existing `BTA2` batch record of N = 3..10 units (60-200 ms) instead of 40 ms `BTA1` pairs; other values keep the pairs. Every stream receiver and relay already splits batches (Android, iOS, Windows; audio-only live streams send 200 ms batches today), so there is no wire change. Cost: the newest unit of a batch leaves up to (N - 1) x 20 ms later; a stream viewer already plays several hundred milliseconds behind its source. A timestamp jump or rewind starts a new batch, and pausing or closing sends what waits. Basis (computed from the record formats, 80-byte units): a mesh record costs 130 bytes of framing (`BMS1` 33, `BMV1` 50, `BME1` 34, packet 13), a direct record 80; at N = 5 audio drops from 25 to 10 records per second per child, saving about 15 kbps per mesh hop link (9 kbps direct), at N = 10 about 21 kbps (13 kbps direct), and 60-80% of the per-child audio seals, envelope encodes and allocations at the source and every forwarder. Untested on phones. | | Legacy call relief (`test_call_legacy_relief`) [call_legacy_relief] | Off | No legacy Qualcomm (API 32 or older) call protection applies on this phone: no 426x240 cap, no 110-160 kbps limits, no VP9-only rules or forced software VP9 decoder, Opus 32, no batching or 40 ms flush, normal playout. The phone also advertises its normal receive limits. Its one switch is `privateCallStackFingerprint()`. Relay and stream rules are unchanged. Untested on phones. | | Call over the lock screen (formerly Settings > Features "Calls on the lock screen"; DEBUG `test_call_lock_screen` is the benchmark switch) [call_lock_screen] | On since 2026-10-17, no Settings switch (design decision; a constant with no stored choice). One constant, `CallFeatureDefaults.LOCK_SCREEN_CALLS` (true) | While a call rings or runs, the call screen may show over the keyguard, and an incoming call turns the screen on. An incoming call also posts a CATEGORY_CALL notification on an IMPORTANCE_HIGH channel with a full-screen intent, which is Android's documented path for incoming calls on a locked device. Before, a locked or backgrounded phone only rang under a low-importance service notice. On Android 13+ the notification permission is part of call readiness (see "Call readiness permissions"). On Android 14+ the full-screen special access is separate: while lock-screen calls are on (the default) and Android refuses it, Settings > Features shows "Android needs your permission..." with a button to the system page (`Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT`); without it a heads-up is shown. Names : over a secure lock screen the call screen shows "Encrypted call" and "Unlock to see who is calling" until the phone is unlocked (ACTION_USER_PRESENT), unless the lock-screen names option below is on. It applies to the next call. Not yet tested on phones. | | First call frame on arrival (`test_call_first_frame_now`) [call_first_frame_now] | Off | The first decoded frame of the peer's call video is shown at once instead of after its 250-500 ms playout target; later frames keep the shared timeline. The aim is a shorter first-picture time (median 2.9-3.2 s from setup). Untested on phones. | | Call encoder fit (`test_call_encoder_fit`) [call_encoder_fit] | Off | Capability-based, not family-based. If a call's native AV1 encoder stays over budget at its fastest preset with fast tools for 3 windows (the A15s encoded at 101-103 ms p95 against a 66.7 ms budget, giving 6.9-7.2 FPS while also decoding), that geometry is remembered for 7 days and later calls start at the next smaller offered size at the same FPS, shown in the status line. Live camera only (raw inputs fix the geometry). Untested on phones. | | Sender-gap audio realignment off (`test_audio_gap_align=false`) [audio_gap_align] | Realignment on | Control arm. After a mute, push-to-talk release or dropped packet the receiver writes silence so the next talk spurt starts on its playout target, never more than the sender's gap. Before, the spurt played early with no jitter buffer and speech slowed to 0.96x for about 6 s. Decoded PCM also follows the sender timestamps now: the S24 decoder path kept output timestamps contiguous across a 6 s mute and then underran 164 times. Line `BENCHMARK audio_gap_align`. Untested on phones. | | Low-delay voice-call playout (`test_voice_call_low_delay`) [voice_call_low_delay] | Off | Voice-only calls on unbatched links start playout at 80 ms (adaptive to 400 ms) instead of 250 ms (the voice runs measured a longest sender audio queue of 27 ms; that is a measurement, not a limit the sender enforces). Line `BENCHMARK call_playout_profile`. Untested on phones. | | Separate call audio clock (`test_call_audio_own_clock`) [call_audio_own_clock] | Off | Call audio keeps its own playout delay, grown only by late audio, instead of the video delay (600 ms in video calls after bursts). Speech may then lead the picture by up to the video's added delay: lip sync traded for conversational delay, a design decision. Untested on phones. | | Call-link pacing (`test_call_link_pacing`) [call_link_pacing] | Off | Classic RFCOMM and LE CoC calls pace writes at the peer-measured arrival rate (seeded like a stream child), and the peer's call reports also teach learned link capacity, which today only stream children do. Each call flush already waits until the stack has read the socket buffer, so only the RFCOMM/L2CAP queues (about 10 KB) and controller buffers could hide below the app. Tradeoff: the bounded call queue can fill sooner and drop video until a keyframe, as on GATT. Needs link pacing. Untested on phones. | | Call resolution ladder (`test_call_resolution_ladder`) [call_resolution_ladder] | On for normal launches (Automatic rate control); Off for DEBUG benchmark launches unless the extra is `true` | Design decision 2026-10-10. Only bitrate and FPS adapted before; the M31 VP9 encoder at 150 kbps and 640x480 sat at its maximum quantizer for 99.5% of frames. Like WebRTC (default singlecast start limits, BSD-3: VP9 480x270 120, 640x360 190, 960x540 350 kbps; AV1 176/256/384 kbps; 320x180 floor), a call steps its camera geometry down when the video target stays below the current size's limit for 3 s, and back up one rung after the target holds 1.25 x the larger size's limit for 10 s (at least 15 s after a change). Rungs are modes every lens offers at the call's exact FPS and aspect ratio, never above the call's start geometry (so device-policy call caps and the encoder fit still bound it). FPS, bitrate target and the call header are unchanged: the header is the maximum and the restarted encoder's first keyframe carries the new size in-band (Android CDD 5.3 dynamic resolution switching); no wire change. Cost: a camera/encoder restart (as for a lens switch) per change. Exact rate control, raw inputs and benchmark launches keep the requested geometry. Lines `BENCHMARK call_resolution requested=... effective=... reason=...` and receiver `BENCHMARK decoder_output_size`. Untested on phones. | | Measured Auto ladder (`test_auto_ladder`; optional `test_auto_ladder_class=strong\|standard\|weak`) [AutoQualityFeature] | **Off** (DEBUG launch extra only; Experimental) | With Video quality on Auto (never with Manual video settings, never with a raw benchmark input) the start rung comes from the measured link instead of the fixed 240p / 15 FPS / 200 kbps default: unmeasured plans a 300 kbps link (360p15); measured plans 85% of the goodput (a call direction gets half of the measured link) with the wire model `1.076 x (video + 0.64 x fps) + audio wire`. Rungs by link goodput (AV1; VP9 differs at the 360p and 480p steps): 60 audio only, 100 240p10 (Opus 16 only), 150 240p12, 200 240p15, 300-450 360p15, 700 480p15 (720p15 for a strong AV1 encoder), 1000 480p24; 24 and 30 FPS only above their fitted minimum. Frame rate is cut before resolution above 15 FPS, resolution before frame rate below it. The request is a climb bound, not a fixed ceiling (a call: the transport maximum; a stream: the learned fan-out budget for one link, at most the transport maximum; fan-out admission still plans at the start rate): the rate controller starts at the plan's rate and climbs only on clean evidence, the resolution ladder moves one rung up after 1.25x headroom held 10 s (15 s between changes) and down after 1.5 s under the rung's need, inside a header that is the class maximum. Calls and live streams use the same ladder; the start rests on delivered evidence (the lower of the probe and the 1M ceiling of a remembered bad-2M peer, capped at what the peer's link last carried; a probe alone starts one rung down), and a climb needs windows that carried 80% of the target. A live stream changes its picture size rarely (down: target under the rung's limit for 3 s and 3 s of continuous queue or lateness evidence; up: 30 s clean, 30 s of target headroom and 60 s since the last change) and viewers keep the last frame through a marked size change. A rise of the frame rate or picture size waits for the sender and peer queues to stay within 150 ms (and no late child) for 5 s; the arm-time start and header use the cold-start rule before delivered evidence (a probe alone: 30% of the plan's video, at most 140 kbps, 240p class, 15 FPS at most). A software encoder is offered only the sizes it can encode at 80% of the frame interval (measured encode time per megapixel; weak CPUs are classed WEAK) and steps a rung down when its live load stays over 1.0; the call frame rate has hysteresis (down after 2 s, up after 10 s, one level); call video pauses (audio first) when the controller sits at its floor and the link stays behind for 6 s. A stream restarts its camera encoder at a rung change (not while paused). Manual picker: a starved combination (bits per pixel per frame under the fitted floor) shows a warning; the request is never changed. Also makes the call resolution ladder default on for the launch, with the measured 640x360 limits (AV1 150, VP9 175 kbps instead of WebRTC's 256 / 190). Logs `BENCHMARK auto_ladder: scope=call|stream|call_start|stream_resolution|stream_fps|call_fps ...` and `call_resolution`. Untested on phones. | | GPU scale ladder (`test_gpu_scale_ladder`) [GpuScaleFeature] | **Off** (DEBUG launch extra only; Experimental; needs `test_auto_ladder=true`) | Capture once, scale on the GPU. With the measured Auto ladder on and a hardware surface-input encoder, the camera opens once at one capture mode (the smallest mode that covers the session's largest picture in its aspect; cropped on the GPU when the sensor has no such aspect) and every frame is drawn through an EGL crop/scale into the encoder's input surface at the rung size. A stream or call rung change then rebuilds only the MediaCodec (its first frame is a keyframe, marked as a discontinuity so viewers rebuild their decoder as before) and never restarts the camera; every rung keeps the session aspect (720/540/480/360/240/180/144p in 16:9, rounded to the encoder's alignment, a few pixels cropped rather than stretched), and the ladder's rungs are no longer limited by the camera's mode list. Software (YUV input) encoders such as the native libaom, raw fixtures, benchmark reference frames and Manual video settings keep the camera-mode path unchanged. Costs one GPU draw per frame; a YUV to RGB to YUV round trip in the encoder input (colour not yet compared). Logs `BENCHMARK gpu_scale: scope=stream|call ...`, `capture opened`, `rung=WxH | encoderResetMs=... | gpuMs/frame ...` and `firstFrameMs`. Untested on phones. | | Call audio focus (`test_call_audio_focus`) [call_audio_focus] | On for user sessions; benchmark launches off unless the extra | `CallAudioFocus`. A call holds AUDIOFOCUS_GAIN_TRANSIENT for USAGE_VOICE_COMMUNICATION while its audio session is open, so music, podcasts and navigation pause instead of playing over the call. A transient loss (a cellular call ringing or active) holds the call on the existing mute path, so the peer gets Microphone muted (signal 9) and the screen says "On hold"; regaining focus restores the user's own mute choice. Permanent and duckable losses are only logged. Line `BENCHMARK call_audio_focus`. Untested on phones. | | Call ringer policy (`test_call_ringer_policy`) [call_ringer_policy] | On for user sessions; benchmark launches off unless the extra | `CallRinger`/`CallRingerPolicy`. An incoming call follows the ringer mode (silent: nothing; vibrate: vibration only; normal: ringtone, and vibration when "vibrate when ringing" is on or unreadable) and Do Not Disturb (no ring under None/Alarms; Priority rings only when calls from anyone are allowed). Vibration uses USAGE_RINGTONE attributes. If the default ringtone cannot be read (some Samsung phones throw SecurityException) it falls back to the settings default, then a bundled generated tone (`res/raw/call_ring_fallback.wav`). Before, it always vibrated and rang, and a Samsung exception played nothing. Line `BENCHMARK call_ringer`. Untested on phones. | | Outgoing ringback (formerly Settings > Features "Ringing sound while calling"; DEBUG `test_call_ringback` is the benchmark switch) [call_ringback] | On since 2026-10-17, no Settings switch (design decision; a constant with no stored choice). One constant, `CallFeatureDefaults.RINGBACK` (true) | The caller hears ToneGenerator TONE_SUP_RINGTONE while the callee rings, instead of silence; it stops on any other phase. A voice call plays it on the voice-call stream (the earpiece, where the phone is held); a video call on the music stream (the loudspeaker), because the call's audio session, which owns the route, does not exist yet. Not yet tested on phones. | | CallStyle call notifications (formerly Settings > Features "Answer and decline from notifications"; DEBUG `test_call_style_notification` is the benchmark switch) [call_style_notification] | On since 2026-10-17, no Settings switch (design decision; a constant with no stored choice). One constant, `CallFeatureDefaults.CALL_NOTIFICATIONS` (true) | `CallNotificationActions`. The incoming alert becomes NotificationCompat.CallStyle with Decline/Answer and a full-screen intent (Android downgrades it to heads-up without the Android 14+ special access); the call's foreground-service notice becomes an ongoing CallStyle with Hang up while calling and in the call. Answer opens the call screen (the camera/microphone service still starts while visible); Decline and Hang up are not-exported broadcasts. Every action carries a per-process random token, because MainActivity is exported. Names follow `NotificationPrivacy` : the incoming person is the caller in the full notification, which a secure lock screen replaces with the name-free public version; the ongoing notice never names the peer. Showing the call screen over the keyguard is the separate lock-screen switch. On Android 13+ the notification permission is part of call readiness (see "Call readiness permissions"). Line `BENCHMARK call_notification_action`. Not yet tested on phones. | | Camera pause at the ear (formerly Settings > Features "Pause video at the ear"; DEBUG `test_call_proximity_camera_pause` is the benchmark switch) [call_proximity_camera_pause] | On since 2026-10-17, no Settings switch (design decision; a constant with no stored choice). One constant, `CallFeatureDefaults.EAR_CAMERA_PAUSE` (true) | `CallProximityCameraPause`. In a video call with audio on the earpiece and the proximity sensor near, the outgoing camera stops and the peer gets Camera off; far again, or a route change away from the earpiece, restarts it with Camera on. The user's camera choice is unchanged; the screen-off lock above is separate and unchanged. Line `BENCHMARK call_proximity_camera`. Not yet tested on phones. Experimental hysteresis (`test_proximity_pause_hysteresis=true`, default off): near must hold 1.5 s before the camera pauses and two toggles are at least 3 s apart, so a flapping sensor cannot cause a camera session rebuild per flap; kept off until the 50-cycle ear check on the ASUS because it changes a shipped feature. | | Call audio starvation concealment (`test_call_audio_conceal`) [call_audio_conceal] | Off (A/B later) | `AudioConcealment` (NetEq Expand/Merge/Accelerate ideas, WebRTC BSD-3, see THIRD_PARTY_NOTICES). When the call AudioTrack holds under 30 ms and no decoded audio is waiting, 10 ms chunks repeat the last pitch period under a 60 ms fade into comfort noise (at most about -50 dBFS) instead of underrunning; the next real audio is cross-faded in over 5 ms. Playout faster than 1.0x is then allowed only in quiet chunks unless the backlog exceeds 400 ms beyond the device baseline. Concealed samples are not traced as accepted media and do not move the sender timeline; they can add up to about 40 ms of queued audio after a gap. Line `BENCHMARK audio_conceal`. Untested on phones. | | Caller names on the lock screen (Settings > Privacy "Show names on lock screen"; preference `notification_privacy`/`lock_screen_peer_names`; DEBUG `test_lock_screen_peer_names`) [lock_screen_peer_names] | Off (a privacy opt-in, not a promoted feature) | `NotificationPrivacy`, rule (2026-10-11): names while unlocked, hidden on the lock screen. Call/stream notifications are VISIBILITY_PRIVATE with a public version that says only "MeshLive / Active" or "Incoming call"; the incoming-call alert's full version (what an unlocked phone shows) names the caller, and a CallStyle alert uses the caller as its person. Opted in, the alert is VISIBILITY_PUBLIC, so the name shows on a secure lock screen too, and so does the call screen's. The ongoing call notification (CallStyle person) and a stream viewer's notification carry the peer's name in their full version only (2026-10-14, design decision Q16), with the same name-free public version, and become public only with this opt-in; audio-only live notifications never carry a name. Message notifications never carry text or sender: VISIBILITY_SECRET, or a generic "New message" when names are allowed. Before, neither set a visibility or public version, so a lock screen showing all content showed the activity and the caller's name. Android's own lock-screen setting "show all notification content" still overrides the public version, for every app. While a call lasts, the call screen follows the lock state: the real name after an unlock, "Encrypted call" again after the screen relocks. Not yet tested on phones. | | Always reachable for calls (`test_always_reachable`) [always_reachable] | On for normal launches; Off for DEBUG benchmark launches unless the extra is `true` | Found by the first iPhone -> Android test: on Home `dumpsys bluetooth_manager` showed `Advertisers: []`, so no caller could find the phone. On: the private-call endpoint (the same availability advertisement and GATT/CoC listener as the Call screen, no scanning) also runs on Home and Settings-from-Home while the app is visible, and in the background while the session service holds the connectedDevice type (started while visible, Android 12+ rule; a refusal stops the endpoint and `BENCHMARK foreground_service` reports it). Going live or watching closes it so the stream offer, title pages and viewer forwarder keep their advertising sets; it never adds a scan start. Low battery and Battery Saver do not turn it off (design decision: relay helping pauses, calls stay core). A call that arrives through Home returns to Home. Home shows "Reachable for calls - Visible to nearby phones" only while the endpoint really advertises. Off: exactly the old Call-screen-only behaviour. Line `BENCHMARK call_reachability`. The Settings switch "Reachable for calls" (default on, preference `reachable_for_calls`) is the user off-switch for normal launches. Untested on phones. | | Reachable while browsing (`test_reachable_while_browsing`) [reachable_while_browsing] | On for normal launches; Off for DEBUG benchmark launches unless the extra is `true` (`false` is the control arm) | The Live tab is the landing screen and the stream browser (auto-scan). Opening it used to apply the Watch rules and close the call endpoint, so a phone sitting on the Live tab could not receive calls. On: browsing (scanning only) keeps an idle call endpoint (call availability advertisement plus GATT/CoC listener, no scanning) running, exactly as on Home, because scanning uses no advertising set; it closes when the user joins a stream (`connectToStream`) or goes live and comes back when they return to browsing. The advertising-set budget argument (stream offer, viewer request, title set) applies only once one of those is on air. An incoming call while browsing stops the stream scan, shows the incoming-call screen, and returns to the Live tab (scan restarted, endpoint restored after the same GATT server pause) when it ends. Same gates as Home: the "Reachable for calls" setting and `test_always_reachable` still decide whether any endpoint runs, background needs the connectedDevice foreground service (Android 12+), low battery and Battery Saver do not turn it off, and it adds no scan start (ScanStartGate). Off: exactly the old behaviour (the Live tab closes the endpoint). Line `BENCHMARK call_reachability: reachable_while_browsing=...` and `role=... reason=live_browsing`. Phone check: a call to a phone resting on the Live tab rings, over BLE GATT and over BLE L2CAP (CoC). Untested on phones. | | In-call link renewal (`test_call_link_renewal`, both phones; DEBUG hook `test_call_tx_stall_after_s=N`) [call_link_renewal] | On (design decision 2026-10-21; `false` is the control arm) | A direct GATT call whose one direction stalled (the phone that stopped hearing its peer still sends) replaces its hop link instead of ending: the silent side offers a renewal, the other side opens a one-time connectable advert, and the call moves to a new link with the same call ID and end-to-end keys. The old link is closed only after the new advert was found, and every failure ends the call within 12 s of the offer. Relayed, Classic, CoC and verification sessions are never renewed. Wire rules: PROTOCOL "Experimental in-call link renewal"; reasons and both reviews: DECISIONS "Design SR". The hook stops this phone's sending on the call's first link N s after the call became active, to induce the stall. CF-CALL-21 is amended (a renewal replaces only the hop link; the call ID and end-to-end keys continue, so it is not a redial; DECISIONS "Design SR") and the user guide says what the user sees. Still open: the A15s-as-peripheral connect failure behind the 3 lost calls. A renewal-negotiated direct GATT call also runs the 14 s stall rule whatever `test_call_stall_tolerance` says (see that row). **Requires / masked by:** a call whose peer lacks the renewal bit, and every relayed, Classic, CoC or verification call, is never renewed. Video fragments and renewal are both on by default for direct GATT user calls (benchmark launches get neither); the combination was not exercised by the 13/16 renewal A/B, which ran with fragments off. | | Stranger call follow-up (`test_stranger_call_followup=false`) | On | DEBUG control disables the after-call Add prompt and restores old Recent taps. Default offers Add contact and explains how to call a stranger again. No stranger keys are saved. | | Complete Recent calls (`test_complete_recent_calls=false`) | On | DEBUG control projects active-only, five-person history at load time without truncating the retained 50 entries. Default records ended calls with direction/outcome, retains 50 calls and reads the old store without inventing old directions. | | Full-screen call guidance (`test_full_screen_call_guidance=false`) | On | DEBUG control skips the new onboarding step and status-sheet warning. Android 14+ opens the app's full-screen call setting; users can skip. | | Call readiness permissions (`test_call_readiness_permissions=false`) | On | DEBUG control restores camera-at-onboarding and notification-independent readiness. Default defers camera until video use and requires notifications on Android 13+. | | Honest call status (`test_honest_call_status=false`) | On | DEBUG control arm restores Ready during watching/broadcasting; default explains paused calls in the chip and sheet. | | Bluetooth headset for call audio (Settings > Video and calls "Bluetooth headset for call audio"; DEBUG `test_call_bluetooth_output`) [call_bluetooth_output] | Off (until phone test T-OUT1) | Lists a connected Bluetooth audio device (SCO, LE Audio headset, hearing aid; Android 12 and newer) in the call screen's audio output sheet and lets the user pick it. Call audio never goes to a Bluetooth headset otherwise, because SCO and LE Audio share airtime with the Bluetooth link that carries the call itself (PLAN "Bluetooth headsets"); it is never chosen automatically even with the switch on. Off: the sheet lists the phone speaker, the earpiece and plugged-in wired/USB headsets only. | | Native libopus call audio (Settings > Features "Call audio with loss recovery"; DEBUG `test_call_native_opus` [call_native_opus] is the benchmark switch; sub-switches `test_call_native_opus_fec` [call_native_opus_fec] (default on), `test_call_native_opus_loss_pct` [call_native_opus_loss_pct] (default 10) and `test_call_native_opus_dtx` [call_native_opus_dtx] (default off) stay DEBUG-only: release builds always use FEC on, 10 % and no DTX) | Promoted 2026-10-11 (default OFF until its phone test passes; one constant, `ConnectivityFeatureDefaults.NATIVE_OPUS`; design decision) | Calls encode and decode Opus with the pinned libopus 1.5.2 (statically linked in `libbluetooth_video_native.so`) instead of MediaCodec: OPUS_APPLICATION_VOIP, in-band FEC with a fixed expected-loss hint, the same rate, 20 ms frames, complexity 10 and constrained VBR. The decoder conceals a gap of up to 100 ms with libopus PLC (the frame just before the arriving packet comes from its FEC when present) at the missing sender timestamps; a longer gap keeps its remainder for the existing sender-gap silence alignment. Concealed frames are counted separately, never as received packets. It is independent of `test_call_audio_conceal` [call_audio_conceal]: native PLC fills missing sender timestamps at decode, that switch fills a dry AudioTrack at playout; both may be on. Why: c2.android.opus hard-codes general-audio mode, no FEC, no DTX and no concealment (AOSP C2SoftOpusEnc.cpp/C2SoftOpusDec.cpp), while WebRTC and Signal use libopus with VOIP mode, FEC and PLC; on a gap the MediaCodec path plays silence. The packets are standard Opus with the Codec2 configuration block, so MediaCodec and iOS libopus peers decode them and older receivers need nothing new; each side switches independently. DTX (sub-switch, off) changes the packet cadence and needs its own A/B plus a PROTOCOL note before promotion. Library or encoder failure falls back to MediaCodec and logs `requested=native | impl=mediacodec | reason=native_unavailable`. Lines: `BENCHMARK audio_codec: role=encoder|decoder | impl=native|mediacodec | fec=... | plc=...`, `BENCHMARK audio_plc` and `BENCHMARK audio_codec_stats`. APK size (always built in, even with the switch off): arm64 `libbluetooth_video_native.so` 7,368,680 -> 7,785,168 bytes (+407 KB stored uncompressed for 16 KB alignment, about +244 KB deflated), 16 KB check OK. **Phone test (A/B, switch off versus on at both phones; M31 <-> ASUS and S24 <-> A15s calls, 3 repeats each, then one call against an iOS or MediaCodec-only peer), pass when:** audio drops and audio-queue age p95 are no worse with the switch on, concealed frames appear under real loss and never count as received packets, no `reason=native_unavailable` fallback occurs on the four phones, the call survives a switch change between calls, and the mixed-codec call plays both ways. DTX stays off: it needs its own A/B and a PROTOCOL note. Not yet tested on phones (speech quality, CPU, FEC overhead at 16/32 kbps and interop with MediaCodec and iOS peers are unmeasured). **Requires / masked by:** the sub-switches (`call_native_opus_fec`, `_loss_pct`, `_dtx`) do nothing unless `call_native_opus` is on. | | Raw self-view rate (`test_call_raw_self_view_fps`) | 5 FPS in raw benchmark calls | Self-view picture-in-picture from the raw fixture. 0 turns it off. | | Call head-of-line policy (`test_call_head_of_line=off\|gatt_default\|on`) [call_head_of_line] | User launches: fragments and speech-led playout on direct BLE GATT calls; benchmark launches: the separate extras (default off) | One resolver for "Call video fragments" and "Speech-led call playout" (it replaced the release-only `callGattSpeechDefaults` flag). `gatt_default` makes a benchmark launch measure exactly the user policy, `off` is the control arm, `on` turns both on for every transport; without the extra a benchmark launch reads the two separate extras. Speech-led playout of the user policy and of `gatt_default`/`on` applies only when the call negotiated fragments: only the pair was measured (DECISIONS "Known interactions" B5); the separate `test_call_speech_led_playout` arm stays ungated. Each call logs its source. | | Dial a recent sighting at once (`test_dial_recent_sighting`) [dial_recent_sighting] | Off (Experimental) | A call to a row heard under 1.5 s ago connects to that sighting at once instead of first waiting for a sighting newer than the tap (`RecentSightingDial`). Not within 10 s of a local rotating-ID epoch boundary, where the old address stops advertising; relayed rows keep their reach-table path. No wire change: the callee sees the same connection to the same advert, and a failed connection takes the existing call failure and rediscovery path. Reason (2026-10-26, saved runs, 2238 dials): the wait for a fresh sighting took a median 160 ms (p90 591 ms) before anything else in tap-to-ring. A/B: tap-to-OUTGOING_RINGING per caller, plus the count of fast dials whose connection failed. Line `call_invite: peer_seen | ... | matchedBy=recent | ageMs=`. | ## Transports, PHY and Bluetooth-stack behaviour | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Classic without pairing (Settings > Advanced > Connection "Classic Bluetooth without pairing" (confirmation dialog "Connect without pairing"), confirmed by a dialog that explains visibility; DEBUG `test_classic_bond_free`, `test_classic_bounded_connect`, `test_classic_request_visible` are the benchmark switches) [classic_bond_free, classic_request_visible] | Promoted 2026-10-11 (default OFF until its phone test passes; one constant, `ConnectivityFeatureDefaults.CLASSIC_WITHOUT_PAIRING`; design decision) | Both phones need it. Calls, the call relay and the stream relay switch from Android's secure RFCOMM (which demands a numeric-comparison bond on both phones) to the insecure sockets streams already used; BMH2/BMS1/BME1 authentication and ordered encryption are unchanged and never depended on Android link security (LE CoC/GATT have none). Android then pairs Just Works without a dialog and stores no bond. Each phone remembers, in no-backup storage, the Classic address at which an authenticated identity was reached in either direction (64 entries, 30 days; a hint that BMH2 re-verifies, never trust), so a phone met once is paged directly. A first call to a never-met phone runs one Classic inquiry (about 13 s) after the normal candidates fail; the other phone must be visible (its Bluetooth settings screen, or the system visibility prompt it shows once per process when ready for Classic calls; benchmark launches only with `test_classic_request_visible`). Streams never run an inquiry (the broadcaster's 12 s budget) and reach only phones already met. Lines `Classic connect plan`, `Classic candidate failed`, `Classic connected`, `Classic address learned`, `BENCHMARK classic_features`. Turning the switch on asks first: a phone that is ready for Classic calls may ask Android to make it visible to nearby Bluetooth devices for two minutes (Android shows its own prompt), and anyone nearby can see its Bluetooth name meanwhile. It also turns on the bounded connect path (per-attempt deadlines, shared candidate order); that path alone stays a benchmark extra. Turning it on or off rebuilds an idle Classic call listener so the next call follows it, and turning it off deletes the remembered Classic addresses (they are used only while it is on). Failure texts name the setting: without it they say to pair, to turn on "Connect without pairing" on both phones, or to use BLE GATT; with it they say that both phones need it and that a phone not met before must be visible. **Phone test (S24, M31, ASUS: one pair with no Android bond, bonds removed first), pass when:** (1) with the switch on in both phones no Android pairing dialog appears and no bond exists afterwards; (2) a first Classic call to a visible phone connects within 30 s (inquiry), and the next call connects in under 12 s using the learned address (`Classic connected | kind=learned`); (3) with the other phone not visible the first call ends within 30 s with the "visible" text, never a hang; (4) with the switch on in one phone only the call ends with the text naming the setting; (5) switching off restores the secure path (`secure=true` in `Classic connect plan`); (6) a stream reaches a phone already met, and an unmet phone fails by the 12 s stream budget. Not yet tested on phones. | | Bounded Classic connect (`test_classic_bounded_connect`; implied by Classic without pairing) [classic_bounded_connect] | Off | Every Classic role (stream, forwarder, call, call relay, stream relay) uses one connect path: a remembered or exactly bonded address first, then bonded phones and computers only (the call relay used to page earbuds and cars), each attempt bounded (exact address 12 s, bonded guess 3 s, BLE address 7 s; 11.5 s for streams inside the broadcaster's 12 s, 30 s otherwise), and a peer this phone cannot resolve fails within seconds with a pairing explanation instead of a bare timeout. A relayed call refuses a link that reaches the endpoint directly. Off: each role connects as before. Untested on phones. | | Classic random-address skip off (`test_classic_page_random_address=true`) | Skip on Android 15+ | Control arm. Where Android reports a scanned BLE address's type (API 35+), Classic never pages an address reported random: BR/EDR paging can only reach a public address, and an unbonded peer's private address cost up to 12 s. Older APIs page it as a 7 s guess. Matcher `classicScanAddressTypeReported`, shaped for the policy registry. | | Classic address overrides (`test_classic_addresses=prefix=AA:BB:..,...`, or `test_classic_address` + `test_classic_address_node`) [classic_addresses] | None | DEBUG: a run supplies a peer's Classic address read over adb for its 12-hex-digit announcement prefix; tried first, never stored. Samsung masks its own address in `dumpsys`, so this cannot always be filled automatically. | | LE PHY (`test_ble_phy=1m`, `coded-s2` or `coded-s8`) [ble_phy], GATT request MTU (`test_gatt_request_mtu`, 23-517) [gatt_request_mtu] | 2M, 517 | 1M is a control for the ASUS <-> A15s ~65 kbps link. Coded is a long-range option for low-rate links such as voice calls (Core spec: 500 kb/s S2, 125 kb/s S8; measured GATT 154/47 kbps, CoC 81/40 kbps); both phones must support it (the M31 does not), connections still start on LE 1M, and learned link capacity is stored apart from 2M. Android 14+ clients always negotiate MTU 517 and ignore the MTU request. 2M stays the only selectable PHY. The `experimentalBle1mPhy` alias of this switch is used only by tests (**Not wired**). Untested on phones. | | Aligned GATT values (`test_gatt_aligned_writes`) [gatt_aligned_writes] | Off | A 512-byte GATT value is a 519-byte L2CAP PDU, sent as three LE data PDUs of 251 + 251 + 17 octets; 495-byte values fill exactly two (about 245 instead of 170 payload bytes per PDU: +12% airtime efficiency on 2M, more where a controller limits PDUs per connection event). Writes and notifications. Untested on phones. | | Stream source remembers a 2M link that went silent (`test_stream_adaptive_phy=false` is the control arm) [stream_adaptive_phy] | On | A direct GATT stream whose single 2M link ended with a link-layer connection timeout (GATT status 8) or the sender's own heartbeat timeout, with the peer's silence starting within 15 s of hop authentication, marks the authenticated hop peer bad on 2M (`BENCHMARK adaptive_phy: link=stream ... early_loss=true`); the next stream link to it requests 1M. A link closed unfinished (the broadcaster's 12 s connection timeout or a connection timeout during the hop handshake, 5 s or more after it opened) counts from the link opening, keyed by the advertised prefix when the peer never authenticated (not for a rotating pseudonym). A viewer leaving (status 19), a quick user cancel, a relay-capable peer, a fan-out source and a 1M link never count. Needs `test_adaptive_phy` and `test_adaptive_phy_memory`. Why: an A15s source never kept a 2M link to the ASUS (9 of 11 attempts ended in status 8 within 5-18 s, the rest by an app close after the link crawled), and the stream path was the only dial without the call and message protections. The first link to a new peer still pays the failure. Off redials on 2M every time; phone-measured, the off arm reproduces the failure (regression control, no routine A/B). | | Call stall remembers 2M at the stall (`test_stall_remembers_2m=false` is the control arm) [stall_remembers_2m] | On | A direct call dial whose first 2M setup was closed by the stall guard records the callee's advertised node as bad on 2M immediately, instead of only after the 1M retry authenticated. A retry that cannot find the callee again (the callee still holds the dead link and is not advertising) used to leave no memory, so every later call repeated the 3 s stall. A relayed offer, a rotating (pseudonym) advertisement not recognised as one contact (see `phy_memory_contact_key`) and any other setup failure are not recorded. Needs `test_adaptive_phy_memory` and `test_phy_setup_stall`. | | Call stall re-discovery budget (DEBUG `test_stall_rediscover_ms`; default 8000, A/B arm 20000) [stall_rediscover_ms] | Off (Experimental; 8000 = earlier behaviour) | After a call setup stall the 1M retry rescans for the callee's new advertisement. Today the scan is capped at 8 s and clamped by the 15 s early-loss window; when the A15s controller never delivers LL_TERMINATE, the ASUS only sees its supervision timeout 7-9 s later and re-advertises about 1.6 s after that (8.6-12.6 s after the close), so the scan ends with nothing and the retry dials the stale address (a 12 s connect timeout). With a value above 8000 the stalled path keeps scanning for the configured total (not clamped by the early-loss window; 60 s at most), dials a found fresh address at once and dials the stale address only if nothing was found. Hop-local, a direct offer only (a relayed offer keeps its relay), no wire change; the setup-stall guard, the bad-2M memory and the single 1M retry are unchanged. Tradeoff: a callee that never re-advertises costs the full total before the stale dial. Call dials only (the message-exchange retry keeps 8 s). Not yet run on phones. | | Fast 0x3E redials on a call's first dial (`test_fast_0x3e_dials`) [fast_0x3e_dials] | Off (Experimental) | A call's first GATT dial that fails fast with HCI 0x3E (GATT 133 or 62 within 1.5 s, no link) is redialed up to 4 dials in total, 0.3 s plus a random 0-0.5 s apart, while the next dial starts inside the call's early-loss window (`FastEstablishDials`); every other failure and every other connect path keep the ordinary rule (one redial after 0.5 s). Reason (2026-10-26, S22 and ASUS with HCI snoop on both): about 1 call in 10 failed when the caller got Connection Complete and then 0x3E 250-440 ms later twice, while the callee never received the CONNECT_IND, a per-event radio miss rather than an absent peer. The jitter keeps redials from repeating the same timing against the callee's scanning. No wire change. A/B: call success rate and tap-to-ring on the same pairs. Line `fast_0x3e_dial: failedAttempt= | delayMs=` or `action=give_up`. With `test_fast_fail_redial_all` too: that arm covers the BLE control client (CoC calls, streams, relay hops) with the same classifier and backoff, so both on = the rule on every LE connect. | | Call 1M retry waits for the stalled link's ACL (`test_setup_retry_acl_wait=false` is the control arm) [setup_retry_acl_wait] | On | After the 2M setup-stall guard closes a call link, the 1M retry waits (at most 5 s) for that link's Bluetooth connection (ACL) to go down before it dials. The ACL can linger 1-4 s after the close while the callee already advertises again under a new address; a dial into the lingering ACL fails at once (HCI 0x3E, then 0x0B, GATT 133) and the call failed with most of the 15 s setup window unused. The watch is armed before the close and keyed by the old device, because the retry dials a new address; the stack's GATT connected list is not used (the A15s reports the peer disconnected while its ACL is up). Logs `BENCHMARK setup_retry_acl_wait: waitedMs=... \| dropped=true\|false`. Since the connection review (T1, 2026-10-27) the wait runs while the retry rescans (not before it) and is bounded at 1.5 s on Android 10/11, whose stacks never deliver the ACL broadcast for an unnamed random-address peer (9 of 9 A15s retries logged `waitedMs=5003 \| dropped=false`). | | Fast-failure redial for the BLE control client - Experimental (`test_fast_fail_redial_all=true`; DEBUG only) [fast_fail_redial_all] | Off | Connection review T2/A11 (2026-10-27). A GATT 133/62 within 1.5 s (HCI 0x3E: the peer never answered the connect request) on the BLE control client (every CoC call, stream and relay-hop connect) is redialed after 300 ms + a random 0-500 ms while elapsed + delay + 600 ms fits an 11 s budget (and any outer setup budget), on any PHY. A slow failure (own deadline, the stack's 30 s timeout: out of range) still ends through the ordinary rule. How it relates to `test_fast_0x3e_dials`: that switch is the same rule for a call's first GATT video dial; this one covers only the BLE control client, so the two never act on the same connect and can be on together (the unified rule = both on). One classifier (`ConnectDurationClass.FAST_ESTABLISH_FAILURE`) and one backoff (`FastEstablishDials.delayMs`, used by `FastFailRedial`); only the window differs (11 s from this client's first connect here, 4 dials inside the call's early-loss window there). Line `BENCHMARK fast_fail_redial: link=ble-control \| ... \| delayMs= \| elapsedMs= \| budgetMs=11000`. Phone A/B: CoC calls and CoC relay hops on a pair with fast-0x3E failures (S22 <-> ASUS, A15s -> ASUS), ABBA, metric = setup success rate and dial-to-RINGING time; guard = no longer time-to-failure for an out-of-range peer. | | Stall retry with its own budget - Experimental (`test_stall_retry_own_budget=true`; DEBUG only) [stall_retry_own_budget] | Off | Connection review A10 (2026-10-27). The 1M call retry after a 2M setup stall gets 10 s from the retry's own start for its stall-retry dials instead of what is left of the first attempt's 15 s window, and skips the ACL wait. Lines: the existing `stall_retry_dial`; no `setup_retry_acl_wait` line in this arm. Phone A/B: A15s -> ASUS calls (the stall pair), metric = retry success rate and stall-to-ACTIVE time. | | Setup-stall guard arm - Experimental (`test_setup_stall_ms=N`, 0 = off; DEBUG only) [setup_stall_ms] | 0 (3000 ms, `AdaptivePhyMemory.SETUP_STALL_MS`) | Connection review A14 (2026-10-27). The first 2M call attempt's stall guard closes a link whose hop authentication has not finished N ms after it opened (healthy pairs authenticate in 71-269 ms after the link opens; the arm to test is 2000). Lines: the existing `adaptive_phy: ... reason=setup_stall \| guardMs=N`. Phone A/B: A15s -> ASUS and S22 -> ASUS calls, 3000 vs 2000, metric = dial-to-RINGING time and false stalls (a stall on a pair that authenticates on 2M otherwise). | | Relayed SETUP budget from hop authentication - Experimental (`test_relayed_budget_from_hop_auth=true`; DEBUG only) [relayed_budget_from_hop_auth] | Off | Connection review A13 (2026-10-27). The caller's 20 s relayed SETUP budget starts when the first hop authenticates instead of at the dial, so a slow first hop (rescan, 1M retry) does not eat the helpers' share. Lines: the existing `relayed_setup: role=caller \| result=...`. Phone A/B: relayed calls through one helper with a slow first hop, metric = relayed setup success and `reason=` of failures. | | Unified connect-retry policy - Experimental (`test_unified_connect_policy=true`; DEBUG only) [unified_connect_policy] | Off | Simplification pass A (2026-10-27), the single rule proposed by the connection review: every LE connect (GATT video and BLE control client: calls, messages, streams, relay hops, any PHY) redials a fast HCI 0x3E failure (GATT 133/62 within 1.5 s) after 300 ms + a random 0-500 ms while the dial still fits one absolute budget (the caller's setup deadline, at most 11 s after the first connect); any other failure keeps the ordinary class and attempt rule against the same budget; every per-attempt wait (GATT 12 s, control 6 s, the CoC socket bound, the MTU wait inside the attempt) is clipped to the budget (at least 2 s), and an attempt not connected after 5.5 s ends there. A relay helper's next hop gets one budgeted layer: no same-connect retry (`test_relayed_hop_retry`), the client loop runs inside the SETUP deadline minus 3 s, and the 2M -> 1M hop retry follows only a link that connected (stall or authentication failure). Replaces `test_fast_0x3e_dials`, `test_fast_fail_redial_all`, `test_stall_retry_dials` and the ordinary unbounded budget while on; `test_relay_establish_budget` still applies first where requested. Off: today's rules exactly, expressed as `ConnectRetryConfig.DEFAULT` and proven equal by JVM equivalence tests. Line `BENCHMARK unified_connect_retry: link= \| failedAttempt= \| class= \| action=dial/ordinary/give_up \| delayMs= \| elapsedMs= \| leftMs=` (GATT setup stage `connect_phase_timeout`). Phone A/B: direct CoC and GATT calls on pairs with fast-0x3E failures (S22 <-> ASUS, A15s -> ASUS), a relayed call through one helper, and an out-of-range callee; ABBA; metrics = setup success rate, dial-to-RINGING time, time to the failure notice for an absent peer (guard: not longer than off). | | CoC control reattach makes one short attempt (`test_coc_attach_single=false` is the control arm; DEBUG only) [coc_attach_single] | On (release too) | Connection review T5 (2026-10-27). After a CoC socket connects, the GATT control client that adds priority and PHY control makes one attempt bounded at 3 s and returns at once; the PHY request follows 600 ms later, off the connect path. Control arm: up to 3 attempts of 6 s, and the connect waits the 600 ms. Phone check: CoC calls and streams on every pair, metric = connect-to-first-byte time and PHY reached (2M where requested). | | Call 1M retry redials fast 0x3E failures (`test_stall_retry_dials=false` is the control arm) [stall_retry_dials] | On | Only for the 1M retry after a 2M setup stall, and only for a fast HCI 0x3E failure (GATT 133 or 62 within 1.5 s): up to 4 GATT dials in total, 1.5 s apart, each starting inside the call's 15 s early-loss window, instead of 2 dials 500 ms apart. Every other failure and every other connect path keep the ordinary rule (the stall retry's own `SetupDeadline`). GATT transport only. Logs `BENCHMARK stall_retry_dial: ... delayMs= \| remainingMs=` or `action=ordinary_rule` (review T3, 2026-10-27: out of stall-retry dials the ordinary rule still decides, so a stall retry never gets fewer dials than another connect; it was `action=give_up`). | | Bad-2M memory keyed by a recognised contact (`test_phy_memory_contact_key=false` is the control arm) [phy_memory_contact_key] | On | A call offer with rotating IDs (pseudonyms) changes its advertised ID every epoch, so the bad-2M pair memory, looked up under that ID but written under the authenticated node after a 1M retry, never matched and every call repeated the 3 s stall. A rotating offer recognised as exactly one contact (local presence-lease recognition) is now looked up, and recorded at the stall, under that contact's node label; an unrecognised one has no memory and keeps the requested PHY. The memory stays in the phone's local preferences and is never sent. The connect log shows the move (`reason=remembered \| ... \| key=contact`). Phones that are not each other's contacts are unaffected. Since 2026-10-26 a relayed-call helper also looks up its next hop's rotating offer this way (it used the epoch's ID before, which no entry is written under); it still never records from a relaying hop. | | Per-direction GATT PHY (`test_gatt_phy_split=tx2m-rx1m` or `tx1m-rx2m`) [gatt_phy_split] | Off (Experimental) | The GATT client asks for different PHYs per direction (`setPreferredPhy(tx, rx)`); applies to the 2M default only (a peer remembered bad on 2M and a `test_ble_phy` arm keep their PHY). `tx2m-rx1m` keeps the central's media direction on 2M and moves the peripheral-to-central direction, the one that went silent on the A15s-central/ASUS-peripheral pair, to 1M; `tx1m-rx2m` is the mirror, so a pair of runs names the failing direction. Logs `BENCHMARK gatt_phy_split` and the observed `PHY TX/RX` line. The controller may refuse an asymmetric pair and keep the old PHY; the observed PHY line is the result. Phone-measured: it helps the A15s <-> ASUS pair but costs or does not help healthy pairs, so it stays a non-default arm (at most a DeviceFamilyPolicy proposal for mt6765, which needs outside evidence). | | 2M requested only after hop authentication (`test_phy_after_auth`) [phy_after_auth] | Off (Experimental) | The stream source's GATT link opens on 1M and asks for 2M only once the hop peer is authenticated, unless the authenticated node is remembered bad on 2M (`BENCHMARK phy_after_auth`). The hop handshake never runs on a 2M link, and the bad-2M memory is read by the authenticated node, which a rotating advertised prefix (the default in user builds) can never be matched to before connecting. Stream sender only; calls and messages keep the connect-time request. A link lost before its deferred 2M request is not counted as a 2M loss (fixed 2026-10-27; before, it could mark the peer bad on 2M). | | Adaptive per-direction GATT PHY (`test_adaptive_phy_split=true`, mirror `test_adaptive_phy_split_dir=tx1m-rx2m`) [adaptive_phy_split] | Off (Experimental) | Links start on the normal 2M preference. Where the adaptive PHY would move a GATT link from 2M to 1M (`one_pdu_per_event` or `stalled_one_way`), the central instead asks for the split that keeps its own transmit direction (the media writer it measures) on 2M and moves only the peripheral -> central direction to 1M (`setPreferredPhy` tx2M/rx1M). Why that side: a lock-step connection event ends when either direction fails, so the writer's slowness cannot name the failing direction; the only snoop evidence of a silent direction is peripheral -> central, the static split `tx2m-rx1m` removed both patterns, and it leaves the large media direction on 2M. The split is tried once per link, the decision is re-armed, and a link that still fails moves to full 1M with the old memory and rollback (`after_split=true`). Applies to GATT only, not to relay child links (`relay_one_pdu` and `relay_scheduling` keep their paths), a link that already started split (`test_gatt_phy_split`) or a `test_ble_phy` arm. A successful split is not remembered as bad on 2M (`memory=none`), so a later link to the same peer pays the detection again. Line `BENCHMARK adaptive_phy: link=gatt ... from=2M to=split-tx2m-rx1m (or split-tx1m-rx2m) \| reason=one_pdu_per_event\|stalled_one_way \| queued=true \| memory=none`; the old full-move lines gain `after_split=true` only after a split. Needs `test_adaptive_phy`. Off is the existing behaviour exactly. | | Stalled 2M writer moves to 1M (`test_stalled_2m_move=false` is the control arm) [stalled_2m_move] | On | A GATT link whose backlogged writer carried under a quarter of one PDU per event (under 33 kbps at 15 ms) for the whole 3-window decision while the peer's data is still arriving (any record within 4 s) moves to 1M and the peer is remembered bad on 2M (`BENCHMARK adaptive_phy: link=gatt ... reason=stalled_one_way`). Before, such a link was `kept` on 2M for the rest of the session, because the one-PDU rule exempts stalls (a relay's downstream link, mesh4-7a6c401c). A relay's own links and a link the peer is not heard on keep 2M and remember nothing. Needs `test_adaptive_phy`. | | Skip the backlog at a PHY move (`test_phy_move_skip_backlog=false` is the control arm, `=true` forces it on) [phy_move_skip_backlog] | On for live streams with a fixed (manual) bitrate; off with Auto quality, calls and audio-only live (default-on since 2026-10-14, design decision Q-SKIP1) | When a live stream sender moves its own link from 2M to 1M (`one_pdu_per_event` or `stalled_one_way`), queued video older than twice the viewer's playout (at least 4 s) is dropped once, codec-safely: it resumes at a queued keyframe still inside the playout, else drops arriving inter frames until a requested keyframe arrives (at most 15 s). Audio, codec configuration, rotation and lifecycle records are never dropped; calls (their bounded-age experiment is separate), audio-only live and relay forwarders never skip. Logs `BENCHMARK phy_move_skip: ... skipped= | skippedRecords= | skippedAgeMs= | thresholdMs=`. Why: the records queued while the link crawled on the bad PHY cannot be absorbed by the viewer's playout; the 6.7 s queue of an ASUS source drained to only 4.5 s in 60 s on 1M, and a fixed-rate stream above the new link's rate grows it again (the skip is not rate control). Evidence: a fixed 300 kbps ASUS-to-A15s stream, three runs each, froze for 64.5 / 59.8 / 25.8 percent of the time without the skip and 9.6 / 9.1 / 8.8 percent with it (about 100 records, 6.7 s of video, skipped once per run; the longest single stall did not improve). With Auto quality on the skip gave no benefit (7.3 / 8.6 / 5.8 percent without, 5.2 / 7.2 / 7.4 with), so it stays off there. It trades one visible jump for much less freezing; the app's default of delay over frame loss still holds elsewhere (no-loss stays the default, bounded age stays Experimental). | | PHY-move flood guard (`test_phy_move_flood_guard=false` is the control arm) [phy_move_flood_guard] | On | At a PHY move (`stalled_one_way` or `one_pdu_per_event`) a live stream sender drops queued media older than 8 s once instead of writing it into the viewer at link speed: video codec-safely (it resumes at a queued keyframe still inside the viewer's playout, else after a requested one) and audio older than the viewer's playout (the viewer sees a sender gap and realigns). Codec configuration and lifecycle records are never dropped; calls and audio-only live never. `BENCHMARK phy_move_skip: ... kind=flood_guard | skippedRecords= | skippedAudio=`. Correctness, not preference: the viewer's receive path cannot take a multi-ten-second burst. The backlog skip (`test_phy_move_skip_backlog`, default on for fixed-rate streams) keeps its lower 4 s video threshold and keeps audio. | | GATT input failure ends the link (`test_gatt_input_failure_ends_link=false` is the control arm) [gatt_input_failure_ends_link] | On | When a GATT video receiver's input pipe fails (overflow, bad chunk header, reordering) it cancels the connection (this applies to the pipe of the GATT server side, the phone that accepted the link; a client-side pipe is unchanged), so the sender sees the loss and the existing redial and re-request recover. Before, the pipe was closed but the link stayed up and the viewer's heartbeats kept the sender satisfied. The input pipe is now bounded by bytes (1 MiB) before chunks (4,096; it was 512 chunks, about 40 KB of small audio records). | | GATT re-registration keeps the advertising address (`test_gatt_reregister_keep_address=false` is the control arm) [gatt_reregister_keep_address] | On (Android 10-11 legacy stack, part of `android11-gatt-server-connection-untracked`) | When the call listener registers its GATT server again (no PHY answer within 400 ms, or no first data write within 1.2 s of the subscribe), only the server is replaced: the advertising set is a separate stack object and keeps running with the same address, so the caller's retry finds it at once instead of waiting for a new set's advert. To keep it for the second stage, the set stays on after a subscribe until the client's first data write (normally 70-140 ms, at most 1.2 s) instead of stopping at the subscribe. A rotating-ID epoch change always stops the running set, also one held through an admission, so an address never carries into a new epoch; a re-registration during the new ID's silence leaves the start to the silence. The control arm stops the set at the subscribe and starts a new set after a re-registration. Both watch lines also log what the app can see of the registration table (its own registrations, server age and idle time); other apps' registrations are not visible to an app. | | Lost GATT chunk ends the stream at once (`test_gatt_gap_fast_fail=false` is the control arm) [gatt_gap_fast_fail] | On | A GATT receiver's input pipe used to treat a missing chunk as reordering and park up to 4,096 later chunks, delivering nothing until the 8 s call liveness check ended the call. The sender never reorders, so a gap held 1 s or behind 128 later chunks is now a loss: the pipe fails with "GATT chunk N was lost (M later chunks waited T ms)" and a call ends with that reason. It does not recover the chunk (DECISIONS "Design GR"). Cause seen: a host that drops a short ACL start fragment (Android 11 legacy fragmenter). Release builds keep it on. | | Lost GATT chunk recovery `GR1` (`test_gatt_chunk_recovery`) [gatt_chunk_recovery] | On (design decision 2026-10-21; `false` is the control arm) | The reader asks for a GATT value lost in its host (a NACK in a type-2 value that older readers ignore) and the sender resends the stored value byte for byte. The sender keeps a history of 2 s, 1,024 values or 256 KiB; resends are capped at 25% of the link's measured budget. The reader waits only after the peer's HELLO, with a 1 s deadline after 3 requests, and keeps the fail-fast above otherwise. Callbacks only queue; a per-link writer thread sends. Off: no history, no thread, nothing sent. Wire rules: PROTOCOL "GATT byte stream and draft lost-chunk recovery"; reasons and review 1: DECISIONS "Design GR". Windows not built. | | DEBUG: drop received GATT chunks (`test_gatt_drop_received_every=N`, 0 = off) [gatt_drop_received_every] | 0 | Every GATT reader drops the first copy of each received data value whose sequence is N-1 mod N, as if its host had lost it, and logs `gatt_debug_drop`. It forces the fail-fast or the recovery above on any pair. Release builds ignore it. | | Nearby list: "Bluetooth needs a restart" (`test_scan_restart_hint=false` is the control arm) [scan_restart_hint] | On | When the Nearby scanner is refused a registration three back-off steps in a row (about 7 s; scan error 2, the phone-wide pool of 32 GATT registrations is full), the empty list says "Bluetooth needs a restart to find nearby people" with an "Open Bluetooth settings" button instead of "No one found yet". It goes away at the scanner's first result. An app cannot free registrations leaked by other processes or switch Bluetooth off and on (API 33+), so this is guidance only; the scanner's own back-off is unchanged. | | Copied-ID warning ignores a PC's legacy and extended twin (`test_copied_id_pc_pair=false` is the control arm) [copied_id_pc_pair] | On | Calling a Windows PC no longer shows "More than one phone is using this ID / Call anyway": Windows sends its compact call offer as one legacy and one extended advertising set from two addresses. Exactly two recent addresses, one received as legacy and one as extended (with a secondary PHY), whose Flags and UUID-list ADs are byte-identical and carry `0x180A`, count as one sender. An address that a newer one of the same form replaced is not counted, so one set restarting with a new address is not a copy. A third address, two of the same kind or any difference still warns. Turned off if V1 finds `0x180A` in an iPhone or Mac advert. Design decision Q-PC-COPIED-ID option A; DECISIONS "Design CI" (review 1). | | Copied-ID warning needs overlapping addresses (`test_copied_id_overlap=false` is the control arm) [copied_id_overlap] | On | "More than one phone is using this ID" now needs two addresses whose sightings interleave (the other address heard again after this one appeared), on two occasions. Before, a phone that replaced its advertising set (a new random address 31 ms after the old one stopped, as Android 10 does when the Meet code boosts the advert and again when the boost ends) was heard on two addresses within 2 s, twice, and every call to it asked "Call anyway". Two senders on the air at once (a copy, or a PC's legacy and extended twin) still interleave and still warn. | | A dial follows its person across a rotating-ID epoch (`test_dial_follows_rotation=false` is the control arm) [dial_follows_rotation] | On | A user's dial waits for the next sighting of the row they tapped. Across a 15-minute rotating-ID boundary that person announces a new ID, so the dial waited for a key that no longer existed and failed after 10 s. Now a sighting continues the dial when its ID is recognised as the same contact, or when it comes from the same advertising address. The call's key check still decides who answers. A rotating row whose address now announces another rotating ID is the same phone's previous epoch and leaves the list at once instead of staying tappable until it expires. | | Crash reports ("Don't ask again" in the prompt; DEBUG `test_crash_reports=false`) [crash_reports] | On | An uncaught exception writes a short scrubbed report (error types, code locations, app and Android version, phone model; names, quoted text, addresses, IDs, keys, numbers, links and paths replaced) to private storage. The next plain launch asks "MeshLive closed unexpectedly. Share a crash report?" before any radio opens, and Export diagnostics includes the report for 7 days. Nothing is sent by the app. Benchmark and debug launches are not interrupted. | | Relay stall hold-off (`test_relay_stall_holdoff=false` is the control arm) [relay_stall_holdoff] | On | After the relay child stall guard closed a child link, sightings of that child for the next 10 s are not dialed and not counted as establishment failures (`BENCHMARK relay_child_stall: ... action=holdoff`). The child is still timing out its dead link and tearing its receiver down; the advertisement it shows meanwhile belongs to the receiver that is going away, and two 5 s status-133 dials to it used to add the second establishment failure that withdraws the relay's offer for 5 minutes. Needs `test_relay_child_stall_guard`. Refusal escalation keeps its own hold-off, so this control arm changes only the stall hold-off (fixed 2026-10-27; before, both shared one map that only this switch read). | | Relay stalls counted apart (`test_relay_stall_separate=false` is the control arm) [relay_stall_separate] | On | A hop-stage stall caught by the relay child stall guard (the dead-anchor signature: the link is silent after the connection update) no longer counts as an establishment refusal. Stalls need 3 within 2 minutes and mark the child for 30 s, then 60, 120 and 300 s on consecutive marks without a success (`relay_parent_fallback ... kind=stall`, `action=count_stall` for a counted stall that does not mark); refusals (status 133/62) keep 2 failures and the 5-minute mark and are not added to by stalls. Off counts a stall as a refusal, as before. Needs `test_relay_establish_fallback` and `test_relay_child_stall_guard`. **Requires / masked by:** `relay_establish_fallback` and `relay_child_stall_guard`, as written above. | | Relay refusal escalation (`test_relay_refusal_escalation=false` is the control arm) [relay_refusal_escalation] | On | A relay's refusal marks (GATT status 133/62 toward a child, the stack's HCI 0x3E) escalate 30, 60, 120, then 300 s on consecutive marks without a success instead of a flat 5 minutes, and a refusal round holds the relay's next dial to that child off for 10 s (`relay_child_stall: ... action=holdoff`), so the two failures are no longer spent in 3 s while the child is rediscovered every second. A successful link clears the level. Needs `test_relay_establish_fallback`. | | Connection-priority retry off (`test_le_priority_retry=false`) [le_priority_retry] | Retry on | Control arm. A central repeats its own priority request up to three times (0.5/1.5/4 s) after the stack reports the LE connection update failed (0x23 collision, 0x3B unacceptable parameters: ASUS 16, S24 39 in saved Bluetooth logs), which otherwise leaves the link at its 45-49 ms creation interval. Needs the hidden `onConnectionUpdated` report; no report means no retry. Line `BENCHMARK le_priority_retry`. Untested on phones. | | Setup PHY after the priority update (`test_phy_after_priority`; DEBUG only) [phy_after_priority] | Off | Experimental. An LE central's setup PHY request (the LE CoC control client, including the reattach after a fresh ACL, and the GATT video client) waits while the link's last reported connection interval is outside the requested priority window: below it (the 7.5 ms interval Android uses during service discovery) or still at the pre-request interval. It is sent 100 ms after the update that brings the link into the window, or after 1.5 s at the latest; a link closed meanwhile never sends it. With no report yet, or a link already in the window, the PHY goes out at once as before. Why kept: it was built because relay child links of an Android 12 helper died after a PHY update that landed between the discovery 7.5 ms update and the requested 15 ms update; the phone A/B did not confirm that cause (child-link deaths with it in 2 of 12 relayed calls, without it in 1 of 11; every death followed the link's update to 15 ms with or without a PHY update, the dual-role pattern in DECISIONS "Relay dual-role scheduling"). No harm was shown, so it stays as an arm. Lines: `BENCHMARK phy_after_priority: link= | event=hold | label= | lastMs= | windowMs= | boundMs=` and `event=release | reason=settled/bound/inactive/superseded | waitedMs=`; the switch is logged in `BENCHMARK le_link_policy` as `phyAfterPriority`. **Requires / masked by:** `coc_sequenced_phy` (CoC only; off requests the PHY at connection, outside this hold), `coc_phy_after_connect` and the GATT PHY-after-auth arm (they defer the PHY past setup), `ll_gate` (when on, the held request still enters the gate), `psm_read_link_quiet` (a quiet PSM-read link sends no PHY at all). | | Quiet PSM-read link (`test_psm_read_link_quiet`; DEBUG only) [psm_read_link_quiet] | Off | Experimental A/B arm. On a phone that connects LE CoC on a fresh ACL (Android 12 and 13, `coc_fresh_acl`), the GATT link that only reads the PSM and is then closed asks for no connection priority, PHY or MTU. Android's service discovery still moves it to 7.5 ms and back to its creation interval, but it no longer reaches the requested 15 ms. Why: relay helper links to the callee that died one supervision timeout after their update to 15 ms left that ACL up past the 2 s fresh-ACL wait, so the CoC connect waited about 4 s more for the dead link's timeout before opening a new link. The arm separates two readings: if the late ACL drop is the fault, calls pass with it; if the standing dual-role anchor phase is (DECISIONS "Relay dual-role scheduling"), the call's next link still dies at its own 15 ms update and the call fails about 5 s sooner. Line: `BENCHMARK psm_read_link_quiet: link=ble-control | event=skip | peer= | priority=skipped | phy=skipped | mtu=skipped`; logged in `BENCHMARK le_link_policy` as `psmReadLinkQuiet`. **Requires / masked by:** `coc_fresh_acl` (without a fresh ACL the PSM-read link is the call's control link and keeps its requests). | | CoC control PHY at connection (`test_coc_sequenced_phy=false`) [coc_sequenced_phy] | Sequenced | Control arm. The CoC control client requests the PHY after its priority update and MTU exchange, and the control server only reads it, as the GATT video path does. The old order (both sides at connection, beside the priority update) logged 339 `BLE PHY unavailable (status 42)` on M31, ASUS and A15s. Untested on phones. | | CoC PHY after connect (`test_coc_phy_after_connect`) [coc_phy_after_connect] | Off | Experimental A/B arm. The CoC central requests no PHY during setup (the control server never does); after the CoC socket is connected and hop authentication finished, it requests the preferred PHY and waits for `onPhyUpdate`, with a `readPhy` fallback after 1 s as Nordic's Android-BLE-Library does (BleManagerHandler L2958-2985/L3838-3860, 46d17813), before the end-to-end handshake and media. Applies to stream sender, forwarder child, call (caller), call relay and video relay CoC links. Line `BENCHMARK link_phy: link=... \| requested=2M \| effective=2M\|1M\|failed(status N)\|unknown\|not_queued \| reason=after_connect \| source=update\|read\|none`. Why: a CoC connect overlapped the 2M PHY update (A15s -> ASUS CoC connect failed 6 ms after the request); Nordic treats a link failing right after an early 2M switch as a controller interop fault (Kotlin-BLE-Library Peripheral.kt L1056-1074). Cost: hop authentication runs on 1M and up to 1.5 s more setup. Off: the sequenced request after the PSM read. Untested on phones. | | Peer address book (`test_peer_address_book`) [peer_address_book] | Off | Experimental. Every accepted scan result refreshes its node-ID prefix's freshest address. A direct LE call to an address older than 10 s, replaced by a newer address of the same node, or never seen rescans for the node first (same prefix and route cost, through the scan-start gate; the handshake verifies identity). The first failed attempt is classified by elapsed time as Nordic does (retry only failures under 20 s, at most twice, 300 ms apart; a 133 after 25 s or more is a stale address: rescan, not a blind retry); the class is logged only. Why: Android rotates its resolvable private address every 7-15 min and at every new advertising set (android11-release `btm_ble_addr.cc` 96-102, `btm_ble_multi_adv.cc` 240-267); a connect to a dead address ends only at the ~30 s direct-connect timeout as status 133 (`connection_manager.cc` 33). Not done: streams and relays still connect to the scanned address. Untested on phones. | | Android 11 and older CoC SDU coalescing (`test_coc_coalesce_sdus`) [coc_coalesce_sdus] | Off | Experimental, scoped by the inert registry proposal `android-11-coc-small-sdu-coalescing` (API 30 and older). Where it matches, CoC media SDUs are up to 8,000 bytes instead of 245-byte single-PDU SDUs (a 50 KB keyframe: 7 SDUs instead of 205), without pacing or drops; elsewhere the switch logs `reason=stack_not_matched`. Line `BENCHMARK coc_sdu: requested=coalesce\|single_pdu\|socket_limit \| effective=... \| sduBytes=...`. Why: Android 11 btif reads one message per SDU into its own buffer, L2CAP counts congestion in queued SDUs and a congested write is freed (`bta_jv_act.cc` 1182, `l2c_main.cc` 997-1007; upstream 471249f4, 46bd51ed); a candidate for the ASUS Bluetooth crash, which has no backtrace yet. It replaces `test_coc_single_pdu_sdus` on matching stacks, so the 251+4-octet keyframe split returns for MPS-251 peers; the app cannot see or cap the stack's own SDU queue. Untested on phones. | | Forwarding CoC SDU coalescing on Android 12 and older (`test_relay_coc_coalesce`) [relay_coc_coalesce] | Off | Experimental, scoped by the inert registry proposal `pre-android-13-coc-congested-write-loss` (API 32 and older). Where it matches, forwarding CoC writers (the private-call relay's own sockets, stream relays and forwarders, and a helper's parent link from the moment it becomes a relay hop) write SDUs of up to 8,000 bytes, never more than the peer's receive limit, instead of 245-byte single-PDU SDUs. Endpoint links are unchanged; nothing is paced or dropped and the peer reads the same bytes. Lines `BENCHMARK coc_sdu: ... \| scope=forwarding \| relayCoalesce=requested\|off \| relayEffective=...` and `coc_sdu: scope=forwarding_handoff \| link=call-relay-parent \| sduBytesBefore=... \| sduBytes=...`. Why: before Android 13 the Bluetooth socket thread could read stale poll entries while several sockets were busy and drop data it read for a congested channel (AOSP 46bd51ed, b/223974328); congestion is counted in queued SDUs, so fewer, larger SDUs reach it less often. Every LE CoC hop-integrity failure in the relayed-call runs had a forwarding phone on Android 10-12 as the transmitter, and none was seen on a direct link. Diagnostics that are always on (no switch): a failed hop record logs `hop_integrity: kind=tag\|sequence\|header \| ... \| next_header=valid_n1\|valid_other\|invalid\|unavailable` (a changed byte leaves the next header intact, a lost SDU shifts it; the peek never waits for bytes), each private-call relay direction logs `relay_forwarded: direction=... \| read=... \| forwarded=... \| destinationRecordsWritten=...` when it stops, and the user-visible error no longer carries the record type, sequence or size. Untested on phones. | | Shared LE CoC listener (`test_coc_shared_listener=true` or `false` forces it) | Android 10 only (registry) | Android 10 never stops a closed LE CoC server (`btsock_l2cap_free_l` lacks `BTA_JvL2capStopServer`; AOSP d3c996662453 added it in Android 11), so every closed listener, graceful or killed, kept a BTA JV security id (18 shared with RFCOMM/L2CAP servers) until Bluetooth restarted; the A15s then refused all LE CoC listens and connects. On that stack family one long-lived listener per role (private call, receiver, relay, call relay, raw benchmark) is leased to sessions; a connection accepted for an earlier or no session is closed. Its idle PSM stays connectable (closing it would leak). Bound: one registration per role per app process, plus one per concurrent same-role session or listener failure. Fixed stacks keep one listener per session. A debug receiver (`DebugBluetoothRestartReceiver`, adb shell only, DUMP-protected, refused during any session) cycles Bluetooth on Android 12 and older. Untested on phones. | | Advertising restart at the balanced step (`test_stable_adv_address=false`) | Same set, parameters changed in place | Control arm. The 10 s low-latency -> balanced step stopped and restarted the advertising set; with LE privacy on (every reference phone) a new set gets a new resolvable private address, and all four A15s -> S24 connect timeouts dialled the old address within about 0.3 s of that restart. This is the app's own lifecycle race, not a stack regression. The default disables the set, changes interval/TX power and enables it again; the address stays until the stack's privacy timer rotates it. A failed or stalled step falls back to a restart whose start is bounded (8 s); if that fails the owner clears its advertising state and starts once more. Untested on phones. | | Deferred GATT client close (`test_deferred_gatt_close=true` or `false`) | Android 11 and older only (registry) | Android 10/11 `ContextMap.removeConnectionsByAppId` is unsynchronized (AOSP 1a2c1e97d203 locked it in Android 12); `close()` 22 ms after `disconnect()` threw ConcurrentModificationException in the A15s Bluetooth process, which skips the native unregister. Its GATT discovery later failed until Bluetooth restarted, but the exception is not proven to have caused that. Deferred: disconnect, then close on the disconnect callback or after 2 s; an expired control attempt is cancelled first so its late callbacks do nothing. Line `GATT client closed after disconnect`. Untested on phones. | | LE CoC client on a fresh ACL (`test_coc_fresh_acl=true` or `false` forces it) | Android 12 and 13 only (registry) | As LE central these stacks start EATT on every link; a peer that refuses it (the S24 on an unencrypted link) fails only the first of the five channels (android12/13 `l2c_ble.cc` 654-663; android14 `l2cble_handle_connect_rsp_neg` fixes it). A later CoC response can match a stranded channel whose EATT registration has no connect callback: the M31 Bluetooth process crashed (SIGSEGV pc 0 in `l2c_csm_indicate_connection_open`) on every saved CoC connect after a refused EATT. The client reads the PSM over GATT, closes that client and waits up to 2 s for the ACL to drop, connects the CoC on a fresh ACL (its channel queued before EATT's) and reattaches a GATT client for priority/PHY. Line `BENCHMARK coc_fresh_acl`. Untested on phones. | | Automatic BLE GATT when LE CoC fails (Settings > Advanced > Connection "Fall back to compatible link"; DEBUG `test_coc_auto_gatt`, `test_coc_gatt_threshold` are the benchmark switches) | Promoted 2026-10-11 (default OFF until its phone test passes; one constant, `ConnectivityFeatureDefaults.COC_AUTO_GATT`) | Design decision (2026-10-09). The A15s stack refuses LE CoC listens and connects after about 15 registrations per Bluetooth process. With the flag on, a session that requests BLE L2CAP is moved to BLE GATT, with a visible notice ("Using BLE GATT: L2CAP unavailable"), when call availability (the CoC listen) fails, an outgoing CoC connect fails (that call is retried once over GATT), or the CoC registration count of this Bluetooth-adapter session reaches the threshold (default 12; reset by an adapter off/on broadcast). While on, the call list also shows GATT-advertising phones and a GATT peer is called over GATT for that call. A GATT or Classic request is never changed. Lines `LE CoC registration: kind=listen|connect | count=N`, `BENCHMARK call transport: event=... | requested=... | effective=... | automatic=... | reason=... | cocRegistrations=N | threshold=T`. The registration threshold (12 in user launches, with no setting; DEBUG `test_coc_gatt_threshold` in benchmark launches) moves a session only on a stack that leaks registrations (`DeviceFamilyPolicy.leCocServerLeaks`, Android 10); a benchmark launch always counts it, as measured. On a healthy stack only a real listen or connect failure moves a session, so ordinary use is never downgraded. The notice reads "Using BLE GATT: ..." and says that turning Bluetooth off and on restores L2CAP (the adapter broadcast resets the count). A peer that only listens on L2CAP cannot be reached after a caller-side fallback; both phones need GATT reachability (the experimental dual listening row below, designed in PLAN "Design D", lets a CoC phone also answer over GATT). An untouched switch follows the constant; an explicit choice sticks; it matters only when the transport is BLE L2CAP (Advanced). **Phone test (A15s CoC exhaustion, S24/M31/ASUS as peers), pass when:** (1) the A15s with the switch on completes 20 consecutive call cycles against a GATT-scanning peer, the endpoint moving to GATT with the visible notice no later than the 12th registration or the first failed listen/connect, and `call transport: ... requested=BLE_L2CAP | effective=BLE_GATT` logged; (2) the same cycle with the switch off reproduces the failure after about 15 registrations; (3) Bluetooth off/on returns the next session to L2CAP; (4) an S24 with the switch on keeps L2CAP for 20 cycles (`thresholdActive=false`); (5) a GATT-advertising peer appears in the call list and a call to it completes. Not yet tested on phones. | | Automatic GATT fallback for streams (`test_coc_auto_gatt_streams`; sub-switch of `coc_auto_gatt`, shipped constant `ConnectivityFeatureDefaults.COC_AUTO_GATT_STREAMS`) | Off (Experimental; does nothing unless `coc_auto_gatt` is on too; a release build ignores the extra) | Design SG (design decision Q50). Stage 0 (always on, text and log only): a stream receiver whose LE CoC listen fails shows "This phone's Bluetooth could not listen for L2CAP streams" and logs `stream_listen_failed` (transport, token, cause, CoC registration count). Stage 1: with both switches on, that receiver restarts on BLE GATT once per adapter session (the same ledger as calls; `requested=BLE_L2CAP effective=BLE_GATT reason=availability_failed`, line `stream_gatt_fallback`) and joins later L2CAP offers over GATT until Bluetooth is turned off and on. It reacts only to its own failed listen, never to a received advertisement, and an L2CAP stream that was already active never restarts. Stage 2: an LE CoC source also accepts GATT viewer requests (same stream-token rule) into a separate GATT child pool (size 1, `StreamGattFallback.GATT_POOL_CAPACITY`) with its own admission: the child must be able to carry the stream's planned rate (else refused, never given another stream), and its airtime is charged against the source's aggregate fan-out budget, which later L2CAP admissions see. L2CAP children are never moved, counted into the pool or evicted; a GATT-pool child does not feed the shared rate controller or the L2CAP ceiling learning, and the starvation guard still applies to it. GATT and Classic sources never accept another transport's request. Hop authentication, ordered encryption and rotating IDs are the same on a GATT child; a direct fallback from a requested relay chain still counts as a failed chain. No new wire byte (PROTOCOL "Source filter"). Lines `stream_gatt_pool: event=admit|refuse`. Untested on phones; tests need the injection below. | | Stream listen-failure injection (DEBUG `test_coc_listen_inject=stream`) | Off (DEBUG only) | Makes the LE CoC listen of a stream receiver throw, so a healthy phone can exercise stage 0 and stage 1 of the automatic GATT fallback for streams (the real trigger is the Android 10 A15s registration leak). Exact value `stream`; a release build ignores it. The failure is logged `stream_listen_failed ... injected=true`. | | Dual listening: a CoC call endpoint also answers over BLE GATT (Settings > Advanced > Connection "Listen on both links"; DEBUG `test_dual_listening` is the benchmark switch, DEBUG `test_dual_listening_inject=coc_connect|coc_hang` fails or stalls the caller's CoC attempt so the GATT fallback can be tested) | Experimental, designed and coded 2026-10-11 (default OFF until its phone test passes; one constant, `ConnectivityFeatureDefaults.DUAL_LISTENING`) | Follow-up to the CoC-to-GATT safety net. Transport is per endpoint, so a phone that falls back to GATT was reachable only by phones that scan GATT and, being a GATT endpoint, could not list or call a CoC phone at all. With the switch on, an LE CoC endpoint keeps its CoC listener and offer, also starts the GATT call service in a second `BluetoothGattServer` that does not advertise (same advertising set, same address, no extra scan filter), and sets `capabilitySummary` bit 3 (`0x08`) of its call offer once that service runs. Every existing build ignores the bit and connects over CoC as before. A GATT endpoint lists such an offer and calls it over GATT; a CoC caller whose CoC attempt fails (PSM read, connect, or the 9 s CoC phase) opens the GATT service of the same device inside the same attempt, with no rescan, visibly ("Using BLE GATT for this call: L2CAP did not connect") and with `call transport: event=dual_fallback | requested=BLE_L2CAP | effective=BLE_GATT` in the log. The endpoint's link hello, codec and bitrate limits, buffers and policies are those of the link it runs (a per-link transport override); BMH2, BMS1, BME1, the prefix/RDI checks and the rotating-ID openings are unchanged and run on whichever transport answers. Not designed here: GATT-primary dual, streams, relay hops, Classic, iPhones, store-and-forward dialing. Cost: one more GATT server and service while available, one more stable capability bit on air (a quasi-identifier like the relay consent bit). Lines `dual_listening: event=offer | hint= | summary= | secondary=`, `event=secondary | state= | gattServers= | adSets=`, `event=link | accepted=`, `event=attempt_failed`. PROTOCOL "Experimental dual listening", PLAN "Design D". Known limits of the first version (PROTOCOL): the activity's call settings, codec choice, buffer and rate memory follow the primary transport while the hello, limits and link policies follow the link in use; a late CoC accept can make the GATT attempt fail once; exchanges are not carried on the second transport; an accepted link must finish its handshakes within 20 s. **Phone test (the phones available, now S24, M31, ASUS and A15s; S22, Xiaomi and A55 as soon as they are available; the A15s is the CoC-exhaustion phone, not a required scope phone), pass when:** (1) switch off: every phone's announcement `summary` byte (read on the scanners from `dual_listening: event=seen`) and the call matrix equal the previous build; (2) switch on everywhere: every ordered pair of the four phones, both setup roles, completes a call over its primary transport, no pair that connects today fails, and the median connect time (5 calls per pair) is within 1 s of the off matrix; (3) the A15s forced to GATT (`test_coc_auto_gatt` with threshold 1) lists and calls each dual phone over GATT and connects, while a phone with the switch off is not listed; (4) with `test_dual_listening_inject=coc_connect` and with `coc_hang` on the caller, the call connects over GATT inside the same attempt with no `BLE scan start` between the attempts, the fallback line states requested and effective, and the prefix/opening check passes; with both transports refused the failure notice comes within 30 s of the tap and the safety net does not fire; (5) while idle and available there is exactly one advertising set and (`dumpsys bluetooth_manager`) two GATT servers per dual phone, CoC registrations per role are not higher than in the off run, 20 consecutive call cycles on the A15s end without an availability failure, and the first 30 s of a call over the second transport are compared with a native GATT call; (6) with `test_rotating_ids` on, the version 5 offer carries the bit, a call over the second transport passes `rotid_open`, and no fixed prefix appears in any line of the second service; (7) a scripted central that enables the second service's notifications and then stalls ends in the 20 s incoming deadline and a rebuilt endpoint, not a stuck phone; (8) Bluetooth off/on and an app restart with the switch on return to a single hint on a single set. Not yet tested on phones. | | Caller answer deadline (`test_caller_answer_deadline=false` is the control arm; DEBUG only) [caller_answer_deadline] | On (release too) | Connection review A3 (2026-10-27). From hop authentication to the callee's RINGING (or Accept) nothing bounded the caller: the end-to-end handshake read and the wait for RINGING had no timeout, so a callee or helper that stopped answering held "Calling" until the user gave up. A link still waiting 15 s after hop authentication (relayed: the 20 s SETUP budget + 10 s) is closed and the call fails with "The other phone connected but did not answer the call". With relayed calls on, the callee's 20 s incoming deadline is armed again after the first record (the relay pre-setup watchdog ended there). Line `call_setup: event=answer_deadline \| relayed= \| deadlineMs= \| action=close`. Phone check: a callee whose app is frozen after hop authentication (DEBUG), and normal direct and relayed calls (no line). | | Incoming call handshake deadline on every endpoint off (`test_call_handshake_deadline_all=false`; DEBUG only) [call_handshake_deadline_all] | Deadline on every CoC and GATT call endpoint (design decision Q29, 2026-10-14) | Control arm. An accepted call link that has not completed both handshakes within 20 s is closed, so a silent stranger that connects and sends nothing cannot hold the endpoint; before this the deadline covered dual endpoints only. With the extra false only dual endpoints keep it, as before. Relayed-call endpoints keep their own pre-setup watchdog in both arms. Release builds always have the deadline. Line `BENCHMARK incoming_handshake: event=deadline | closed=true | deadlineMs=20000 | scope=single|dual`. Phone test T-HS20 (untested on phones). Pass criteria: with a raw connection that sends nothing, the endpoint closes it at 20 s (+-1 s) with the line above; a normal call from a second phone still connects and rings; a call whose handshakes take under 20 s (a verified contact, a stranger delayed by the tarpit for under 20 s) is not closed. **Requires / masked by:** a no-op while relayed calls are on: the deadline is then not applied to any endpoint (the relayed-call endpoint keeps its own pre-setup watchdog). | | GATT MTU wait probe off (`test_gatt_mtu_probe=false`) [gatt_mtu_probe] | Probe on | Control arm (2026-10-11, `GattMtuWait`). A GATT client setup that has no `onMtuChanged` after 0.75 s sends one `readPhy`; a PHY callback proves the platform still owns the connection and the MTU request is only queued behind the stack's own service discovery (A15s median 1.3 s, S24 relay with 4 children 3.2 s+), so the wait runs to 8 s instead of cancelling that discovery at 3 s. No PHY callback keeps the old 3 s verdict (`configureMTU() - No connection`: ASUS 6 of 6, A15s 5 of 7 saved stalls), so the probe never shortens a wait. Off: fixed 3 s. Lines `GattVideoSetup ... stage=mtu_probe_request|mtu_timeout reason=` (DEBUG) and `BENCHMARK le_link_policy ... gattMtuProbe=`. Control arm, not yet tested on phones: GATT call and stream joins on M31, ASUS, A15s and S24 as client (both call roles); pass = no new `mtu_timeout`, setup time not above the control, and every `mtu_timeout reason=FAIL_QUEUED_TOO_LONG` accompanied by `platformAlive=true`. | | Source/forwarder GATT priority (`test_source_gatt_priority`, `test_forwarder_gatt_priority`) | Policy by rate | Connection-priority experiments. Production: HIGH below 500 kbps and DCK from 500 kbps, except at the source, where a relay-capable child (`maxChildren > 0`) gets HIGH after its handshake whatever the rate (a forwarder keeps the rate rule). | | Deferred call GATT priority (`test_gatt_defer_connection_priority`) | Off (DEBUG) | The private-call GATT client does not request its connection priority when the link connects; it requests it after the subscribe. DEBUG builds only. | | Direct GATT heartbeat off (`test_direct_gatt_heartbeat=false`) | Heartbeat on (DEBUG control arm) | A direct GATT stream receiver sends periodic heartbeats and its source expects them (a mesh link or a non-GATT transport always does). Off removes them on a direct GATT link in a DEBUG build; a release build always sends them. | | Scan on LE 1M only off (`test_scan_le_1m_only=false`) | On | Control arm (2026-10-19, F11). Every app scan asks for the LE 1M PHY (`ScanSettings.setLegacy(true)`, `setPhy(PHY_LE_1M)`): all of the app's adverts are legacy adverts, always on LE 1M, while the default `PHY_LE_ALL_SUPPORTED` makes Android 13+ stacks scan LE 1M and LE Coded in turn (`le_scanning_manager.cc SetScanParameters scan_phy 255 phy 5` on a Redmi 12). Line `scan_duty ... | phy=1M|all`. While the Find PCs filter is active that scan asks for extended advertising instead (a PC advertises extended), so this arm does not apply to it. | | Call list software filter (`test_call_list_software_filter`) | Off (Experimental) | F11 follow-up (2026-10-20): the Nearby call list's scanner, too, gives the stack an empty filter and matches the service UUID in software, for an A/B on a phone whose offloaded filter misses another phone's advert (the Redmi 12 and the A15s). The list scans only while visible (screen on). Line `scan_duty`/`scan_stats` of that scanner; compare sightings of the missed phone with and without. | | Call stall tolerance (`test_call_stall_tolerance`) | Off for a plain call; effectively ON for every renewal-negotiated direct GATT call (Experimental) | F17 Stage A (2026-10-20): a call whose peer went quiet is kept while this phone's own writes still complete, for up to 14 s instead of 8 s, with "Poor connection" from 3 s; if the own writes stop too, the 8 s rule ends it. Reason: an S24 whose sending stalled for 10 s (with a paired laptop connecting to it) recovered by itself after the M31 had ended the call at 8 s. It changes only this phone's timer (CF-CALL-10 says 8 s; promotion needs that rule amended). A call that negotiated in-call link renewal runs this rule whatever the switch says (renewal needs more than 8 s), so CF-CALL-10 is already not true for those calls: an open decision (DECISIONS "Known interactions", B6). Stale audio is not dropped after the stall (late audio is played, user direction 2026-10-07). Line `call_stall: phase=soft|resumed|ended`. | | Skip redundant priority request (`test_skip_redundant_priority`) | Off (Experimental) | A connection-priority request for the priority already requested is not sent when the link's last update already put the interval inside that priority's window. Reason (2026-10-20, one instance): an Android 10 radio source re-requested HIGH right after the stream handshake on a link already at 15 ms; no update followed on either phone and the link dropped with a supervision timeout 5.07 s later. A/B on that source's radio joins. Line `prio_request: skipped | intervalMs= | window=`. | | Drop leading blank camera frames (`test_drop_leading_blank_frames`) | Off (Experimental) | An Image-input encoder (software VP9, native AV1) drops the camera's leading blank buffers (luma below 1, chroma within 1 of 128) until the first real frame, at most 15 frames or 1 s after the first; nothing is dropped after the first real frame, and surface-input encoders are not affected. Reason (2026-10-20): the S22 as a source delivered 4 exactly black buffers before its first real frame in all 8 prewarm A/B runs (decoded: luma 0.008 for frames 1-4, then 50.7 rising), so a viewer's first picture was black; the ASUS never did. On, the first frame sent is a real picture, about 0.27 s later on the S22. Line `camera_leading_blank: dropped=`. | | Source media pre-warm (`test_source_media_prewarm`) | On (design decision 2026-10-21; `false` is the control arm) | A broadcast source starts its camera and encoder when it begins connecting to its first viewer, not after that viewer authenticated; the microphone still waits for an authenticated viewer, and no viewer means the existing all-disconnected path closes the media. Not for radio, the Auto ladder or replay. Reason (2026-10-20, 8 S22/ASUS runs, GATT and CoC, both directions): selection to first surface 2.9-3.8 s against the 3 s gate; the first key frame came a median 995 ms after authentication because media started then (and its first frame was black in 8 of 8), while link setup before it took 1.2-2 s. Expected: about 0.9 s off selection to first surface. Lines `source_prewarm: event=start|first_viewer`. | | Viewer admission bounds off (`test_viewer_admission_bounds=false`) | On | Control arm (2026-10-20, Design QV review 1, R3). A source starts at most max(4, fan-out) new pre-authentication viewer attempts per 10 s, oldest request key first; a key whose attempt failed before authentication sorts after untried ones for 10 s (never excluded); requests over the limit wait for the next scan without the 30 s capacity cooldown; recovery requests are not counted. Reason: fresh RDI/address pairs were always new keys, skipped every per-key cooldown and held fan-out slots for the 12 s connection timeout. ViewerAdmissionBoundsTest simulates a 50-per-second flood and requires the real viewer admitted within 15 s with at most 4 attempts per 10 s, and the old admission (control arm) never admitting it in 120 s. The bounded refusal tables (512 entries, swept) are not switchable. Line `viewer_admission: deferred= | chosen= | tracked=`. | | Join retry and message off (`test_stream_join_retry=false`) | On | Control arm (2026-10-20). A live join (radio or video) that fails before it becomes active shows "Couldn't join ." with Try again, and joins once more by itself when the same stream is listed again after the route cooldown (one automatic retry per stream until a join succeeds; a pending join waits at most 30 s). Off: only the browser status line, as before. Lines `stream_join: failed|try_again|retry`. | | Ring timeout off (`test_call_ring_timeout=false`) | On | Control arm (2026-10-20, F19): direct calls ring without a limit, as before. On: the caller cancels after 60 s of ringing ("No answer"), the callee declines after 65 s ("Missed call"; 75 s until the 2026-10-21 decision), existing signals only (PROTOCOL "Ring timeout"). Reason: an unanswered call rang for 6 minutes, and both phones were unreachable meanwhile. Line `call_ring_timeout: role= | rangMs=`. | | Remote video fit off (`test_remote_video_fit=false`) | On | Control arm (2026-10-28). On: in a private call, the other person's video is shown whole and centred with plain black bars when its displayed shape (after the sender's quarter-turn rotation) differs strongly from the view's (longer/shorter aspect at least 1.8, or landscape against portrait), and fills when the shapes are close (portrait to portrait); the app-wide "Show full frame" setting still always fits; a double-tap on the large remote video toggles Fit/Fill for the rest of that call; the activity is never rotated and the self-view picture-in-picture is unchanged. Off: the app-wide framing setting only (Fill by default), no double-tap. Reason: a landscape Windows laptop camera on a portrait S24 was cropped to a narrow middle strip. A blurred backdrop was rejected by the user; bars stay black. | | Liveness hang-up off (`test_call_liveness_hang_up=false`) | On | Control arm (2026-10-20, F17). A phone ending a call on 8 s of silence first writes Hang-up/Error (best effort, 300 ms), so a peer that can still hear it ends at once instead of after its own 8 s (PROTOCOL "Hang-up before a liveness close"). Line `call_liveness_hang_up: written= | waitedMs=`. | | Meet scan software filter off (`test_meet_software_filter=false`) | On | Control arm (2026-10-20, F11). The Meet scan (Add contact, scanning a code: screen on, at most the Meet scan limit) gives the stack an empty filter and matches the service UUID in software; every other scanner keeps the offloaded filter (the process-wide S-06 stage is unchanged). Reason: a Redmi 12's offloaded filter never reported an Android 10 phone's code advert (no report from it at all in the HCI log), while the same phone's unfiltered observer heard it; that advert differs only in its flags (0x02, which an Android 10 app cannot change). Line `meet_scan: start ... | filter=software|hardware`. | | Weak-pair reach off (`test_weak_pair_reach=false`) | On | Control arm (2026-10-19, F11). While a verification code is on screen the call listener advertises at low latency and high power (100 ms, `TX_POWER_HIGH`; a set that already took its balanced step is restarted once). The Meet scan looks for 30 s with one fresh scanner after 15 s. A Contacts call waits for room for five sightings at the scanner's measured median gap (10-30 s). Off: the balanced step after 10 s, a 10 s Meet search, a 10 s wait. Lines `meet_code: shown`, `meet_scan: start ... searchMs | restart`, `call_invite: waiting ... typicalGapMs`, `adv_payload ... mode | intervalMs | tx`. | | Reachable phones' list scans yield (control arm `test_reachable_scan_yield=false`) | On (promoted 2026-10-27) | Better or equal on every measured pair, never worse: S22/ASUS 10/10 vs 8/10 calls (status 133 2 vs 8), M31/A15s 4/5 vs 2/5, S24/M31 6/6 vs 3/6, ASUS/A15s 3/6 vs 3/6; median tap->ring 1.9 vs 2.5 s (profiling-20261026 predial10, relay-showcase direct A/Bs on e6a7ff06). Xiaomi and A55 still to measure. F11 item 5 A/B (2026-10-19). The Nearby call list and the Live list scan at low latency for 8 s, then balanced, instead of low latency without end, so that a reachable phone's radio is free to accept a caller's connection. Setup-latency use (2026-10-26): the LL connect of a direct GATT call (connect request to connected) is a median 497 ms, p90 1124 ms over 1074 saved calls although the caller had just heard the callee; compare that stage with the switch on and off. Hypothesis: status 133 with HCI 0x3E ("failed to be established") toward phones that scan without pause (counts of 2026-10-19 by target: Xiaomi 42, A15s 26, ASUS 20). Measure establishment per pair with it on and off before any default change. | | Experimental: call link sharing (`test_call_link_share=true`) | Off | F16 fix 1 A/B (2026-10-20). An Automatic call caps its own video rate when the video it RECEIVES keeps arriving late (playout lateness >= 250 ms in 2 consecutive 1 s windows): 75 % of what it sends, never below 60 kbps; 5 calm windows raise the cap by 15 % until it reaches the request and is lifted. Windows without received video do not count. No wire change. A fixed rate the user chose is untouched. Evidence: the M31 sent 200 kbps over a ~254 kbps GATT link (`blocked` 0.52-0.62) while the A15s's and the Xiaomi's video waited up to 2063 / 1218 ms in their send queues (19 and 9 freezes). Line `call_link_share: requested | target | effective | cap | reason`. | | Experimental: follow the camera's frame rate (`test_call_fps_follow=true`) | Off | F16 fix 2 A/B (2026-10-20). An Automatic call whose camera keeps a sensor frame above 1.2x the requested interval for 2 encoder windows (5 s each) asks the camera and the encoder for the camera's real rate (an even 10 fps instead of 10/15 bursts); an exposure below 0.8x the requested interval for 2 windows returns to the request. A fixed frame rate the user chose is untouched. Evidence: the A15s in low light, sensor frame 100.05 ms with AE range 15-15. Line `call_fps_follow: requested | effective | sensorFrameMs | exposureMs`. | | Scan-failure recovery off (`test_scan_recovery=false`) [scan_recovery] | Recovery on | Control arm. Every production BLE scanner goes through `BleL2capDiscovery` (call list, stream discovery, receiver/relay recovery, forwarder, benchmark); any `onScanFailed` used to end discovery and hand the error to the caller, which showed it and restarted after its own delay (30 s for viewer requests). Now ALREADY_STARTED stops and restarts the scan; registration failure, internal error, out of resources, scanning too frequently and unknown codes back off 3 s x n (capped at 30 s, reset by any result); FEATURE_UNSUPPORTED stops and reports. Each restart takes a slot from the scan-start gate. A continuous scan hands the failure to its caller's visible error path once the back-off reaches 30 s (about 2 minutes of failures); a timed scan that never recovered still reports its failure. Classic inquiry is not a BLE scan and is unchanged. Line `BENCHMARK scan_recovery`. Untested on phones. The same switch also turns off scan-stall recovery (2026-10-19, `ScanStallWatch`): the Nearby call list and the Live list restart a scan that heard nothing at all for 10 s, only when the scan-start gate has a free slot (never past 5 starts in 30 s), doubling the wait after each restart that changed nothing (up to 80 s) and returning to 10 s on any result; line `scan_stall: scanner | silentMs | restart=yes/quota_full/failed | startsIn30s`. It answers scans that the stack accepted but never fed (a Redmi 12 on HyperOS: raw=0 for 28-90 s with five phones advertising). | | Bluetooth ON recovery off (`test_bluetooth_on_recovery=false`; DEBUG only) [bluetooth_on_recovery] | Recovery on | Control arm. Switching Bluetooth off and on destroys every native object: scans, advertising sets, GATT servers and their services, CoC listeners. The epoch callback already resumed the scanners it had paused; the rest stayed broken. Now, when Bluetooth is back: (1) every scanner that was started while the adapter was going down or in BLE-only mode, so it is still marked running but holds a dead stack registration, is stopped and restarted through its gated start path (a stream viewer, call list or request-discovery scan that stayed at zero results); (2) a live broadcast no longer disarms when its offer cannot start while Bluetooth is down: the stream, its encoder session and its status stay, the status line says Bluetooth is off, and the offer, title pages and viewer-request discovery start again through the normal start path when Bluetooth is back; (3) a stream viewer (including a forwarding viewer's upstream link) whose adapter goes off closes its parent link at once, so the read fails like any link loss and the normal route recovery runs ("Connection lost. Finding another route...") instead of showing a frozen picture on a dead link; (4) an idle private-call listener (GATT server with its video service, CoC control server and listener, Classic socket, advertising set) is closed and reopened through the same path as the rotating-ID restart, so a caller no longer finds the phone and then fails service discovery. A call or message exchange in progress is left alone. Relay and receiver GATT servers are not touched. Hop-local, no wire change. Control arm: only the scanners the off callback paused are resumed, a failed offer disarms the broadcast, and the listener keeps its dead server. One line per component `BENCHMARK bluetooth_on_recovery: component=scanner|stream_advertise|call_listener|viewer_link | action=restarted|skipped | reason=`. Untested on phones. | | LE scan start quota (`test_device_policy_off=le-scan-start-quota`) | On (registry, every phone) | AOSP drops a 6th BLE scan start inside 30 s per app UID without telling the app (a "scanning too frequently" line in the Bluetooth log, seen on the A15s and S24 in multi-phone runs; no onScanFailed callback arrives on any release, so only this budget protects). Starts wait for a free slot instead of failing, the user sees "searching again in N s", and the low-latency -> balanced switch is skipped when no slot is free. Starts are persisted per boot (elapsedRealtime keyed by Settings.Global.BOOT_COUNT) because the stack's count survives app process restarts but not a reboot. Untested on phones. | | LE connect retry beyond 133 (`test_device_policy_off=le-connect-retry-link-statuses`) | On (registry, every phone) | GATT video and CoC control setup also retry status 8, 0x22, 0x3E and 147 (Nordic's Android-BLE-Library retries any connect-stage failure), at most once, and never past a private call's early-loss budget so the 2M -> 1M setup retry stays possible. Upstream-documented, not locally tested. | | GATT write busy retry (`test_device_policy_off=gatt-write-busy-retry`) | On (API 33+) | A write the stack reports busy (one permit per peer address for every client and app) is repeated every 2 ms for at most 500 ms instead of failing the link. Upstream-documented, not locally tested. | | Android 14 MTU note (`test_device_policy_off=gatt-mtu-fixed-517`) | On (API 34+, diagnostics) | The `LE link:` line marks a `test_gatt_request_mtu` below 517 as ignored: Android 14+ always requests 517. | | Transport proposals (`android16-bond-loss-dialog`, `samsung-gatt-direct-connect-5s`, `android15-qcom-coc-connect-stall`, `android12-gatt-disconnect-status-zero`, `exynos9611-dual-role-equal-interval-child-starvation`) | Inert (reported only) | Sourced in `BluetoothTransportPolicy.kt`; promotion needs a phone A/B. The Exynos 9611 entry is a diagnostic record only (matcher: hardware `exynos9611`): a relay that is peripheral to its parent and central to its child, both at 15 ms, loses its child link in about half of the attempts; the generic mitigations (`relay_child_stall_guard`, `relay_child_reanchor`, `relay_establish_fallback`) apply on every phone. | | Decoder init fallback (`test_device_policy_off=decoder-init-fallback` to disable) | On for user sessions only | Media3 `maybeInitCodecWithFallback`. A video decoder that fails to create, configure or start is released and the next decoder for the format is tried: hardware first, aliases skipped, at most 3 attempts. A policy-forced software decoder (`qcom-legacy-call-vp9-software-decoder`) falls back only to another software decoder, or the call fails. Never in a DEBUG launch with `test_*` extras, and never for a `test_decoder` request: those keep the former single choice, and `BENCHMARK decoder requested=/effective=` lets the scorer fail a substitution. Upstream-documented, untested on phones. | | Device policy entry off (`test_device_policy_off=<id>[,<id>]` or `all`) | Every entry on | Turns individual registry entries off for an A/B; release builds ignore it. Codec and YUV entries that apply on every phone: `codec-exception-recoverable-reinit` (a call decoder whose CodecException reports `isRecoverable()` is re-created at the next keyframe, at most twice) and `yuv-image-crop-rect` (camera YUV copies and the reference recorder start from `Image.getCropRect()`). Both are upstream-documented and untested on phones. | | GATT dial probe - DEBUG (`test_gatt_dial_address=<MAC>`, `test_gatt_dial_service`, `test_gatt_dial_address_type=random\|public`) | Off | Design QV gate W2: dials one exact BLE address through the production GATT client (LE 1M, connect, MTU, service discovery, subscribe), writes one test chunk and closes, logging `debug_gatt_dial` per stage. DEBUG builds only; a release build never reads the extras. | ## Background and lifecycle | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Foreground-service refusal fallback (`test_device_policy_off=bg-fgs-start-fallback`) | On (API 31+) | Only Android's two refusals are handled: a background start (ForegroundServiceStartNotAllowedException) or a while-in-use camera/microphone type refusal. The service keeps connectedDevice, shows "Camera and microphone are unavailable while the app is in the background", and logs `BENCHMARK foreground_service` (requested, effective and refused types); the scorer fails a session whose refusal is not `none`. Off: the exception propagates (crash) as before. Untested on phones. | | Background reliability checks (`test_device_policy_off=bg-battery-optimization`, `bg-background-restricted`, `bg-standby-bucket`, `bg-unused-app-restrictions`, `bg-power-save-mode`, `bg-sony-stamina`) | On (diagnostics) | `BackgroundReliability.status(context)` reports Android's documented restriction states with settings links; line `Background reliability:` at start. No keep-alive hacks, no exemption prompt. | | Phone-maker background guidance (`test_device_policy_off=bg-oem-<maker>`, 20 makers) | On (by `Build.MANUFACTURER`) | Don't Kill My App (3616bc8b) steps per maker (Samsung, Xiaomi, Huawei, OnePlus, OPPO, vivo, realme, Motorola, Sony, Nokia/HMD, Google, ASUS, Tecno and others) plus documented killer apps (manifest `<queries>`, detection only). Makers without a page get generic advice. Upstream-documented, not locally tested. | | Play battery explanation (`test_play_battery_guidance=false`) | On (Play only) | DEBUG control restores the previous explanation and suppresses the added guidance toast. Both arms open the optimisation list, never the direct exemption prompt. GitHub/F-Droid keep their direct prompt. JVM regressions added; no build or phone test run. | ## Build and benchmark harness Build options and DEBUG tools of the benchmark harness. None changes a user session. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Optional armeabi-v7a library (Gradle `-PenableArmeabiV7a=true`) | Off | Adds the 32-bit ARM native library so phones without arm64-v8a can install. Release APK 10.7 -> 17.6 MB (+6.9 MB stored, about +2.6 MB compressed); software AV1 speed on 32-bit cores is unmeasured. Registry proposal `native-armeabi-v7a-optional-build`. Release builds set it through `make_release.py`. | | Automated-run speaker gain (`test_playback_gain`) | Unset (runners pass 0.05) | Quiet speakers in automated runs. It does not affect measured PCM. | | Encoder bench, radio-free (DEBUG `test_encoder_bench`; settings extras `test_bench_codec`, `test_bench_bitrate_mode` auto/cq/vbr/cbr/cbr_fd/encoder_default (Design HC: no bitrate mode key, only for an encoder that declares none), `test_bench_keyframe_s`, `test_bench_duration_s`, `test_bench_libaom_cpu_used`, `test_bench_libaom_threads`, plus the suite's `test_width/height/fps/bitrate_kbps`, `test_intra_refresh_frames` and the raw fixture `test_raw_yuv/width/height`) | Off (Experimental, DEBUG builds only) | Encodes the declared native raw-motion fixture through the phone's real selected encoder (the app's own `CameraVideoEncoder` raw-input path: MediaCodec configuration, rate control and libaom setup are the product's) for a fixed time and logs the result, without starting Bluetooth, advertising or scanning, so a phone that is outside a radio A/B can still do per-chip-family encoder work: hardware intra refresh, bitrate mode CBR/VBR/CQ/CBR-FD, libaom real-time speed and threads, hardware AV1, bench-only HEVC and AVC hardware encoders (`test_bench_codec` `hevc`/`avc`, `video/hevc`/`video/avc` or an exact encoder name; these never enter a product path, the stream protocol or negotiation, and their bitrate mode is always set explicitly, CBR unless another mode is requested), the bundled libvpx VP9 encoder (Design VX: `test_bench_codec=libvpx-vp9` or `libvpx` only; its cpu-used pin must be 5..9), and later an SVT-AV1 comparison. `MainActivity` hands the launch to `EncoderBenchActivity` before it creates any view, service or radio object; a bench launch into an already running app is refused. A guard (`EncoderBenchGuard`) is active in the bench process and every Bluetooth start path (scan, advertise, GATT, L2CAP, RFCOMM, discovery) calls it first: a start during the bench is refused, counted and reported as `ble_violations` in the summary, which then fails. A setting an encoder cannot apply is never swapped silently: statically known impossibilities (CQ on an encoder that does not report it, a libaom knob on a MediaCodec encoder, intra refresh on libaom, a size the encoder reports unsupported, a fixture of another size) refuse the run with the reason, and a setting the encoder may ignore (intra refresh) is passed through and its `effective` value is read back from the encoder's output format. The launch does not change any default; with the extra absent nothing differs. Untested on phones. | ## Device-family research batches (integrated 2026-10-14) Switches and registry arms from the device-family batches R, T, U, S, B1, G, D, C, V, B2, A and P (research files and decisions: [decisions](decisions/0046-device-family-research-2026-10-14.md)). Every non-default arm is DEBUG-only: a release build ignores the launch extras, and each launch starts from the defaults. Entries that are on by default are turned off with `test_device_policy_off=<id>` (or `all`). Nothing here has a phone result yet unless a row says so; "untested on phones" means unit tests only. ### Registry matchers (batch R) | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Device-policy matchers: security-patch window, five Bluetooth-stack eras, runtime traits (`DeviceTraitsProbe`) | On (read-only) | One off-switch rule for every batch (the launch extra and a traits view's own off list). An entry whose effect waits for an Experimental arm is reported under `armOff`, not `active`, until the arm is on; a proposal superseded by an implemented entry is never reported as matching (2026-10-27). Entries can match a security-patch window, a stack era (AOSP base, vendor stack) and runtime facts read once at start (ROM version properties, ABIs, memory, Bluetooth package versions, audio features). No switch of its own: an entry using them is turned off with `test_device_policy_off=<id>`. ROM property names are unverified (`rom=unverified{...}` in the device_policy line). The policy report tool ignores patch and runtime fields until the first entry uses them. | ### Thermal, memory and native (batch T) All Experimental and default off except the always-on memory-tier log. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Thermal headroom forecast (`test_thermal_forecast=true`) | Off | Treats the ADPF forecast `getThermalHeadroom(10)` above 0.85/0.95/1.0 as LIGHT/MODERATE/SEVERE for the FPS/bitrate ladder only (never a stream stop), and only while automatic cooling is on (benchmark profiles turn it off). Guide thresholds, not locally tested; registry proposal `thermal-headroom-forecast`. Since 2026-10-14 the forecast is sampled every 2 s with a 3 s warm-up and a decision every 10 s (the old one call per 10 s could never produce a forecast, see the thermal rows below). | | Thermal libaom speed floor (`test_thermal_speed_floor=true`) | Off | While automatic cooling is on and the effective status is MODERATE/SEVERE, the software AV1 encoder's cpu-used floor is 8/10 and slower trial presets are blocked (the raised preset is not remembered). Not measured on a hot phone; registry proposal `thermal-native-speed-floor`. | | Legacy forecast sampling (`test_thermal_forecast_legacy_10s`) | Off (Experimental) | Keeps the old arm of the forecast path (one call per 10 s, no warm-up discard) so the corrected 2 s sampler can be compared with it. Only meaningful with `test_thermal_forecast=true`. Untested on phones. Planned A/B: S24 and ASUS. | | Battery-temperature fallback (`test_thermal_battery_fallback`; registry `thermal-battery-temperature-fallback`) | Off (Experimental) | When the sampler latches "no skin thresholds" (three NaN headroom answers, or an empty threshold map on API 35+), the battery temperature (40/43/46 C for light/moderate/severe) can raise the status the FPS/bitrate ladder follows; it never reaches critical, so it never stops a stream. Replaces the former SoC-family matcher with runtime detection. Untested on phones. **Requires / masked by:** `test_device_policy_off=thermal-battery-temperature-fallback` does nothing; the reader ignores the registry id (use `test_thermal_battery_fallback`). | | Memory-tier sizing (`test_memory_tier`; registry `memory-tier-sizing`) | Off (Experimental); the tier log is always on | Sender queue and lag-frame caps by memory tier (from MemoryInfo, not a model name). The `memory_tier` log line is written on every launch (off with `test_device_policy_off=memory-tier-log`). Planned check: A55 and M31. | | Native SIMD safe mode (`test_native_simd_safe_mode`; registry `native-simd-safe-mode`) | Off (Experimental) | After a native crash at encoder start on arm64 the app relaunches once with the libaom SIMD capability mask reduced (`AOM_SIMD_CAPS_MASK=0x3`). Marker files live in the app files directory. | | 32-bit userspace tier (`test_userspace32_tier`; registry `userspace32-tier`) | Off (Experimental) | On a 64-bit CPU running 32-bit userspace the software AV1 thread count is capped (a MediaCodec preference over libaom is a PLAN idea, not a switch). | ### Audio capture, routing and focus (batch U) Active entries (off with `test_device_policy_off=<id>`): `android15-audio-focus-requires-top-or-fgs`, `audio-effect-default-state-logging`, `audio-capture-silence-watchdog`, `audio-echo-risk-hint`, `call-route-set-result-and-reassert`, `opus-encoder-native-fallback`. Inert proposals: `audio-capture-source-fallback-chain`, `audio-off-main-thread-commands`, `call-audio-mode-change-wait`, `call-usb-headset-mode-normal`, `opus-complexity-governor`. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Capture source fallback (`test_capture_source_fallback`) | Off (Experimental) | After confirmed digital silence the capture restarts once per step on the next source (voice communication, mic, camcorder). It loses the platform echo canceller, so it is never used in benchmarks (requested != effective is logged). | | Blank-capture evidence off (`test_capture_silence_evidence=false`) | On | Control arm (2026-10-20, F18): the old notice after any 2 s of exact digital zeros. With the evidence rule the "microphone is not picking up any sound" notice for a blank capture needs no signal since the recorder started (8 s) or one 30 s zero run; the platform's own silencing (isClientSilenced) is still told at once. Reason: Samsung voice-communication capture outputs exact zeros in a quiet room and recovers by itself (16-34 episodes and 6-8 false notices per 4-minute call on the S24 and M31). Lines `audio_capture_silence ... | notice=` and `audio_capture_blank: evidence=never_signal|persistent`. | | Audio work off the main thread (`test_audio_off_main`) | Off (Experimental) | AudioRecord/AudioTrack/AudioManager teardown, and the call microphone start, run on one serialised command thread with a watchdog. Aimed at the M31 audio ANR class (Oboe #1216/#1474). Planned check: 50 call cycles on the M31 with the switch off and on. Setup-latency use (2026-10-26): the call microphone starts on the UI thread, so the peer's stream header waits there a median 584 ms (p90 988 ms, 4475 saved calls) before the call turns ACTIVE and the first remote picture can show, on both phones; A/B with `call_ui_lag event=remote_header`, accept to ACTIVE and ACTIVE to first surface. | | Mode-change wait (`test_audio_mode_change_wait`) | Off (Experimental, API 31+) | After setMode(IN_COMMUNICATION) waits up to 500 ms for the mode-changed listener before choosing the communication device (Signal sequence). | | USB headset mode (`test_audio_usb_headset_mode_normal`) | Off (Experimental, low confidence) | One UI with Qualcomm and a USB headset uses MODE_NORMAL (Twilio workaround). Needs a USB-headset hardware test before any promotion. | | Opus complexity governor (`test_audio_complexity_governor`) | Off (Experimental) | The native call encoder steps complexity 10 to 8 to 5 when the measured encode time stays high; the requested bitrate never changes. | | Route re-assert off (`test_audio_route_reassert=false`) | Re-assert on (control arm) | The call re-asserts its communication device when the platform or another app changes it (rate limited). false restores setting the route once. Risk: OEM route moves. | ### BLE scanning and adapter state (batch S) Shipped on (off with `test_device_policy_off=<id>`): `android11-gatt-server-connection-untracked` (Android 10-11 legacy stack: a client connection whose PHY read gets no answer within 400 ms is missing from the stack's server map; the call listener cancels it and registers again so the client's retry reaches a fresh server; second stage: a subscribed client whose first data write does not reach the app within 1.2 s is ended the same way, never a link that has received data; a re-registration keeps the running advertising set and its address, see `test_gatt_reregister_keep_address`), `android10-11-location-switch-scan-gate` (Location guidance on Android 10-11), `scan-silence-watchdog` (log only), `bluetooth-process-restart-recovery` (its off-switch stops only the scanner rebuild; the Bluetooth-epoch receiver that calls, viewers and advertising use stays attached, fixed 2026-10-27), `android10-scanner-leak-on-process-death` (scope SDK 29-30 since the lane11 ASUS dumpsys showed 25 leaked scanner registrations of force-killed Android 11 processes; the DEBUG `test_control=shutdown` also stops all scans before a runner's force-stop). Default change: the relay-helper scan restarts after 8 min on Android 14+ and 25 min on 10-13 (was 20 min). | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Restart interval control (`test_scan_renewal_legacy`) | Off (control arm) | The relay-helper scan restarts every 20 min as before. | | App-wide scan renewal (`test_scan_renewal_all`; registry `le-scan-timeout-renewal`) | Off (Experimental) | Every scanner of the app stops together and restarts through the scan-start gate. Planned check: S24, 25 minutes, results per minute. | | Stale-scan restart (`test_scan_stale_restart`) | Off (Experimental); the `scan_stale` log is always on | A stale-scan verdict requests one gated restart. Planned check: Xiaomi, force-idle. | | Software filter fallback (`test_scan_sw_filter_fallback`; registry `scan-hardware-filter-fallback`) | Off (Experimental) | A filtered scan that yields nothing is probed once; if unfiltered scans see adverts the app switches to an unfiltered scan with a software filter while the screen is on. | | Probe scans ungated (`test_scan_probe_ungated`) | Off (control arm) | The DEBUG probes start scans without a scan-start gate slot, as before 2026-10-14. The shipped behaviour gates them: the stack drops a 6th start in 30 s silently (no onScanFailed on any release). | ### Foreground service, onboarding and boot (batch B1) The Google Play flavour never uses the Android 10-11 `location` foreground-service type (`BuildConfig.LOCATION_FGS_AVAILABLE=false`; its manifest declares neither the type nor `FOREGROUND_SERVICE_LOCATION`), whatever the family-policy entry says; ARCHITECTURE "Distribution and releases" gives the reason. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Session wake lock (`test_session_wake_lock=true`; registry `fgs-session-wake-lock`; promotion constant `SwitchDefaults.SESSION_WAKE_LOCK`, Q44) | Off (Experimental, DEBUG) | A partial wake lock while a screen-off session forwards media. A held lock does not prove the CPU stays awake: measure with dumpsys power before any default. Planned check: S24 and ASUS, 20 min screen-off relay with the lock off and on. | | Wake-lock daily cap (`test_session_wake_lock_cap_min=N`) | 100 minutes (0 = uncapped, A/B only) | Budget of the lock per day; Play vitals call two hours of screen-off partial wake lock in 24 h excessive. | | Stop scans on exit off (`test_stop_scans_on_exit=false`) | On (control arm) | The app's BLE scanners are stopped together when the session service ends or the task is removed (the reason is the Android 10 scanner leak, but the stop applies on every Android version). false keeps them. | | Boot resume (`test_boot_resume=true`; registry `boot-resume-after-restart`, Q41) | Off (Experimental, DEBUG only) | Starts the connectedDevice service after a restart or app update. Only the DEBUG extra switches it on; a release opt-in setting is a PLAN item (Q-N2). The boot receiver is disabled in the manifest until the switch is on. Known gap: the relay controllers live in the activity, so a service started at boot holds the notification but does not relay until a headless host exists. Reboot checks only after Q41. | ### GATT links and capacity (batch G) On by default (off with `test_device_policy_off=<id>`): GATT client close after registration for SDK 34 and lower (G-02), stale CONNECTED callback cleanup, one client per remote, link-silence classification, pool-exhaustion guard, link-slot limit, Bluetooth-audio diagnostics. The link-budget admission, the wedge arm and every sizing arm below are Experimental. Release builds ignore all non-default arms. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Busy-write wedge (`test_gatt_busy_wedge=legacy` or `recover`) | legacy (review 2026-10-14) | `recover` (Experimental) ends a link on one busy answer while MeshLive has nothing in flight on that client (Android 13-14) and retries every 25 ms; `legacy` is the old 2 ms spin up to 500 ms. Kept at legacy because the wedge check would end links on one answer on three of four lane phones without a phone A/B under load (A15s, ASUS, M31). | | Close deferral off (`test_gatt_close_deferral=false`) | On for SDK 34 and lower (control arm) | A GATT client is not closed before its first callback (bounded 2 s) so the stack registration finishes first. | | Pool back-off off (`test_gatt_pool_backoff=false`) | On (control arm) | After the phone-wide GATT registration pool refuses a client registration (status 257/128; scanner code 2 and a null GATT server are recorded the same way), automatic stream connects from viewer requests wait an exponential hold (1 s doubling to 30 s, reset by the first success) instead of re-dialling at once (lane11 ASUS Android 11: about 1,500 `MAX client reached: 32` stack errors per 3-minute run). One `BENCHMARK gatt_pool_exhausted: source=server\|client\|scanner` line per step; three steps of one source raise the Bluetooth off/on guidance on the reliability card. Off removes only the hold; the evidence lines and the card feed stay. User dials and DebugDialWait are not held. | | Link budget admission (`test_link_budget_admission`) | Off (Experimental) | Refuse a child past the phone-wide LE link budget with the distinct reason "relay full" (never "low memory"). The decision is computed and logged either way (`gatt_link_budget`). Planned check: S24 fan-out of five and more. | | Server hold (`test_gatt_server_hold`), ACL wait on redial (`test_gatt_acl_wait`), remote-full fail-fast (`test_gatt_remote_full_failfast`) | Off (Experimental) | Make-before-break pieces of G-04/G-05: `BluetoothGattServer.connect(device,false)` on an admitted link, wait for the ACL to drop before a redial, fail a link at once when the peer's GATT pool is full. | | Settle delay (`test_gatt_settle_ms=<0..3000>`) and connectGatt on the main looper (`test_connect_gatt_main`) | Off (Experimental) | A wait after CONNECTED before the first GATT operation (G-05), and `connectGatt` posted to the main looper (G-06, Huawei/Samsung reports). A/B on the A15s. | | Notification sizing (`test_gatt_payload=244`, `495` or `default`; `test_gatt_byte_window=<bytes>`, `test_gatt_window_kbps=<kbps>`) | default, no window (Experimental) | ATT value size per write or notification (one or two data PDUs), and a byte window with an assumed drain rate. Planned check: 244 and 495 between M31 and S24. | | Bluetooth audio coexistence (`test_bt_audio_capacity_key`, `test_bt_audio_load_cut`) | Off (Experimental) | Learned capacity is kept apart with an active Bluetooth audio output (".btaudio" key), and the planned load is cut to 0.6x. Planned check: earbuds. | | GATT cache refresh (`test_gatt_refresh`) | Off (Experimental) | Refresh the GATT cache before service discovery. | ### Decoder robustness (batch D) On by default (off with `test_device_policy_off=<id>`): `c2-mtk-release-instead-of-flush` (a c2.mtk.* decoder is rebuilt instead of flushed on recovery), `qcom-decoder-vendor-low-latency` (a Qualcomm decoder that does not offer the standard low-latency key gets Qualcomm's own, `vendor.qti-ext-dec-low-latency.enable=1`, retried without it if configure refuses it; logged `decoder_low_latency` requested/effective), `decoder-no-output-watchdog` (logging), `decoder-bounded-release` (5 s), slow and hung configure logging, and a transient 0x0 output format is ignored. No owned phone has a c2.mtk decoder. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Stall recovery (`test_decoder_stall_recovery`; registry arm `decoder-stall-recovery-arm`) | Off (Experimental) | A decoder stall (input queued, no output) rebuilds the decoder once, then excludes the component for the session and tries the next candidate. Off: a stall is only logged. Planned check: A15s with the OMX.MTK decoder. | | Low-latency ladder (`test_decoder_low_latency_ladder`; registry arm `decoder-low-latency-ladder-arm`) | Off (Experimental) | Decoders that declare FEATURE_LowLatency are tried first and the format carries the vendor low-latency keys the component lists (MediaTek vdec-lowlatency, vdec-no-record, use-clearmotion-mode=0), dropping the newest key first when configure fails. Vendor key evidence is AVC/HEVC. Planned check: S24 and M31 via `decoder_behaviour`. | | Real-time priority (`test_decoder_priority`; registry arm `decoder-realtime-priority-arm`) | Off (Experimental) | KEY_PRIORITY=0 on the decoder format, dropped first when configure fails; never an operating rate. | ### CoC and Classic (batch C) On by default (off with `test_device_policy_off=<id>`): `coc-connect-bounded` (every LE CoC connect bounded at 12 s, because the stack timer is 60 s; failure goes to the existing retry and automatic GATT path), `coc-socket-error-codes`, `coc-android10-address-type-precondition`, `classic-connect-runtime-guard`, `classic-pairing-request-logging`, `coc-credit-stall-classification` (classifier only; the writer does not feed it yet). | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Connect bound (`test_coc_connect_bound_ms=<ms>`) | 12000 (0 = unbounded as before) | Value of the CoC connect bound. | | MTU exchange before CoC traffic (`test_coc_mtu_prime=false` is the DEBUG control arm; registry `coc-mtu-exchange-before-traffic`, off-switch `test_device_policy_off=coc-mtu-exchange-before-traffic`) | On for fresh-ACL CoC centrals (Android 12-13 by registry, Q49); other stacks log `coc_mtu_prime ... outcome=skipped reason=control-link-alive` | One GATT MTU exchange (517) on the fresh CoC ACL, because LE Data Length rises to 251 only after an ATT MTU exchange (MTU-02); a CoC-only link keeps the controller default (27-byte payloads). Only the fresh-ACL client path runs it (Android 12-13, for example the M31); stacks whose control GATT client stays open already exchanged the MTU, so the central logs a skipped line and does nothing else. It never fails the CoC connect. Control arm: DEBUG `test_coc_mtu_prime=false` restores the old no-prime connect exactly (a release build cannot change it by extra). Phones: M31 central, ASUS peer, 632 kbps on 5/5 runs with the prime against about 325 kbps on 5/5 without; S24 peer neutral. | | Echo verification (`test_coc_echo_verify`) | Off (Experimental, both ends) | A 1 KB echo over the new CoC socket must round-trip before the socket is used; the listener answers it before anything else, so both phones need the switch. | | Classic RFCOMM peer cap (`test_classic_rfcomm_cap=<n>`; registry `classic-rfcomm-peer-cap`) | Log only (refusal off; root review 2026-10-14) | A Classic connect to a seventh distinct peer writes a log-only `classic_rfcomm_cap` line; with `=6` (Experimental) it is refused as capacity. Refusal is off because it refuses connections without phone evidence and incoming peers are undercounted until the accept paths call `ClassicRfcommCap.note`. | ### Camera and encoder configuration (batch V) On by default (off with `test_device_policy_off=<id>`): `camera-session-wait-closed` (create the next session only after onClosed; Qualcomm, SDK 31 and lower, so the ASUS and the Xiaomi when its API is 31 or lower), `camera-stream-combination-validation` (log), `camera-open-error-retry`, `camera-frame-stall-watchdog` (log), `camera-privacy-toggle-notice`, `encoder-init-fallback` (after a failure only, user sessions only, never in benchmark launches). The Experimental arms never apply in a release build. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Camera device reopen (`test_camera_reopen`) | Off (Experimental) | Close and reopen the CameraDevice when the session is rebuilt, on families that need it (V-01). Costs 300-800 ms and flickers the privacy indicator. Planned A/B: S24. | | SessionConfiguration path (`test_camera_session_configuration`) | Off (Experimental) | SessionConfiguration plus session parameters (V-02). | | LEGACY AE range (`test_camera_legacy_ae_range`), starvation fallback (`test_camera_starvation_fallback`), capability probe (`test_capability_probe_configure`) | Off (Experimental) | LEGACY hardware level picks the smallest lower bound of the AE range; a frame stall rebuilds the session with TEMPLATE_PREVIEW; a size within 8 px of the reported limit is probe-configured. | | Encoder colour keys (`test_encoder_colour_keys`), Exynos QP range (`test_exynos_qp_range=<min>,<max>`), encoder KEY_LATENCY (`test_encoder_key_latency`) | Off (Experimental) | BT.709 limited SDR colour keys on the encoder format; vendor QP range of OMX.Exynos/c2.exynos encoders when the component lists the keys; KEY_LATENCY=1 retried without the key. One key at a time, newest key dropped first. Planned checks: M31 alignment and Exynos QP range against the VP9 overshoot. | | Release timing, rotate and crop, YUV tail clamp (`test_camera_release_timing`, `test_camera_rotate_and_crop_none`, `test_yuv_tail_clamp`) | Off (Experimental) | Capture-interval monitor outside DEBUG logging; SCALER_ROTATE_AND_CROP=NONE where supported; a YUV plane short by its last samples is copied with the tail clamped. | | Proximity pause hysteresis (`test_proximity_pause_hysteresis`) | Off (Experimental; root review 2026-10-14) | Near held 1.5 s before the camera pauses and two toggles at least 3 s apart. Shipped behaviour (pause and resume follow the sensor at once) stays until the 50-cycle ear check on the ASUS. | ### Reliability banner and phone-maker guidance (batch B2) On by default as text and diagnostics (off with `test_device_policy_off=<id>`): `bg-oem-honor`, `bg-oem-infinix-itel`, `bg-system-bluetooth-app`, `bg-phone-updated` (a phone-update stamp: build fingerprint, patch and boot count, kept locally), `bg-incoming-call-popup`, `bg-killer-package-scan`, `bg-process-exit` (the last unexpected kill reason), `bg-scan-paused-by-maker`. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | MIUI/HyperOS app-ops probe (`test_miui_appops_probe`) | Off (Experimental) | Reads the background pop-up permission through the hidden app-ops service on Xiaomi-family phones to refine the incoming-call advice. Planned check: Xiaomi HyperOS card. | ### Advertising (batch A) On by default as after-failure behaviour (each has a control arm): watchdog restart of an unexpectedly stopped set, title/reach preemption after TOO_MANY, stop-settle wait up to 0.7 s, non-throwing start, learned budget, stuck report, 28-byte payload test. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Keep the set when an in-place disable is never confirmed off (`test_adv_inplace_keep_set=false`) | On | Control arm (2026-10-20). The call listener's in-place balanced step (disable, set parameters, enable on the same set) normally keeps the address. On an Android 11 ASUS the stack disabled the set but its confirmation never reached the app (DeadObjectException on the stack's callback), so after the 2 s step timeout the set was replaced with a new address 12 s after every low-latency start, and a PC's connect in flight failed. On: a step still waiting for that disable enables the same set again (same address, low latency kept), and the process skips later balanced steps; a timeout in the later steps restarts as before. Lines `adv_set_churn: event=reenable_same_set|balanced_skipped`. **Requires / masked by:** `stable_adv_address` (the in-place step). | | Boost and its end on the same set off (`test_adv_inplace_boost=false`) | On | Control arm (2026-10-21). Showing the Meet code boosts the call listener to low latency and its end returns it to balanced; both are now in-place steps on the same advertising set (disable, set parameters, enable), so the address stays and a caller that just saw it still reaches it. A step on a set already in that mode does nothing (before, the second balanced step on a boosted set replaced it: one more address). A phone whose in-place disable is never confirmed keeps the set as it is. A failed step replaces the set in the target mode. The control arm replaces the set for the boost and its end, as before. **Requires / masked by:** `stable_adv_address` (the in-place step). | | Watchdog, preemption and stop-settle control arms (`test_adv_watchdog=false`, `test_adv_preempt=false`, `test_adv_stop_settle=false`) | All on | false restores the earlier behaviour for an A/B in either direction. | | Advertising budget simulation (`test_adv_budget_cap=<1..8>`) | Off (Experimental) | Act as if the controller offered only N sets, to exercise the one-instance paths (no owned 1-slot phone). Planned check: N=1. | | Quiesce before stop (`test_adv_quiesce_stop`) | Off (Experimental) | Disable a set and let that confirm before stopping it, so a stop cannot meet the stack's address-rotation command (the Android 15 slot-leak hypothesis). | ### PHY and link-layer procedures (batch P) On by default (off with `test_device_policy_off=<id>`): `android13-phy-update-callback-backstop` (poll readPhy when the PHY callback may be missing), `le-priority-request-supervision-log`, `dual-role-probe-log`. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | LL procedure gate (`test_ll_gate`) | Off (Experimental) | One MTU, discovery, PHY or connection-update request in flight per link with settle gaps, a peripheral waiting for the central's first connection update, and one 1M retry on 0x23/0x2A. Delays the 2M start by seconds. Off: every request goes straight to the stack as before (the gate is a pass-through). On, it also switches on an in-session rollback memory (a link lost after a gated change remembers that change as bad for the peer until the app process ends); the persisted memory is the next row. | | Link change rollback (`test_link_change_rollback`) | Off (Experimental) | A link lost with a fault status within 10 s of an MTU, PHY or priority change remembers that change as bad for the peer (persisted; cleared by a Bluetooth restart, a security-patch change or 7 days). The MTU and PHY rollbacks are applied; a lost link after a priority change is recorded but nothing rolls the priority back (a PLAN idea, not a switch). | | Priority re-assert (`test_priority_reassert`) | Off (Experimental) | When the stack reports an interval above the requested priority's maximum (CONN-02) the same priority is requested again, at most twice. Off: the drift is only logged. | ## Discovery privacy, identity and messages | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Rotating discovery IDs, phases 1, 1b and 2 (`test_rotating_ids`; Settings > Privacy > Advanced privacy "Private Bluetooth ID"; DEBUG `test_rotating_visible_window`, `test_rotating_neutral_name`, `test_relay_callee_rotating`) | Promoted 2026-10-12 for 0.2.0-beta: default ON in user launches (one constant, `RotatingDiscoveryFeature.DEFAULT_ENABLED`; a stored choice always wins; a DEBUG benchmark launch reads only `test_rotating_ids`) | LE (CoC/GATT) call-ready presence, stream offers, viewer requests and forwarder offers broadcast a 15-minute rotating ID (announcement version 5; compact UUID v2 decodes too) instead of the fixed node prefix, so a passive scanner cannot follow a phone between places. Each boundary restarts every app advertising set (call listener, stream offer, waiting viewer request, forwarder offer, title pages) after a random 1-8 s silence on a new address; a connection in progress keeps its link. Whoever connects proves the ID it advertised with a link opening `BDO1` (hop type 13, link hello bit 3; stream connector role 1, viewer role 2, call listener role 0) checked against the advert it selected, with a two-epoch window against precomputed forgery; call endpoints exchange the callee's `BDX1` opening (endpoint hello bit 3) before its first answer, which also verifies relayed calls end to end (the DEBUG call relay mirrors version 5 offers). Stream tokens follow the ID and a source accepts the previous token for 60 s; a viewer that lost a forwarder sends a recovery hint bound to its own ID. A rotating viewer that joins an older (version 2) source shows its fixed ID while it joins (status line and `rotid_downgrade`). Contacts: mutual verified contacts that both offer exchange daily presence leases in an answered call (per-contact switch "Can see when I'm nearby" on the verify screen, default on; sealed store; Forget, Reset and the switch start a new key, "Reset presence key" in Settings) and then see "name . seen nearby . unconfirmed" in the people and stream lists (never a check mark, never an automatic connection). Strangers' repeated silent handshakes wait 2^(n-6) s (max 30 s). Hardening (2026-10-11): on API 34+ every connectable app advertising set is non-discoverable (control: DEBUG `test_adv_non_discoverable=false`), the phone is no longer renamed automatically (the explicit neutral-name option has its own row), contact keys are independent random daily keys in a rolling window with the earlier HMAC derivation kept only to migrate an old store, version 5 viewer requests carry no codec summary and stream bitrates are rounded to 25 kbps, and a legacy-only controller is detected by capability and reported (`rotid_address_reuse`). The Settings button "Visible by handle for 10-25 minutes" advertises version 2 (calls and streams) so old builds can find the phone. Classic roles, the DEBUG direct-receiver mode and `BGK1` group hops stay version 2; old builds cannot see the phone outside a window. Promoted after the scanner test (P5 PASS, zero carry-over at 3 of 3 boundaries) and the phone cases P1 to P4 and P7 of the combined privacy session of 2026-10-11 (build 3996bfb2); P6 (battery) was not measured. PROTOCOL "Experimental rotating discovery IDs". Lines `rotating_ids: enabled=`, `rotid_open`, `rotid_tarpit`, `rotid_name`, `rotid_adv`, `rotid_address_reuse`, `rotid_visible_window`, `rotid_presence` (including `event=window`), `rotid_downgrade`, DEBUG `rotid_boundary`, `rotid_restart`, `adv_set_churn`, `adv_address_seen` (`tools/rotid_phase0.py`, `tools/rotid_tracker.py`). Fast retest: DEBUG `test_rotid_epoch_s=60` shortens the epoch to one minute. | | Debug contact verification and day-roll clock (`test_verify_contact=<token>`, `test_forget_contact=<token>`, `test_presence_day_offset=<n>`; DEBUG builds of a debuggable package only) | No trigger; clock unshifted | Lets the rotating-ID contact-verification test run without two humans scanning QR codes. Same gating as the debug emergency wipe: adb writes a one-shot file into app-private storage with `run-as` (`files/debug-verify-contact` or `files/debug-forget-contact`: a 32-128 hex token, the peer's full 64-hex fingerprint, an optional name) and the launch extra must carry the same token. At launch or on a new intent the app deletes the file when the token matches, then marks that fingerprint verified in the same Keystore-sealed verified-contact store the QR flow writes (method QR_SCAN) and logs `BENCHMARK contact_verified source=debug fingerprint_prefix=...`; forget calls `IdentityVerification.forget` (including presence-lease revocation). A mismatch leaves the file and does nothing; its own fingerprint is refused. `BENCHMARK identity fingerprint=<64 hex> node=<32 hex>` is logged at every debug launch; `tools/debug_verify_contacts.py --phones A,B [--forget]` reads it from both phones and writes the files. `test_presence_day_offset` (cold start, 0-28, tunable `presence_day_offset`) adds whole days to the discovery wall clock only (`DiscoveryWallClock`: epoch ticks, the RD4 tolerance, recognition rebuilds, the Visible window and lease received-times); use the same value on both phones of a pair, and clear app data afterwards (RV4 recovery otherwise needs an hour). Release builds ignore all three extras. Not yet tested on phones. | | DEBUG short rotating-ID epoch (`test_rotid_epoch_s=<60..900>`; tunable `rotid_epoch_s`; DEBUG builds of a debuggable package only) | Off: 900 s epochs | Shortens the epoch of the rotating discovery IDs so a functional phone test sees three boundaries in 3 minutes (60 s) instead of 45. Read once at cold start before rotating discovery starts (a later intent in a running process is ignored); release builds, non-debuggable packages, values outside 60-900 and launches without the extra keep 900 s. The wire format is unchanged (`e = floor(unix_seconds / S)`); every epoch-counted rule keeps its meaning in epochs, presence-lease days stay real days (`floor(e * S / 86400)`, use `test_presence_day_offset` to roll days). **Give every phone of a test the same value**: unequal lengths fail the two-epoch opening check as stale, by design. The stored RV4 counter carries its length, so the next normal launch restarts at the wall epoch (no app-data clearing needed; contacts' stored epoch skews correct themselves at the next lease exchange). Line `BENCHMARK rotid_epoch_s: requested= | epochSeconds= | applied=`; `tools/rotid_tracker.py --epoch-s <S>` places boundaries and scales the grace and early offsets. Tests: `ShortEpochHarnessTest`, `tools/tests/test_rotid_tracker.py`. PROTOCOL "DEBUG short epoch". Not yet run on phones. | | Hidden identities: the BMH3 handshake (Settings > Privacy > Advanced privacy "Hide my ID from strangers"; DEBUG `test_bmh3` is the benchmark switch, DEBUG sub-options `test_bmh3_only`, `test_bmh3_contacts_only` and `test_bmh3_test_lease=<64 hex>`) | Promoted 2026-10-12 for 0.2.0-beta: default ON in user launches (one constant, `ConnectivityFeatureDefaults.BMH3`; the Settings switch is the off-switch; a DEBUG benchmark launch still reads only `test_bmh3`) after the BMH3 test steps run in the combined privacy session of 2026-10-11 (build 3996bfb2) | Rotating discovery IDs hide the advertisement only; BMH2 sends both identity keys in the clear on every hop and shows the responder's key to anyone who connects. BMH3 (SIGMA, PROTOCOL "Experimental BMH3 identity-hiding link handshake") sends only a one-time key and a nonce first, then each identity, its capabilities, a signature over the whole transcript and a key-confirmation MAC encrypted under keys derived from that exchange: a passive sniffer learns neither identity, and a caller reveals itself only after the answerer's signature, the discovery binding (advert prefix or rotating-ID opening) and, for a recognised contact, its full node ID passed. Both phones need it. With the switch on a call to a rotating-ID advert is always BMH3, a call to a fixed-prefix advert uses BMH3 when the callee's announcement carries one more capability bit (`capabilitySummary` bit 5) or the prefix is remembered from an earlier BMH3 call (30 days, kept in no-backup storage), and otherwise stays BMH2 so older phones keep working; toward a relay the caller presents a throwaway key. The updater's build tag, when that switch is on, travels inside the encrypted block instead of the cleartext hello. Costs: 887 handshake bytes against about 638, one more bit on air, and phones with rotating IDs on but BMH3 off cannot be called by phones with both on. **What it does not hide:** a phone that accepts calls from anyone still shows its identity to someone who connects to it on purpose (the public tracking warning stays true for that case; a Contacts-only mode keyed by the presence leases is Phase 2, a DEBUG fixed-lease version exists for the test), message sizes and timing, the order (3 or 4 messages), the advertisement itself, and any caller that speaks BMH2 to a fixed-prefix advert whose hint was stripped. Phase 1 covers calls and message exchanges, not streams, viewers, forwarders or Design C helper routes. Lines `bmh3: event=link|endpoint | role= | order= | result= | ms=` (no identifiers), `event=choice | version= | reason=`. PLAN "Design F" holds both security reviews (SR1-SR14, BR1-BR11) and the Phase 2 list. **Phone test (PLAN Design F has the full list), pass when:** (1) switch off: calls unchanged and an HCI snoop of a call shows the BMH2 hello (the control); (2) switch on, every ordered pair and transport, both roles: ringing time within +300 ms of BMH2 on CoC and Classic and +600 ms on GATT; (3) `tools/bmh3_snoop_check.py --expect hidden` finds no SPKI, node ID, prefix or capability block of either phone in any L2CAP or ATT stream of a BMH3 call; (4) a Contacts-only responder answers a stranger with at most the 105-byte msg2 and a lease holder with the full handshake; (5) a copied rotating advert, also at route cost 1, collects nothing from the caller; (6) the negotiation matrix fails closed as specified; (7) relays, message exchange, replay and flood checks and the combined rotating-ID passive-tracker run pass. **BMH3 test result:** steps 1, 3 and 6 passed on the S24 and ASUS (control call with the cleartext hello visible; snoop check finds none of the four identity needles in the stranger calls; negotiation: BMH3 caller to BMH3-off callee works over BMH2, the reverse works, `test_bmh3_only` to a BMH3-off callee is refused); step 2 was measured on one pair and transport only (GATT S24 to ASUS, 3 calls per arm: mean invite to ringing 3.8 s with BMH3 against 3.3 s without, link handshake 101 to 137 ms, end-to-end 115 to 838 ms, variance dominated by GATT connection time), so the +600 ms GATT gate is not decided and CoC and Classic were not run; steps 4 (active probe with a lease key), 5 (fake adverts), 7 (relays), 8 (message exchange), 10 (abuse) and 11 (battery) were not run. | | Pseudonymous toward strangers on top of BMH3 (Settings > Privacy > Advanced privacy "New identity for each stranger", a child of "Hide my ID from strangers"; DEBUG `test_bmh3_pseudonyms` next to `test_bmh3`) | Promoted 2026-10-12 for 0.2.0-beta: default ON in user launches (one constant, `ConnectivityFeatureDefaults.BMH3_PSEUDONYMS`; the Settings switch is the off-switch; a DEBUG benchmark launch reads only `test_bmh3_pseudonyms`); needs BMH3 and rotating discovery IDs, and is off at run time while either is off | Design decision: a phone that accepts calls from anyone should not hand its long-term identity to whoever connects. With the switch on, every connection that is not a recognised contact meets a throwaway signing key (a fresh one per connection for the callee, per attempt for the caller; software keys, never the Keystore, nothing derived from your identity); a recognised contact (the presence-lease tag, now wired to the real lease keys) still gets the real identity at once. The callee rings as an "unverified stranger"; if the user accepts, the callee sends a signed `REVEAL` (bound to that session, with the opening of the rotating ID that it withheld until then), the caller checks that it opens the offer it selected and the verified-contact store, and only then reveals itself; declining reveals nothing. The salt of the rotating ID, which would let someone who knows your public key confirm that you are here, is withheld from strangers; BMH2 is refused (it would answer with the real key), the replay cache and signing budget are kept per 15-minute epoch, the caller name is held until the callee is bound. Message exchanges and relay requests are refused with a stranger (their cards and bundle IDs are stable identities); relayed calls to such a phone, Design C helpers and phones with BMH3 off cannot reach it, and a BMH3-on caller without this switch cannot call it. Streams still answer BMH2 and show the source (streams on BMH3 with a per-stream pseudonym are specified in PROTOCOL, not built). **What it does not hide, and what stays true for the public warning:** accepting a call from a stranger shows them who you are and calling a stranger shows whoever answers who you are; someone who can see you can ring phones and watch which reacts, and a connection held across the 15-minute change can link the old and new ID; within one epoch the advert and every connection are the same phone; anyone who connects learns phone class, app build and feature bits; Bluetooth leaks outside the protocol (GAP name on older Android, Classic, chip fingerprints, bonded addresses); streams you send; contacts' leases for up to four weeks. Lines `bmh3: event=present | layer= | role= | key=real|pseudonym | reason=`, `event=reveal | role= | result=`, `event=lease_misuse`. PLAN "Design F" holds the review (PR1-PR15). **Phone test (inside the one combined privacy session, PLAN "Phone test"), pass when:** a stranger call shows no node ID or name before Accept and the prober phone's log shows a different throwaway per attempt; the real identity appears on both phones only after Accept; a verified-contact call reveals nothing (real keys at once, no `REVEAL`); a BMH2 hello is refused; `bmh3_snoop_check.py --expect hidden` finds no real SPKI in any stranger call. **Pseudonym test result:** a stranger call showed throwaways on both layers and both phones and a different one per attempt (also across a boundary and after a restart inside the epoch); the callee rang as an unverified stranger; the reveal followed Accept in order (caller verified and sent, callee sent and verified); a declined call had no reveal; the snoop check found none of the four needles in the stranger calls; verified contacts called with real keys and no reveal after their lease exchange. Not run: wrong contact (step 5), a prober across a boundary (step 6; the BMH2 prober call hung 170 s in OUTGOING, now bounded to 10 s of hop handshake with a reason on the caller and a log line on the answerer), message exchange and relayed call refusal for a stranger (step 8). | | Non-discoverable connectable sets (part of rotating discovery IDs; DEBUG `test_adv_non_discoverable`) | On while rotating IDs are on, API 34+ only; `=false` is the control arm | Pre-handshake exposure of the GAP Device Name, hardening 2026-10-11 (PROTOCOL "Pre-handshake exposure"). Every connectable app set starts with `AdvertisingSetParameters.Builder.setDiscoverable(false)`: AOSP then drops the Flags AD and refuses the Device Name (0x2A00) to an unbonded, unencrypted central that connected through the set (`gap_ble.cc`, Android 14 and newer; verified in the sources, to be measured per phone). Not covered: links this phone opens as central, Android 13 and older, legacy-only controllers, OEM stacks that differ, Classic. A stack that refuses the parameters gets the set started discoverable again, visibly and logged. Phone test P7 measured the advertising side (S24 non-discoverable; ASUS and M31 not); its unpaired-central probe is the DEBUG extra `test_gap_name_probe=read|pair` (any type of extra works since 2026-10-12; it starts from onResume as well as after the permission flow, and logs `gap_name_probe: event=skipped | reason=` when it cannot run). | | Discoverable call offers while the name is neutral (B2, 2026-10-27; DEBUG `test_discoverable_when_neutral=true` or `=false`) | Default on exactly when the neutral-name option ("Hide phone name", on by default since 0.4.1 after the user's answer) is on; otherwise off (sets stay non-discoverable as before). Release builds ignore the extra | A Mac (CoreBluetooth) reads the GAP Device Name (0x2A00) right after connecting; through a non-discoverable set on Android 14+ that read fails with ATT error 0x05 and macOS starts LE pairing, with a prompt on both devices. The call-offer and viewer-request sets (`DiscoverableWhenNeutral`, `StableAdvertisingSet(neutralRole=)`) are started discoverable only when the switch is on and the adapter name read just before the start is exactly "Phone"; a rename away from "Phone" (`ACTION_LOCAL_NAME_CHANGED`) stops each such running set and its owner restarts it non-discoverable. Stream offers, forwarder offers, the in-call renewal advert and the name-window offer are unchanged. No wire change. Log (DEBUG) `adv_discoverable: set= ; discoverable= ; reason=` (pipe-separated in the log; set `call_offer` or `viewer_request`; reason `neutral_name`, `switch_off`, `name_not_neutral`, `name_unreadable` or `name_changed`). Phone evidence before the switch existed: m5-v1 (non-discoverable: 0x2A00 refused, SMP prompt) vs m5-v2 (`test_adv_non_discoverable=false` + neutral name: name read, no SMP, link open 1.7-2.3 s). Not yet measured with this switch (DECISIONS "Mac name read: B2"). | | Use a neutral Bluetooth name ("Phone") (Settings > Privacy "Hide phone name"; preference `neutral_bluetooth_name`; onboarding page "Hide your phone's name"; DEBUG `test_neutral_bluetooth_name`, alias `test_rotating_neutral_name`) | On by default since 0.4.1 (design decision 2026-10-27, DECISIONS "Hide phone name on by default"): one constant, `NeutralNameOption.spec` `defaultEnabled`; a stored choice always wins, so someone who turned it off keeps it off. The phone is renamed only after the user answers the question (onboarding page for a new install, a one-time dialog after an update for someone who never chose: "Hide name" / "Keep my name"). A DEBUG benchmark launch reads only its extra, default off | Turned on by default so a Mac or PC can call without a pairing prompt (with B2 below) and the real name is not broadcast. Cost: the phone shows as "Phone" to headphones, cars and other devices. Without the Nearby devices permission, or when Android refuses the rename, the switch shows off with a short reason. Replaces the automatic rename that came with the rotating switch. Asks first, shows the saved original name, restores it on switch-off, never writes the name per session, pauses itself when the user renames the phone, and keeps the name of an earlier automatic rename with a one-time notice (a benchmark launch restores it). Recommended on Android 13 and older, on legacy-only controllers and with Classic roles. Uninstalling while it is on leaves `Phone` until the phone is renamed in Android settings. | | Relayed calls and multi-hop discovery (`test_relayed_calls`; Settings > Experiments "Relayed calls (beta)"; DEBUG `test_relayed_calls_help`, `test_relayed_calls_consent`, `test_relayed_calls_max_relays` 1..4, `test_relayed_calls_require=<n>`, `test_relayed_calls_no_direct`) | Off. The stored choice counts only while Manual settings (Advanced) is on, where the switch is shown, so the experiment never runs hidden (spec option `advancedOnly`) | Naming phase 2 (design decision2026-10-10; PLAN "Design C", independently reviewed 2026-10-11). Ordinary phones relay other people's private calls in the background and a caller can find and call a phone one or two relays away. On: a call-ready phone sets the consent bit (call-offer capability bit 4, "Let phones nearby relay calls to me") and, as a helper ("Help calls nearby", both preselected when the experiment is switched on), keeps a reach table from the adverts it scans itself, advertises its best entries in a second legacy set (reach pages, version 4, needs a free advertising set) and accepts one relay request at a time: the caller sends `SETUP` (hop type 12) after the BMH2 link handshake, each helper regenerates it for the next hop, `READY` comes back, and the existing opaque call relay copies the end-to-end records. Relays cannot read or impersonate (the caller still checks the target's prefix and a verified contact's full fingerprint), but each relay on the path sees the signed endpoint hello and the target prefix. Helping pauses below 20 % battery when not charging, in Battery Saver, when hot, or while the phone has its own call or stream; the phone's own activity ends a relay at once and a relayed call already running is not cut by a pause; the notification reads "Helping a call nearby". A chain is abandoned unless the end-to-end handshake crosses within 10 s and the callee answers within 20 s of READY, and a relayed incoming call that rings 60 s is declined. LE CoC/GATT only, one bearer end to end. Targets are fixed-prefix (version 2), or role-0 rotating IDs when rotating discovery IDs are also on (reach pages version 6, `SETUP` message 4, every hop checked with `BDO1`/`BDX1`; tables flush at each boundary; DEBUG `test_relayed_rotating=false` restores the earlier exclusion). Direct fallback fails a benchmark that requires a chain. Off: every byte on air and on the link is unchanged. PROTOCOL "Experimental relayed calls and multi-hop discovery". Lines `relayed_calls: enabled=`, `relayed_setup: role=`, `relayed_helper: state=`, `relayed_reach_table` (`entries` counts the fixed-prefix table, `rotating_entries` the role-0 one). First phone probe 2026-10-11 (ASUS helper, GATT, one relay, rotating IDs on and off): the helper app crashed 1.2 s after it accepted the SETUP (`InputStream.nullInputStream()` is API 33, minSdk is 29; fixed on `fix/relayed-call-gatt`, with `tools/dex_api_scan.py` and a source guard against the class); one-helper calls completed on GATT and CoC in build 3996bfb2 (case ). Two helpers (`require=2`) never formed a route there because the four phones were in one room: every phone hears the target, the table prefers the shortest path, so nobody advertised a two-relay entry and a first helper with budget 2 would have dialled the target directly. Experimental DEBUG topology mask `test_relayed_calls_no_direct=true` (default off) makes a phone ignore every target's own offer (offers of helpers are kept) as if out of radio range: mark the caller and the first helper, leave the second helper normal (PLAN "Design C" phone verification queue). Production behaviour (shortest path wins) is unchanged. The `relayed_reach_table` line now also prints `costs=`/`rotating_costs=` (entries by cost heard), and a refused required-chain call logs `relayed_call: refused`. The setting takes effect only while Advanced settings are visible; hiding them turns relayed calls off. Under the BMH3 pseudonym defaults relayed calls need DECISIONS Design C "Amendment CP" (the rows below). | | Pseudonymous relay edges (`test_relayed_pseudonymous_edges=false` is the control arm) [relayed_pseudonymous_edges] | On (2026-10-27, reviews CP-1) | Every relay edge runs BMH3 with a throwaway that declares PSEUDONYM, fresh per connection, never BMH2 while pseudonym mode is on; a hop answering with a real key is bound to the selected offer, a declared throwaway is not; a helper accepts one `SETUP` on such a link; per-parent limit keyed on the remote address; own-ID requests answer FAIL 1; closures before READY are FAIL 4; a route is marked used only after the target's identity checked out. Off: relayed calls fail at the first link under the BMH3 pseudonym defaults (`pseudonymous_answerer_to_a_real_caller`). DECISIONS Design C "Amendment CP". Not yet run on phones. | | Relayed stranger calls (`test_relayed_stranger_calls=false` is the control arm, contacts only) [relayed_stranger_calls] | On within the relayed-calls experiment (design decision 2026-10-27) | A relayed call whose end-to-end caller is a throwaway rings, with the status "via a phone nearby" and the wording that a reveal goes to whoever holds the call, which may be a relaying phone; a caller may place a relayed call to a target it has no contact tag for. Off: the target declines such calls quietly before ringing and the caller dials relayed routes only to tagged contacts. A relay can sit in the middle of a stranger call (the callee's reveal is the exposure); contact calls are protected by the full node-ID check. Independently of this switch, a target declines any relayed call unless its relayed calls and relay consent are both on, and caps every relayed ring at 60 s. | | Contact-helper relay edge - Experimental (`test_relayed_contact_helper=true`) [relayed_contact_helper] | Off | A caller that recognises the selected helper's offer as exactly one contact tags that helper's own offer and runs real/real with the opening check, instead of a throwaway: the first hop is authenticated, at the price that the helper learns who is calling. A throwaway answer fails that candidate (no anonymous retry). | | Routed strangers in the Nearby list (DEBUG `test_relayed_list_strangers=true`; part of relayed calls) | Off (contacts only) | Design decision 2026-10-26: with relayed calls on, the Nearby list shows a person reached only through other phones when they are a saved contact (fixed prefix or a rotating ID recognised from their presence lease); strangers several phones away are not listed. Tapping Call on a contact searches direct and routed sightings and waits up to 45 s (two relays) before saying the contact cannot be found. On: the earlier arm that lists everyone reachable through helpers ("via 1 phone"). The background reach tables are unchanged either way; an on-demand route query (no background tables) is a separate design. **Requires / masked by:** relayed calls (see the row above); the whole relayed family is inert without it. | | Experimental: on-demand relayed route search, Design OR revision 3 (DEBUG launch extras only, no release or Advanced setting: `test_relayed_on_demand` arm O, `test_relayed_on_demand_keep_tables` arm B, tunables `test_relayed_seek_scan_ms` 1000..6000, `test_relayed_seek_first_hops` 1..3, `test_relayed_seek_fanout` 1..2, `test_relayed_seek_order=strong|far`, `test_relayed_seek_connect_ms`, `test_relayed_seek_budget_scale` 1..20, `test_relayed_seek_no_backoff`; all need `test_relayed_calls`) | Off | A/B arm of the proactive reach tables (DECISIONS "Design OR", PROTOCOL "Draft: on-demand route seek"). Arm O: helpers run no reach-table scan and no reach pages; a contact call not heard directly searches through up to three first hops in expanding rings (`SEEK`, hop type 12 message 5, a blinded 6-byte token; budget 1 to every first hop, then budget 2 with a skip list) under one 45 s give-up time; helpers sign BMH3 extension type 5 `RELAY_SEEK`, take a phone-wide relay duty and charge phone-wide budgets (60 s duty, 30 s scanning, 12 dials and 12 handshakes per 10 minutes); the target declines a relayed call unless its current consent is on. Scope OR-1: rotating IDs and BMH3 on, pseudonyms off on helpers and targets (Amendment CP not built), version 5 targets only. Kept off: prototype for the A/B; a user-facing build needs OR-T and a design decision (the presence-oracle question). With Amendment CP (`test_relayed_pseudonymous_edges`, on by default): the declared-throwaway onward edge applies to a helper's SETUP duty only; a search keeps its own BMH3 onward link and identity checks, and an incoming relayed call must pass both the CP decision (`RelayIdentityPolicy.incoming`) and this prototype's consent check. | | iPhone compact discovery (Settings > Features "Find iPhones"; DEBUG `test_ios_compact_discovery` is the benchmark switch) | On since 2026-10-17 (first physical iPhone: calls and streams both ways passed; design decision; key renamed `_v2`; one constant, `ConnectivityFeatureDefaults.IPHONE_DISCOVERY`) | Discovery also accepts the one-UUID announcement a foreground iPhone can advertise (iOS apps cannot send manufacturer data): one more masked scan filter (top 16 bits `0xC8D6`) in the same scan start, decoded into the ordinary announcement, so iPhone call offers, viewer requests and stream offers reach the existing lists, matching and handshakes. The 22-byte announcement wins when both are present; this phone's advertising and all authentication are unchanged. On by default; the masked-filter behaviour of the six reference phones is still to be checked (phone test below). PROTOCOL "Experimental iPhone compact discovery". Line `compact_discovery: enabled=`, DEBUG `scan_stats ... compact= | compactInvalid=`. The masked service-UUID filter is the unknown: a phone whose scanner refuses it (`SCAN_FAILED_FEATURE_UNSUPPORTED` or `OUT_OF_HARDWARE_RESOURCES` while the filter is active) drops only that filter for the rest of the process and restarts the scan with the app filter alone, logging `compact_filter: event=dropped | code=`, so Android-to-Android discovery never depends on it. **Phone test (all six Android phones, plus an iPhone with the app in the foreground when available), pass when:** (1) no scan failure with the filter active on any phone, or a failure ends in the logged drop with discovery still working; (2) Android-to-Android discovery counts over three 60 s scans equal the switch-off counts (`scan_stats ... accepted=`); (3) with an iPhone announcing, `compact=` above 0 with `compactInvalid=0`, and the iPhone appears in the call and stream lists within 10 s. Not yet tested on phones. | | Find PCs (Settings > Advanced > Connection "Find PCs (experimental)"; DEBUG `test_find_pcs` is the benchmark switch; preference `experimental_find_pcs`; one constant, `ConnectivityFeatureDefaults.FIND_PCS`) | Off. Experimental (Design QW, DECISIONS) | A Windows PC advertises one connectable EXTENDED advert whose 128-bit service data under `c8d5a10a-9f88-4f65-a03d-7f5a2fb921fa` is a strict 9-byte rotating call offer (version/kind, RDI, flags GATT/relay/consent, codec and cost; shared vectors `app/src/test/resources/pc_announcement/pc_announcement_v1.json`). On, a call-list scanner (one that accepts rotating offers) adds one `setServiceData(uuid, empty)` filter next to the existing ones and, only where `BluetoothAdapter.isLeExtendedAdvertisingSupported`, `setLegacy(false)` with `PHY_LE_1M` (the PC's secondary PHY is LE 1M per gate 1, and Coded scanning hurt reception on the Xiaomi in F11; for that scan only, the F11 LE-1M-only settings otherwise stay) and logs `pc_scan: ... extended_supported=`; without extended support it logs `pc_scan: unsupported reason=no_extended` and does nothing else. A valid payload maps to the ordinary rotating call offer (version 5, ACCEPTING|CALL|GATT, +RELAY when relay-capable, summary = codecs, +0x10 on consent, cost from the payload) and goes through `acceptAnnouncement` like any rotating offer, so rotating IDs must also be on. Precedence in one record: 22-byte manufacturer announcement, then compact UUID, then PC service data. Own counters `scan_stats ... pc= | pcInvalid=` (shown only while on), one rate-limited log line per decoded announcement (`pc_announcement:`) and per rejection with its error (`pc_announcement_rejected:`). Filter-slot guard: on `SCAN_FAILED_FEATURE_UNSUPPORTED` or `OUT_OF_HARDWARE_RESOURCES` the PC filter (and the extended settings) is dropped FIRST for the rest of the process (`pc_filter: event=dropped`), then the compact filter, never the app filter, so normal discovery cannot be taken down. Off: filters and settings are exactly as before (`DiscoveryScanPlan`, JVM-tested). Android never advertises this form. **Phone test (QW, not yet run):** screen-on and screen-off PC appearance time, filter offload status and slot exhaustion on every required phone, and a phone calling the PC. | | PC live streams (Settings > Advanced > Connection "PC live streams (experimental)"; DEBUG `test_pc_streams` is the benchmark switch; preference `experimental_pc_streams`; Design QV revision 4) | Off. Experimental (DECISIONS "Design QV") | Needs Find PCs. With both on, a Windows PC's kind 1 viewer requests and kind 2 stream offers map to ordinary version 5 announcements. With this switch off they are dropped and counted as `pcDisabled`. The existing PC-filter guard, precedence, GATT transport, admission, openings and encryption are unchanged. Extended-capable phones only; physical interoperability remains unmeasured for this implementation. | | Names and subjects, formerly "Names and titles" (no Settings switch since 2026-10-17, design decision: an empty name is anonymous; a constant with no stored choice; DEBUG `test_peer_labels` is the benchmark switch) [peer_labels] | On since 2026-10-17 (`CallFeatureDefaults.NAMES_AND_TITLES`; design decision) | Naming design phase 1 (review R1-R10, R13; design decision2026-10-10). Settings > Your ID gets an optional name (empty = anonymous: handle only); placing a call asks for an optional subject and Go live for an optional title. Names and subjects are identity-signed `BNL1` labels bound to the call ID, sent only inside the encrypted endpoint session when both endpoint hellos carry bit 5 (the callee's name only with Accept, or at Ringing to a verified contact); stream titles are bound to the stream ID, sent to direct viewers before the header and passed on unchanged by forwarders after `GROUP_KEY`. The title is also advertised publicly in a second non-connectable legacy set (title pages, one-time free-set probe `BENCHMARK adv_set_probe`; no free set = signed title only) and shown "unconfirmed" until the signed copy arrives after joining. That set is the registry entry `peer-label-title-advertising-set` (DEBUG `test_device_policy_off=peer-label-title-advertising-set` removes it; the matcher is every phone until a stack is shown to mishandle two legacy sets). Self-claimed names are quoted beside the full handle; strangers always show the full handle (R5, also with the switch off). A self-claimed name whose UTS #39 confusable skeleton equals a contact's name or your own (for example an all-Cyrillic look-alike of "Alice", which the script check alone passes) is flagged on the incoming-call screen: "Looks like your contact Alice. Check the ID before you answer." The bundled table is the unmodified Unicode 18.0.0 `confusables.txt` MA data (`assets/uts39/confusables_ma.txt`, Unicode License v3 in THIRD_PARTY_NOTICES); a minimal Cyrillic/Greek table is the fallback if it cannot be read. Off: no hello bit, no type-10 record (wire unchanged). Since Naming v2 the public title pages are their own switch, "Show my stream title to everyone nearby", below; a user who had explicitly set this switch keeps that choice for the pages (one-time carry-over), and a DEBUG launch with `test_peer_labels` still switches the pages too. DEBUG extras `test_peer_label_name`, `test_call_subject`, `test_stream_title`; lines `BENCHMARK peer_labels ...`. PROTOCOL "Peer labels". Not yet tested on phones. | | Store-and-forward messages (Settings > Experiments "Chats (beta)"; DEBUG `test_store_forward` is the benchmark switch) | Off (one constant, `CallFeatureDefaults.MESSAGES`, false; flipped only after its phone test passes). With Messages on, chat uses Messaging v2 (see that section) | Intercom pillar, PLAN Design A v1 (direct neighbours only; the carrier role stays off). Home shows Messages with an unread count; the screen follows the shared monochrome theme. Copy your contact card or add one from text, accept cards received over a link after comparing their safety number, write text, a voice note (up to 2 min) or a photo (up to 1280 px) with a 1/3/7/14-day lifetime, and read an encrypted-at-rest inbox and sent list; unread inbox rows carry a dot until the screen is left. A new message after an exchange posts one quiet notification ("N new messages", never text or sender; VISIBILITY_SECRET, so nothing on a secure lock screen) only when the app and the Messages screen are not showing. A voice note cannot be recorded while a call or live stream holds the microphone. Messages move when both phones have Call open and one taps the other and chooses Exchange messages, or by themselves with "Sync messages automatically". A call tap or an incoming call pauses a running exchange within one chunk. The screen is FLAG_SECURE and the copied card is marked sensitive. Everything is stored under `noBackupFilesDir/store-forward` (excluded from backup and device transfer) plus a voice-note recording in `cache/` while capturing; the emergency wipe deletes both and both Keystore keys. Wire format: PROTOCOL "Store-and-forward messages" (hop type 11). DEBUG controls `sf_sync:PREFIX`, `sf_send:PREFIX:BYTES[:COUNT]`, `sf_accept_cards`, `sf_suspend`, `sf_status`, `sf_clear`, `sf_background` (measurement without taps, TESTING "Message transfer measurement"); lines `BENCHMARK store_forward session` and `BENCHMARK message_transfer session|bundle|app`. Not yet tested on phones. | | Automatic store-and-forward sync (Settings > Experiments and the Chats screen, "Send chats automatically"; needs Messages; DEBUG `test_sf_auto_sync` is the benchmark switch) | Off (one constant, `CallFeatureDefaults.AUTO_SYNC`, false; flipped only after its phone test passes) | Starts the same BSF1 exchange without a tap. Local only: no wire change and the same responder limits (60 s cooldown, 10 per hour). Sightings from the Call screen's existing discovery trigger it; Home keeps advertising without scanning, and a Home phone takes part as the responder. It runs for an accepted contact when this phone has queued messages for them or carried copies (carrier role, hard-off in v1). Otherwise it pulls what they may hold, at most every 10-30 min. Backoff is per peer: 65 s x 1.5^failures, capped at 10 min, with +-15% jitter. The phone with the higher node prefix waits 10 s, and an exchange this phone answered also resets the wait. Live media wins: there is no start during a call, stream, relay, user selection or thermal block. A user tap or requested call cancels a pending start, and a running exchange suspends within one chunk. Outbox states are queued, carried, delivered and failed. Failed means the recipient was reached 8 times without taking the message; suspends and rate limits do not count. Attempts are sealed at rest in `outbox-attempts.properties`. A failed message stops triggering automatic sync, but a tapped Exchange still offers it. Re-implemented from Briar's backoff poller and Bitchat's outbox (patterns only, no code). Line `BENCHMARK sf_sync auto=feature|start|wait|skip|connecting|connect_failed|preempted|result`. JVM tests only; not yet tested on phones. | | Raw advert observer (DEBUG `test_raw_observer=<hex substring>`, optional `test_raw_observer_seconds`, default 1800; `DebugRawObserver.kt`) | No trigger | Starts one extra unfiltered LOW_LATENCY scan (extended when supported) and logs each raw advertisement containing the hex (at most one line per address per second) plus a 60 s summary of the matched section and address first/last seen, to prove what a Windows PC puts on air (DECISIONS "Design QW"). Screen-on use; stops with the app or after the duration; DEBUG builds only, no UI. Any `test_` extra makes a benchmark launch. | | Debug emergency wipe (`test_emergency_wipe=<token>`, DEBUG builds only) | No trigger | Lets a benchmark exercise the full wipe. It needs a one-shot 32-128 hex token that adb writes into internal storage with `run-as`; release builds have no external trigger. Panic and confirmed wipes are in-app only. | | Block unknown callers (Settings > Your identity; preference `block_unknown_callers`; `CallBlocking.kt`) | Off | Declines an incoming private call without ringing when the caller is not a recognised verified contact: a stranger (one-time key), an unverified real key, and a contact whose presence lease expired (they too arrive as a one-time key). The caller sees the ordinary Ringing, then the ordinary Decline after 3-7 s; this phone shows nothing and records one "Blocked call" row in Recent. An unreadable contact store rings (fail open). Local only, no wire change. Untested on phones. | | Blocked people (Settings > Blocked people; `BlockedPeople` store `identity-verification/blocked-people.bin`, sealed with the verified-contact Keystore key) | Empty list | A blocked person (full identity fingerprint) never rings and their live streams are hidden from Watch. It matches only callers that present their real key before ringing (verified contacts with a current lease, phones that do not use pseudonyms); a stranger's one-time key can never match, which the block dialog says. Erased by the emergency wipe. Local only, no wire change. Untested on phones. | | Quick wipe gesture (Settings > Safety "Quick wipe: triple-tap the app name"; preference `panic_wipe_gesture`) | Off | Three quick taps on the home-screen app name start an emergency wipe after a 3 s Undo. Off by default so a stray tap cannot erase anything; Settings > Emergency wipe (type WIPE to confirm) is always available. Untested on phones. | | Local reporting (`test_local_reporting=false`) | On | DEBUG control removes Report and restores the old lists. Report uses the existing local block path, hides that identity from chats, notifications, Recent and attributable streams, and remembers the watched stream token across relays. No submission; optional `report_url` is empty and opens only on a separate tap. One-time identities cannot be individually blocked; the existing explanation applies. JVM regressions added; not run on phones. | | Nearby list UX (`test_nearby_list_ux=false`) | On | DEBUG control restores alphabetical order, no search and technical row details. Default keeps first-seen order per session, contacts first, search above eight people, and Call/Add contact/Block on long press. One-time strangers use the existing explained Block unknown callers option. | | QL consent and result off (`test_ql_consent_ui=false`) [ql_consent_ui] | On | Control arm. On (Design QL revision 2, design decisions Q-QL2a-c): the scan confirmation has "Let <name>'s phone recognise yours nearby" (checked by default; unchecking writes "Can see when I'm nearby" off before any lease can leave, and a failed write withholds the lease), Add contact shows a plain result (callable from Contacts / declined / not finished / one scan) and Call now for 60 s after a lease was stored (an ordinary Contacts call). Off: today's dialog and texts. Turning "Can see when I'm nearby" off warns that contacts will need to call or scan again. | ## Naming and identity display (Design G, Naming v2) Branch `feature/naming-v2`, built 2026-10-12 from DECISIONS "Design G" and PROTOCOL "Naming v2". Every row is a pure core with unit tests; what each row still needs from a phone is its check (N1-N9 in DECISIONS). The one precedence function is `DisplayIdentityResolver`; screens call it and draw `DisplayIdentity` (rank, title, note, mark, warning), never building a name string by hand. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Friendly names for nearby phones (`generated_names`; Settings > Privacy; DEBUG `test_generated_names`; `NamingFeatureDefaults.GENERATED_NAMES`) | On (display only; phone check N3 pending) | A phone with a rotating ID shows as a two-word name such as "Blue Otter", derived from its public 6-byte rotating ID (or the throwaway key of a stranger call), so people can say it aloud and nothing stable is revealed. A name is never an authenticator; colliding names get digits from the ID. Off: the full handle, as in 0.2.0-beta. The 256-word lists `assets/names/adjectives-v2.txt` and `nouns-v2.txt` and the pair blocklist `blocked-pairs-v2.txt` are original work, frozen and hash-pinned (a changed list is `names-v3`; `names-v1` is kept for vector history). A name from a blocked pair is never shown: the same ID is re-hashed with a counter byte until the pair is allowed. | | Who sees my name (`names_audience`: Everyone I call or answer / Only people I verified / Nobody; Settings > Your ID (a row under the name, shown only when names are on and a name is set); DEBUG `test_names_audience=everyone\|verified\|nobody`) | Everyone I call or answer | Needs "Names and subjects" on (on by default). Decides whether the kind 1 name is sent at all; a subject is per call. "Only people I verified": the caller sends its name only to a callee known to be a verified contact, or after the callee's verified reveal proves it is one; the callee sends its name only to a verified contact at Ringing, or after the caller's verified reveal (a contact whose lease expired arrives as a stranger, so nothing is guessed before then). Declining or letting it ring reveals nothing. A peer without hello bit 5 (a 0.2.0-beta phone with names off) gets nothing and nothing breaks. `CallLabelPolicy`. Untested on phones. | | Show my name before they answer (`names_before_answer`; Settings > Privacy, a sub-option of Names and subjects; DEBUG `test_names_before_answer`) | On for user launches once names are on; a DEBUG launch reads only the extra (default off) | Stranger calls: the caller's name is queued before `INVITE`, signed by the attempt pseudonym, so the ring screen shows the claimed name ("~Rahul") instead of nothing. Exposure: whoever answers a copied advert learns the name the caller chose to give to anyone it calls (never the real key, which still waits for the verified reveal). The call subject is held until the reveal in both arms. Off is the Design F control arm (name held until the callee's reveal is verified), kept as the comparison. Untested on phones (check N2). | | Verification codes `BVMID2` (`bvmid2`; Settings > Privacy "Codes that expire when you add a contact"; DEBUG `test_bvmid2`; `NamingFeatureDefaults.BVMID2`) | On by default since 2026-10-19 (design decision Q-QL1, after N5 and the Design QL phone test passed on two Android pairs); an explicit stored choice wins | Replaces the static BVMID1 QR with an expiring one-use code: a fresh secret `K_qr` plus the displaying phone's current role-0 ID, valid 300 s, reserved atomically for one connection's attempt (link and end-to-end handshake together), consumed only by a completed handshake, with a per-code cap of 3 attempts and a signing budget of its own (3, one more every 10 s; since 2026-10-27, before that the shared one). Needs Hidden identities (BMH3) as well as rotating IDs: with BMH3 off the BVMID1 flow remains (since 2026-10-27), and while only the Meet screen makes the phone reachable every link that did not match the code is closed, whatever handshake it spoke. The session opened with it is verify-only. The scanner confirms "Is this Rahul's screen you scanned?" before the shield; "scanned each other" needs both phones to scan. With it ON, a BVMID1 scan records only "Checked by code" (outline) unless the next switch is on, and a code-only contact gets no new presence leases until scanned both ways. Off: nothing changes. Bound on Android (branch feature/naming-v2-bind): Add contact > Show my code draws the code on the role-0 ID on air (FLAG_SECURE; screen-only reachability when Allow calls is off) and Scan their code dials the phone as a quiet verification scan (no call screen), asks "Is this X's screen?" and only then records the shield. JVM-tested core; the radio path passed N5 and the Design QL phone test on two Android pairs. Design QL (2026-10-19): the verification session also carries the presence OFFER/LEASE once each way (scanner after its confirmation, displayer only to a scanner it already verified), so after two scans Contacts can call at once; DEBUG harness (DebugMeetControl) drives N5 and the QL phone test without a camera in a normal (user-default) launch: `--es debug_meet open` opens the screen (no `test_` prefix, so the launch is not a benchmark launch), then the app-private file `files/debug-meet-command` (`show`, or `scan` + code + `yes`/`no`) drives it; the live code is in `files/debug-meet-code`. | | Visible by name (`visible_by_name`; Settings > Privacy switch "Visible by name" and, when on, the row "Show my name nearby"; DEBUG `test_visible_by_name`; `NamingFeatureDefaults.VISIBLE_BY_NAME`) | Off until phone check N7 passes | An opt-in ten-minute window (for a meeting, a camp, a festival line) in which people nearby see a name you picked ("~Rahul, calls itself") next to a dedicated window ID (role 3, announcement version 8, a connectable offer carrying the 4-byte name hash) and name pages (version 7, a non-connectable set). The ordinary call-ready ID never carries the name, and the window ends after exactly ten minutes of elapsed time, shared with Visible-by-handle (starting one ends the other). The name is an unsigned claim: shown only while exactly one hash is heard for the ID, never as trusted, replaced by the signed label once a call authenticates the peer. With the switch off nothing is read or sent; 0.2.0-beta scanners reject versions 7 and 8 and show no row and no error, so they cannot see or call a phone through its window ID. Built: pages and offer codec, bounded scanner reassembly and board, rows ("~Name, calls itself - Gold Fox"), window state and exclusion, opening entries, and (branch feature/naming-v2-bind) the connectable role-3 window offer set and the name-page set owned by the call endpoint (rebuilt on the new window ID at each epoch boundary, closed while a call or exchange holds the endpoint, "This phone cannot show a name to others" without three free advertising sets), the call listener serving role 3, the ongoing notification with Stop now, the Settings row with name, cost text and the optional "allow anyone to call for these ten minutes". Untested on phones (N7). | | Nearby-row and contact warnings (no switch) | Always on | "More than one phone is using this ID" (the same ID heard on two addresses at once, repeatedly: a second tap before the call), a neutral "Says they are Mum. The app will check when you answer." beside a window name that imitates a contact, and on the contact sheet "They call themselves X" with the one-time change note (Use it as my label / Ignore) and "Scan to confirm" for a contact only checked by code. Contact labels are unique (a rename that collides is refused). | | Stranger recents (no switch) | Always on | An unverified stranger's Recent entry keeps the generated name and the time only (no node ID, no fingerprint); a person verified afterwards appears under Contacts. Review F20. | | Text rules for person names (no switch) | Always on | A person name may not start or end with a tilde or look-alike, contain a tilde, shield or lock symbol or the app's separators, or reduce to a generated name; a receiver drops such a label (`reserved_form`). Needed so a claimed name cannot imitate the marks. Contact labels are also forced unique (case- and skeleton-insensitive). | | Contact claims file (no switch) | Written only for verified contacts | `identity-verification/contact-claims.bin` (sealed, atomic) keeps what a verified contact last called themselves and whether both phones scanned. A downgrade loses only that note. Forget, Reset and the emergency wipe delete it. | ## Sharing the app | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Share app nearby (Settings > Features "Show Share app nearby in Help"; DEBUG `test_offline_app_share` is the benchmark switch) | Promoted 2026-10-11 (Help entry hidden until its phone test passes; one constant, `ConnectivityFeatureDefaults.SHARE_APP_NEARBY`; flipping it makes the entry a normal Help entry) | Experimental (2026-10-10; Briar/Bitchat pattern re-implemented from a description, no GPL code). Available in all three distributions (`BuildConfig.APP_SHARE_AVAILABLE=true`), with the same control and split-install refusal; outbound sharing is not self-updating (PLAY_RELEASE "Policy conclusions"). Adds Help > "Share app nearby": a Wi-Fi Direct group (`WifiP2pConfig.Builder` with `GROUP_OWNER_BAND_5GHZ` first where `WifiManager.is5GHzBandSupported`, then AUTO, then legacy `createGroup`, because 2.4 GHz Wi-Fi halved M31 Bluetooth; random `DIRECT-xy-...` name without the app name, random 12-character passphrase), and `OfflineAppShareServer`, a read-only HTTP server bound to the group IPv4 address that accepts only peers in the group subnet and serves only `/<20-character random token>/` (a static page with the APK SHA-256 and signing-certificate SHA-256) and `/<token>/BluetoothVideo-<version>-<code>.apk` (single Range for resume); everything else is 404, no listing. Two QR codes: `WIFI:T:WPA;S:..;P:..;;` and the URL. Stops after 10 minutes, when the screen is left, or when a stream, call, relay or reconnect exists (it refuses to start then). Needs `NEARBY_WIFI_DEVICES` (`neverForLocation`, Android 13+) or fine location with Location on (Android 10-12), requested only by that screen; adds the install-time `INTERNET` and `CHANGE_WIFI_STATE` permissions (an IP socket needs `INTERNET`; the share server itself makes no internet connection, and the only internet request the app can make is the opt-in update check below). Split installs and debug-key release builds are refused as in `AppShare`. DEBUG line `BENCHMARK offline_app_share`. No streaming case changes. **Phone test (S24 sharing, M31 and ASUS receiving; then the reverse), pass when:** the receiver joins from the Wi-Fi QR code, opens the URL, downloads the APK, and its SHA-256 and signing-certificate SHA-256 match the page and the release; the share stops by itself after 10 minutes and when the screen is left; it refuses to start during a call or stream, with the reason shown; a stream or call started after stopping runs as without the share; and with Wi-Fi Direct unavailable the screen says so. Not yet tested on phones. | | Check for updates (Settings > About and updates, github flavour only; DEBUG `test_check_for_updates` is the benchmark switch; DEBUG `test_update_latest_url`, `test_update_pin_own_signer`, `test_update_release_number`, `test_update_release_key` stand in for GitHub and the release key in phone test) [check_for_updates] | Off until the one-time question is answered (`UpdateFeatureDefaults.CHECK_FOR_UPDATES`); the answer is stored as an explicit choice | Product feature (2026-10-11, design decision; patterns from Signal-Android `ApkUpdateJob`/`ApkUpdateInstaller` (PackageInstaller session, flavour-only manifest, digest check), NewPipe `NewVersionWorker` (only a copy signed with the release key checks), Molly's opt-in megaphone, Obtainium (`getPackageArchiveInfo` + signing certificates); none copied). The `github` flavour (what `tools/make_release.py` builds; `app/build.gradle.kts` `productFlavors`) adds `UpdateActivity`, a not-persisted daily `JobScheduler` job and `REQUEST_INSTALL_PACKAGES`; the `fdroid` flavour (the F-Droid recipe) has a stub `FlavourUpdater`, no job and the permission removed from the merged manifest (`tools/tests/test_flavour_manifests.py`). Offered only when the installed APK's only signer is `AppSharePolicy.PUBLISHED_RELEASE_CERT_SHA256` (the NewPipe guard). The first home screen after install asks "Check for updates when online?" once (yes or no, never again; a benchmark launch never asks; DEBUG `test_user_launch=true` makes a launch with test extras behave as a person's, so the question and its Yes path can be driven with the stand-ins above); Settings shows the same explanation before it turns the switch on. With yes: `GET https://api.github.com/repos/MeshLive/meshlive-android/releases/latest` (`UpdateSource.GITHUB`; a mirror is one more `UpdateSource`) at most once a day across the app, the job and a manual "Check now" (`UpdateSchedule`: 24 h after an answer, 1 h after an offline attempt, GitHub's reset time after a rate limit, 10 min between manual checks), minimal headers (`User-Agent: MeshLive-updater`, no cookies), redirects only to github.com, api.github.com or githubusercontent.com over HTTPS. A newer semantic version (`ReleaseVersion`) shows a quiet home note "Update available"; **Download** (a tap, only while the switch is on) fetches `MeshLive-<version>-<abi>.apk` for the installed ABI (never the `MeshLive.apk` alias) into `noBackupFilesDir/updates/` while hashing it, requires its SHA-256 to equal the API asset `digest` or the release `SHA256SUMS` (none: nothing is installed), then `PackageManager.getPackageArchiveInfo(GET_SIGNING_CERTIFICATES)` must show this package, a strictly higher release number, the installed ABI digit (or universal), exactly one signer equal to the pinned release certificate, or a signing lineage signed by it for a later key rotation (`UpdateVerifier`). On Android 10 the archive is read with `GET_SIGNATURES` as well, because that release collects no certificates otherwise. Install writes the same file into a `PackageInstaller` session while hashing again and aborts if the digest differs from the verified one (`HashedCopy.copyBoundTo`: no check-then-use gap), with `USER_ACTION_REQUIRED` (Android's own confirmation; never silent, never a downgrade). If "Install unknown apps" is off for MeshLive the screen explains and opens Android's settings page. The 120 MB limit, the host allowlist and the redirect rule are in `GitHubReleases`/`UpdateSource`. Serval's trick of a manifest inside the APK zip comment is not used (it breaks APK signing); a separate signed manifest was considered and left out because the APK signature already authenticates the bytes (PLAN "Distribution and field reports"). Log lines `BENCHMARK update_check`, `update_prepare`, `update_install`. No streaming case changes. **Phone test (github flavour, S24 and one more phone; test server on the host computer through `adb reverse` or Wi-Fi, `test_update_latest_url=http://127.0.0.1:8000/releases/latest`, `test_update_pin_own_signer=true`, a debug-signed "newer" APK with a higher release number), pass when:** with no answer or "No thanks" the server log shows zero requests across a day of app starts and reboots; with yes it shows one request per day however often the app, the job or Check now run, and Check now within 10 minutes is refused; a newer release shows the home note, Download progresses, Install shows Android's confirmation and the phone ends on the new versionCode; and each refusal case leaves the installed app unchanged and the file deleted: wrong SHA-256, no published hash, a file signed with another key, the same or a lower versionCode, another package name, an armeabi-v7a file for an arm64 install, a redirect to another host, a 403 rate limit, an oversized or truncated download, going offline mid-download. A release-signed build also shows the one-time question exactly once; build it with the release key only at the current base `versionCode` (never above the next release's, because a nearby phone would accept it as an update) and never publish it. The F-Droid-flavour APK has no "Check for updates" row, makes no request while the nearby note is on, and `dumpsys package` lists no `REQUEST_INSTALL_PACKAGES`. Final check after the first release that contains the updater: a phone on it sees the next release through the real GitHub API. Not yet tested on phones. | | Tell me about newer versions nearby (Settings > About and updates, both flavours; DEBUG `test_newer_versions_nearby=true` is the benchmark switch) [newer_versions_nearby] | **On** (`UpdateFeatureDefaults.NEWER_VERSIONS_NEARBY`, design decision2026-10-11) | Product feature with a wire change (2026-10-11, PROTOCOL "Build tag in the link hello and update verification"). Link-hello transport-mask bit 7 says nonce bytes 0-1 are a build tag (bit 15 project release key, bits 14-0 the release number = installed `versionCode / 10`, so the CPU type is not revealed); no length, version or record type changes, so every older BMH2 build interoperates and simply sees no tag (hello decoding, tagged and untagged handshakes in both roles, signature binding and invisibility to the rest of the app: `BuildTagTest`). The tag is read only after the handshake verified it, handed to the notice only from links this phone opened (a stranger who connects to a listener cannot plant a note), kept out of `peerCapabilities`, never put in an advertisement or an end-to-end hello, and unset when the switch is off, in a benchmark launch without `test_newer_versions_nearby`, and for a debug or non-split build (so measured cases keep their exact hello bytes). It is a claim by the peer's identity key, not proof about its APK, so it only decides whether a quiet home note "Newer version nearby (release N)" is shown (`NewerVersionNearby`: newer than installed, at most 20 above, release-key phones only believe release-key claims, dismissal hides for a week, a claim fades after two weeks, nothing about the peer is stored, never during a call or stream). Tapping it opens the update screen's nearby card (github flavour: explains the steps, **Choose the file** copies the picked APK into the private directory and verifies it exactly like a download except that the signer must equal the installed one, then the same hashed PackageInstaller install) or a dialog (F-Droid flavour: update through F-Droid or share the file; nothing is installed by MeshLive). Needs the other phone's Help > Share app nearby or any other way of moving the file. **Phone test (two phones with debug builds, `test_newer_versions_nearby=true`, `test_update_release_number=1` on one and `2` on the other, `test_update_release_key=true` on both), pass when:** after a call and a stream join in each role over CoC, GATT and Classic the older phone shows the home note naming release 2 once the session is over, the newer phone shows none, and `BENCHMARK newer_version_nearby` / `newer_versions_nearby: ... sendsTag=` lines agree; a 0.1.0-beta release build connects to a tagged phone with the same setup success and time as a control run (no `SECURITY`, "Invalid handshake" or "Trailing handshake data" lines on either side); switching off on either phone removes the tag (`sendsTag=null`) and the note; a claim 21 or more above (`test_update_release_number=30`) shows nothing; Hide removes the note until a higher release; a benchmark launch without the extra sends no tag. **Phone test (after the share test passes; github flavour, a newer and an older build signed with the same key):** the newer phone shares through Help > Share app nearby, the older phone receives the file by the browser or any other route and picks it with **Choose the file**; the older phone offers Install with the new version name and Android's confirmation appears; the same flow with a file that is older, signed with another key, a different package, or for the other CPU type ends with the plain-language refusal and nothing installed; the picked file and `noBackupFilesDir/updates/` are empty after an install or a refusal on the next app start. Not yet tested on phones. | ## Audio-only live (radio; implemented 2026-10-12, on by default since 2026-10-17) Design: DECISIONS "Design AL"; wire: PROTOCOL "Experimental audio-only live streams". Every row is off, or is the control arm, unless stated. With the feature off, the app is byte-for-byte unchanged on air. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | `test_audio_live_kbps` | 24 (Voice) | Overrides the preset: 16 Low data, 24 Voice, 48 Music. | | `test_audio_live_batch_frames` | 10 (200 ms) | Opus frames per record (1-20). 10 gives 31.8 kbps on the mesh link at 24 kbps audio; 2 (the pair record) gives 52 kbps; kept for the airtime-against-granularity comparison. | | `test_audio_live_fec` | Off | In-band Opus FEC with a loss hint. Off because every hop is a reliable ordered link and the only loss is whole-record age shedding, which FEC cannot repair. Arm kept. | | `test_audio_live_dtx` | Off | Opus DTX. Off because it removes the steady cadence that liveness, the relay trickle test and the playout clock assume, and VBR already codes silence in a few bytes. Arm kept. | | `test_audio_live_age_bound` | On inside the feature; `false` is the control arm | Sending hops drop their oldest whole records beyond 3 s (down to 1.5 s). Control: unbounded queues, as for video. A/B T-AL8 informs the user's decision. | | `test_audio_live_max_children` | 4 | Direct listeners per source, below the bandwidth-derived count (11-18), until the fan-out ladder (T-AL4) measures a cap per transport. | | `test_audio_live_link_priority` | `high` | `balanced` requests a 30-50 ms connection interval on audio-only GATT links (source to child and relay to child) to save power and avoid the equal-interval relay stage. Experimental; the weak-pair rate is the risk. | | `test_audio_live_adaptive` | Off | Closed-loop 16/24/48 kbps from link and queue evidence, no wire change. Experimental. **Not wired (reserved only): the name is accepted and does nothing in this version.** | | `test_audio_live_relay_children` | 1 | Children per audio relay; 2 is phase 2, Experimental, gated on the dual-role relay measurements. **Not wired (reserved only): the name is accepted, a relay keeps one child.** | | `test_audio_live_autostart` (DEBUG; with `test_audio_live=true`) | Off | Starts an audio-only broadcast without taps, for phones that ignore injected taps. Skips the first-use disclosure in this launch only, logged as `BENCHMARK audio_live_autostart`. Optional `test_audio_live_preset=voice\|lowdata\|music` (default voice) and `test_audio_title=<text>`. Also works as a new intent on a running app. | | `test_audio_live_digests` (DEBUG) | Off | One `BENCHMARK audio_live_units` line per record on both ends (`role=tx` at the source, `role=rx` at a listener after verification) with a short SHA-256 of every Opus unit; lines join on `seq`, and equal hashes show bit-identical delivery where nothing was shed. | | `test_control=leave`, `audio_mute`, `audio_unmute` (DEBUG) | n/a | Listener side: `leave` returns to the Live tab (`end` does the same while listening, and also ends a broadcast that has no listener yet). `audio_mute` / `audio_unmute` mute the source or the listener. Always logged: `BENCHMARK audio_live_verify` (a signature-verification summary every 50 verified records and one line per rejected record), `audio_live_codec_choice` (native or MediaCodec, requested and effective, for encoder and decoder). | | `test_audio_live_native_opus` | On | `false` forces MediaCodec encode and decode (no application type, no concealment) as the control arm. | ## Messaging v2 (chat threads, files of every format; phases 1a to 1d built behind the master switch) Design: DECISIONS "Design M"; wire: PROTOCOL "Messaging v2". Coded so far: the master switch (`MessagingV2Feature`, `MessagingV2Defaults.ENABLED`, benchmark extra `test_messaging_v2`; a constant of the feature catalog: no Settings row and no stored choice), **on whenever Messages is on since the design decision of 2026-10-21** (control arm: DEBUG `test_messaging_v2=false`; v1 history stays readable as legacy rows, unsent v1 messages are resealed once, a contact on an older app shows "Their app is older") with the limits of `MessagingV2Limits` fixed at their defaults; the other rows below are built unless marked. Switches that exist only in the design (bulk-listed segments, a Chats-tab or Contacts-rows arm, the preview toggle, prekeys, the ratchet, message tags, the background check) are unbuilt work and are listed in PLAN. DEBUG controls while the UI does not exist: `sf2_send:PREFIX12:BYTES[:COUNT]` (text up to 8000 bytes, otherwise a generated file), `sf2_approve`, `sf2_read`, `sf2_status` (lines `BENCHMARK message_v2 ...`, sender and receiver SHA-256 side by side). Every row is off, or is the control arm, unless stated. With the master switch off, the app is byte-for-byte the v1 Messages behaviour on air and on the link. | Switch | Default | What it does and why it is kept | | --- | --- | --- | | Messages v2 (no Settings row; `test_messaging_v2`) | On whenever Messages is on (`MessagingV2Defaults.ENABLED` is true, design decision 2026-10-21); Messages itself is off by default, so v2 is off by default too | Conversation threads with receipts, attachments of any format up to 64 MB, a thread link that stays open while the thread is on screen. A benchmark launch follows the same default (an absent extra means the release default; `--ez test_messaging_v2 false` is the v1 control). **Requires / masked by:** Messages. Untested on phones. | | `test_mv2_chat_session` (built, phase 1d) | On inside the feature; `false` is the control arm | `false` exchanges per message as v1 does (wait for the next sync). Kept for the battery and latency comparison T-M7. DEBUG controls: `sf2_chat_open:PREFIX12` (the thread counts as on screen; discovery on the Call screen dials it), `sf2_chat_close[:PREFIX12]`, `sf2_chat_status`, `sf2_keys:PREFIX12:accept` or `dismiss` (resolve a key change). | | Read receipts (`test_mv2_read_receipts`, built in 1c; Settings > Privacy "Read receipts", and a per-conversation choice in the thread info) | Off, also a user setting | A message asks for a read receipt only when the sender's switch for that contact is on (the conversation's own choice, else the global switch), and the receiver answers only when its own is on. A DEBUG benchmark launch reads only the extra. | | Download files automatically (`test_mv2_autodownload_mb`, built in 1b; integer extra, clamped 0 to 64; Settings > Privacy row in 1c: Never, 1, 8, 25 MB, any size) | 8 | Largest segmented file fetched without a tap, from accepted contacts only. 0 never, 64 everything. A benchmark launch ignores the stored Settings choice. | | Disappearing messages per conversation (thread info; not a launch switch) | Off | The timer new messages of that thread carry (1 hour, 1 day, 1 week, 30 days); the receiver's clock starts on read, the sender's on delivery (Briar's rule). Stored sealed per contact. | | `test_allow_screenshots` (DEBUG launch extra) | Off | Lifts `FLAG_SECURE` from the main window while a thread is open, so benchmark screenshots of the conversation UI work. Release builds ignore it. DEBUG controls: `sf2_thread:PREFIX12` opens that saved contact's conversation (Contacts tab first), `sf2_thread_close` leaves it. | | `test_mv2_max_file_mb` (built in 1b; integer extra, clamped 1 to 64) | 64 | Largest file the sender accepts to send; checked before anything is sealed. | | `test_mv2_photo_edge`, `test_mv2_photo_quality` (built in 1b; clamped 320 to 4096 and 30 to 95) | 1600, 80 | Photo re-encode preset; "Original" on the attach sheet bypasses it (a JPEG keeps its pixels, loses every metadata segment). | | `test_mv2_decode_isolated` (built in 1b) | On | Tap-to-decode of a received picture runs in an isolated-process service (own uid, no permissions, destroyed after the decode). Off is the control arm: the same bounds in the app process, also the automatic fallback when the service cannot be bound. Kept for the T-M9 comparison on the reference phones. | | Message notifications (no switch of their own: inside Messages v2; built 2026-10-13) | On when Messages v2 is on | One notification per conversation (channel "Messages", default importance, the user controls sound in system settings), replacing the v1 quiet count notification while the master switch is on. Lock screen: app name and "New message" only (the contact's label too when Settings > Features > "Show names on lock screen" is on); unlocked: label and the text of one unread text message. Tap opens that thread; "Mark as read" only when the text was shown. Untested on phones. | | `test_mv2_auto_thumbnails` (extra read in 1b; no code consumes it yet: **Not wired**) | Off | Decode manifest thumbnails automatically. Off shows a generic icon and decodes a picture only after a tap. May be turned on only after isolated, resource- and time-limited decoding is proven on every reference phone (Q-M12). | | `test_mv2_progress_total` (2026-10-13) | On; `false` is the control arm | A plain (non-chat) Messaging v2 exchange keeps its 120 s total limit only while no transfer is moving: while at least 4 chunks per 10 s keep flowing in either direction the total is extended up to 15 minutes (`StoreForwardLimits.sessionHardCapMs`); the 10 s liveness and progress deadlines stay strict. Off restores the fixed 120 s, so a 1 MB or 5 MB file on a slow path (the A15s answering at about 89 kbps) is cut and resumed in a further session behind the cooldown. | | `test_message_adaptive_phy` (2026-10-13) | On; `false` is the control arm | A message-exchange dial gets the call dial's 2M protections (`MessageDialPhy`): a peer remembered bad on 2M starts on 1M, a first 2M link whose hop authentication has not finished 3 s after it opened is closed (`BENCHMARK adaptive_phy: link=message ... reason=setup_stall`), one bounded 1M retry to the peer's current advertisement follows inside 15 s, and a peer that needed it, or whose authenticated 2M exchange died within 15 s with no HELLO and no chunk (`reason=early_loss`), is remembered as bad on 2M. Needs `test_adaptive_phy`, `test_adaptive_phy_memory` and `test_phy_setup_stall`. Why: six of seven A15s-initiated exchanges to the ASUS failed with an EOFException about 5 s after the 2M PHY update; the same signature as the call setup stall. Off is one attempt on the endpoint PHY, as before. |