From bb7a8434a46b676c99744ecd30fd48d430233972 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Wed, 14 Oct 2026 22:39:00 +0200 Subject: [PATCH 01/11] feat(data): subscribe to the Buds' settings stream and read setting 13 at every session (ADR-064/065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once per MAESTRO session — at Connect and at every ADR-044 re-open — the Connect sequence sends one SubscribeToSettingsChanges and one ReadSetting 4:13 after SubscribeRuntimeInfo and before GetHardwareInfo, byte-identical to the official app's CAP-051 1177/1178 (channel 21) and CAP-056 1451/1450 (channel 19). Every SERVER_STREAM packet for a field the app reads goes through the same decoders as a read answer and replaces the value with the packet's receive time; setting 13 is the noise-control mode (0 = no mode settable, never shown as a mode; it also feeds the availability). A stream packet for a field the app does not read is logged payload-free (RoutedFrame.UnreadSetting), not an UnidentifiedFrame. Why: a press-and-hold on a bud changes the mode on the Buds, and the Message Stream's Notify reaches only the client holding DLCI 0x04 — in the user with Google Play services about half of the holds at best. The Buds push the same change on the app's own channel to a subscribed client (CAP-051 1717/1755/1802, CAP-038 2170/2205 with no Message Stream open), so the hold and the re-claim of ADR-061/062 are no longer needed; this file also drops them (ADR-065). Cause rules (ADR-065 item 4, the maintainer's choices in chat 2026-10-14): a stream value equal to the app's own Set within ANC_SET_WINDOW_MS (2 s) is the app's change; a differing push is "Changed by the Buds"; a differing read answer (the Get's Notify or the 4:13 RESPONSE) is "Changed since the app last saw it", also across a Connect — the cause is no longer reset at Connect. Nothing is written in reaction to a stream packet; no retry, no polling. Co-Authored-By: Claude Fable 5.1 --- .../data/BudsRepositoryImpl.kt | 448 ++++++++---------- .../data/codec/CodecRouter.kt | 20 +- .../data/codec/Maestro.kt | 17 +- .../data/codec/SettingFrame.kt | 40 ++ .../data/codec/PwRpcTest.kt | 6 +- .../data/codec/SettingsCodecTest.kt | 10 +- .../data/codec/SettingsFixtures.kt | 7 + .../data/codec/SettingsStreamCodecTest.kt | 282 +++++++++++ .../data/codec/SettingsStreamFixtures.kt | 129 +++++ .../domain/BudsRepository.kt | 26 - 10 files changed, 697 insertions(+), 288 deletions(-) create mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamCodecTest.kt create mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamFixtures.kt diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt index f29a90df..60589349 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt @@ -56,9 +56,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.SessionLossCause import io.github.tedsluis.opencontrolpixelbuds.domain.classifySessionLoss import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryLevel import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryStatus -import io.github.tedsluis.opencontrolpixelbuds.domain.ChannelTaken import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingReading -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingSource import io.github.tedsluis.opencontrolpixelbuds.domain.markedFromEarlierSession import io.github.tedsluis.opencontrolpixelbuds.domain.ComponentSerial @@ -120,10 +118,11 @@ import kotlinx.coroutines.withTimeoutOrNull * DLCI 0x08 is no longer opened). HFP `AT+BIEV` is not consumed (ADR-040). * - **Safe Mode** (ARCHITECTURE.md §8.1, ADR-042): every write/control command passes [SafeModeGate] first. * - **Find My Buds**: no persisted state to reconcile. - * - **The Message Stream claim** (ADR-032) is held while the noise-control tab is on screen (ADR-061, [setAncTabShown]), so a `Notify` the app did not provoke - * (a press-and-hold on a bud) reaches it. When another client closes that held claim (Google Play services' Fast Pair, ≈ 5 s after it lost the channel — - * `CAP-074`), the Settings tab's [holdReclaimMode] decides (ADR-062): a hint and no re-claim, or — ADR-062 Update 2 — a re-claim 3 s after every such close, - * at most 20 times per chain, then the hint; the hint's "Take it back again" ([reclaimAncChannel]) starts a new chain. + * - **The noise-control mode without a claim** (DECISIONS.md ADR-064/065, `ai-sessions/0091`): once per MAESTRO session the Connect sequence subscribes to + * the Buds' settings stream and reads setting 13 ([subscribeToSettingsChanges], [readAncSetting]); every change of the mode — a press-and-hold on a bud, + * the Buds' own switch, the app's own `Set` — then arrives as a `4:{13:n}` stream packet on the app's own channel (`CAP-051` 1717/1755/1802, `CAP-038` + * 2170 with no Message Stream open), and every other setting the app reads follows the same stream. The Message Stream claim (ADR-032) is again only a + * tap's: ADR-061's hold, ADR-062's re-claim and ADR-063's Play-services line are gone (ADR-065 item 5). * * This class stays unit-testable against a [io.github.tedsluis.opencontrolpixelbuds.hardware.FakeBudsTransport] * and scripted flows, per AGENTS.md §11. [connectionStateMachine] is the real, already @@ -138,8 +137,6 @@ class BudsRepositoryImpl( private val connectionStateMachine: ConnectionStateMachine, private val bondedDeviceProvider: () -> BluetoothDevice?, debugModeEnabled: Flow, - /** The Settings tab's "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") (DECISIONS.md ADR-062), persisted by `HoldReclaimSettingsStore`; followed at once. */ - holdReclaimMode: Flow, private val scope: CoroutineScope, /** Wall clock for the provisional Settable rule (ADR-049, which superseded ADR-024's dock-state reading), the loss cause (I-7) and the re-open rules (ADR-044); a test passes its virtual clock. */ private val clock: () -> Long = System::currentTimeMillis, @@ -269,6 +266,17 @@ class BudsRepositoryImpl( @Volatile private var setPendingMode: AncMode? = null + /** + * ADR-065 item 4 (`ai-sessions/0091`): the mode and the time of the app's last `Set` — the settings stream's `4:{13:n}` for it can arrive after the ACK + * ended the Set's wait (`CAP-051` 1527 Set → 1529 ACK → 1534 stream, 269 ms) or between two Notifies (`CAP-056` 4001 → 4028, 55 ms); a stream value equal + * to it within [ANC_SET_WINDOW_MS] is the app's own change, not the Buds'. + */ + @Volatile + private var lastSetMode: AncMode? = null + + @Volatile + private var lastSetAt: Long = Long.MIN_VALUE + private val _eqProfile = MutableStateFlow(null) override val eqProfile: StateFlow = _eqProfile @@ -357,21 +365,60 @@ class BudsRepositoryImpl( } /** - * The cause a `Notify` gives the mode it carries (`ai-sessions/0082` item 1, the maintainer's rule): the answer to the app's `Get` is a reading — unless - * (ADR-062 Update 2, `ai-sessions/0089`) this connection already reported a mode ([_ancModeCause] is reset at every Connect) and the answer differs from it: - * then the mode changed while this app did not have the channel (`CAP-075` 2980, 5102, 5361); the requested mode while the app's `Set` waits is the app's own + * The cause a `Notify` gives the mode it carries (`ai-sessions/0082` item 1, the maintainer's rule; ADR-065 item 4 and the maintainer's choice in chat + * 2026-10-14, `ai-sessions/0091` Q4 — one rule for every read answer): the answer to the app's `Get` is a reading — unless a mode was shown before and the + * answer differs from it: then the mode changed while this app was not connected or did not have the channel (`CAP-075` 2980, 5102, 5361; `CAP-051` 2247 + * after the Buds' own `DISC`) — also across a Connect or a re-open, since `ai-sessions/0091` (the kept mode of the ended session is the reference; before, + * [_ancModeCause] was reset at Connect and a connection's first read was always a reading). The requested mode while the app's `Set` waits is the app's own * change; a mode other than the shown one, with nothing waiting, is the Buds' — a press-and-hold on a bud or their own change; the first mode ever shown this - * run is a reading too (nothing was shown that could have changed); the same mode again changes nothing (`null`). + * run is a reading (nothing was shown that could have changed); the same mode again changes nothing (`null`). */ private fun causeOfNotify(mode: AncMode, answersGet: Boolean): AncModeCause? = when { - answersGet && _ancModeCause.value != null && _ancMode.value != null && mode != _ancMode.value -> AncModeCause.CHANGED_SINCE_LAST_READ - answersGet -> AncModeCause.READ + answersGet -> causeOfReadAnswer(mode) + setPendingMode == mode -> AncModeCause.SET_BY_APP + _ancMode.value == null -> AncModeCause.READ + mode != _ancMode.value -> AncModeCause.CHANGED_BY_BUDS + else -> null + } + + /** ADR-065 item 4: the cause of a read answer — the `Notify` to the app's `Get`, or the RESPONSE to its `ReadSetting 4:13`. */ + private fun causeOfReadAnswer(mode: AncMode): AncModeCause = + if (_ancMode.value != null && mode != _ancMode.value) AncModeCause.CHANGED_SINCE_LAST_READ else AncModeCause.READ + + /** + * ADR-065 item 4 (`ai-sessions/0091`): the cause of a `4:{13:n}` settings-stream packet — the app's own `Set` of that mode within [ANC_SET_WINDOW_MS] + * (whether its wait is still open or already ended by the ACK), else the push is the Buds' ("Changed by the Buds": a press-and-hold on a bud is one + * trigger; the Buds' own switch on a wear change is 🟡, PROTOCOL.md §6); the first mode ever shown is a reading; the same mode again changes nothing. + */ + private fun causeOfStream(mode: AncMode): AncModeCause? = when { setPendingMode == mode -> AncModeCause.SET_BY_APP + lastSetMode == mode && clock() - lastSetAt <= ANC_SET_WINDOW_MS -> AncModeCause.SET_BY_APP _ancMode.value == null -> AncModeCause.READ mode != _ancMode.value -> AncModeCause.CHANGED_BY_BUDS else -> null } + /** + * Setting 13 from the Buds (DECISIONS.md ADR-064/065, `ai-sessions/0091`): the RESPONSE to the app's `ReadSetting 4:13` ([stream] false) or a + * `SubscribeToSettingsChanges` push ([stream] true). 1–4 is the mode, shown with the packet's receive time and its cause ([causeOfReadAnswer] / + * [causeOfStream]); the availability follows (ADR-065 item 3, the maintainer's choice in chat 2026-10-14, Q5): 0 = no mode settable — ADR-049's Settable + * `00` — never shown as a mode (`CAP-045` 2277, `CAP-051` 2247), 1–4 = allowed. A stream value equal to the shown mode moves only its time. + */ + private fun applyAncState(state: SettingValue.AncState, stream: Boolean) { + val mode = state.mode + updateAncAvailability(if (mode == null) AncAvailability.NOT_ALLOWED else AncAvailability.ALLOWED) + val source = if (stream) "the settings stream" else "the mode read" + if (mode == null) { + BleLogger.logConnectionEvent("Noise-control mode from $source: no mode settable") + return + } + val cause = if (stream) causeOfStream(mode) else causeOfReadAnswer(mode) + _ancModeUnconfirmedAt.value = null // the Buds' own report — whatever a cut-off answer would have said, this is their mode now (F-3) + emitAncMode(mode, cause) + _ancModeFresh.tryEmit(mode) + BleLogger.logConnectionEvent("Noise-control mode from $source (${(cause ?: _ancModeCause.value)?.name?.lowercase() ?: "unchanged"})") + } + private fun updateEqProfile(gains: EqBandGains?) { _eqProfile.value = gains _eqProfileUpdatedAt.value = if (gains == null) null else clock() @@ -413,181 +460,16 @@ class BudsRepositoryImpl( // I-2: leaving before any reading decided the cause (the observer stops now) — the reading taken on return will decide it. if (!visible && lossAtMillis != null && _lastLossCause.value == SessionLossCause.UNDETERMINED) lossWhileHidden = true reopener.onVisible(visible) - // ADR-062: the app's return starts a new re-claim chain; leaving it drops a pending re-claim (never in the background). - if (visible) startReclaimChain() else cancelReclaim() - reconcileHold(if (visible) null else HoldEnd.APP_HIDDEN) - } - - override fun setAncTabShown(shown: Boolean) { - val entering = shown && !ancTabShown - ancTabShown = shown - // ADR-062: a (re-)entry of the tab starts a new re-claim chain; leaving it drops a pending re-claim (the tab left during the delay ⇒ no claim). - if (entering) startReclaimChain() else if (!shown) cancelReclaim() - reconcileHold(if (shown) null else HoldEnd.TAB_LEFT) - } - - // ---- ADR-061: the Message Stream claim held while the noise-control tab is on screen (`ai-sessions/0084`) -------------------------------------------- - - /** Whether `:ui` shows the noise-control tab ([setAncTabShown]); with [appVisible] it decides whether the claim is held ([holdWanted]). */ - @Volatile - private var ancTabShown = false - - /** ADR-061 (the maintainer's choice in chat 2026-10-10, "Tab selected + app visible"): the tab is shown and the app is visible (resume … stop). */ - private val holdWanted: Boolean get() = ancTabShown && appVisible - - /** Whether an open claim is being held now (logged when it starts and ends). Changed only with [claimMutex] held, or when the session ends. */ - @Volatile - private var holdOpen = false - - /** Why the hold ends when it is next found unwanted ([releaseOrHold]) — the last reason given by [setAncTabShown] / [onAppVisible]. */ - @Volatile - private var holdEndReason = HoldEnd.TAB_LEFT - - @Volatile - private var holdJob: Job? = null - - /** Why a hold ended — the always-on log line's words (AGENTS.md §9: no payload, no address). */ - private enum class HoldEnd(val text: String) { - TAB_LEFT("the noise-control tab was left"), - APP_HIDDEN("the app is not visible"), - SESSION_LOST("the session was lost"), - DISCONNECT("Disconnect"), - CHANNEL_CLOSED("the channel was closed, not by this app"), } /** - * Entering ([holdWanted] true): an open claim (one lingering after an action) is kept — its pending release is cancelled; with the channel closed and the - * session `Ready` one claim is made with the existing claim code and its `Get` ([refreshAncMode], `08 11 00 00`), which [releaseOrHold] then keeps. - * Leaving: an open hold ends and the ordinary release ([MESSAGE_STREAM_LINGER_MS]) is scheduled. Runs in [scope], serialised by [claimMutex]. + * Stores one reported setting value with its time; a press-and-hold packet only touches the bud(s) it names. ADR-065 item 2 (`ai-sessions/0091`): a + * settings-stream packet carries [changedByApp] `false` — except that the Buds' mirror of the app's own write (`CAP-069` 2492 → 2502, the same value + * 300 ms later) keeps the reading's "changed by this app" mark instead of turning it into a plain read ([keepsOwnMark]). */ - private fun reconcileHold(endReason: HoldEnd?) { - if (endReason != null) holdEndReason = endReason - holdJob = scope.launch { - val claimNow = claimMutex.withLock { - when { - !holdWanted -> { - if (holdOpen) releaseOrHold() - false - } - transport.isChannelOpen(Dlci.FAST_PAIR_MESSAGE_STREAM) -> { - releaseJob?.cancel() - releaseJob = null - releaseOrHold() - false - } - else -> connectionStateMachine.state.value is ConnectionState.Ready - } - } - // The claim's own `finally` ([releaseOrHold]) keeps it while the tab is still shown. - if (claimNow) refreshAncMode(GET_RESPONSE_TIMEOUT_MS) - } - } - - /** - * The end of every claim (`withMessageStream`'s `finally`) and of every hold change, with [claimMutex] held: while [holdWanted] and the channel is open - * the claim is kept (ADR-061) — nothing is scheduled; otherwise a running hold ends (logged with [holdEndReason]) and the release is scheduled as ADR-032 - * item 3 always did. - */ - private fun releaseOrHold() { - if (holdWanted && transport.isChannelOpen(Dlci.FAST_PAIR_MESSAGE_STREAM)) { - if (!holdOpen) { - holdOpen = true - _ancChannelTaken.value = null // ADR-062 item 2: the app holds the channel again — the hint is cleared - BleLogger.logConnectionEvent("Message Stream hold started: the noise-control tab is on screen (ADR-061)") - } - return - } - endHold(holdEndReason) - scheduleRelease() - } - - /** The hold ends without a release of its own: the session ended (the transport closed every socket) or the channel was closed by someone else. */ - private fun endHold(reason: HoldEnd) { - if (!holdOpen) return - holdOpen = false - BleLogger.logConnectionEvent("Message Stream hold ended: ${reason.text}") - } - - // ---- ADR-062 (`ai-sessions/0087`): what follows a foreign close of the held claim — the Settings tab's choice ------------------------------------------- - - /** The Settings tab's choice, followed at once ([holdReclaimMode]'s collector). */ - @Volatile - private var holdReclaimModeSnapshot = HoldReclaimMode.DEFAULT - - private val _ancChannelTaken = MutableStateFlow(null) - override val ancChannelTaken: StateFlow = _ancChannelTaken - - /** The pending re-claim of mode [HoldReclaimMode.RECLAIM] (one bounded delay, then one claim) — cancelled by anything that ends the chain. */ - @Volatile - private var reclaimJob: Job? = null - - /** Re-claims made in this chain — since the tab was entered, the app returned, the user's last claim, "Take it back again" or the Connect (ADR-062 item 3). */ - @Volatile - private var reclaimAttempts = 0 - - /** - * A held claim was closed by someone else — in `CAP-074`/`CAP-075` Google Play services' Fast Pair recovering the channel 5.3–5.8 s after it lost it to this - * app, the stack closing this app's port on that collision (`CAP-075-FINDINGS.md` §5). Mode HINT_ONLY: the hint with the close's time, nothing re-claims - * (ADR-061 as written). Mode RECLAIM (ADR-062 Update 2): one bounded delay of [HOLD_RECLAIM_STEP_MS] after **every** close — after Play's own retry (2.1–2.8 s - * after the cut) has taken the channel, so the app's attempt 1 is refused, kills Play's socket, and attempt 2 connects — then the existing claim with its `Get` - * ([refreshAncMode]), provided the tab is still shown, the app visible, the session `Ready` and the channel still closed; after [HOLD_RECLAIM_CAP] re-claims - * the hint. Nothing new on the wire; never a loop: the chain has its cap and ends with the tab, the app's visibility, the session or the setting. - */ - private fun onHeldClaimTaken() { - val takenAt = clock() - when (holdReclaimModeSnapshot) { - HoldReclaimMode.HINT_ONLY -> { - _ancChannelTaken.value = ChannelTaken(takenAt, reclaimAttempts = 0) - BleLogger.logConnectionEvent("Noise-control channel taken by another client: the hint is shown, no re-claim (ADR-062, hint only)") - } - HoldReclaimMode.RECLAIM -> { - val attempt = reclaimAttempts - if (attempt >= HOLD_RECLAIM_CAP) { - _ancChannelTaken.value = ChannelTaken(takenAt, reclaimAttempts = attempt) - BleLogger.logConnectionEvent( - "Message Stream re-claim stopped after $attempt re-claims — Take it back again, a tap, a pull or re-entering the noise-control tab " + - "claims again (ADR-062)", - ) - return - } - val delayMs = HOLD_RECLAIM_STEP_MS - reclaimAttempts = attempt + 1 - reclaimJob?.cancel() - reclaimJob = scope.launch { - delay(delayMs) - // Re-checked under the claim mutex at the moment of the claim, not only when scheduled: the tab, the app's visibility and the session may - // have changed meanwhile (each of those also cancels this job — a second line of defence, as the loss collector's state check). - val claimNow = claimMutex.withLock { - holdWanted && !transport.isChannelOpen(Dlci.FAST_PAIR_MESSAGE_STREAM) && connectionStateMachine.state.value is ConnectionState.Ready - } - if (!claimNow) return@launch - BleLogger.logConnectionEvent( - "Message Stream re-claim ${attempt + 1}/$HOLD_RECLAIM_CAP after a foreign close, $delayMs ms later (ADR-062)", - ) - refreshAncMode(GET_RESPONSE_TIMEOUT_MS) // the claim's own `finally` keeps it as the hold while the tab is shown (ADR-061) - } - } - } - } - - /** A new chain (ADR-062 item 3): the counter resets and a pending re-claim is dropped — the claim that starts the chain replaces it. */ - private fun startReclaimChain() { - reclaimAttempts = 0 - cancelReclaim() - } - - private fun cancelReclaim() { - val job = reclaimJob ?: return - reclaimJob = null - if (job.isActive) { - job.cancel() - BleLogger.logConnectionEvent("Message Stream re-claim cancelled (ADR-062)") - } - } - - /** Stores one reported setting value with its time; a press-and-hold packet only touches the bud(s) it names. */ private fun applySetting(value: SettingValue, atMillis: Long, changedByApp: Boolean = false) { _settings.update { s -> + val changedByApp = changedByApp || keepsOwnMark(s, value) when (value) { is SettingValue.Flag -> { val r = SettingReading(value.on, atMillis, changedByApp) @@ -610,8 +492,38 @@ class BudsRepositoryImpl( ) is SettingValue.AncModes -> s.copy(ancModeList = SettingReading(value.list, atMillis, changedByApp)) is SettingValue.HeadGestures -> s.copy(headGestures = SettingReading(value.on, atMillis, changedByApp)) + is SettingValue.AncState -> s // the mode is not a settings-card value: [handleRoutedFrame] gives it to [emitAncMode] + } + } + } + + /** Whether the current reading of [value]'s setting already holds the same value as the app's own change (the stream mirrors the app's write). */ + private fun keepsOwnMark(s: BudsSettings, value: SettingValue): Boolean { + val current: SettingReading<*>? = when (value) { + is SettingValue.Flag -> when (value.field) { + SettingsCodec.FIELD_IN_EAR_DETECTION -> s.inEarDetection + SettingsCodec.FIELD_TOUCH_CONTROLS -> s.touchControls + SettingsCodec.FIELD_MONO_AUDIO -> s.monoAudio + SettingsCodec.FIELD_CONVERSATION_DETECTION -> s.conversationDetection + SettingsCodec.FIELD_MULTIPOINT -> s.multipoint + SettingsCodec.FIELD_CASE_SOUND_OTHER_ALERTS -> s.caseSoundOtherAlerts + SettingsCodec.FIELD_CASE_SOUND_EARBUDS_REPLACED -> s.caseSoundEarbudsReplaced + SettingsCodec.FIELD_VOLUME_EQ -> s.volumeEq + else -> null } + is SettingValue.Balance -> s.volumeBalance + is SettingValue.AncModes -> s.ancModeList + is SettingValue.HeadGestures -> s.headGestures + is SettingValue.PressAndHold, is SettingValue.AncState -> null + } + val reported: Any = when (value) { + is SettingValue.Flag -> value.on + is SettingValue.Balance -> value.value + is SettingValue.AncModes -> value.list + is SettingValue.HeadGestures -> value.on + is SettingValue.PressAndHold, is SettingValue.AncState -> return false } + return current != null && current.changedByApp && current.value == reported } /** ADR-044: the same Connect sequence as a tap, but it leaves the re-open policy as it is; a failure outside the state machine is shown. */ @@ -705,13 +617,6 @@ class BudsRepositoryImpl( scope.launch { debugModeEnabled.collect { debugModeEnabledSnapshot = it } } - scope.launch { - // ADR-062: the setting is followed at once; a switch to "hint only" drops a pending re-claim. - holdReclaimMode.collect { mode -> - holdReclaimModeSnapshot = mode - if (mode == HoldReclaimMode.HINT_ONLY) cancelReclaim() - } - } scope.launch { transport.inbound.collect { (channelId, bytes) -> lastInboundChannel = channelId @@ -736,15 +641,9 @@ class BudsRepositoryImpl( // so the next claim cannot start misaligned (0044 finding APP-2). transport.channelClosed.collect { closed -> codecRouter.reset(closed.channelId) - if (closed.channelId == Dlci.FAST_PAIR_MESSAGE_STREAM) { - _modelIdThisClaim.value = null - // ADR-061 (the maintainer's choice in chat 2026-10-10, "Keep it as the hold"): the hold ends; by itself the next tap, pull or a re-entry - // of the noise-control tab claims again. ADR-062 (`ai-sessions/0087`): a *held* claim closed by someone else then shows the hint or — the - // Settings tab's choice — re-claims after a bounded delay ([onHeldClaimTaken]); a close of a claim that was not held changes nothing. - val wasHeld = holdOpen - endHold(HoldEnd.CHANNEL_CLOSED) - if (wasHeld) onHeldClaimTaken() - } + // ADR-065 (`ai-sessions/0091`): nothing re-claims by itself — the next tap, pull or Refresh claims again (ADR-032); the mode meanwhile + // comes from the settings stream on the app's own channel. + if (closed.channelId == Dlci.FAST_PAIR_MESSAGE_STREAM) _modelIdThisClaim.value = null } } scope.launch { @@ -777,9 +676,6 @@ class BudsRepositoryImpl( markRingFromEarlierSession() markValuesFromEndedSession() reopener.onSessionLost() - endHold(HoldEnd.SESSION_LOST) // the transport has closed every socket; ADR-044's re-open may hold its own snapshot claim again (ADR-061) - _ancChannelTaken.value = null // ADR-062: the session is gone — the hint about its channel with it; the re-open starts a new chain - reclaimAttempts = 0 cancelClaimJobs() codecRouter.resetAll() connectionStateMachine.onDisconnected() @@ -869,12 +765,17 @@ class BudsRepositoryImpl( _maestroReplies.tryEmit(MaestroReply.Result(frame)) } - // ADR-036/045 (`ai-sessions/0052`): a setting the Buds reported — a read answer (or a settings-change push). Applied with its receive time. + // ADR-036/045 (`ai-sessions/0052`): a setting the Buds reported — a read answer or, since ADR-064/065 (`ai-sessions/0091`), a settings-stream + // push. Applied with its receive time; the last value received wins, whatever its channel (ADR-065 item 3). is RoutedFrame.Setting -> { - applySetting(frame.value, clock()) - _maestroReplies.tryEmit(MaestroReply.Setting(frame.value)) + val value = frame.value + if (value is SettingValue.AncState) applyAncState(value, frame.stream) else applySetting(value, clock()) + _maestroReplies.tryEmit(MaestroReply.Setting(value)) } + // ADR-065 item 2: a pushed change of a field this app does not read — said (payload-free, AGENTS.md §9), not shown, not an UnidentifiedFrame. + is RoutedFrame.UnreadSetting -> BleLogger.logConnectionEvent("Settings stream: field ${frame.field} not shown (not read by this app)") + // No persisted state (ARCHITECTURE.md §3.1's table). The Buds **do** send this frame themselves (corrected `ai-sessions/0069`, A68-APP-12): after the // app's Ring or Stop they send their own copy `04 01 00 01 xx` — the ringing-status sync of the Fast Pair spec (`CAP-059` 2312, 2494, 2507, 2604; // PROTOCOL.md §4.4) — besides their ACK (a Reply). The app reads nothing from it and sends no acknowledgement for it: an ACK would be a new send and @@ -944,11 +845,11 @@ class BudsRepositoryImpl( // A68-APP-02 (`ai-sessions/0069`): from here on, whatever the Buds reported earlier is "from the last connection" — the ANC mode is kept and // judged by this time ([isCurrent]); it becomes current again with this connection's first Notify or ACK. _sessionSince.value = clock() - // Item 1 (`ai-sessions/0082`): the kept mode's cause is this connection's to re-establish — its first Notify is a reading. Before the lookup, like - // `_sessionSince`: it belongs to the attempt, whatever the attempt does. The Settable byte likewise (I-3: unknown ⇒ enabled; ADR-059's reading - // then starts at "—" with the settings, which are reset above). - _ancModeCause.value = null - _ancAvailability.value = AncAvailability.UNKNOWN // re-read by this connection's snapshot claim + // ADR-065 item 4 (the maintainer's choice in chat 2026-10-14, `ai-sessions/0091` Q4): the kept mode and its cause stay — this connection's first read + // that differs from it is "changed since the app last saw it" (before `0091`, item 1 of `ai-sessions/0082` reset the cause here and a connection's + // first read was always a reading). The Settable byte is re-read (I-3: unknown ⇒ enabled; ADR-059's reading then starts at "—" with the settings, + // which are reset above). + _ancAvailability.value = AncAvailability.UNKNOWN // re-read by this connection's snapshot claim and its ReadSetting 4:13 // 0044 APP-8: no bonded device is "not paired", not a missing permission (AGENTS.md §8 — a specific message per cause). val device = bondedDeviceProvider() ?: return@withLock BudsResult.Failure(BudsError.NotPaired) @@ -965,8 +866,6 @@ class BudsRepositoryImpl( _modelIdThisClaim.value = null modelIdSeen = null lastInboundChannel = null - _ancChannelTaken.value = null // ADR-062: a new connection, a new chain - startReclaimChain() codecRouter.resetAll() // a new connection starts with empty per-channel buffers (0044 APP-2) connectionStateMachine.onConnectRequested() // ADR-032: the session is the MAESTRO channel only. The Message Stream channel (DLCI 0x04) is shared @@ -1016,9 +915,6 @@ class BudsRepositoryImpl( markRingFromEarlierSession() markValuesFromEndedSession() _safeMode.value = null - endHold(HoldEnd.DISCONNECT) - _ancChannelTaken.value = null // ADR-062 - reclaimAttempts = 0 cancelClaimJobs() transport.disconnect() codecRouter.resetAll() @@ -1046,7 +942,6 @@ class BudsRepositoryImpl( * I-1's rule, now on every tap). A `Notify` whose mode byte is unknown passes `null` to [choose]. */ private suspend fun setAncModeAfterGet(choose: (current: AncMode?) -> AncMode): BudsResult = withMessageStream { - startReclaimChain() // ADR-062 item 3: the user's own claim starts a new chain writeGate(requireModelIdOfClaim = true)?.let { return@withMessageStream BudsResult.Failure(it) } when (val got = sendAncGetAndAwait(_ancNotifies, GET_RESPONSE_TIMEOUT_MS)) { is ClaimWait.SendFailed -> BudsResult.Failure(got.error) @@ -1069,8 +964,11 @@ class BudsRepositoryImpl( private suspend fun ancSetOnClaim(mode: AncMode): BudsResult { writeGate(requireModelIdOfClaim = true)?.let { return BudsResult.Failure(it) } val sentAt = clock() // "the change at HH:MM:SS" of the not-confirmed mark - // Item 1 (`ai-sessions/0082`): while this Set waits, a Notify of the requested mode is the app's own change — not one by the Buds. + // Item 1 (`ai-sessions/0082`): while this Set waits, a Notify of the requested mode is the app's own change — not one by the Buds. ADR-065 item 4: the + // settings stream's packet for it may come after the wait ended — remembered with its time for [causeOfStream]. setPendingMode = mode + lastSetMode = mode + lastSetAt = sentAt val wait = try { sendAndAwaitOnClaim(_ancOutcomes, ACK_WAIT_MS) { transport.send(Dlci.FAST_PAIR_MESSAGE_STREAM, AncFrameEncoder.encode(AncFrame.Set(mode))) @@ -1118,20 +1016,7 @@ class BudsRepositoryImpl( return BudsError.CommandRejected(reason, name) } - override suspend fun refreshAncMode(): BudsResult { - startReclaimChain() // ADR-062 item 3: Refresh or a pull is the user's own claim — a new chain - return refreshAncMode(GET_RESPONSE_TIMEOUT_MS) - } - - /** - * The hint card's "Take it back again" (ADR-062 Update 2, `ai-sessions/0089`; the maintainer's choice in chat 2026-10-13, "one button, both modes"): a new - * chain, then the existing claim with its `Get` — the same path as Refresh's ANC claim, without a `Set` and without the battery claim of *Refresh battery*. - */ - override suspend fun reclaimAncChannel(): BudsResult { - BleLogger.logConnectionEvent("Message Stream claim by the hint card's action (ADR-062 Update 2)") - startReclaimChain() - return refreshAncMode(GET_RESPONSE_TIMEOUT_MS) - } + override suspend fun refreshAncMode(): BudsResult = refreshAncMode(GET_RESPONSE_TIMEOUT_MS) private suspend fun refreshAncMode(timeoutMs: Long, freshOpen: Boolean = false): BudsResult = withMessageStream(freshOpen) { when (val got = sendAncGetAndAwait(_ancModeFresh, timeoutMs)) { @@ -1465,7 +1350,6 @@ class BudsRepositoryImpl( */ override suspend fun refreshBattery(): BudsResult { if (connectionStateMachine.state.value !is ConnectionState.Ready) return BudsResult.Failure(BudsError.ConnectionLost) - startReclaimChain() // ADR-062 item 3: the user's own (fresh) claim // DECISIONS.md ADR-043 Update 2026-09-26: one SubscribeRuntimeInfo per Refresh (the Connect-time bytes), no wait, no retry — an answer, if // the Buds give one, arrives on the stream like any other packet; none changes nothing. Queued behind any running settings request. resubscribeJob = scope.launch { subscribeRuntimeInfo(fromRefresh = true) } @@ -1524,8 +1408,8 @@ class BudsRepositoryImpl( /** * Claims the Message Stream channel (DLCI 0x04) for one user action, runs [action], and schedules * its release [MESSAGE_STREAM_LINGER_MS] later so Google Play services can take the channel back - * and hold it stably — **unless the noise-control tab is on screen**: then the claim is held until the tab or the app is left (DECISIONS.md ADR-061, - * [releaseOrHold]); an action during the hold reuses the open channel. Serialised by [claimMutex]. Requires an open session (the MAESTRO channel); + * and hold it stably (ADR-061's hold while the noise-control tab was shown is gone — DECISIONS.md ADR-065, `ai-sessions/0091`: the mode comes from the + * settings stream on the app's own channel). Serialised by [claimMutex]. Requires an open session (the MAESTRO channel); * a channel that is busy is retried inside `transport.openChannel` and, if still busy, reported * through [messageStreamError] and the returned failure — never silently dropped. * @@ -1569,8 +1453,8 @@ class BudsRepositoryImpl( _messageStreamError.value = (result as? BudsResult.Failure)?.error?.takeIf { it != BudsError.AncNotAllowed } } finally { // Always released — a tap cancelled mid-action must not leave DLCI 0x04 claimed and Play services locked out - // (0044 APP-4) — except while the noise-control tab is on screen, when the claim is held (ADR-061). - releaseOrHold() + // (0044 APP-4). + scheduleRelease() } result } @@ -1595,9 +1479,11 @@ class BudsRepositoryImpl( } /** - * Started by [connect] once the session is `Ready` (ADR-034): the EQ read, the settings reads (ADR-036, `ai-sessions/0052`), the one - * runtime-info subscription (ADR-043), then the one hardware-info read (ADR-058) — all wait for the Buds' channel announcement. `internal` so a test - * can start it without a `BluetoothDevice`. + * Started by [openSession] once the session is `Ready` — at Connect and at every ADR-044 re-open (ADR-034): the EQ read, the settings reads (ADR-036, + * `ai-sessions/0052`), the one runtime-info subscription (ADR-043), then — DECISIONS.md ADR-064/065, `ai-sessions/0091`, the order the maintainer chose in + * chat 2026-10-14 (Q6) — the one settings-changes subscription and the one read of setting 13, and last the one hardware-info read (ADR-058) — all wait for + * the Buds' channel announcement. Each step fails on its own and is never retried (a lost session fails them all; the re-open runs the sequence again). + * `internal` so a test can start it without a `BluetoothDevice`. */ internal fun launchInitialEqRead() { eqReadJob?.cancel() @@ -1605,10 +1491,77 @@ class BudsRepositoryImpl( readEq() readSettings() subscribeRuntimeInfo() + subscribeToSettingsChanges() + readAncSetting() readHardwareInfo() } } + /** + * `SubscribeToSettingsChanges` (DECISIONS.md ADR-064 item 1, ADR-065 item 1): one REQUEST per MAESTRO session, byte-identical to the official app's + * `CAP-051` 1177 (channel 21) / `CAP-056` 1451 (channel 19), on the announced channel with its ADR-034 address, after [subscribeRuntimeInfo]. Never + * retried, never polled; a lost session is subscribed again by the re-open's own sequence (its failure is logged; the sequence goes on, as every step). + * A read, not gated by Safe Mode (ADR-042 item 3). The Buds + * answer nothing to it directly — the stream's packets reach [handleRoutedFrame] as [RoutedFrame.Setting] with `stream = true`. + */ + private suspend fun subscribeToSettingsChanges(): BudsResult = eqMutex.withLock { + val channel = when (val c = awaitMaestroChannel()) { + is BudsResult.Failure -> { + BleLogger.logConnectionEvent("Settings stream not subscribed: ${eqErrorLogText(c.error)}") + return@withLock BudsResult.Failure(c.error) + } + is BudsResult.Success -> c.value + } + val wire = Hdlc.encode(channel.requestAddress, PW_HDLC_CONTROL_UI, PwRpc.encode(Maestro.subscribeToSettingsChangesRequest(channel.channelId))) + awaitWriteQuarantine() + when (val sent = transport.send(Dlci.MAESTRO, wire)) { + is BudsResult.Failure -> { + BleLogger.logConnectionEvent("Settings stream not subscribed: ${eqErrorLogText(sent.error)}") + sent + } + is BudsResult.Success -> { + BleLogger.logConnectionEvent("Settings stream subscribed (channel ${channel.channelId})") + BudsResult.Success(Unit) + } + } + } + + /** + * `ReadSetting 4:13` (DECISIONS.md ADR-064 item 1, ADR-065 item 1): one read of the noise-control mode per MAESTRO session, right after the subscription — + * byte-identical to `CAP-051` 1178 / `CAP-056` 1450 — waiting ≤ [SETTING_READ_TIMEOUT_MS] for its answer, never retried. The answer reaches + * [applyAncState] through [handleRoutedFrame] (a read answer: "changed since the app last saw it" when it differs from the shown mode, ADR-065 item 4); + * a failure is logged only — the Connect's snapshot claim and the stream are the other sources of the mode. + */ + private suspend fun readAncSetting(): Unit = eqMutex.withLock { + val channel = when (val c = awaitMaestroChannel()) { + is BudsResult.Failure -> { + BleLogger.logConnectionEvent("Noise-control mode not read: ${eqErrorLogText(c.error)}") + return@withLock + } + is BudsResult.Success -> c.value + } + val request = Maestro.readSettingRequest(channel.channelId, SettingsCodec.FIELD_ANC_STATE) ?: return@withLock + val wire = Hdlc.encode(channel.requestAddress, PW_HDLC_CONTROL_UI, PwRpc.encode(request)) + val accept: (MaestroReply) -> Boolean = { + (it is MaestroReply.Setting && it.value.field == SettingsCodec.FIELD_ANC_STATE) || + (it is MaestroReply.Result && it.result.methodId == Maestro.METHOD_READ_SETTING) + } + awaitWriteQuarantine() + val (sent, reply) = sendAndAwait(_maestroReplies, SETTING_READ_TIMEOUT_MS, accept) { transport.send(Dlci.MAESTRO, wire) } + val error = when { + sent is BudsResult.Failure -> sent.error + reply == null -> BudsError.Timeout + reply is MaestroReply.Result && reply.result.isOk -> BudsError.UnreadableAnswer + reply is MaestroReply.Result -> BudsError.MaestroRejected("${PwRpc.typeName(reply.result.type)} ${PwRpc.statusName(reply.result.status)}") + else -> null + } + if (error != null) { + BleLogger.logConnectionEvent("Noise-control mode read failed: ${eqErrorLogText(error)}") + } else { + BleLogger.logConnectionEvent("Noise-control mode read (channel ${channel.channelId})") + } + } + /** * `GetHardwareInfo` (DECISIONS.md ADR-058): one unary request per Connect on the announced channel with its ADR-034 address — byte-identical to the official * app's `CAP-036` 1415 / `CAP-024` 801 — after the runtime-info subscription; waits ≤ [SETTING_READ_TIMEOUT_MS] for the answer, never retried. A read: not @@ -1693,9 +1646,6 @@ class BudsRepositoryImpl( snapshotJob = null resubscribeJob?.cancel() resubscribeJob = null - holdJob?.cancel() - holdJob = null - cancelReclaim() // ADR-062: a loss or a Disconnect ends the chain } /** @@ -1781,16 +1731,12 @@ class BudsRepositoryImpl( private const val MESSAGE_STREAM_LINGER_MS = 1_500L /** - * ADR-062 Update 2 (`ai-sessions/0089`; the maintainer's choice in chat 2026-10-13, "P1"): the delay before the re-claim after **every** foreign close of - * the held claim, measured from the close (1.2.1's first step, kept). `CAP-075` (one Play version): Play retries 2.1–2.8 s after the cut, so at 3 s the - * app's attempt 1 meets Play on the channel, is refused and kills Play's socket, attempt 2 connects 0.7–1.0 s later; Play recovers 5.3–5.8 s after its loss - * and cuts the app again — a held 4.55–4.92 s of every ≈ 9.3 s (`CAP-075-FINDINGS.md` §1, §4). 1.2.1's 6- and 12-s steps only lengthened the dead time - * (Play's timers did not change over 15 cycles). Not a polling interval: each delay follows one foreign close, and the chain is capped. + * ADR-065 item 4 (`ai-sessions/0091`, ARCHITECTURE.md §3.2): how long after the app's own `Set` a settings-stream `4:{13:n}` of that mode still counts as + * the app's change. Observed: 269 ms from the Set (`CAP-051` 1527 → 1534, after the ACK and two Notifies) and 55 ms after the second Notify (`CAP-056` + * 4001 → 4028). A hold on a bud to the mode just set changes nothing, so a wrong "set by this app" inside the window has no visible effect. Not a timer: + * compared at the packet's arrival. */ - internal const val HOLD_RECLAIM_STEP_MS: Long = 3_000L - - /** ADR-062 Update 2 (the maintainer's choice in chat 2026-10-13, "20 re-claims"): the re-claims per chain, ≈ 20 × 9.3 s ≈ 3 minutes; then the hint. */ - internal const val HOLD_RECLAIM_CAP: Int = 20 + internal const val ANC_SET_WINDOW_MS: Long = 2_000L /** How long a read/write waits for the Buds' channel announcement (their first Maestro packet). */ private const val MAESTRO_ANNOUNCE_WAIT_MS = 3_000L diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt index c83a0fb2..33923f98 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt @@ -42,8 +42,17 @@ sealed class RoutedFrame { data class ModelId(val frame: ModelIdFrame) : RoutedFrame() data class Eq(val frame: EqFrame) : RoutedFrame() - /** A non-EQ setting value (`4:{N: …}`, ADR-036/045) from a `ReadSetting` answer or the settings-change stream (`ai-sessions/0052`). */ - data class Setting(val value: SettingValue) : RoutedFrame() + /** + * A non-EQ setting value (`4:{N: …}`, ADR-036/045) from a `ReadSetting` answer ([stream] false, a `RESPONSE`) or the settings-change stream the app + * subscribes to ([stream] true, a `SERVER_STREAM` packet — DECISIONS.md ADR-064/065, `ai-sessions/0091`; `CAP-056` 1696/2175, `CAP-069` 2502). + */ + data class Setting(val value: SettingValue, val stream: Boolean = false) : RoutedFrame() + + /** + * A settings-stream packet `4:{N: …}` for a field this app does not read (N outside `Maestro.READABLE_FIELDS`) — DECISIONS.md ADR-065 item 2: logged + * payload-free and not interpreted; not an [UnidentifiedFrame], since its meaning is known (a setting changed) even though its value is not shown. + */ + data class UnreadSetting(val field: Int) : RoutedFrame() data class Ring(val frame: RingFrame) : RoutedFrame() data class Battery(val frame: BatteryFrame) : RoutedFrame() @@ -296,7 +305,12 @@ internal fun routeMaestro(packet: RpcPacket): RoutedFrame? { ) { val eq = EqFrameDecoder.decode(packet) if (eq is BudsResult.Success) return RoutedFrame.Eq(eq.value) - SettingsCodec.decode(packet.payload)?.let { return RoutedFrame.Setting(it) } + val stream = packet.type == PwRpc.TYPE_SERVER_STREAM + SettingsCodec.decode(packet.payload)?.let { return RoutedFrame.Setting(it, stream) } + // ADR-065 item 2 (`ai-sessions/0091`): a pushed change of a field this app does not read is said, not shown — and not an UnidentifiedFrame. + if (stream && method == Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES) { + SettingsCodec.fieldOf(packet.payload)?.takeIf { it !in Maestro.READABLE_FIELDS }?.let { return RoutedFrame.UnreadSetting(it) } + } } // ADR-043 (+ its 2026-09-25 Update): the runtime-info stream the app subscribes to once per Connect; the Case and each bud's charging state. if (method == Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO && diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt index b3b5e202..e0f7b21d 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt @@ -50,7 +50,8 @@ object Maestro { * controls), 7 (press-and-hold), 17 (balance), 19 (mono), 22 (conversation detection); since `ai-sessions/0056` 12 (the press-and-hold ANC-mode list, * ADR-046 amending ADR-036; request = `CAP-056` frame 1529 on channel 19, `CAP-036` 1514 on 21); since `ai-sessions/0074` 11 (Multipoint, ADR-036/053; * request = `CAP-024` frame 1009 on channel 19, `CAP-069` 1261 on 21), 15 (Volume EQ, ADR-055; `CAP-024` 1031 on 19, `CAP-036` 1520 on 21), 27 and 28 (case sounds, ADR-054; `CAP-024` 1089/1093 on 19, `CAP-036` 1553/1556 on - * 21) and 29 (head gestures, ADR-052; `CAP-024` 1097 on 19, `CAP-036` 1559 on 21). + * 21) and 29 (head gestures, ADR-052; `CAP-024` 1097 on 19, `CAP-036` 1559 on 21); since `ai-sessions/0091` 13 (the noise-control mode, ADR-064/065; + * request = `CAP-051` 1178 on channel 21, `CAP-056` 1450 on 19). */ val READABLE_FIELDS: Set = EQ_FIELDS + setOf( SettingsCodec.FIELD_IN_EAR_DETECTION, @@ -58,6 +59,7 @@ object Maestro { SettingsCodec.FIELD_PRESS_AND_HOLD, SettingsCodec.FIELD_MULTIPOINT, SettingsCodec.FIELD_ANC_MODE_LIST, + SettingsCodec.FIELD_ANC_STATE, SettingsCodec.FIELD_VOLUME_EQ, SettingsCodec.FIELD_VOLUME_BALANCE, SettingsCodec.FIELD_MONO_AUDIO, @@ -94,6 +96,19 @@ object Maestro { methodId = METHOD_SUBSCRIBE_RUNTIME_INFO, ) + /** + * `SubscribeToSettingsChanges` (DECISIONS.md ADR-064 item 1, ADR-065 item 1; `ai-sessions/0091`): the one server-stream subscription per MAESTRO session + * after which the Buds push every setting change as `4:{N: …}` (PROTOCOL.md §4.1 Update of 2026-10-14). An empty payload, like + * [subscribeRuntimeInfoRequest]; sealed with the announced channel's address it is byte-identical to the official app's `CAP-051` 1177 (channel 21, + * `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25 f5 ad 21 28 01 97 54 b0 7e`) and `CAP-056` 1451 (channel 19). + */ + fun subscribeToSettingsChangesRequest(channelId: Int): RpcPacket = RpcPacket( + type = PwRpc.TYPE_REQUEST, + channelId = channelId, + serviceId = SERVICE_ID, + methodId = METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, + ) + /** * `GetHardwareInfo` request (DECISIONS.md ADR-058, `ai-sessions/0082`): no payload, no call id — byte-identical to the official app's `CAP-036` frame 1415 * (`10 15 1d ea 71 de 7e 25 e3 a5 ec 28` on channel 21) and `CAP-024` frame 801 (`10 13 …` on channel 19). One per Connect, after the runtime-info diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingFrame.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingFrame.kt index 96077568..9337cb3e 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingFrame.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingFrame.kt @@ -19,6 +19,7 @@ */ package io.github.tedsluis.opencontrolpixelbuds.data.codec +import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeList import io.github.tedsluis.opencontrolpixelbuds.domain.Bud import io.github.tedsluis.opencontrolpixelbuds.domain.HoldAction @@ -60,6 +61,16 @@ sealed class SettingValue { data class HeadGestures(val on: Boolean) : SettingValue() { override val field: Int get() = SettingsCodec.FIELD_HEAD_GESTURES } + + /** + * Field 13, the noise-control mode as a setting (🟢 PROTOCOL.md §4.1 Update of 2026-10-14; DECISIONS.md ADR-064, ADR-065; `ai-sessions/0091`): + * 1 = Off, 2 = Noise cancellation, 3 = Transparency, 4 = Adaptive — `CAP-051` 1225 (`13:4`), 1717 (`13:2`), 1802 (`13:3`), `CAP-021` 1089 (`13:1`); + * 0 = no mode settable ([mode] `null` — ADR-049's Settable `00`: `CAP-045` 2033/2277, `CAP-051` 2247 after the Buds' own `DISC`), never shown as a mode. + * Any other value is not interpreted (the decoder returns `null`). **Read only** — never encoded (ADR-064 item 4: `WriteSetting 4:{13:n}` stays gated). + */ + data class AncState(val mode: AncMode?) : SettingValue() { + override val field: Int get() = SettingsCodec.FIELD_ANC_STATE + } } /** @@ -76,6 +87,9 @@ object SettingsCodec { const val FIELD_MULTIPOINT = 11 const val FIELD_ANC_MODE_LIST = 12 + /** The noise-control mode as a setting (ADR-064/065, `ai-sessions/0091`) — see [SettingValue.AncState]; read only. */ + const val FIELD_ANC_STATE = 13 + /** * "Volume EQ" (🟢 PROTOCOL.md §4.5.6; ADR-055). Writes are byte-identical to `CAP-022` 1871/1895 (channel 19), `CAP-041` 2461 (channel 21, off) and — the * channel-21 "on" form ADR-055 left uncaptured — OpenControl 1.1.0's own `CAP-070` frame A4800 @@ -109,6 +123,13 @@ object SettingsCodec { FIELD_CASE_SOUND_OTHER_ALERTS, FIELD_CASE_SOUND_EARBUDS_REPLACED, ) + /** Field 13's wire values (PROTOCOL.md §4.1 Update of 2026-10-14): 0 = none settable, 1 = Off, 2 = Noise cancellation, 3 = Transparency, 4 = Adaptive. */ + private const val ANC_STATE_NONE = 0 + private const val ANC_STATE_OFF = 1 + private const val ANC_STATE_ACTIVE = 2 + private const val ANC_STATE_TRANSPARENT = 3 + private const val ANC_STATE_ADAPTIVE = 4 + private const val FIELD4 = 4 private const val WIRETYPE_VARINT = 0 private const val WIRETYPE_LEN = 2 @@ -203,10 +224,29 @@ object SettingsCodec { HEAD_GESTURES_ON -> SettingValue.HeadGestures(true) else -> null // 0, 3, … : not interpreted (ADR-052: shown as "—") } + FIELD_ANC_STATE -> when (f.varint) { + ANC_STATE_NONE -> SettingValue.AncState(null) + ANC_STATE_OFF -> SettingValue.AncState(AncMode.OFF) + ANC_STATE_ACTIVE -> SettingValue.AncState(AncMode.ACTIVE) + ANC_STATE_TRANSPARENT -> SettingValue.AncState(AncMode.TRANSPARENT) + ANC_STATE_ADAPTIVE -> SettingValue.AncState(AncMode.ADAPTIVE) + else -> null // 5, … : not interpreted + } else -> null } } + /** + * The one field number of a `4:{N: …}` payload, whatever N — for the router's "a stream packet for a field this app does not read" line (ADR-065 item 2: + * logged payload-free, not interpreted, not an [UnidentifiedFrame]). `null` for any other shape. Never throws. + */ + fun fieldOf(payload: ByteArray): Int? { + val top = Proto.fields(payload) ?: return null + if (top.size != 1 || top[0].number != FIELD4) return null + val inner = Proto.fields(top[0].bytes ?: return null) ?: return null + return inner.singleOrNull()?.number + } + /** * `qht`: exactly the four booleans 1–4, each once, each 0 or 1 — the only shape in any capture (`qht` has presence bits, so a `false` is sent as `0`, * never omitted: `CAP-056` 1531/1689, `CAP-036` 1516). Anything else (a missing, repeated or unknown field, another value) is not interpreted. diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/PwRpcTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/PwRpcTest.kt index 722be844..9d587184 100644 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/PwRpcTest.kt +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/PwRpcTest.kt @@ -63,9 +63,9 @@ class PwRpcTest { @Test @DisplayName("only the fields ADR-034/036/046/052 unblock and ai-sessions/0052/0056/0074 read can be read — no arbitrary setting read exists") fun `refuses to build a read for any other field`() { - // ai-sessions/0074 added 11, 15, 27, 28 (ADR-036) and 29 (ADR-052); 13 (approved for nothing), 21, 23 … 39 stay unreadable. - for (field in listOf(0, 1, 13, 21, 23, 30, 31, 39, 200)) assertNull(Maestro.readSettingRequest(21, field), "field $field") - assertEquals(setOf(2, 4, 7, 11, 12, 15, 16, 17, 18, 19, 22, 27, 28, 29), Maestro.READABLE_FIELDS) + // ai-sessions/0074 added 11, 15, 27, 28 (ADR-036) and 29 (ADR-052); ai-sessions/0091 added 13 (ADR-064/065); 21, 23 … 39 stay unreadable. + for (field in listOf(0, 1, 21, 23, 30, 31, 39, 200)) assertNull(Maestro.readSettingRequest(21, field), "field $field") + assertEquals(setOf(2, 4, 7, 11, 12, 13, 15, 16, 17, 18, 19, 22, 27, 28, 29), Maestro.READABLE_FIELDS) } @Test diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsCodecTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsCodecTest.kt index 2efca70e..ee09531b 100644 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsCodecTest.kt +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsCodecTest.kt @@ -256,8 +256,8 @@ class SettingsCodecTest { } for (ack in listOf(Settings074.ACK_CH19_2061, Settings074.ACK_CH21_5683)) assertEquals(true, (route(ack) as RoutedFrame.RpcResult).isOk) // The Buds' own pushes of an "off" (CAP-058 5682 / 5627): 27 and 28 are told apart on the read side too. - assertEquals(RoutedFrame.Setting(SettingValue.Flag(27, false)), route(Settings074.STREAM_27_OFF_CH21_5682)) - assertEquals(RoutedFrame.Setting(SettingValue.Flag(28, false)), route(Settings074.STREAM_28_OFF_CH21_5627)) + assertEquals(RoutedFrame.Setting(SettingValue.Flag(27, false), stream = true), route(Settings074.STREAM_27_OFF_CH21_5682)) + assertEquals(RoutedFrame.Setting(SettingValue.Flag(28, false), stream = true), route(Settings074.STREAM_28_OFF_CH21_5627)) } // ---- field 15, "Volume EQ" (ADR-055, ai-sessions/0074) ---- @@ -367,7 +367,7 @@ class SettingsCodecTest { fun ancModeListDecodes() { assertEquals(RoutedFrame.Setting(SettingValue.AncModes(all)), route(Settings056.READ_12_RESP_ALL_1531)) assertEquals(RoutedFrame.Setting(SettingValue.AncModes(all.with(AncMode.OFF, false))), route(Settings056.READ_12_RESP_OFF_UNTICKED_1516)) - assertEquals(RoutedFrame.Setting(SettingValue.AncModes(all.with(AncMode.ACTIVE, false))), route(Settings056.PUSH_NC_OFF_1696)) + assertEquals(RoutedFrame.Setting(SettingValue.AncModes(all.with(AncMode.ACTIVE, false)), stream = true), route(Settings056.PUSH_NC_OFF_1696)) fun payload(frame: String) = (PwRpc.decode((Hdlc.decode(hex(frame)) as BudsResult.Success).value.payload) as BudsResult.Success).value.payload assertEquals(SettingValue.AncModes(all.with(AncMode.ADAPTIVE, false)), SettingsCodec.decode(payload(Settings056.WRITE_ADAPTIVE_OFF_1815))) assertEquals(SettingValue.AncModes(all.with(AncMode.TRANSPARENT, false)), SettingsCodec.decode(payload(Settings056.WRITE_TRANSPARENCY_OFF_1843))) @@ -396,7 +396,9 @@ class SettingsCodecTest { // ADR-046: field 12 is readable — CAP-036 frame 1514 (channel 21) and CAP-056 frame 1529 (channel 19), byte for byte. assertEquals(Settings056.READ_12_REQ_CH21_1514, wire(Maestro.readSettingRequest(21, 12)!!)) assertEquals(Settings056.READ_12_REQ_CH19, wire(Maestro.readSettingRequest(19, 12)!!)) - assertNull(Maestro.readSettingRequest(21, 13), "setting 13: no read is approved (ai-sessions/0074 §3)") + // ADR-064/065 (`ai-sessions/0091`): field 13 is readable — CAP-051 1178 (channel 21) and CAP-056 1450 (channel 19), byte for byte. + assertEquals(SettingsStream.READ_13_REQ_CH21_CAP051_1178, wire(Maestro.readSettingRequest(21, 13)!!)) + assertEquals(SettingsStream.READ_13_REQ_CH19_CAP056_1450, wire(Maestro.readSettingRequest(19, 13)!!)) assertNull(Maestro.readSettingRequest(21, 39)) } diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsFixtures.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsFixtures.kt index dd6cfd1b..d9061f1c 100644 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsFixtures.kt +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsFixtures.kt @@ -95,6 +95,12 @@ internal object Settings036 { /** Frame 1561 (04:36:34.034): `4:{29:2}` — head gestures **on** (2 = on, ADR-052); byte-identical to `CAP-069` 1226 and `CAP-058` 4520. */ const val READ_29_RESP = "7e00a5032a052203e80102080110151dea71de7d5e2551aed0ae898482177e" + /** Frame 1517 (06:36:33.200): `ReadSetting 4:13` — the noise-control mode (ADR-064/065, `ai-sessions/0091`; = `CAP-051` 1178). */ + const val READ_13_REQ = "7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e" + + /** Frame 1519 (06:36:33.249): `4:{13:0}` — no mode settable (the Buds in the case; = `CAP-045` 2033). */ + const val READ_13_RESP = "7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e" + /** Frame 1525: `4:{16:[0.1, 0.0, 0.3, 0.2, 0.2]}` — the EQ read answer of the same sweep. */ const val READ_16_RESP = "7e00a5032a1e221c8201190dc0cccc3d15000000001da099993e25c0cc4c3e2dc0cc4c3e080110151dea71de7d5e2551aed0ae85b618ed7e" @@ -107,6 +113,7 @@ internal object Settings036 { 2 to READ_2_RESP, 4 to READ_4_RESP, 7 to READ_7_RESP, 16 to READ_16_RESP, 17 to READ_17_RESP, 19 to READ_19_RESP, 22 to READ_22_RESP, 12 to Settings056.READ_12_RESP_OFF_UNTICKED_1516, // the same sweep's field-12 answer (frame 1516, request 1514) — ai-sessions/0056 11 to READ_11_RESP, 15 to READ_15_RESP, 27 to READ_27_RESP, 28 to READ_28_RESP, 29 to READ_29_RESP, // ai-sessions/0074 + 13 to READ_13_RESP, // ai-sessions/0091 (ADR-064/065): the same sweep's field-13 answer (frame 1519, request 1517) ) } diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamCodecTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamCodecTest.kt new file mode 100644 index 00000000..d102d333 --- /dev/null +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamCodecTest.kt @@ -0,0 +1,282 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.codec + +import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode +import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeList +import io.github.tedsluis.opencontrolpixelbuds.domain.BudsResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.Duration +import kotlin.random.Random + +/** + * DECISIONS.md ADR-064/065 (`ai-sessions/0091`): the settings-changes subscription and setting 13 against the real frames of [SettingsStream] — the + * request bytes on both channels, every captured answer and stream packet, value 0, the other fields' stream packets, the unread-field route, and a fuzz + * over server-stream packets (AGENTS.md §11: an inbound frame is untrusted input). + */ +class SettingsStreamCodecTest { + + /** The whole pw_hdlc frame for a request on the announced channel, as the app puts it on the wire. */ + private fun wire(packet: RpcPacket): String { + val address = MaestroChannel.forChannel(packet.channelId)!!.requestAddress + return Hdlc.encode(address, PW_HDLC_CONTROL_UI, PwRpc.encode(packet)).toHex() + } + + private fun rpc(frameHex: String): RpcPacket = + (PwRpc.decode((Hdlc.decode(hex(frameHex)) as BudsResult.Success).value.payload) as BudsResult.Success).value + + /** Hdlc → pw_rpc → the router's Maestro classification, the path every inbound frame takes. */ + private fun route(frameHex: String): RoutedFrame? = routeMaestro(rpc(frameHex)) + + private fun setting(frameHex: String): RoutedFrame.Setting = assertInstanceOf(RoutedFrame.Setting::class.java, route(frameHex)) + + // ---- the two requests: byte-identical to the official app's, whole frame incl. CRC ---- + + @Test + @DisplayName("SubscribeToSettingsChanges: CAP-051 1177 on channel 21 and CAP-056 1451 on channel 19, byte for byte; method 0x2821adf5, empty payload") + fun `the subscription request on both channels`() { + assertEquals(SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177, wire(Maestro.subscribeToSettingsChangesRequest(21))) + assertEquals(SettingsStream.SUBSCRIBE_REQ_CH19_CAP056_1451, wire(Maestro.subscribeToSettingsChangesRequest(19))) + val packet = Maestro.subscribeToSettingsChangesRequest(21) + assertEquals(PwRpc.TYPE_REQUEST, packet.type) + assertEquals(0x2821adf5, packet.methodId) + assertEquals(0, packet.payload.size) + } + + @Test + @DisplayName("ReadSetting 4:13: CAP-051 1178 on channel 21 and CAP-056 1450 on channel 19, byte for byte — field 13 is readable since ADR-064") + fun `the read of setting 13 on both channels`() { + assertEquals(SettingsStream.READ_13_REQ_CH21_CAP051_1178, wire(Maestro.readSettingRequest(21, SettingsCodec.FIELD_ANC_STATE)!!)) + assertEquals(SettingsStream.READ_13_REQ_CH19_CAP056_1450, wire(Maestro.readSettingRequest(19, SettingsCodec.FIELD_ANC_STATE)!!)) + assertEquals(Settings036.READ_13_REQ, wire(Maestro.readSettingRequest(21, 13)!!), "CAP-036 1517: the official sweep reads it too") + assertTrue(13 in Maestro.READABLE_FIELDS) + } + + @Test + @DisplayName("setting 13 is never written: no flag request, no writable field (ADR-064 item 4)") + fun `setting 13 has no write path`() { + assertNull(SettingsCodec.flagRequest(21, SettingsCodec.FIELD_ANC_STATE, true)) + assertEquals(false, SettingsCodec.FIELD_ANC_STATE in SettingsCodec.WRITABLE_FLAG_FIELDS) + } + + // ---- RESPONSE: the read answers ---- + + @Test + @DisplayName("every captured 4:{13:n} RESPONSE decodes to its mode: 4 Adaptive (CAP-051 1225), 2 Noise cancellation (CAP-056 1468), 3 Transparency (CAP-027 923), 1 Off (CAP-021 1089); stream = false") + fun `the read answers decode to the four modes`() { + for ((frame, mode) in listOf( + SettingsStream.READ_13_RESP_ADAPTIVE_CH21_CAP051_1225 to AncMode.ADAPTIVE, + SettingsStream.READ_13_RESP_ACTIVE_CH19_CAP056_1468 to AncMode.ACTIVE, + SettingsStream.READ_13_RESP_TRANSPARENT_CH19_CAP027_923 to AncMode.TRANSPARENT, + SettingsStream.READ_13_RESP_OFF_CH19_CAP021_1089 to AncMode.OFF, + )) { + val routed = setting(frame) + assertEquals(SettingValue.AncState(mode), routed.value, frame) + assertEquals(false, routed.stream, "a RESPONSE is a read answer, not a push") + assertEquals(13, routed.value.field) + } + } + + @Test + @DisplayName("4:{13:0} (CAP-045 2033, CAP-051 2247, CAP-036 1519) is a value — no mode settable — not an unreadable answer") + fun `value 0 is no mode settable`() { + for (frame in listOf( + SettingsStream.READ_13_RESP_NONE_CH21_CAP045_2033, + SettingsStream.READ_13_RESP_NONE_CH19_CAP051_2247, + Settings036.READ_13_RESP, + )) { + assertEquals(SettingValue.AncState(null), setting(frame).value, frame) + } + assertEquals(SettingValue.AncState(null), setting(SettingsStream.STREAM_13_NONE_CH21_CAP045_2277).value) + assertEquals(true, setting(SettingsStream.STREAM_13_NONE_CH21_CAP045_2277).stream) + } + + // ---- SERVER_STREAM: the pushes ---- + + @Test + @DisplayName("every captured 4:{13:n} stream packet decodes to its mode with stream = true (CAP-051 1717/1755/1802, CAP-038 2170/2205, CAP-021 1842, CAP-056 2259)") + fun `the stream packets decode to the four modes`() { + for ((frame, mode) in listOf( + SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717 to AncMode.ACTIVE, + SettingsStream.STREAM_13_ADAPTIVE_CH21_CAP051_1755 to AncMode.ADAPTIVE, + SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802 to AncMode.TRANSPARENT, + SettingsStream.STREAM_13_TRANSPARENT_CH19_CAP038_2170 to AncMode.TRANSPARENT, + SettingsStream.STREAM_13_ACTIVE_CH19_CAP038_2205 to AncMode.ACTIVE, + SettingsStream.STREAM_13_OFF_CH19_CAP021_1842 to AncMode.OFF, + SettingsStream.STREAM_13_ADAPTIVE_CH19_CAP056_2259 to AncMode.ADAPTIVE, + )) { + val packet = rpc(frame) + assertEquals(PwRpc.TYPE_SERVER_STREAM, packet.type, frame) + assertEquals(Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, packet.methodId, frame) + val routed = setting(frame) + assertEquals(SettingValue.AncState(mode), routed.value, frame) + assertEquals(true, routed.stream, frame) + } + } + + @Test + @DisplayName("ADR-065 item 2: the other fields' stream packets decode through the same decoders — 12 (CAP-056 1696), 2 (CAP-056 2175), 29 (CAP-069 2502); 16 is an EQ frame (CAP-015 2116)") + fun `other fields from the stream`() { + assertEquals( + SettingValue.AncModes(AncModeList(noiseCancellation = false, off = true, transparency = true, adaptive = true)), + setting(SettingsStream.STREAM_12_CH19_CAP056_1696).value, + ) + assertEquals(true, setting(SettingsStream.STREAM_12_CH19_CAP056_1696).stream) + assertEquals(SettingValue.Flag(SettingsCodec.FIELD_IN_EAR_DETECTION, true), setting(SettingsStream.STREAM_2_ON_CH19_CAP056_2175).value) + assertEquals(SettingValue.HeadGestures(false), setting(SettingsStream.STREAM_29_OFF_CH21_CAP069_2502).value) + val eq = assertInstanceOf(RoutedFrame.Eq::class.java, route(SettingsStream.STREAM_16_FLAT_CH19_CAP015_2116)) + assertEquals(false, eq.frame.persist) + assertEquals(listOf(0f, 0f, 0f, 0f, 0f), listOf(eq.frame.gains.lowBass, eq.frame.gains.bass, eq.frame.gains.mid, eq.frame.gains.treble, eq.frame.gains.upperTreble)) + } + + // ---- values and fields the app does not interpret ---- + + /** A stream packet `4:{field: varint}` in `CAP-045` 2277's envelope (channel 21), re-sealed — **synthetic**, labelled (AGENTS.md §11). */ + private fun syntheticStream(field: Int, value: Int, type: Int = PwRpc.TYPE_SERVER_STREAM, method: Int = Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES): ByteArray { + val inner = Varint.encode((field shl 3) or 0) + Varint.encode(value) + val payload = byteArrayOf(0x22, inner.size.toByte()) + inner + return Hdlc.encode(Cap061.CHANNEL_21_RESPONSE_ADDRESS, PW_HDLC_CONTROL_UI, PwRpc.encode(RpcPacket(type, 21, Maestro.SERVICE_ID, method, payload))) + } + + private class Seen { + var unidentified = 0 + var malformed = 0 + } + + private fun CodecRouter.feedCounting(bytes: ByteArray, seen: Seen): List = + feed(Dlci.MAESTRO, bytes, 0L, onUnidentified = { seen.unidentified++ }, onMalformed = { seen.malformed++ }) + + @Test + @DisplayName("synthetic: field 13 with every value 0–255 — 0 is none, 1–4 the modes, 5–255 not interpreted (an UnidentifiedFrame, never a mode, never a throw)") + fun `field 13 with the values 0 to 255`() { + val router = CodecRouter() + for (value in 0..255) { + val seen = Seen() + val routed = router.feedCounting(syntheticStream(13, value), seen) + when (value) { + 0 -> assertEquals(listOf(RoutedFrame.Setting(SettingValue.AncState(null), stream = true)), routed) + 1 -> assertEquals(listOf(RoutedFrame.Setting(SettingValue.AncState(AncMode.OFF), stream = true)), routed) + 2 -> assertEquals(listOf(RoutedFrame.Setting(SettingValue.AncState(AncMode.ACTIVE), stream = true)), routed) + 3 -> assertEquals(listOf(RoutedFrame.Setting(SettingValue.AncState(AncMode.TRANSPARENT), stream = true)), routed) + 4 -> assertEquals(listOf(RoutedFrame.Setting(SettingValue.AncState(AncMode.ADAPTIVE), stream = true)), routed) + else -> { + assertEquals(emptyList(), routed, "value $value is not interpreted") + assertEquals(1, seen.unidentified, "value $value: an UnidentifiedFrame for the Debug tab") + } + } + assertEquals(0, seen.malformed) + } + } + + @Test + @DisplayName("synthetic: a stream packet for a field this app does not read (10, 20, 31) is UnreadSetting, not an UnidentifiedFrame; the same payload as a RESPONSE stays an RpcResult") + fun `an unread field from the stream is said, not shown`() { + val router = CodecRouter() + for (field in listOf(8, 10, 20, 31)) { + val seen = Seen() + assertEquals(listOf(RoutedFrame.UnreadSetting(field)), router.feedCounting(syntheticStream(field, 1), seen), "field $field") + assertEquals(0, seen.unidentified, "field $field: not an UnidentifiedFrame") + } + assertEquals(10, SettingsCodec.fieldOf(rpc(syntheticStream(10, 1).toHex()).payload)) + assertNull(SettingsCodec.decode(rpc(syntheticStream(10, 1).toHex()).payload)) + // A RESPONSE for a field the app never asked for is the ReadSetting's own answer path (an OK result without a readable value), as before. + val response = router.feedCounting(syntheticStream(10, 1, type = PwRpc.TYPE_RESPONSE, method = Maestro.METHOD_READ_SETTING), Seen()) + assertInstanceOf(RoutedFrame.RpcResult::class.java, response.single()) + } + + @Test + @DisplayName("a stream packet of field 18 (the saved custom EQ) is an EQ frame with persist = true — read, not shown (ADR-065 item 2)") + fun `field 18 from the stream is the saved EQ`() { + val inner = hex("9201190d0000000015000000001d0000000025000000002d00000000") // field 18, the all-zero quintet of CAP-015 2116 + val payload = byteArrayOf(0x22, inner.size.toByte()) + inner + val packet = RpcPacket(PwRpc.TYPE_SERVER_STREAM, 21, Maestro.SERVICE_ID, Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, payload) + val eq = assertInstanceOf(RoutedFrame.Eq::class.java, routeMaestro(packet)) + assertEquals(true, eq.frame.persist) + } + + @Test + @DisplayName("fuzz: the real stream packets with random bytes, truncation, oversized lengths and random field numbers, re-sealed, never throw; a real packet still decodes after") + fun `fuzzed stream packets never throw`() { + val random = Random(9101) + val real = listOf( + SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717, + SettingsStream.STREAM_13_NONE_CH21_CAP045_2277, + SettingsStream.STREAM_12_CH19_CAP056_1696, + SettingsStream.STREAM_2_ON_CH19_CAP056_2175, + SettingsStream.STREAM_29_OFF_CH21_CAP069_2502, + SettingsStream.STREAM_16_FLAT_CH19_CAP015_2116, + SettingsStream.READ_13_RESP_ADAPTIVE_CH21_CAP051_1225, + ).map(::hex) + val lengths = listOf("ffffffff07", "ffffffff0f", "ffffffffffffffff7f", "f6ffffffffffffff7f", "8080808010").map(::hex) + val router = CodecRouter() + val seen = Seen() + var decoded = 0 + assertTimeoutPreemptively(Duration.ofSeconds(60)) { + for (frame in real) { + val hdlc = (Hdlc.decode(frame) as BudsResult.Success).value + repeat(1_200) { + var payload = hdlc.payload.copyOf() + when (random.nextInt(5)) { + 0 -> repeat(1 + random.nextInt(4)) { payload[random.nextInt(payload.size)] = random.nextInt(256).toByte() } + 1 -> payload = payload.copyOf(random.nextInt(payload.size)) // truncated + 2 -> { // an oversized length varint somewhere + val length = lengths.random(random) + val at = random.nextInt(payload.size) + for (k in length.indices) if (at + k < payload.size) payload[at + k] = length[k] + } + 3 -> { // a random inner field number with a random varint value + val inner = Varint.encode((1 + random.nextInt(60)) shl 3) + Varint.encode(random.nextInt(300)) + val setting = byteArrayOf(0x22, inner.size.toByte()) + inner + payload = PwRpc.encode( + RpcPacket(PwRpc.TYPE_SERVER_STREAM, 21, Maestro.SERVICE_ID, Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, setting), + ) + } + else -> payload = ByteArray(random.nextInt(40)) { random.nextInt(256).toByte() } // noise + } + val routed = router.feedCounting(Hdlc.encode(hdlc.address, hdlc.control, payload), seen) + decoded += routed.size + for (r in routed) { + if (r is RoutedFrame.Setting && r.value is SettingValue.AncState) { + val mode = (r.value as SettingValue.AncState).mode + assertTrue(mode == null || mode in AncMode.entries) // never a mode outside the four + } + } + } + // Truncated raw frames too (a cut-off read): every prefix of the real frame. + for (n in 1 until frame.size) router.feedCounting(frame.copyOf(n), seen) + router.reset(Dlci.MAESTRO) + } + } + assertTrue(seen.unidentified + seen.malformed > 0) + assertEquals( + listOf(RoutedFrame.Setting(SettingValue.AncState(AncMode.ACTIVE), stream = true)), + router.feedCounting(hex(SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717), seen), + "nothing was poisoned", + ) + } +} + +private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamFixtures.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamFixtures.kt new file mode 100644 index 00000000..08f82515 --- /dev/null +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/SettingsStreamFixtures.kt @@ -0,0 +1,129 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.codec + +/** + * Real DLCI 0x02 pw_hdlc frames (flags, escapes and CRC as on the wire) of the settings-changes stream — `SubscribeToSettingsChanges` + * (DECISIONS.md ADR-064/065, `ai-sessions/0091`). Every value is one HDLC frame of the named HCI frame's RFCOMM payload (a payload holding several + * frames was split on `7e`; the CRC-32 of each verified): + * `tshark -r CAP-0NN-btsnoop_hci.log -Y 'frame.number==N' -T fields -E separator='|' -e frame.number -e frame.time -e frame.p2p_dir -e bthci_acl.chandle + * -e btrfcomm.dlci -e data.data`, decoded with `python3 scripts/pwrpc_decode.py CAP-0NN-btsnoop_hci.log`. Nothing is redacted: these frames carry no + * identifier (the pw_rpc header, a setting number and its value). "app" = the official Pixel Buds app. Channel 21: request address `00 4b`, response + * `00 a5`; channel 19: `00 3b` / `80 a3` (ADR-034). + */ +internal object SettingsStream { + // ---- the two requests of every official-app MAESTRO open ---- + + /** `CAP-051` frame 1177 (21:43:13.004), app → Buds, channel 21: `SubscribeToSettingsChanges` REQUEST, empty payload. */ + const val SUBSCRIBE_REQ_CH21_CAP051_1177 = "7e004b0310151dea71de7d5e25f5ad2128019754b07e" + + /** `CAP-056` frame 1451 (17:31:15.770), channel 19 (= `CAP-051` 2229, the re-open after the Buds' own `DISC`). */ + const val SUBSCRIBE_REQ_CH19_CAP056_1451 = "7e003b0310131dea71de7d5e25f5ad212807ee8eb27e" + + /** `CAP-051` frame 1178, channel 21: `ReadSetting 4:13` REQUEST (= `CAP-045` 2021, `CAP-036` 1517). */ + const val READ_13_REQ_CH21_CAP051_1178 = "7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e" + + /** `CAP-056` frame 1450, channel 19 (= `CAP-021` 1069, `CAP-027` 910, `CAP-051` 2231). */ + const val READ_13_REQ_CH19_CAP056_1450 = "7e003b0310131dea71de7d5e2551aed0ae2a02200d24c239477e" + + // ---- RESPONSE (type 1) to `ReadSetting 4:13` — `4:{13:n}` ---- + + /** `CAP-051` frame 1225 (the 2nd of 5 HDLC frames in that RFCOMM payload), channel 21: `4:{13:4}` = Adaptive. */ + const val READ_13_RESP_ADAPTIVE_CH21_CAP051_1225 = "7e00a5032a0422026804080110151dea71de7d5e2551aed0ae067d5e749e7e" + + /** `CAP-056` frame 1468, channel 19: `4:{13:2}` = Noise cancellation. */ + const val READ_13_RESP_ACTIVE_CH19_CAP056_1468 = "7e80a3032a0422026802080110131dea71de7d5e2551aed0ae57085e2d7e" + + /** `CAP-027` frame 923 (the 4th of 5), channel 19: `4:{13:3}` = Transparency. */ + const val READ_13_RESP_TRANSPARENT_CH19_CAP027_923 = "7e80a3032a0422026803080110131dea71de7d5e2551aed0aebfd3a5947e" + + /** `CAP-021` frame 1089 (the 2nd of 4), channel 19: `4:{13:1}` = Off. */ + const val READ_13_RESP_OFF_CH19_CAP021_1089 = "7e80a3032a0422026801080110131dea71de7d5e2551aed0ae2e62233c7e" + + /** `CAP-045` frame 2033 (the 1st of 3), channel 21: `4:{13:0}` = no mode settable (= `CAP-036` 1519 / 1421's 3rd frame). */ + const val READ_13_RESP_NONE_CH21_CAP045_2033 = "7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e" + + /** `CAP-051` frame 2247 (the 2nd of 2), channel 19: `4:{13:0}` — the re-open's read after the Buds' own `DISC` (1996), 21:45:50.260. */ + const val READ_13_RESP_NONE_CH19_CAP051_2247 = "7e80a3032a0422026800080110131dea71de7d5e2551aed0aec6b9d8857e" + + // ---- SERVER_STREAM (type 7) packets of the subscription — `4:{13:n}` ---- + + /** `CAP-051` frame 1717 (21:43:43.479), channel 21: `4:{13:2}` — 144 ms after the Notify 1707 of a press-and-hold. */ + const val STREAM_13_ACTIVE_CH21_CAP051_1717 = "7e00a5032a0422026802080710151dea71de7d5e25f5ad2128cbb358d57e" + + /** `CAP-051` frame 1755 (21:43:56.004), channel 21: `4:{13:4}` — 2 ms after the Notify 1754. */ + const val STREAM_13_ADAPTIVE_CH21_CAP051_1755 = "7e00a5032a0422026804080710151dea71de7d5e25f5ad21283967a2f77e" + + /** `CAP-051` frame 1802 (21:44:05.150) and 1534 (28.376, 269 ms after the app's own Set 1527), channel 21: `4:{13:3}`. */ + const val STREAM_13_TRANSPARENT_CH21_CAP051_1802 = "7e00a5032a0422026803080710151dea71de7d5e25f5ad21282368a36c7e" + + /** `CAP-038` frame 2170 (06:50:50.589), channel 19 (DLCI 0x03 in that log, no Message Stream open): `4:{13:3}`. */ + const val STREAM_13_TRANSPARENT_CH19_CAP038_2170 = "7e80a3032a0422026803080710131dea71de7d5e25f5ad212880ca73fd7e" + + /** `CAP-038` frame 2205, channel 19 (= `CAP-056` 2324 and 4028's 1st frame): `4:{13:2}`. */ + const val STREAM_13_ACTIVE_CH19_CAP038_2205 = "7e80a3032a0422026802080710131dea71de7d5e25f5ad2128681188447e" + + /** `CAP-045` frame 2277, channel 21 (= `CAP-038` 2479): `4:{13:0}` — no mode settable. */ + const val STREAM_13_NONE_CH21_CAP045_2277 = "7e00a5032a0422026800080710151dea71de7d5e25f5ad21285a02de7d5d7e" + + /** `CAP-021` frame 1842, channel 19: `4:{13:1}` = Off. */ + const val STREAM_13_OFF_CH19_CAP021_1842 = "7e80a3032a0422026801080710131dea71de7d5e25f5ad2128117bf5557e" + + /** `CAP-056` frame 2259 (17:33:46.727, 11 ms after the Notify 2257) and 4025, channel 19: `4:{13:4}`. */ + const val STREAM_13_ADAPTIVE_CH19_CAP056_2259 = "7e80a3032a0422026804080710131dea71de7d5e25f5ad21289ac572667e" + + // ---- SERVER_STREAM packets for other fields ---- + + /** `CAP-056` frame 1696, channel 19: `4:{12:{1:0 2:1 3:1 4:1}}` — the press-and-hold mode list after the app's write 1689. */ + const val STREAM_12_CH19_CAP056_1696 = "7e80a3032a0c220a62080800100118012001080710131dea71de7d5e25f5ad21281fbcad167e" + + /** `CAP-056` frame 2175, channel 19: `4:{2:1}` — in-ear detection on, after the app's write 2173. */ + const val STREAM_2_ON_CH19_CAP056_2175 = "7e80a3032a0422021001080710131dea71de7d5e25f5ad2128475780f97e" + + /** `CAP-069` frame 2502, channel 21: `4:{29:1}` — head gestures off, the mirror of the app's write 2492 (its RESPONSE 2506). */ + const val STREAM_29_OFF_CH21_CAP069_2502 = "7e00a5032a052203e80101080710151dea71de7d5e25f5ad2128cff7296f7e" + + /** `CAP-015` frame 2116, channel 19: field 16, the all-zero EQ quintet mirrored by the Buds after the app's write 2111 (2117 is that write's ACK). */ + const val STREAM_16_FLAT_CH19_CAP015_2116 = + "7e80a3032a1e221c8201190d0000000015000000001d0000000025000000002d00000000080710131dea71de7d5e25f5ad2128b1cbb10b7e" + + // ---- the Message Stream frames around the same changes (DLCI 0x04 payloads) ---- + + /** `CAP-051` 1707 (21:43:43.336): `Notify ANC state` Noise cancellation, 144 ms before the stream packet 1717. */ + const val NOTIFY_ACTIVE_CAP051_1707 = "0813000401e8e808" + + /** `CAP-051` 1754 (Adaptive) and 2257 of `CAP-056` (17:33:46.716). */ + const val NOTIFY_ADAPTIVE_CAP051_1754 = "0813000401e8e840" + + /** `CAP-051` 1801 and 1530/1533 (Transparency); 1993 (21:45:45.603, before the Buds' own `DISC` 1996). */ + const val NOTIFY_TRANSPARENT_CAP051_1801 = "0813000401e8e880" + + /** `CAP-051` 1527 (21:43:28.107): the app's own `Set` Transparency. */ + const val SET_TRANSPARENT_CAP051_1527 = "0812001401e8e880" + "00000000000000000000000000000000" + + /** `CAP-051` 1529 (21:43:28.219): the Buds' ACK of that Set. */ + const val ACK_TRANSPARENT_CAP051_1529 = "ff010006081201e8e880" + + /** `CAP-051`: Set 1527 → stream 1534, in ms. */ + const val SET_TO_STREAM_MS = 269L + + /** `CAP-056`: the second Notify 4001 (17:35:31.534) → the stream packet 4028 (31.589), in ms. */ + const val NOTIFY_TO_STREAM_MS = 55L +} diff --git a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt index b2e12333..30c98fe5 100644 --- a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt +++ b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt @@ -68,32 +68,6 @@ interface BudsRepository { */ fun onAppVisible(visible: Boolean) - /** - * The noise-control tab became the shown tab (`true`) or not (`false`) — `:ui`'s selected tab with the settings menu closed, forwarded by `:app`. DECISIONS.md - * ADR-061: while it is shown **and** the app is visible ([onAppVisible]) the Message Stream claim (DLCI 0x04, ADR-032) is held instead of released, so the - * Buds' `Notify` of a change made on a bud reaches the app ([ancModeCause] `CHANGED_BY_BUDS`). Entering the tab with the session `Ready` claims once (the - * existing claim with its `Get`) unless a claim is open; leaving the tab or the app releases it the usual 1.5 s later (ADR-032's linger). Never - * opens anything in the background. After the channel was closed by the Buds or another client it re-claims by itself only in the mode - * [HoldReclaimMode.RECLAIM] of the Settings tab (DECISIONS.md ADR-062 Update 2: 3 s after every foreign close, at most 20 times per chain, then it stops); - * in the default mode [HoldReclaimMode.HINT_ONLY] it never does — [ancChannelTaken] says so. - */ - fun setAncTabShown(shown: Boolean) - - /** - * The hint card's "Take it back again" (DECISIONS.md ADR-062 Update 2, `ai-sessions/0089`): starts a new re-claim chain and makes the existing claim with its - * `Get` (`08 11 00 00`) — no `Set`, no battery read, no `SubscribeRuntimeInfo`. While the noise-control tab is shown the claim is held (ADR-061); in mode - * [HoldReclaimMode.HINT_ONLY] until the next foreign close, in mode [HoldReclaimMode.RECLAIM] with up to 20 re-claims after it. Requires an open session. - */ - suspend fun reclaimAncChannel(): BudsResult - - /** - * DECISIONS.md ADR-062 (`ai-sessions/0087`): the Message Stream claim the app held on the noise-control tab was closed by someone else — Google Play services' - * Fast Pair recovering the channel ≈ 5 s after it lost it to this app (`CAP-074`). Set at the first such close in mode [HoldReclaimMode.HINT_ONLY], and once - * the re-claims of mode [HoldReclaimMode.RECLAIM] are exhausted; `null` otherwise. Cleared when the app holds the channel again (a tap, Refresh, a pull, a - * re-entry of the tab, a re-claim, [reclaimAncChannel]), at Connect, at Disconnect and at a session loss. Shown on the noise-control card only. - */ - val ancChannelTaken: Flow - /** * The Buds' last reported ANC mode, `null` before any report in this app run. It is **not** cleared when a session ends: whether it is current is * decided by [isCurrent] from [ancModeUpdatedAt] and [sessionSince] (`ai-sessions/0069`, A68-APP-02). A state, not an event stream: the newest report From 7756e552bcb87e370d37c949078364f0fcbf9eae Mon Sep 17 00:00:00 2001 From: tedsluis Date: Wed, 14 Oct 2026 22:39:00 +0200 Subject: [PATCH 02/11] refactor: remove the Message Stream hold, the re-claim, the Play-services setting and the package query (ADR-065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gone: the hold of the DLCI 0x04 claim while the noise-control tab is shown (ADR-061), the re-claim chain with the "Noise control with Google Play services" setting, its card, "Take it back again" and the hint line (ADR-062), the "Google Play services: found / not found" line, PlayServicesProbe and the manifest's entry (ADR-063). HoldReclaimMode, ChannelTaken, PlayServicesPresence and HoldReclaimSettingsStore are deleted; the stored key hold_reclaim_mode is no longer read — ignored, never migrated, never removed (DarkModeSettingsStore documents it and gains a DataStore constructor for the JVM test). The ANC (i) ends with the settings-stream sentence (the maintainer's text, hedged: the wear-driven switch stays a hypothesis) and carries the two rewritten cause lines; the Message Stream claim is ADR-032's again: a tap, the tile, Refresh, a pull and the Connect's snapshot, released after the linger. Why: with the settings stream (previous commit) the mode reaches the app on its own channel, so nothing needs the shared channel beyond a tap; the Play-user tug of war of 1.2.1/1.2.2 and its setting lose their reason (DECISIONS.md ADR-065, maintainer-approved in chat 2026-10-14, ai-sessions/0091). Co-Authored-By: Claude Fable 5.1 --- android/app/src/main/AndroidManifest.xml | 11 --- .../opencontrolpixelbuds/app/MainActivity.kt | 24 ----- .../app/PlayServicesProbe.kt | 55 ----------- .../app/di/RepositoryModule.kt | 9 -- .../data/settings/DarkModeSettingsStore.kt | 14 ++- .../data/settings/HoldReclaimSettingsStore.kt | 52 ---------- .../data/codec/Cap074Fixtures.kt | 65 ------------ .../settings/HoldReclaimSettingsStoreTest.kt | 89 ----------------- .../domain/HoldReclaimMode.kt | 62 ------------ .../opencontrolpixelbuds/ui/AncScreen.kt | 81 +++++---------- .../ui/OpenControlNavHost.kt | 29 ------ .../opencontrolpixelbuds/ui/SettingsMenu.kt | 98 +------------------ 12 files changed, 37 insertions(+), 552 deletions(-) delete mode 100644 android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/PlayServicesProbe.kt delete mode 100644 android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStore.kt delete mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap074Fixtures.kt delete mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStoreTest.kt delete mode 100644 android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/HoldReclaimMode.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 98953686..a70908aa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,17 +38,6 @@ RECEIVER_NOT_EXPORTED). It is private to this app — only an app signed with this app's key could hold it — and grants nothing outside the app. It is not a runtime permission and asks the user for nothing. --> - - - - - (null) } var bondedLookup by remember { mutableStateOf(companionPairing.lookupBonded()) } // "Use different Buds" (A68-APP-05) changes which bonded device is this app's — re-read the lookup when it has run. @@ -419,9 +407,6 @@ class MainActivity : ComponentActivity() { refreshPermissions("resume") if (permissionStateFlow.value.bluetoothConnect == PermissionStatus.NOT_REQUESTED) requestPermissions() bondedLookup = companionPairing.lookupBonded() - val presence = PlayServicesProbe.presence(packageManager) - if (presence != playServicesPresence) BleLogger.logConnectionEvent(PlayServicesProbe.logLine(presence)) - playServicesPresence = presence linkRefresh.tryEmit(Unit) // ADR-044 (c): visible again — the session may be re-opened once Android reports the Buds connected. budsRepository.onAppVisible(true) @@ -470,9 +455,6 @@ class MainActivity : ComponentActivity() { ancAvailabilityUpdatedAt = ancAvailabilityUpdatedAt, ancModeUnconfirmedAt = ancModeUnconfirmedAt, ancModeCause = ancModeCause, - ancChannelTaken = ancChannelTaken, - holdReclaimMode = holdReclaimMode, - playServicesPresence = playServicesPresence, caseBatteryError = caseBatteryError, batteryRefreshError = batteryRefreshError, safeMode = safeMode, @@ -515,8 +497,6 @@ class MainActivity : ComponentActivity() { onDisconnect = { applicationScope.launch { budsRepository.disconnect() } }, onAncModeSelected = { mode -> applicationScope.launch { budsRepository.setAncMode(mode) } }, onRefreshAncMode = { applicationScope.launch { budsRepository.refreshAncMode() } }, - // DECISIONS.md ADR-062 Update 2 (`ai-sessions/0089`): the hint's "Take it back again" — a claim with its Get, no Set. - onReclaimAncChannel = { applicationScope.launch { budsRepository.reclaimAncChannel() } }, onRequestAddAncTile = { // Asks Android directly to add the tile (API 33+, this app's minSdk) instead of // requiring the user to find it themselves via Quick Settings' own edit screen @@ -559,10 +539,6 @@ class MainActivity : ComponentActivity() { onExportLog = { startLogExport() }, onOpenUrl = { url -> openUrl(url) }, onUseDifferentBuds = { useDifferentBuds() }, - // DECISIONS.md ADR-061 (`ai-sessions/0084`): the repository holds the Message Stream claim while this is true and the app is visible. - onAncTabShown = { shown -> budsRepository.setAncTabShown(shown) }, - // DECISIONS.md ADR-062 (`ai-sessions/0087`): stored like dark mode; the repository follows the store's flow at once. - onHoldReclaimModeChanged = { mode -> applicationScope.launch { holdReclaimSettingsStore.setMode(mode) } }, ) // `ai-sessions/0057` D-10: a pull runs the one existing action `pullActionFor` chose — the same repository call or system prompt its button makes — // once, in the application scope; the returned job lets the pull indicator end when the repository has answered. Nothing is retried or scheduled. diff --git a/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/PlayServicesProbe.kt b/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/PlayServicesProbe.kt deleted file mode 100644 index fa93453f..00000000 --- a/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/PlayServicesProbe.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * OpenControl for Pixel Buds Pro 2 - * Copyright (C) 2026 Ted Sluis - * - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero - * General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package io.github.tedsluis.opencontrolpixelbuds.app - -import android.content.pm.PackageManager -import io.github.tedsluis.opencontrolpixelbuds.domain.PlayServicesPresence -import io.github.tedsluis.opencontrolpixelbuds.hardware.BleLogger - -/** - * DECISIONS.md ADR-063 (`ai-sessions/0089`; the maintainer's choice in chat 2026-10-13): asks Android's package manager whether Google Play services is installed - * for this user and enabled — the Settings card's information line, nothing more. Nothing of Play services is imported, bound, started or read; no permission - * and no network (AGENTS.md §1's dated note of 2026-10-13). The query needs the manifest's `` entry for this one package (package visibility, - * Android 11+); without it every answer would be "not found". - */ -internal object PlayServicesProbe { - /** The package name Android is asked about — a name only (ADR-063 item 1). */ - private const val PLAY_SERVICES_PACKAGE = "com.google.android.gms" - - /** Found (enabled), found but disabled, not found for this user — or `null` when the package manager failed (ADR-063 item 4: then no line, never a guess). */ - fun presence(packageManager: PackageManager): PlayServicesPresence? = try { - val info = packageManager.getApplicationInfo(PLAY_SERVICES_PACKAGE, PackageManager.ApplicationInfoFlags.of(0)) - if (info.enabled) PlayServicesPresence.FOUND else PlayServicesPresence.FOUND_DISABLED - } catch (e: PackageManager.NameNotFoundException) { - PlayServicesPresence.NOT_FOUND - } catch (e: RuntimeException) { - // AGENTS.md §8: not swallowed — logged (payload-free), and the card shows no line. - BleLogger.logConnectionEvent("Google Play services presence not determined: ${e.javaClass.simpleName} (ADR-063)") - null - } - - /** The always-on log line when the answer changes (no identifier: a package's presence only). */ - fun logLine(presence: PlayServicesPresence?): String = "Google Play services on this phone: " + when (presence) { - PlayServicesPresence.FOUND -> "found" - PlayServicesPresence.FOUND_DISABLED -> "found but disabled" - PlayServicesPresence.NOT_FOUND -> "not found" - null -> "not determined" - } + " (ADR-063, information only)" -} diff --git a/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/di/RepositoryModule.kt b/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/di/RepositoryModule.kt index eb5dd611..c063fa06 100644 --- a/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/di/RepositoryModule.kt +++ b/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/di/RepositoryModule.kt @@ -28,7 +28,6 @@ import dagger.hilt.components.SingletonComponent import io.github.tedsluis.opencontrolpixelbuds.data.BudsRepositoryImpl import io.github.tedsluis.opencontrolpixelbuds.data.settings.DarkModeSettingsStore import io.github.tedsluis.opencontrolpixelbuds.data.settings.DebugSettingsStore -import io.github.tedsluis.opencontrolpixelbuds.data.settings.HoldReclaimSettingsStore import io.github.tedsluis.opencontrolpixelbuds.domain.BudsRepository import io.github.tedsluis.opencontrolpixelbuds.hardware.BudsCompanionPairing import io.github.tedsluis.opencontrolpixelbuds.hardware.BudsTransport @@ -68,12 +67,6 @@ object RepositoryModule { fun provideDarkModeSettingsStore(@ApplicationContext context: Context): DarkModeSettingsStore = DarkModeSettingsStore(context) - /** The Settings tab's "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") (DECISIONS.md ADR-062, `ai-sessions/0087`) — the same settings file as [DebugSettingsStore]. */ - @Provides - @Singleton - fun provideHoldReclaimSettingsStore(@ApplicationContext context: Context): HoldReclaimSettingsStore = - HoldReclaimSettingsStore(context) - @Provides @Singleton fun provideBudsCompanionPairing(@ApplicationContext context: Context): BudsCompanionPairing = @@ -86,14 +79,12 @@ object RepositoryModule { connectionStateMachine: ConnectionStateMachine, companionPairing: BudsCompanionPairing, debugSettingsStore: DebugSettingsStore, - holdReclaimSettingsStore: HoldReclaimSettingsStore, scope: CoroutineScope, ): BudsRepository = BudsRepositoryImpl( transport = transport, connectionStateMachine = connectionStateMachine, bondedDeviceProvider = companionPairing::bondedDevice, debugModeEnabled = debugSettingsStore.debugModeEnabled, - holdReclaimMode = holdReclaimSettingsStore.mode, scope = scope, ) } diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStore.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStore.kt index 7fd1ee56..92ea889a 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStore.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStore.kt @@ -20,6 +20,8 @@ package io.github.tedsluis.opencontrolpixelbuds.data.settings import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode @@ -28,14 +30,18 @@ import kotlinx.coroutines.flow.map /** * Persists the dark-mode choice of the Settings tab (`ai-sessions/0062` F-6, the maintainer's choice in chat 2026-10-01: System / On / Off, default System) in - * the app's existing settings file ([openControlSettings]). Stored as the [DarkMode] name; an unknown or missing value reads as [DarkMode.DEFAULT]. + * the app's existing settings file ([openControlSettings]). Stored as the [DarkMode] name; an unknown or missing value reads as [DarkMode.DEFAULT]. Reads and + * writes its own key only: the key `hold_reclaim_mode` that 1.2.1/1.2.2 stored in the same file (ADR-062) is ignored since `ai-sessions/0091` (DECISIONS.md + * ADR-065 item 5) — never read, never migrated, never removed. The [DataStore] constructor is for the JVM test over a temporary file. */ -class DarkModeSettingsStore(private val context: Context) { +class DarkModeSettingsStore internal constructor(private val dataStore: DataStore) { - val darkMode: Flow = context.openControlSettings.data.map { prefs -> DarkMode.fromStored(prefs[DARK_MODE_KEY]) } + constructor(context: Context) : this(context.openControlSettings) + + val darkMode: Flow = dataStore.data.map { prefs -> DarkMode.fromStored(prefs[DARK_MODE_KEY]) } suspend fun setDarkMode(mode: DarkMode) { - context.openControlSettings.edit { prefs -> prefs[DARK_MODE_KEY] = mode.name } + dataStore.edit { prefs -> prefs[DARK_MODE_KEY] = mode.name } } companion object { diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStore.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStore.kt deleted file mode 100644 index 5e2e5d97..00000000 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStore.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * OpenControl for Pixel Buds Pro 2 - * Copyright (C) 2026 Ted Sluis - * - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero - * General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package io.github.tedsluis.opencontrolpixelbuds.data.settings - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -/** - * Persists the Settings tab's "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") (DECISIONS.md ADR-062, `ai-sessions/0087`) in the app's existing settings file - * ([openControlSettings] — a third key, no new dependency, as [DarkModeSettingsStore]). Stored as the [HoldReclaimMode] name; an unknown or missing value reads - * as [HoldReclaimMode.DEFAULT]. Non-sensitive (a two-way choice), so not encrypted (`ARCHITECTURE.md` §2/§9, `AGENTS.md` §10). - * - * The primary constructor takes the [DataStore] itself so the round trip is unit-tested on the JVM against a temporary file - * (`PreferenceDataStoreFactory`, no Android context); the app constructs it from its [Context]. - */ -class HoldReclaimSettingsStore(private val dataStore: DataStore) { - - constructor(context: Context) : this(context.openControlSettings) - - val mode: Flow = dataStore.data.map { prefs -> HoldReclaimMode.fromStored(prefs[HOLD_RECLAIM_MODE_KEY]) } - - suspend fun setMode(mode: HoldReclaimMode) { - dataStore.edit { prefs -> prefs[HOLD_RECLAIM_MODE_KEY] = mode.name } - } - - companion object { - private val HOLD_RECLAIM_MODE_KEY = stringPreferencesKey("hold_reclaim_mode") - } -} diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap074Fixtures.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap074Fixtures.kt deleted file mode 100644 index 32392f75..00000000 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap074Fixtures.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * OpenControl for Pixel Buds Pro 2 - * Copyright (C) 2026 Ted Sluis - * - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero - * General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package io.github.tedsluis.opencontrolpixelbuds.data.codec - -/** - * Real Message Stream bytes and timings from `CAP-074` (`captures/CAP-074-2026-10-12_06-40-16_06-45-31-Group_BJ/`, the released 1.2.0 `5b4d5db` in the - * GrapheneOS Owner user **with** Google Play services — `CAP-074-FINDINGS.md` §4), for DECISIONS.md ADR-062 (`ai-sessions/0087`): the hold the app kept on - * the noise-control tab was cut by Play services' Fast Pair recovering the channel, 4.7–5.1 s after the app's `SABM`. Every byte value is the RFCOMM payload of the - * named HCI frame, unchanged; the timings are the frames' own. "B" = `CAP-074-btsnoop_hci.log`, the Buds' handle `0x000b` (Connection Complete B 10528). - * Command (`ai-sessions/0087` RESULT §A.1): `tshark -r B -Y "frame.number==N" -T fields -e frame.time -e frame.p2p_dir -e btrfcomm.dlci -e btrfcomm.frame_type - * -e data.data` (exit 0); the claim inventory `tshark -r B -Y "bthci_acl.chandle==0x000b && btrfcomm && (btrfcomm.dlci==4 || btrfcomm.dlci==5)" …` (exit 0, - * 1,418 frames). - */ -internal object Cap074 { - /** B 15822 (06:42:30.709), phone → Buds: the `SABM` of the Connect's snapshot claim after the Connect tap of 06:42:29.7 — no payload; the reference point of the cut below. */ - const val SABM_B15822_TIME = "06:42:30.709" - - /** B 15853 (06:42:30.868), Buds → phone: the snapshot `Get`'s answer — ADAPTIVE (`40`), Settable `e8` (both buds worn). The tab's hold started 06:42:31.044 (E 318). */ - const val NOTIFY_ADAPTIVE_B15853 = "0813000401e8e840" - - /** B 15860 (06:42:33.204), Buds → phone, **unprovoked** (no `Get`, no `Set` before it): TRANSPARENT (`80`) — the one press-and-hold shown after that Connect (film t=138). */ - const val NOTIFY_TRANSPARENT_B15860 = "0813000401e8e880" - - /** - * B 15909 (06:42:35.842), phone → Buds: the `DISC` of the app's claim — **not** the app's release (no "released" line in the export; E 321/322 "RFCOMM on-demand - * channel 0x04 closed (IOException: bt socket closed, read return: -1)" / "hold ended: the channel was closed, not by this app" at 06:42:35.972/.974): - * the stack closed the app's port on Play's recovery connect, 5.13 s after the app's `SABM`, 5.71 s after Play lost its socket (B 15774, 06:42:30.128). - */ - const val CUT_AFTER_SABM_MS = 5_133L - - /** B 15931 (06:42:38.219), phone → Buds: Play services' `SABM` — its retry 2.38 s after the cut; it succeeded (B 15957 `0813000401e8e808` answered its `Get`). */ - const val PLAY_RETRY_AFTER_CUT_MS = 2_377L - - /** B 14552 (06:40:54.639), Buds → phone: the tap's `Get` answered on the held channel — ADAPTIVE, Settable `e8`. */ - const val NOTIFY_ADAPTIVE_B14552 = "0813000401e8e840" - - /** B 14553 (06:40:54.645), phone → Buds: the `Set` TRANSPARENT of the tap (the 16 trailing bytes `00`, `PROTOCOL.md` §4.1). */ - const val SET_TRANSPARENT_B14553 = "0812001401e8e880" + "00000000000000000000000000000000" - - /** B 14555 (06:40:54.727), Buds → phone: its ACK. */ - const val ACK_TRANSPARENT_B14555 = "ff010006081201e8e880" - - /** B 14556 (06:40:54.753), Buds → phone: the `Notify` of the new mode (TRANSPARENT). */ - const val NOTIFY_TRANSPARENT_B14556 = "0813000401e8e880" - - /** The `read return: -1` text of every cut, as `RfcommBudsTransport` reports it on `channelClosed` (E 161, 176, 189, …). */ - const val CUT_DETAIL = "IOException: bt socket closed, read return: -1" -} diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStoreTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStoreTest.kt deleted file mode 100644 index b3afda1b..00000000 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStoreTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * OpenControl for Pixel Buds Pro 2 - * Copyright (C) 2026 Ted Sluis - * - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero - * General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package io.github.tedsluis.opencontrolpixelbuds.data.settings - -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File - -/** - * DECISIONS.md ADR-062 (`ai-sessions/0087`): the "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") round-trips through the preferences file — on the JVM, against a temporary - * file (`PreferenceDataStoreFactory`, no Android context), and read back by a **second** instance over the same file, so a value kept in memory only would - * fail. Nothing stored, or an unknown name, reads as the default (hint only). - */ -class HoldReclaimSettingsStoreTest { - - @TempDir - lateinit var dir: File - - private fun storeOver(file: File, scope: CoroutineScope) = HoldReclaimSettingsStore(PreferenceDataStoreFactory.create(scope = scope) { file }) - - @Test - fun `the choice is persisted in the preferences file and read back by a fresh instance, nothing stored is hint only`() = runBlocking { - val file = File(dir, "opencontrol_settings.preferences_pb") - val first = CoroutineScope(Dispatchers.IO + SupervisorJob()) - val store = storeOver(file, first) - assertEquals(HoldReclaimMode.HINT_ONLY, store.mode.first(), "nothing stored: the default") - store.setMode(HoldReclaimMode.RECLAIM) - assertEquals(HoldReclaimMode.RECLAIM, store.mode.first()) - first.cancel() // DataStore allows one active instance per file - - val second = CoroutineScope(Dispatchers.IO + SupervisorJob()) - assertEquals(HoldReclaimMode.RECLAIM, storeOver(file, second).mode.first(), "read from the file, not from memory") - second.cancel() - } - - @Test - fun `an unknown or missing stored value is the default, the default is hint only`() { - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.DEFAULT) - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.fromStored(null)) - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.fromStored("SOMETHING_ELSE")) - assertEquals(HoldReclaimMode.RECLAIM, HoldReclaimMode.fromStored("RECLAIM")) - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.fromStored("HINT_ONLY")) - } - - /** - * ADR-062 Update 2 (`ai-sessions/0089`): the card was renamed and its options reworded, but a choice stored by 1.2.1 keeps its meaning — the same key - * (`hold_reclaim_mode`) and the same names. The file is written here as 1.2.1 wrote it (the raw key and string), then read by this build's store. - */ - @Test - fun `a choice stored by 1_2_1 keeps its meaning after the update`() = runBlocking { - for ((stored, expected) in listOf("RECLAIM" to HoldReclaimMode.RECLAIM, "HINT_ONLY" to HoldReclaimMode.HINT_ONLY)) { - val file = File(dir, "v121_$stored.preferences_pb") - val writer = CoroutineScope(Dispatchers.IO + SupervisorJob()) - PreferenceDataStoreFactory.create(scope = writer) { file }.edit { it[stringPreferencesKey("hold_reclaim_mode")] = stored } - writer.cancel() - val reader = CoroutineScope(Dispatchers.IO + SupervisorJob()) - assertEquals(expected, storeOver(file, reader).mode.first(), "1.2.1's \"$stored\"") - reader.cancel() - } - } -} diff --git a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/HoldReclaimMode.kt b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/HoldReclaimMode.kt deleted file mode 100644 index 1b57035a..00000000 --- a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/HoldReclaimMode.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * OpenControl for Pixel Buds Pro 2 - * Copyright (C) 2026 Ted Sluis - * - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or (at your - * option) any later version. - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero - * General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -package io.github.tedsluis.opencontrolpixelbuds.domain - -/** - * The Settings tab's "Noise control with Google Play services" choice (DECISIONS.md ADR-062, `ai-sessions/0087`; renamed and explained by ADR-062 Update 2, - * `ai-sessions/0089` — the maintainer's choices in chat 2026-10-12 and 2026-10-13): what the app does when another client — Google Play services' Fast Pair on a - * phone that has it (`CAP-074`/`CAP-075`: 5.3–5.8 s after it lost the channel to this app) — closes the Message Stream claim the app holds while the - * noise-control tab is on screen (ADR-061). - * - * - [HINT_ONLY] (the default; shown as "This phone has no Google Play services"): the hold ends and nothing re-claims (ADR-061 as written); the noise-control card - * says the channel was taken and when; "Take it back again", the next tap, Refresh, pull or re-entry of the tab claims again. - * - [RECLAIM] (shown as "This phone has Google Play services"): the app claims again 3 s after every foreign close — the existing claim with its `Get` (nothing - * new on the wire) — at most 20 times per chain, then stops and shows the hint; never in the background, never after a session loss, never when the tab or the - * app was left. - * - * Stored locally (AndroidX DataStore, `:data/settings`), never sent anywhere. The stored names are 1.2.1's and keep their meaning ([fromStored]). - */ -enum class HoldReclaimMode { - HINT_ONLY, - RECLAIM, - ; - - companion object { - /** The maintainer's choice in chat 2026-10-12 ("Default mode" → "Hint only"): no tug of war with another app unless the user opts in. */ - val DEFAULT: HoldReclaimMode = HINT_ONLY - - /** The stored name back to the setting; anything unknown (or nothing stored) is the default — never a crash on an old or damaged value. */ - fun fromStored(name: String?): HoldReclaimMode = entries.firstOrNull { it.name == name } ?: DEFAULT - } -} - -/** - * The noise-control card's hint (ADR-062 item 2): the Message Stream claim the app held while the noise-control tab was shown was closed by someone else at - * [atMillis] — in mode [HoldReclaimMode.HINT_ONLY] at the first such close, in mode [HoldReclaimMode.RECLAIM] once the re-claims are exhausted - * ([reclaimAttempts] = the re-claims made, 0 in the hint-only mode). Cleared when the app holds the channel again, at Connect, Disconnect and a session loss. - */ -data class ChannelTaken(val atMillis: Long, val reclaimAttempts: Int) - -/** - * DECISIONS.md ADR-063 (`ai-sessions/0089`; the maintainer's choice in chat 2026-10-13): whether Google Play services is installed for this user, as Android's - * package manager answers `:app` — information for the Settings card only; it never changes the stored [HoldReclaimMode]. `null` where it is used = the query - * failed or has not run: no line is shown (never a guess). - */ -enum class PlayServicesPresence { FOUND, FOUND_DISABLED, NOT_FOUND } diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt index 00b82df7..6753851e 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt @@ -52,7 +52,6 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError -import io.github.tedsluis.opencontrolpixelbuds.domain.ChannelTaken import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.isCurrent @@ -74,14 +73,10 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.isCurrent * **`ai-sessions/0082` item 1 (the maintainer's wording, chat 2026-10-09):** while [ancModeCause] is [AncModeCause.CHANGED_BY_BUDS] — a `Notify` the app did * not provoke changed the mode — the (i) carries [ancChangedByBudsLine]; nothing on the main surface, no notification. * - * **DECISIONS.md ADR-061 (`ai-sessions/0084`):** while this tab is shown the repository holds the Message Stream claim (reported by [OpenControlNavHost]), so - * such a `Notify` reaches the app; the (i) ends with [ANC_HOLD_HINT_TEXT]. - * - * **DECISIONS.md ADR-062 (`ai-sessions/0087`, the maintainer's texts in chat 2026-10-12):** while [channelTaken] is set — the held channel was closed by another - * app (Google Play services' Fast Pair, `CAP-074`) and, in the "take it back" mode, the re-claims are exhausted — [ancChannelTakenLine] is shown below the mode - * buttons and in the (i); it clears when the app holds the channel again. **ADR-062 Update 2 (`ai-sessions/0089`, the maintainer's choices in chat 2026-10-13):** - * under that line the card offers [TAKE_IT_BACK_AGAIN_LABEL] ([onReclaimAncChannel]: a new chain and the existing claim with its `Get`, no `Set`), enabled only - * while the session is `Ready`; a mode the app's own `Get` found changed since it last saw it ([AncModeCause.CHANGED_SINCE_LAST_READ]) gets + * **DECISIONS.md ADR-064/065 (`ai-sessions/0091`, the maintainer's texts in chat 2026-10-14):** the mode reaches the app on its own channel (the settings + * stream), whatever tab is shown and without a claim — ADR-061's hold, ADR-062's channel-taken line with "Take it back again" and ADR-063's Play line are gone; + * the (i) ends with [ANC_STREAM_HINT_TEXT]. A stream value that differs from the shown mode is [ancChangedByBudsLine]; a mode a read (the `Get` of a tap or + * Refresh, the `ReadSetting 4:13` of a Connect or re-open) found changed since the app last saw it ([AncModeCause.CHANGED_SINCE_LAST_READ]) gets * [ancChangedSinceLastReadLine] in the (i). */ @Composable @@ -99,8 +94,6 @@ fun AncScreen( ancModeUnconfirmedAt: Long? = null, sessionSince: Long? = null, ancModeCause: AncModeCause? = null, - channelTaken: ChannelTaken? = null, - onReclaimAncChannel: () -> Unit = {}, ) { val ready = connectionState.isReady() val notAllowed = ready && ancAvailability == AncAvailability.NOT_ALLOWED @@ -123,19 +116,12 @@ fun AncScreen( "Noise control", ancDetailLines( connectionState, ancMode, ancModeUpdatedAt, notAllowed, ancAvailabilityUpdatedAt, ancModeUnconfirmedAt, fromLastConnection, ancModeCause, - channelTaken, ), notCurrent = notCurrent, ) if (ancMode == null) Text(ancModeLine(null, null), style = MaterialTheme.typography.bodyLarge) // The reason a tap may not switch stays visible; its time is in the (i). if (notAllowed) Text(ancNotAllowedLine(null), style = MaterialTheme.typography.bodyMedium) - // ADR-062: the channel was taken by another app — on the main surface too (the maintainer's choice in chat 2026-10-12, "Hint texts"), with its time. - if (channelTaken != null) { - Text(ancChannelTakenLine(channelTaken), style = MaterialTheme.typography.bodyMedium) - // ADR-062 Update 2 ("one button, both modes"): the claim again, without a mode write. - TextButton(onClick = onReclaimAncChannel, enabled = ready, modifier = Modifier.align(Alignment.End)) { Text(TAKE_IT_BACK_AGAIN_LABEL) } - } ANC_MODE_LIST_ORDER.chunked(2).forEach { row -> // F-3 / A68-APP-02: a mode that is not confirmed or not current is dimmed — always together with the (i) dot and its line, never alone // (WCAG 1.4.1). @@ -178,9 +164,9 @@ internal fun ancModeLine(ancMode: AncMode?, updatedAt: Long?, fromLastConnection } /** - * The ANC card's (i) lines: the not-confirmed line after a cut-off answer (F-3), the mode with its time, the "changed by the Buds" line when a `Notify` the app did - * not provoke changed the mode (`ai-sessions/0082` item 1) or the "changed since the app last saw it" line (ADR-062 Update 2), the "checked HH:MM:SS" line while not allowed, the channel-taken line (ADR-062), the session line, the - * hold explanation (ADR-061). + * The ANC card's (i) lines: the not-confirmed line after a cut-off answer (F-3), the mode with its time, the "changed by the Buds" line when a push the app did + * not provoke changed the mode (`ai-sessions/0082` item 1; since ADR-065 the settings stream) or the "changed since the app last saw it" line (ADR-062 Update 2, + * ADR-065 item 4), the "checked HH:MM:SS" line while not allowed, the session line, the stream explanation (ADR-065). */ internal fun ancDetailLines( connectionState: ConnectionState, @@ -191,45 +177,23 @@ internal fun ancDetailLines( unconfirmedAt: Long? = null, fromLastConnection: Boolean = false, cause: AncModeCause? = null, - channelTaken: ChannelTaken? = null, ): List = listOfNotNull( unconfirmedAt?.let(::ancUnconfirmedLine), ancModeLine(ancMode, ancModeUpdatedAt, fromLastConnection), if (cause == AncModeCause.CHANGED_BY_BUDS && ancMode != null) ancChangedByBudsLine(ancModeUpdatedAt) else null, if (cause == AncModeCause.CHANGED_SINCE_LAST_READ && ancMode != null) ancChangedSinceLastReadLine(ancModeUpdatedAt) else null, if (notAllowed) ancNotAllowedLine(checkedAt) else null, - channelTaken?.let(::ancChannelTakenLine), "Connection: ${connectionState::class.simpleName}", - ANC_HOLD_HINT_TEXT, + ANC_STREAM_HINT_TEXT, ) /** - * DECISIONS.md ADR-061 (the maintainer's text in chat 2026-10-10, `ai-sessions/0084`): the noise-control card's (i) says that the channel is held while this tab - * is open — Find keeps [MESSAGE_STREAM_HINT_TEXT], since its taps still claim briefly. Corrected by ADR-062 (`ai-sessions/0087`: Play's Fast Pair takes the - * channel back, `CAP-074`) and reworded by ADR-062 Update 2 (`ai-sessions/0089`, the maintainer's text in chat 2026-10-13, "ANC (i)") with the card's new name. - */ -internal const val ANC_HOLD_HINT_TEXT: String = "While this tab is open the app keeps the Buds' noise-control channel, so a press-and-hold on a bud shows " + - "here; it is released 1.5 seconds after you leave the tab. On a phone with Google Play services, Play's Fast Pair takes the channel back about 5 " + - "seconds later; what this app does then is set on the Settings tab under Noise control with Google Play services." - -/** ADR-062 Update 2 (`ai-sessions/0089`, the maintainer's choice in chat 2026-10-13, "Button"): the hint's action, the same label in both modes. */ -internal const val TAKE_IT_BACK_AGAIN_LABEL: String = "Take it back again" - -/** - * DECISIONS.md ADR-062 — the maintainer's texts in chat 2026-10-12 ("Hint texts"), reworded by ADR-062 Update 2 (`ai-sessions/0089`, chat 2026-10-13, "Hint - * text"): the line while [ChannelTaken] is set — with the close's time, and in the "Google Play services" mode with the re-claims made before the app stopped. - * Play services' Fast Pair is named as the usual taker only (the app cannot see who took the channel — the stack closed its socket, `CAP-075-FINDINGS.md` §5). + * DECISIONS.md ADR-065 (`ai-sessions/0091`; the maintainer's text in chat 2026-10-14, "ANC (i)"): the noise-control card's (i) says where the mode comes from. + * The wear-change part is a hypothesis (🟡 PROTOCOL.md §6, item of 2026-10-14) and is said so — never a fact in the UI. Replaces ADR-061's hold explanation + * (1.2.0 … 1.2.2). Find keeps [MESSAGE_STREAM_HINT_TEXT], since its taps still claim briefly. */ -internal fun ancChannelTakenLine(taken: ChannelTaken): String { - val time = formatUpdatedAt(taken.atMillis)?.let { " at $it" } ?: "" - val reclaimed = when (taken.reclaimAttempts) { - 0 -> "" - 1 -> "; this app took it back once and stopped" - else -> "; this app took it back ${taken.reclaimAttempts} times and stopped" - } - return "Another app took the noise-control channel$time — usually Google Play services' Fast Pair$reclaimed. A press-and-hold on a bud is not shown " + - "until this app has the channel again: tap $TAKE_IT_BACK_AGAIN_LABEL, a mode, Refresh, or pull down." -} +internal const val ANC_STREAM_HINT_TEXT: String = "The Buds report every change of the mode on the app's own channel. A press-and-hold on a bud is one " + + "cause; on film the Buds also switched the mode by themselves when a bud was put in or taken out — that part is a hypothesis." /** * `ai-sessions/0062` F-2 (the maintainer's wording, chat 2026-10-01): "The Buds don't allow changing noise control right now (usually because no bud is in an @@ -239,21 +203,24 @@ internal fun ancNotAllowedLine(checkedAt: Long?): String = ANC_NOT_ALLOWED_TEXT.removeSuffix(").") + (formatUpdatedAt(checkedAt)?.let { "; checked $it" } ?: "") + "). Tapping a mode checks again first." /** - * `ai-sessions/0082` item 1 (the maintainer's wording, chat 2026-10-09): the (i) line while the shown mode came from a `Notify` the app did not provoke — a - * press-and-hold on a bud (`CAP-045` 1583/1755/1818) or the Buds' own change (`CAP-067` §2); the app cannot tell which and says so. [changedAt] is the - * mode's own receive time; without one the time is left out. + * `ai-sessions/0082` item 1 (the maintainer's wording, chat 2026-10-09), reworded by ADR-065 (`ai-sessions/0091`, the maintainer's text in chat 2026-10-14, + * "Cause lines"): the (i) line while the shown mode came from a push the app did not provoke — since ADR-065 the settings stream's `4:{13:n}` (`CAP-051` + * 1717/1755/1802), before it a `Notify` on a held claim — a press-and-hold on a bud (`CAP-045` 1583/1755/1818) or the Buds' own change (`CAP-067` §2); the + * app cannot tell which and says so. [changedAt] is the mode's own receive time; without one the time is left out. */ internal fun ancChangedByBudsLine(changedAt: Long?): String = - "Changed by the Buds" + (formatUpdatedAt(changedAt)?.let { " at $it" } ?: "") + " (a press-and-hold on a bud, or the Buds' own change)." + "Changed by the Buds" + (formatUpdatedAt(changedAt)?.let { " at $it" } ?: "") + + ": the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves." /** - * ADR-062 Update 2 (`ai-sessions/0089`, the maintainer's wording in chat 2026-10-13, "Cause line"): the (i) line while the shown mode came from the app's own - * `Get` and differs from the mode it last saw on this connection — `CAP-075` 2980/5102/5361: the change was made on Play services' claim or between claims. - * [readAt] is the mode's own receive time; without one the time is left out. + * ADR-062 Update 2 (`ai-sessions/0089`, the maintainer's wording in chat 2026-10-13, "Cause line"), reworded by ADR-065 (`ai-sessions/0091`, chat 2026-10-14): + * the (i) line while the shown mode came from a read — the app's own `Get` or the `ReadSetting 4:13` of a Connect or re-open — and differs from the mode it + * last saw (`CAP-075` 2980/5102/5361; `CAP-051` 2247 after the Buds' own `DISC`): the change was made while this app was not connected. [readAt] is the mode's + * own receive time; without one the time is left out. */ internal fun ancChangedSinceLastReadLine(readAt: Long?): String = "Changed since the app last saw it" + (formatUpdatedAt(readAt)?.let { " (read at $it)" } ?: "") + - ": a press-and-hold on a bud or the Buds' own change, made while this app did not have the channel." + ": a press-and-hold on a bud or the Buds' own change, made while this app was not connected." /** F-3 (the maintainer's wording, chat 2026-10-01): the (i) line while the shown mode is not confirmed; [changedAt] is when the cut-off change was sent. */ internal fun ancUnconfirmedLine(changedAt: Long): String = diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt index 4a4690b6..37f7bb4f 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt @@ -51,15 +51,12 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode import io.github.tedsluis.opencontrolpixelbuds.domain.AndroidLink import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryStatus import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError -import io.github.tedsluis.opencontrolpixelbuds.domain.ChannelTaken import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceInfo import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceStatus import io.github.tedsluis.opencontrolpixelbuds.domain.EqBandGains import io.github.tedsluis.opencontrolpixelbuds.domain.EqPreset -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode -import io.github.tedsluis.opencontrolpixelbuds.domain.PlayServicesPresence import io.github.tedsluis.opencontrolpixelbuds.domain.PermissionState import io.github.tedsluis.opencontrolpixelbuds.domain.RingTarget import io.github.tedsluis.opencontrolpixelbuds.domain.UnidentifiedFrame @@ -113,8 +110,6 @@ data class OpenControlActions( val onDisconnect: () -> Unit, val onAncModeSelected: (AncMode) -> Unit, val onRefreshAncMode: () -> Unit, - /** The noise-control hint's "Take it back again" (DECISIONS.md ADR-062 Update 2, `ai-sessions/0089`): `BudsRepository.reclaimAncChannel`. */ - val onReclaimAncChannel: () -> Unit = {}, /** Asks Android directly to add the ANC Quick Settings tile (`StatusBarManager.requestAddTileService`, * API 33+) instead of requiring the user to find it themselves via Quick Settings' edit screen * (`ai-sessions/0043`, `CAP-060-FINDINGS.md` §4 — the tile's code was correct but never discovered). */ @@ -165,13 +160,6 @@ data class OpenControlActions( * application scope — `null` when it only opened a system prompt or did nothing — so the pull indicator lasts exactly as long as the action. */ val onPull: (PullAction) -> Job? = { null }, - /** - * DECISIONS.md ADR-061 (`ai-sessions/0084`): `true` while the noise-control tab is the selected tab and the settings menu is closed, `false` otherwise — - * `:app` forwards it to `BudsRepository.setAncTabShown`, which holds the Message Stream claim while it is `true` and the app is visible. - */ - val onAncTabShown: (Boolean) -> Unit = {}, - /** The Settings tab's "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") (DECISIONS.md ADR-062, `ai-sessions/0087`): stored by `:app`, followed by the repository at once. */ - val onHoldReclaimModeChanged: (HoldReclaimMode) -> Unit = {}, ) data class OpenControlUiState( @@ -209,12 +197,6 @@ data class OpenControlUiState( val ancModeUnconfirmedAt: Long? = null, /** Why the shown ANC mode is what it is (`ai-sessions/0082` item 1): the Noise control (i) says "changed by the Buds" for a `Notify` the app did not provoke. */ val ancModeCause: io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause? = null, - /** DECISIONS.md ADR-062: the held noise-control channel was taken by another app (`BudsRepository.ancChannelTaken`); `null` = no hint. */ - val ancChannelTaken: ChannelTaken? = null, - /** The Settings tab's "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") (ADR-062). */ - val holdReclaimMode: HoldReclaimMode = HoldReclaimMode.DEFAULT, - /** DECISIONS.md ADR-063 (`ai-sessions/0089`): whether Android finds Google Play services on this phone — the Settings card's information line; `null` = none. */ - val playServicesPresence: PlayServicesPresence? = null, /** Why the last *Refresh battery* brought no new reading (`null` = it did / none yet), `ai-sessions/0052`. */ val batteryRefreshError: BudsError? = null, /** Why the Case level could not be requested this connection (`null` = requested / not yet), ADR-043. */ @@ -296,12 +278,6 @@ fun OpenControlNavHost(state: OpenControlUiState, actions: OpenControlActions) { val index = currentIndex ?: return@LaunchedEffect if (!inSettings && pagerState.currentPage != index) pagerState.scrollToPage(index) } - // ADR-061: report whether the noise-control tab is the one shown — not before the back stack is known (F-1), and not while the settings menu covers it. - // An (i) dialog on the tab keeps it shown (the maintainer's choice in chat 2026-10-10, "Tab selected + app visible"). - LaunchedEffect(currentIndex, inSettings) { - val index = currentIndex ?: return@LaunchedEffect - actions.onAncTabShown(!inSettings && TAB_DESTINATIONS[index].route == Routes.ANC) - } // A completed swipe (the pager settles on a new page): drive the exact same navigation call a bottom-nav tap uses, so back-stack semantics never // depend on which trigger changed the tab. Nothing is navigated while the back stack is not known yet (F-1). LaunchedEffect(pagerState) { @@ -373,9 +349,6 @@ fun OpenControlNavHost(state: OpenControlUiState, actions: OpenControlActions) { settingsError = state.settingsError, onCaseSoundEarbudsReplacedChanged = actions.onCaseSoundEarbudsReplacedChanged, onCaseSoundOtherAlertsChanged = actions.onCaseSoundOtherAlertsChanged, - holdReclaimMode = state.holdReclaimMode, - onHoldReclaimModeChanged = actions.onHoldReclaimModeChanged, - playServicesPresence = state.playServicesPresence, modifier = Modifier.padding(padding), ) } else { @@ -427,11 +400,9 @@ private fun TabContent(route: String, state: OpenControlUiState, actions: OpenCo ancModeUnconfirmedAt = state.ancModeUnconfirmedAt, sessionSince = state.sessionSince, ancModeCause = state.ancModeCause, - channelTaken = state.ancChannelTaken, onAncModeSelected = actions.onAncModeSelected, onRefreshAncMode = actions.onRefreshAncMode, onRequestAddAncTile = actions.onRequestAddAncTile, - onReclaimAncChannel = actions.onReclaimAncChannel, ) Routes.EQ -> EqScreen( connectionState = state.connectionState, diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt index 93b6a942..6215ef89 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt @@ -56,8 +56,6 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.BudsSettings import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceInfo -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode -import io.github.tedsluis.opencontrolpixelbuds.domain.PlayServicesPresence import io.github.tedsluis.opencontrolpixelbuds.domain.SettingsFailure import io.github.tedsluis.opencontrolpixelbuds.domain.UnidentifiedFrame @@ -107,10 +105,7 @@ fun SettingsMenuScreen( onCaseSoundEarbudsReplacedChanged: (Boolean) -> Unit = {}, onCaseSoundOtherAlertsChanged: (Boolean) -> Unit = {}, /** DECISIONS.md ADR-062 (`ai-sessions/0087`): the "Noise control with Google Play services" choice (1.2.1: "Noise-control channel") — stored by `:app`, applied by the repository at once. */ - holdReclaimMode: HoldReclaimMode = HoldReclaimMode.DEFAULT, - onHoldReclaimModeChanged: (HoldReclaimMode) -> Unit = {}, /** DECISIONS.md ADR-063 (`ai-sessions/0089`): whether Android finds Google Play services on this phone — information only; `null` = no line. */ - playServicesPresence: PlayServicesPresence? = null, ) { var selected by rememberSaveable { mutableIntStateOf(SettingsTab.SETTINGS.ordinal) } Column(modifier = modifier.fillMaxSize()) { @@ -126,7 +121,6 @@ fun SettingsMenuScreen( when (SettingsTab.entries[selected]) { SettingsTab.SETTINGS -> SettingsTabContent( darkMode, onDarkModeChanged, onUseDifferentBuds, connectionState, settings, settingsError, onCaseSoundEarbudsReplacedChanged, onCaseSoundOtherAlertsChanged, - holdReclaimMode, onHoldReclaimModeChanged, playServicesPresence, ) SettingsTab.DEBUG -> DebugScreen( debugModeEnabled = debugModeEnabled, @@ -149,11 +143,9 @@ internal fun darkModeLabel(mode: DarkMode): String = when (mode) { /** * The Settings tab — F-6: one radio group for dark mode; a choice is stored and applied at once (the theme follows the stored value — no restart). **Since * `ai-sessions/0082` (item 5, the maintainer's order in chat 2026-10-09: Dark mode · Case sounds · Use different Buds):** the "Case sounds" card of ADR-054 - * between them — the same [SettingsCard]/[SettingSwitchRow] as on Controls, the same (i) lines, plus the note that the setting lives on the case. **Since - * `ai-sessions/0087` (ADR-062, the maintainer's order in chat 2026-10-12: Dark mode · Noise-control channel · Case sounds · Use different Buds):** the - * [HoldReclaimCard] after Dark mode — the app's own settings together, then the Buds', then the pairing action. **Since `ai-sessions/0089`** (ADR-062 Update 2, - * ADR-063) that card is "Noise control with Google Play services", its options say whether the phone has Google Play services, and it shows whether Android - * finds Play services on this phone ([playServicesPresenceLine]). + * between them — the same [SettingsCard]/[SettingSwitchRow] as on Controls, the same (i) lines, plus the note that the setting lives on the case. The + * "Noise control with Google Play services" card of 1.2.1/1.2.2 (ADR-062/063) is gone since `ai-sessions/0091` (DECISIONS.md ADR-065: the mode comes from the + * Buds' settings stream; a choice stored by those versions is ignored). */ @Composable private fun SettingsTabContent( @@ -165,9 +157,6 @@ private fun SettingsTabContent( settingsError: SettingsFailure?, onCaseSoundEarbudsReplacedChanged: (Boolean) -> Unit, onCaseSoundOtherAlertsChanged: (Boolean) -> Unit, - holdReclaimMode: HoldReclaimMode, - onHoldReclaimModeChanged: (HoldReclaimMode) -> Unit, - playServicesPresence: PlayServicesPresence?, ) { val enabled = connectionState.isReady() Surface(modifier = Modifier.fillMaxSize()) { @@ -191,8 +180,6 @@ private fun SettingsTabContent( } } } - // ADR-062 (`ai-sessions/0087`): what the app does when another app takes the held noise-control channel. - HoldReclaimCard(holdReclaimMode, onHoldReclaimModeChanged, playServicesPresence) // `ai-sessions/0082` item 5: the Case sounds card, moved from Controls (ADR-054's labels of `ai-sessions/0074`); a failed write says so here too. if (enabled) SettingsFailureNotice(settingsError) SettingsCard( @@ -218,85 +205,6 @@ private fun SettingsTabContent( internal const val USE_DIFFERENT_BUDS_TITLE: String = "Use different Buds" -/** - * DECISIONS.md ADR-062 (`ai-sessions/0087`; the maintainer's texts in chat 2026-10-12, "Switch card") — renamed and reworded by ADR-062 Update 2 - * (`ai-sessions/0089`; the maintainer's texts in chat 2026-10-13, "Card name", "Options", "Card (i)"): the "Noise control with Google Play services" card — a - * title with an (i), the intro line, ADR-063's line on whether Android finds Play services on this phone, and a two-way radio group whose rows say whether the - * phone has Google Play services, each with a supporting line. The stored names (`HINT_ONLY`, `RECLAIM`) are 1.2.1's; only the texts changed. The numbers in - * the (i) are `CAP-074`/`CAP-075`'s (one Play services version, measured in October 2026 — said there). - */ -internal const val HOLD_RECLAIM_TITLE: String = "Noise control with Google Play services" -internal const val HOLD_RECLAIM_INTRO: String = "The Buds report a press-and-hold on a bud to one app at a time. Choose what fits this phone:" - -internal fun holdReclaimLabel(mode: HoldReclaimMode): String = when (mode) { - HoldReclaimMode.HINT_ONLY -> "This phone has no Google Play services" - HoldReclaimMode.RECLAIM -> "This phone has Google Play services" -} - -internal fun holdReclaimSupportingText(mode: HoldReclaimMode): String = when (mode) { - HoldReclaimMode.HINT_ONLY -> - "Nothing else uses the noise-control channel. If another app takes it anyway, the noise-control tab says so." - HoldReclaimMode.RECLAIM -> - "While the noise-control tab is open, take the channel back from Play services' Fast Pair 3 seconds after each time it takes it, so a " + - "press-and-hold shows about half of the time." -} - -internal val HOLD_RECLAIM_DETAIL_LINES: List = listOf( - "The Buds send a press-and-hold's noise-control change to one app at a time: the app that has their noise-control channel open. While the " + - "noise-control tab is open this app keeps that channel.", - "On a phone with Google Play services, Play's Fast Pair takes the channel back 5 to 6 seconds after it lost it to this app, every time (measured in " + - "October 2026 with one version of Play services; another version may differ).", - "No Google Play services: the noise-control tab shows every press-and-hold. If another app takes the channel anyway, the tab says so and when; Take " + - "it back again, a tap, Refresh or a pull-down takes it back.", - "Google Play services: 3 seconds after each time Play takes the channel this app takes it back, up to 20 times (about 3 minutes) per visit of the " + - "tab, so a press-and-hold shows about half of the time. It is a tug of war with Play: Android's own view of the Buds may lag meanwhile. After the " + - "20th time the tab says so; Take it back again starts over.", - "Without Google Play services both choices behave the same.", -) - -/** - * DECISIONS.md ADR-063 (`ai-sessions/0089`; the maintainer's choice in chat 2026-10-13): the card's information line — what Android's package manager says about - * Google Play services for this user. Information only: the stored choice never changes by itself. `null` (the query failed or did not run) ⇒ no line. - */ -internal fun playServicesPresenceLine(presence: PlayServicesPresence?): String? = when (presence) { - PlayServicesPresence.FOUND -> "Google Play services: found on this phone" - PlayServicesPresence.FOUND_DISABLED -> "Google Play services: found but disabled on this phone" - PlayServicesPresence.NOT_FOUND -> "Google Play services: not found on this phone" - null -> null -} - -@Composable -private fun HoldReclaimCard(mode: HoldReclaimMode, onChange: (HoldReclaimMode) -> Unit, playServicesPresence: PlayServicesPresence?) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - CardTitle(HOLD_RECLAIM_TITLE, HOLD_RECLAIM_DETAIL_LINES) - Text(HOLD_RECLAIM_INTRO, style = MaterialTheme.typography.bodyMedium) - playServicesPresenceLine(playServicesPresence)?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } - Column(Modifier.selectableGroup()) { - HoldReclaimMode.entries.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable(selected = mode == option, onClick = { onChange(option) }, role = Role.RadioButton) - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = mode == option, onClick = null) // the row is the touch target (as Dark mode) - Column(modifier = Modifier.padding(start = 16.dp)) { - Text(holdReclaimLabel(option), style = MaterialTheme.typography.bodyLarge) - Text( - holdReclaimSupportingText(option), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - } -} - /** * `ai-sessions/0074`: the "Case sounds" card — ADR-054's labels, chosen by the maintainer in chat 2026-10-06: 28 = "Earbuds replaced" (the official settings * list's wording; its switch reads "Bud return", PROTOCOL.md §4.5.8), 27 = "Other alerts"; no note under the switches. On gear → Settings since From 152c5b3be3795adca2aa66ac298948a19ba59223 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Wed, 14 Oct 2026 22:39:00 +0200 Subject: [PATCH 03/11] test: real-byte fixtures and tests for the settings stream; the hold/re-claim tests removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository (FakeBudsTransport, runCurrent/settle): the Connect sequence's bytes and order (fifteen requests); the ADR-044 re-open subscribing again, end to end through connect() with a stub android.bluetooth.BluetoothDevice (the SDK stub's package-private constructor); a session lost at the subscription not retried; a push shown as changed by the Buds with its time, nothing sent, no claim; value 0 never a mode; a differing read of 13 ("changed since"), the CAP-051 2247 case; the own Set in CAP-051's order, before the ACK and after a cut-off answer; the CAP-056 wear sequence; the other fields from the stream with the own-write mirror keeping its mark; an unread field logged and ignored; nothing held or re-claimed. The 0089 cause tests rewritten for one rule across a Connect; the ADR-061/062 blocks deleted. UI: the (i) text, the two cause lines, no card, no hint, no button, no Play line. A 1.2.2 settings file with hold_reclaim_mode is read and written without touching that key. Mutation checks, one at a time: M1 the subscription once per process, M2 0 as Off, M3 the own Set's stream packet as the Buds' change, M4 the stream to UnidentifiedFrame, M5 an EQ stream packet ignored — each caught (2/4/1/2/6 failing tests). Co-Authored-By: Claude Fable 5.1 --- .../android/bluetooth/StubBluetoothDevice.kt | 28 + .../data/BudsRepositoryImplTest.kt | 898 +++++++++--------- .../settings/DarkModeSettingsStoreTest.kt | 74 ++ .../opencontrolpixelbuds/ui/AncScreenTest.kt | 103 +- .../ui/SettingsMenuTest.kt | 124 +-- .../opencontrolpixelbuds/ui/TabRestoreTest.kt | 24 - 6 files changed, 569 insertions(+), 682 deletions(-) create mode 100644 android/data/src/test/kotlin/android/bluetooth/StubBluetoothDevice.kt create mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStoreTest.kt diff --git a/android/data/src/test/kotlin/android/bluetooth/StubBluetoothDevice.kt b/android/data/src/test/kotlin/android/bluetooth/StubBluetoothDevice.kt new file mode 100644 index 00000000..7d664943 --- /dev/null +++ b/android/data/src/test/kotlin/android/bluetooth/StubBluetoothDevice.kt @@ -0,0 +1,28 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package android.bluetooth + +/** + * A stub `BluetoothDevice` for `:data`'s JVM tests (`ai-sessions/0091`): the SDK stub's no-argument constructor is package-private, so this file lives in + * `android.bluetooth`. With `unitTests.isReturnDefaultValues = true` every method of the stub returns a default — the repository only hands the object to + * the (fake) transport, which ignores it. Lets a test run `BudsRepositoryImpl.connect()` and the ADR-044 re-open end to end instead of stopping at `NotPaired`. + * Never used by the app. + */ +fun stubBluetoothDevice(): BluetoothDevice = BluetoothDevice() diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt index 173cd8d2..2e4abb6f 100644 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt @@ -29,7 +29,6 @@ import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap064 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap065 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap066Balance import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap072 -import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap074 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap075 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Dlci import io.github.tedsluis.opencontrolpixelbuds.data.codec.HardwareInfoFixtures @@ -43,6 +42,7 @@ import io.github.tedsluis.opencontrolpixelbuds.data.codec.SettingsWrites import io.github.tedsluis.opencontrolpixelbuds.data.codec.Settings056 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Settings070 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Settings074 +import io.github.tedsluis.opencontrolpixelbuds.data.codec.SettingsStream import io.github.tedsluis.opencontrolpixelbuds.domain.Bud import io.github.tedsluis.opencontrolpixelbuds.domain.BudsSettings import io.github.tedsluis.opencontrolpixelbuds.domain.HoldAction @@ -56,10 +56,8 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.SessionLossCause import io.github.tedsluis.opencontrolpixelbuds.domain.WornReading import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryLevel import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError -import io.github.tedsluis.opencontrolpixelbuds.domain.ChannelTaken import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingReading import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingSource -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode import io.github.tedsluis.opencontrolpixelbuds.domain.BudsResult import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.EqBandGains @@ -69,11 +67,14 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.RingTarget import io.github.tedsluis.opencontrolpixelbuds.hardware.BleLogger import io.github.tedsluis.opencontrolpixelbuds.hardware.ConnectionLoss import io.github.tedsluis.opencontrolpixelbuds.hardware.ConnectionStateMachine +import io.github.tedsluis.opencontrolpixelbuds.domain.currentAncMode import io.github.tedsluis.opencontrolpixelbuds.domain.isCurrent import io.github.tedsluis.opencontrolpixelbuds.hardware.FakeBudsTransport import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import android.bluetooth.BluetoothDevice +import android.bluetooth.stubBluetoothDevice import kotlinx.coroutines.flow.first import kotlinx.coroutines.async import kotlinx.coroutines.test.TestScope @@ -120,8 +121,8 @@ class BudsRepositoryImplTest { verified: Boolean = true, /** Called on every connect attempt that reaches the bonded-device lookup (a re-open attempt, ADR-044, counts here). */ onConnectAttempt: () -> Unit = {}, - /** The Settings tab's "Noise control with Google Play services" choice (ADR-062; 1.2.1: "Noise-control channel"); the default — hint only — is the behaviour of every test written before `ai-sessions/0087`. */ - holdReclaimMode: MutableStateFlow = MutableStateFlow(HoldReclaimMode.DEFAULT), + /** `ai-sessions/0091`: a stub device lets `connect()` and the ADR-044 re-open run end to end against the fake transport; `null` (the default) ends at `NotPaired`. */ + bondedDevice: BluetoothDevice? = null, ): Pair { val transport = FakeBudsTransport() // backgroundScope, not `scope` itself: BudsRepositoryImpl's init block launches @@ -132,11 +133,9 @@ class BudsRepositoryImplTest { val repo = BudsRepositoryImpl( transport = transport, connectionStateMachine = connectionStateMachine, - // No real BluetoothDevice needed — none of the tests below exercise connect() against - // a bonded device (see the dedicated connect()-failure test instead). - bondedDeviceProvider = { onConnectAttempt(); null }, + // No real BluetoothDevice by default — most tests drive the Connect sequence directly; the re-open tests of `ai-sessions/0091` pass a stub. + bondedDeviceProvider = { onConnectAttempt(); bondedDevice }, debugModeEnabled = MutableStateFlow(false), - holdReclaimMode = holdReclaimMode, scope = scope.backgroundScope, clock = { scope.testScheduler.currentTime }, ) @@ -466,12 +465,15 @@ class BudsRepositoryImplTest { val requests = transport.maestroRequests() assertEquals("2010", requests.first().payload.toHex(), "4:16 first") assertEquals( - 3 + settingReads.size, + 5 + settingReads.size, requests.size, - "the EQ read, the settings reads (ADR-036, ADR-046, ADR-053), the runtime-info subscription (ADR-043), then GetHardwareInfo (ADR-058)", + "the EQ read, the settings reads (ADR-036, ADR-046, ADR-053), the runtime-info subscription (ADR-043), the settings-changes subscription and the " + + "read of setting 13 (ADR-064/065), then GetHardwareInfo (ADR-058)", ) - assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, requests[requests.size - 2].methodId) - assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, requests.last().methodId, "last, after the subscription") + assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, requests[requests.size - 4].methodId) + assertEquals(Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, requests[requests.size - 3].methodId) + assertEquals(Maestro.METHOD_READ_SETTING, requests[requests.size - 2].methodId) + assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, requests.last().methodId, "last, after the subscriptions and the read of 13") assertEquals(0.3f, repo.eqProfile.first()!!.mid, 1e-4f) // CAP-036 frame 1525 } @@ -488,7 +490,7 @@ class BudsRepositoryImplTest { val maestro = transport.sent.filter { it.first == Dlci.MAESTRO }.map { it.second.toHex() } assertEquals(cap036SettingReads, maestro.subList(1, 1 + settingReads.size)) - assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().let { it[it.size - 2] }.methodId) + assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().let { it[it.size - 4] }.methodId) assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, transport.maestroRequests().last().methodId, "ADR-058: last") assertEquals( BudsSettings( @@ -533,12 +535,12 @@ class BudsRepositoryImplTest { settle() val reads = transport.maestroRequests().filter { it.methodId == Maestro.METHOD_READ_SETTING }.map { it.payload[1].toInt() } - assertEquals(listOf(16) + settingReadFields, reads, "one pass, nothing retried") + assertEquals(listOf(16) + settingReadFields + 13, reads, "one pass, nothing retried (13 after the subscriptions, ADR-064/065)") assertNull(repo.settings.value.touchControls) assertNull(repo.settings.value.volumeBalance) assertEquals(true, repo.settings.value.conversationDetection?.value) assertEquals(io.github.tedsluis.opencontrolpixelbuds.domain.SettingsFailure(BudsError.Timeout, write = false), repo.settingsError.first(), "the last reason, a read") - assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().let { it[it.size - 2] }.methodId) + assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().let { it[it.size - 4] }.methodId) assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, transport.maestroRequests().last().methodId, "ADR-058: the sequence goes on to the hardware-info read") } @@ -569,7 +571,7 @@ class BudsRepositoryImplTest { settle() val reads = transport.maestroRequests().filter { it.methodId == Maestro.METHOD_READ_SETTING }.map { it.payload[1].toInt() } - assertEquals(listOf(16) + settingReadFields, reads, "the pass went on without waiting") + assertEquals(listOf(16) + settingReadFields + 13, reads, "the pass went on without waiting (13 after the subscriptions, ADR-064/065)") assertEquals(start, currentTime, "no timeout was waited for") assertNull(repo.settings.value.holdLeft) assertNull(repo.settings.value.holdRight) @@ -849,9 +851,16 @@ class BudsRepositoryImplTest { settle() val maestro = transport.sent.filter { it.first == Dlci.MAESTRO }.map { it.second.toHex() } - assertEquals(3 + settingReads.size, maestro.size, "EQ read, the settings reads (field 12 added by ADR-046, 11 by ai-sessions/0074), the subscription, GetHardwareInfo (ADR-058)") + assertEquals( + 5 + settingReads.size, + maestro.size, + "EQ read, the settings reads (field 12 added by ADR-046, 11 by ai-sessions/0074), the subscription, the settings-changes subscription and the read " + + "of 13 (ADR-064/065), GetHardwareInfo (ADR-058)", + ) assertEquals(1, maestro.count { it == "7e004b0310151dea71de7d5e2590821ee66654bfab7e" }) - assertEquals("7e004b0310151dea71de7d5e2590821ee66654bfab7e", maestro[maestro.size - 2], "the subscription right after the settings reads") + assertEquals("7e004b0310151dea71de7d5e2590821ee66654bfab7e", maestro[maestro.size - 4], "the subscription right after the settings reads") + assertEquals(SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177, maestro[maestro.size - 3], "ADR-064: the settings-changes subscription after it") + assertEquals(SettingsStream.READ_13_REQ_CH21_CAP051_1178, maestro[maestro.size - 2], "ADR-064: then the read of setting 13") assertEquals(HardwareInfoFixtures.REQUEST_CH21_CAP072_A7593, maestro.last(), "ADR-058: GetHardwareInfo last") assertEquals(0.3f, repo.eqProfile.first()!!.mid, 1e-4f) } @@ -1003,6 +1012,10 @@ class BudsRepositoryImplTest { runCurrent() advanceTimeBy(3_100) // … for the subscription … runCurrent() + advanceTimeBy(3_100) // … for the settings-changes subscription (ADR-064/065) … + runCurrent() + advanceTimeBy(3_100) // … for the read of setting 13 … + runCurrent() advanceTimeBy(3_100) // … and for the hardware-info read (ADR-058) runCurrent() @@ -3062,8 +3075,8 @@ class BudsRepositoryImplTest { } @Test - @DisplayName("Item 1: a new Connect resets the cause; the first Notify of this app run is a reading") - fun `Connect resets the cause and the first mode ever shown is a reading`() = runTest { + @DisplayName("Item 1: the first Notify of this app run is a reading; ADR-065 item 4 (`ai-sessions/0091` Q4): a new Connect keeps the mode and its cause — the reference for its first read") + fun `the first mode ever shown is a reading and a Connect keeps the cause`() = runTest { val (repo, transport) = buildRepository() settle() // No Get pending, no mode shown yet: nothing could have changed. @@ -3078,381 +3091,7 @@ class BudsRepositoryImplTest { repo.connect() // no bonded device in this test: NotPaired, but a new connection attempt all the same (A68-APP-02) settle() assertEquals(AncMode.TRANSPARENT, repo.ancMode.first(), "the mode is kept (from the last connection)") - assertNull(repo.ancModeCause.first(), "its cause is this connection's to re-establish") - } - - // ---- DECISIONS.md ADR-061 (`ai-sessions/0084`): the Message Stream claim held while the noise-control tab is on screen ---------------------------------- - - /** The repository with the app visible and the noise-control tab shown: the entry claim's `Get` answered by [getAnswer] (a real `Notify`). */ - private suspend fun TestScope.holdingRepository( - getAnswer: String = Cap045.NOTIFY_ADAPTIVE_612, - holdReclaimMode: MutableStateFlow = MutableStateFlow(HoldReclaimMode.DEFAULT), - ): Pair { - val (repo, transport) = buildRepository(holdReclaimMode = holdReclaimMode) - transport.onSent = { ch, frame -> - when (frame.toHex().take(4)) { - "0811" -> transport.emit(ch, hex(getAnswer)) - "0812" -> transport.emit(ch, hex("ff010006081201e8e8") + byteArrayOf(frame[7])) // CAP-001 2041's ACK shape - "0401" -> transport.emit(ch, hex("ff010003040100")) - } - } - repo.onAppVisible(true) // Android's link is UNKNOWN in these tests: ADR-044's re-open never fires - settle() - repo.setAncTabShown(true) - settle() - return repo to transport - } - - @Test - @DisplayName("ADR-061: entering the noise-control tab claims once — one open, the Get 08 11 00 00 — and holds it: nothing released after 10 s") - fun `entering the noise-control tab claims once and holds the claim`() = runTest { - BleLogger.clear() - val (repo, transport) = holdingRepository() - - assertEquals(listOf(Dlci.FAST_PAIR_MESSAGE_STREAM), transport.openChannelCalls, "one claim") - assertEquals(listOf(Cap072.GET_A13499), transport.sent.map { it.second.toHex() }, "the existing claim's Get, nothing else") - assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) - assertEquals(AncModeCause.READ, repo.ancModeCause.first(), "the Get's answer is a reading") - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "held, not released") - assertEquals(true, transport.isChannelOpen(Dlci.FAST_PAIR_MESSAGE_STREAM)) - val log = BleLogger.exportLog() - assertEquals(true, "Message Stream hold started: the noise-control tab is on screen (ADR-061)" in log) - } - - @Test - @DisplayName("ADR-061: during the hold CAP-045 1583 (unprovoked, nothing pending) arrives and is shown as changed by the Buds") - fun `an unprovoked Notify during the hold is changed by the Buds`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap045.NOTIFY_ADAPTIVE_612) - advanceTimeBy(24_477) // CAP-045: 06:22:57.471 -> 06:23:21.948 - transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_TRANSPARENT_1583)) - settle() - assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) - assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) - assertEquals(currentTime, repo.ancModeUpdatedAt.first()) - assertEquals(1, transport.sent.size, "the app sends nothing in reaction") - assertEquals(1, transport.openChannelCalls.size) - } - - @Test - @DisplayName("ADR-061: CAP-072 A 13515 -> A 13519 and B 825 — the Buds' own changes during the hold are changed by the Buds; the Settable byte follows") - fun `the CAP-072 Notify frames during the hold`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap072.NOTIFY_OFF_00_A13515) - assertEquals(AncMode.OFF, repo.ancMode.first()) - assertEquals(AncModeCause.READ, repo.ancModeCause.first()) - assertEquals(AncAvailability.NOT_ALLOWED, repo.ancAvailability.first()) - - advanceTimeBy(63) // A 13515 -> A 13519 - transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap072.NOTIFY_TRANSPARENT_A13519)) - settle() - assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) - assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) - assertEquals(AncAvailability.ALLOWED, repo.ancAvailability.first()) - - advanceTimeBy(60_000) - transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap072.NOTIFY_ACTIVE_B825)) - settle() - assertEquals(AncMode.ACTIVE, repo.ancMode.first()) - assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) - assertEquals(1, transport.openChannelCalls.size) - assertEquals(emptyList(), transport.closeChannelCalls) - } - - @Test - @DisplayName("ADR-061: a tap during the hold reuses the open channel (no second claim), sends its Get first, is set by this app, and the claim stays held") - fun `a tap during the hold reuses the channel`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap072.NOTIFY_TRANSPARENT_A11938) - - assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ACTIVE)) - assertEquals(1, transport.openChannelCalls.size, "no second SABM") - assertEquals( - listOf(Cap072.GET_A13499, Cap072.GET_A13499, Cap065.SET_ACTIVE_10790), - transport.sent.map { it.second.toHex() }, - "the entry's Get, the tap's Get, then its Set (CAP-065 10790 = CAP-072 A 11939's bytes)", - ) - assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first()) - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "still held after the tap") - } - - @Test - @DisplayName("ADR-061: the tile during the hold (stepAncMode) is no second claim and does not end the hold") - fun `the tile during the hold keeps it`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap072.NOTIFY_TRANSPARENT_A11938) - assertEquals(BudsResult.Success(Unit), repo.stepAncMode(AncMode::nextForTile)) - assertEquals(1, transport.openChannelCalls.size) - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls) - } - - @Test - @DisplayName("ADR-061: leaving the tab releases the claim after MESSAGE_STREAM_LINGER_MS (1.5 s), not before; the log names the reason") - fun `leaving the tab releases after the linger`() = runTest { - BleLogger.clear() - val (repo, transport) = holdingRepository() - advanceTimeBy(5_000); runCurrent() - repo.setAncTabShown(false) - runCurrent() - advanceTimeBy(1_400); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "not before 1.5 s") - advanceTimeBy(200); runCurrent() - assertEquals(listOf(Dlci.FAST_PAIR_MESSAGE_STREAM), transport.closeChannelCalls) - assertEquals(true, "Message Stream hold ended: the noise-control tab was left" in BleLogger.exportLog()) - } - - @Test - @DisplayName("ADR-061: leaving the app (onAppVisible false) releases after 1.5 s, not before") - fun `leaving the app releases after the linger`() = runTest { - BleLogger.clear() - val (repo, transport) = holdingRepository() - repo.onAppVisible(false) - runCurrent() - advanceTimeBy(1_400); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls) - advanceTimeBy(200); runCurrent() - assertEquals(listOf(Dlci.FAST_PAIR_MESSAGE_STREAM), transport.closeChannelCalls) - assertEquals(true, "Message Stream hold ended: the app is not visible" in BleLogger.exportLog()) - } - - @Test - @DisplayName("ADR-061: re-entering the tab after the release claims once more; a quick return within the linger keeps the claim instead") - fun `re-entering the tab claims again, a quick return keeps the claim`() = runTest { - val (repo, transport) = holdingRepository() - repo.setAncTabShown(false); runCurrent() - advanceTimeBy(700); runCurrent() - repo.setAncTabShown(true); settle() // back within the linger: the pending release is cancelled, no new claim - advanceTimeBy(10_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size) - assertEquals(emptyList(), transport.closeChannelCalls) - - repo.setAncTabShown(false); runCurrent() - advanceTimeBy(1_600); runCurrent() - assertEquals(1, transport.closeChannelCalls.size) - repo.setAncTabShown(true); settle() - assertEquals(2, transport.openChannelCalls.size, "one new claim with its Get") - assertEquals(listOf(Cap072.GET_A13499, Cap072.GET_A13499), transport.sent.map { it.second.toHex() }) - } - - @Test - @DisplayName("ADR-061: the Buds (or another client) close DLCI 0x04 under the hold — no re-claim by itself; the next tap claims again and holds") - fun `a channel closed under the hold is not re-claimed by itself`() = runTest { - BleLogger.clear() - val (repo, transport) = holdingRepository(getAnswer = Cap072.NOTIFY_TRANSPARENT_A11938) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, "stream closed (EOF)") - settle() - advanceTimeBy(30_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size, "no re-claim by itself") - assertEquals(true, "Message Stream hold ended: the channel was closed, not by this app" in BleLogger.exportLog()) - - assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ACTIVE)) - assertEquals(2, transport.openChannelCalls.size, "the tap claims again") - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "and the claim is held again (the tab is still shown)") - } - - @Test - @DisplayName("ADR-061: a session loss ends the hold; nothing claims by itself afterwards (the next claim — a pull here — is held again)") - fun `a session loss under the hold claims nothing by itself`() = runTest { - BleLogger.clear() - val machine = buildConnectionStateMachine() - val (repo, transport) = buildRepository(connectionStateMachine = machine) - transport.onSent = { ch, frame -> if (frame.toHex().take(4) == "0811") transport.emit(ch, hex(Cap045.NOTIFY_ADAPTIVE_612)) } - repo.onAppVisible(true); repo.setAncTabShown(true); settle() - assertEquals(1, transport.openChannelCalls.size) - - transport.emitConnectionLost(ConnectionLoss(channelId = Dlci.MAESTRO, detail = "bt socket closed, read return: -1")) - transport.openChannels.clear() // the real transport has closed every socket of the connection before it reports the loss (BudsTransport.connectionLost) - settle() - assertEquals(ConnectionState.Disconnected, repo.connectionState.first()) - assertEquals(true, "Message Stream hold ended: the session was lost" in BleLogger.exportLog()) - advanceTimeBy(30_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size, "no claim by itself after the loss") - - // A new session (here driven directly — connect() needs a BluetoothDevice): its first claim is kept as the hold, the tab still shown. - transport.connected = true - machine.onConnectRequested(); machine.onLinkEstablished(); machine.onReady() - assertEquals(BudsResult.Success(AncMode.ADAPTIVE), repo.refreshAncMode()) - assertEquals(2, transport.openChannelCalls.size) - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "held again: the noise-control tab is still shown") - } - - // ---- DECISIONS.md ADR-062 (`ai-sessions/0087`): a held claim closed by someone else — the Settings tab's choice (CAP-074's bytes and timings) ------------- - - /** - * `CAP-074` 06:42:30–06:42:36 (`CAP-074-FINDINGS.md` §4, `ai-sessions/0087` §A.1): the Connect's snapshot claim answered by B 15853 (ADAPTIVE) becomes the hold - * on entering the tab; the unprovoked B 15860 (TRANSPARENT, 2.336 s later) is shown; then the stack closes the app's port (the `DISC` B 15909, 5.133 s after the - * `SABM`) — `channelClosed` with the export's `read return: -1` text. - */ - private suspend fun TestScope.cutAfterTheShownHold(repo: BudsRepositoryImpl, transport: FakeBudsTransport) { - advanceTimeBy(2_336) // B 15853 06:42:30.868 -> B 15860 06:42:33.204 - transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap074.NOTIFY_TRANSPARENT_B15860)) - settle() - assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) - assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first(), "the one hold shown after that Connect") - advanceTimeBy(Cap074.CUT_AFTER_SABM_MS - 2_336 - 159) // the SABM B 15822 was 159 ms before its answer B 15853; the DISC B 15909 5.133 s after the SABM - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap074.CUT_DETAIL) - settle() - } - - /** The ADR-062 re-claim lines of the log (not the hint-only line, which says "no re-claim"). */ - private fun reclaimLines(log: String): List = log.lines().filter { "Message Stream re-claim" in it } - - @Test - @DisplayName("ADR-062 hint only (the default): the cut ends the hold, nothing re-claims for 60 s, the hint carries the close's time; a tap claims again and clears it") - fun `hint only - no re-claim after the cut, the hint with the close's time, a tap clears it`() = runTest { - BleLogger.clear() - val (repo, transport) = holdingRepository(getAnswer = Cap074.NOTIFY_ADAPTIVE_B15853) - assertNull(repo.ancChannelTaken.first(), "no hint while the hold is open") - - cutAfterTheShownHold(repo, transport) - val cutAt = currentTime - assertEquals(true, "Message Stream hold ended: the channel was closed, not by this app" in BleLogger.exportLog()) - assertEquals(ChannelTaken(cutAt, reclaimAttempts = 0), repo.ancChannelTaken.first(), "the hint, with the close's time") - assertEquals(AncMode.TRANSPARENT, repo.ancMode.first(), "the shown mode stays") - - advanceTimeBy(60_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size, "no re-claim by itself within 60 s") - assertEquals(listOf(Cap072.GET_A13499), transport.sent.map { it.second.toHex() }, "nothing sent since the entry's Get") - assertEquals(emptyList(), reclaimLines(BleLogger.exportLog())) - assertEquals(ChannelTaken(cutAt, 0), repo.ancChannelTaken.first(), "the hint stays until a claim") - - assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ACTIVE)) // the user's tap: the existing claim with its Get - assertEquals(2, transport.openChannelCalls.size, "one claim") - assertNull(repo.ancChannelTaken.first(), "the hint is cleared: the app holds the channel again") - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "and the claim is held (the tab is still shown)") - } - - @Test - @DisplayName("ADR-062 hint only: a close of a claim that was not held (the Connection tab shown) sets no hint") - fun `hint only - a close of a claim that was not held is not a taken channel`() = runTest { - val (repo, transport) = buildRepository() - repo.onAppVisible(true); settle() // the noise-control tab is not shown: the snapshot-like claim lingers, no hold - assertEquals(BudsResult.Success(AncMode.OFF), repo.refreshAncMode()) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap074.CUT_DETAIL) - settle() - assertNull(repo.ancChannelTaken.first()) - } - - @Test - @DisplayName("ADR-062 Update 2 take it back: openChannel exactly 3 s after every cut (not before), the Get only, the hold again — 20 times; after the 20th re-claim's cut the hint with 20 and no further claim (CAP-075 timings)") - fun `take it back - re-claims 3 s after every cut up to 20, then stops with the hint`() = runTest { - BleLogger.clear() - val mode = MutableStateFlow(HoldReclaimMode.RECLAIM) - val (repo, transport) = holdingRepository(getAnswer = Cap074.NOTIFY_ADAPTIVE_B15853, holdReclaimMode = mode) - cutAfterTheShownHold(repo, transport) - assertNull(repo.ancChannelTaken.first(), "no hint while re-claims are left") - - for (n in 1..BudsRepositoryImpl.HOLD_RECLAIM_CAP) { - // CAP-075: Play's retry is on the channel 2.1–2.8 s after the cut, so the re-claim at 3.0 s meets it (attempt 1 refused, attempt 2 connects). - advanceTimeBy(Cap075.STEP_MS - 1); runCurrent() - assertEquals(n, transport.openChannelCalls.size, "re-claim $n: not before 3 s") - advanceTimeBy(1); settle() - assertEquals(n + 1, transport.openChannelCalls.size, "re-claim $n at 3.0 s — the same step every time (no 6/12-s back-off)") - assertEquals(List(n + 1) { Cap072.GET_A13499 }, transport.sent.map { it.second.toHex() }, "the existing claim's Get, nothing else") - assertNull(repo.ancChannelTaken.first(), "no hint while re-claim $n holds") - // The re-claimed hold lives 4.749 s (CAP-075 H2 2502 → 2570) before Play's recovery cuts it. - advanceTimeBy(Cap075.REHELD_MS); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "held, not released") - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - } - - // The cut after the 20th re-claim → the cap: the hint with the re-claims made, no further claim. - val stoppedAt = currentTime - assertEquals(ChannelTaken(stoppedAt, reclaimAttempts = 20), repo.ancChannelTaken.first()) - advanceTimeBy(60_000); runCurrent() - assertEquals(21, transport.openChannelCalls.size, "no claim after the cap") - val lines = reclaimLines(BleLogger.exportLog()).map { it.substringAfter(' ') } - assertEquals( - (1..20).map { "Message Stream re-claim $it/20 after a foreign close, 3000 ms later (ADR-062)" } + - "Message Stream re-claim stopped after 20 re-claims — Take it back again, a tap, a pull or re-entering the noise-control tab claims again (ADR-062)", - lines, - ) - - // A tap starts a new chain: the hint clears, the claim is held, and the next cut is re-claimed 3 s later again. - assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ACTIVE)) - assertEquals(22, transport.openChannelCalls.size) - assertNull(repo.ancChannelTaken.first()) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - advanceTimeBy(3_000); settle() - assertEquals(23, transport.openChannelCalls.size, "the chain restarted at 3 s") - } - - @Test - @DisplayName("ADR-062 Update 2: the second step is 3 s, not 6 s — nothing at 3 s would be the 1.2.1 back-off (M1)") - fun `take it back - the second re-claim follows its cut by 3 s`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap074.NOTIFY_ADAPTIVE_B15853, holdReclaimMode = MutableStateFlow(HoldReclaimMode.RECLAIM)) - cutAfterTheShownHold(repo, transport) - advanceTimeBy(3_000); settle() - assertEquals(2, transport.openChannelCalls.size) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - advanceTimeBy(3_000); settle() - assertEquals(3, transport.openChannelCalls.size, "3.0 s after the second cut") - } - - // ---- DECISIONS.md ADR-062 Update 2 (`ai-sessions/0089`): the hint card's "Take it back again" --------------------------------------------------------------- - - @Test - @DisplayName("ADR-062 Update 2: Take it back again after the cap — one claim with its Get, no Set, no battery claim; the hint clears; the counter is reset (the next cut is re-claim 1/20 at 3 s)") - fun `take it back again - one claim, no Set, a new chain`() = runTest { - BleLogger.clear() - val (repo, transport) = holdingRepository(getAnswer = Cap075.NOTIFY_ADAPTIVE_2735, holdReclaimMode = MutableStateFlow(HoldReclaimMode.RECLAIM)) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - repeat(BudsRepositoryImpl.HOLD_RECLAIM_CAP) { - advanceTimeBy(Cap075.STEP_MS); settle() - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - } - assertEquals(20, repo.ancChannelTaken.first()?.reclaimAttempts, "the cap reached") - val opensBefore = transport.openChannelCalls.size - val sentBefore = transport.sent.size - BleLogger.clear() - - assertEquals(BudsResult.Success(AncMode.ADAPTIVE), repo.reclaimAncChannel()) - assertEquals(opensBefore + 1, transport.openChannelCalls.size, "one claim") - assertEquals(listOf(Cap075.GET_2968), transport.sent.drop(sentBefore).map { it.second.toHex() }, "only the Get: no Set (08 12), nothing else") - assertEquals(false, transport.sent.any { it.second.toHex().startsWith("0812") }, "never a Set") - assertNull(repo.ancChannelTaken.first(), "the hint clears: the app holds the channel again") - assertEquals(true, "Message Stream claim by the hint card's action (ADR-062 Update 2)" in BleLogger.exportLog()) - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "held (the tab is still shown)") - - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - advanceTimeBy(Cap075.STEP_MS); settle() - assertEquals(opensBefore + 2, transport.openChannelCalls.size, "a new chain: re-claim 1 at 3 s") - assertEquals( - listOf("Message Stream re-claim 1/20 after a foreign close, 3000 ms later (ADR-062)"), - reclaimLines(BleLogger.exportLog()).map { it.substringAfter(' ') }, - "the counter was reset by the action", - ) - } - - @Test - @DisplayName("ADR-062 Update 2: Take it back again in hint only — one claim with its Get, held until the next cut; then the hint again and no re-claim") - fun `take it back again - hint only claims once and never re-claims`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap075.NOTIFY_ADAPTIVE_2735) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - assertEquals(0, repo.ancChannelTaken.first()?.reclaimAttempts) - - assertEquals(BudsResult.Success(AncMode.ADAPTIVE), repo.reclaimAncChannel()) - assertEquals(2, transport.openChannelCalls.size) - assertEquals(listOf(Cap075.GET_2968, Cap075.GET_2968), transport.sent.map { it.second.toHex() }, "the entry's Get and the action's Get, no Set") - assertNull(repo.ancChannelTaken.first()) - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "held until the next cut") - - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap075.CUT_DETAIL); settle() - assertEquals(ChannelTaken(currentTime, 0), repo.ancChannelTaken.first(), "the hint again") - advanceTimeBy(60_000); runCurrent() - assertEquals(2, transport.openChannelCalls.size, "no re-claim in hint only") - } - - @Test - @DisplayName("ADR-062 Update 2: Take it back again without a session claims nothing") - fun `take it back again - no session, no claim`() = runTest { - val (repo, transport) = buildRepository(connectionStateMachine = buildConnectionStateMachine(driveToReady = false)) - assertEquals(BudsResult.Failure(BudsError.ConnectionLost), repo.reclaimAncChannel()) - assertEquals(0, transport.openChannelCalls.size) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first(), "and so is its cause (before 0091 it was reset to null here)") } // ---- DECISIONS.md ADR-062 Update 2 (`ai-sessions/0089`): the cause of a mode learned by the app's own Get (CAP-075 H2's bytes) ------------------------------- @@ -3492,8 +3131,8 @@ class BudsRepositoryImplTest { } @Test - @DisplayName("ADR-062 Update 2: a new connection's first report stays a reading even when it differs from the last connection's mode (CAP-075 3538 OFF → Connect → 3964 ACTIVE)") - fun `a session's first read stays a reading`() = runTest { + @DisplayName("ADR-065 item 4 (`ai-sessions/0091` Q4): a new connection's first read that differs from the last connection's mode is changed since the app last saw it (CAP-075 3538 OFF → Connect → 3964 ACTIVE)") + fun `a session's first differing read is changed since the app last saw it`() = runTest { val machine = buildConnectionStateMachine() val (repo, transport) = buildRepository(connectionStateMachine = machine) transport.onSent = null @@ -3507,15 +3146,23 @@ class BudsRepositoryImplTest { assertEquals(AncMode.OFF, repo.ancMode.first()) repo.disconnect() - repo.connect() // no bonded device in this test: NotPaired, but a new connection attempt all the same — it resets the cause (A68-APP-02) + repo.connect() // no bonded device in this test: NotPaired, but a new connection attempt all the same — the kept mode stays the reference settle() - assertNull(repo.ancModeCause.first()) - // The new session (driven directly — connect() needs a BluetoothDevice): its snapshot Get answered by 3964. + assertEquals(AncMode.OFF, repo.ancMode.first()) + // The new session (driven directly): its snapshot Get answered by 3964. transport.connected = true machine.onConnectRequested(); machine.onLinkEstablished(); machine.onReady() read(Cap075.NOTIFY_ACTIVE_3964) assertEquals(AncMode.ACTIVE, repo.ancMode.first()) - assertEquals(AncModeCause.READ, repo.ancModeCause.first(), "the first report of this connection: a reading, not a change since the last read") + assertEquals(AncModeCause.CHANGED_SINCE_LAST_READ, repo.ancModeCause.first(), "ACTIVE after the last connection's OFF: changed while the app was not connected") + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + + // The same mode read again by the next connection is a reading. + repo.disconnect(); repo.connect(); settle() + transport.connected = true + machine.onConnectRequested(); machine.onLinkEstablished(); machine.onReady() + read(Cap075.NOTIFY_ACTIVE_3964) + assertEquals(AncModeCause.READ, repo.ancModeCause.first()) } @Test @@ -3538,93 +3185,404 @@ class BudsRepositoryImplTest { assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first(), "… and the ACKed Set makes it the app's own change") } - @Test - @DisplayName("ADR-062 take it back: the tab left, or the app hidden, during the delay → no claim (the re-claim is cancelled)") - fun `take it back - leaving the tab or the app during the delay claims nothing`() = runTest { - for (leave in listOf<(BudsRepositoryImpl) -> Unit>({ it.setAncTabShown(false) }, { it.onAppVisible(false) })) { - BleLogger.clear() - val (repo, transport) = holdingRepository(getAnswer = Cap074.NOTIFY_ADAPTIVE_B15853, holdReclaimMode = MutableStateFlow(HoldReclaimMode.RECLAIM)) - cutAfterTheShownHold(repo, transport) - advanceTimeBy(1_000); runCurrent() - leave(repo); settle() - advanceTimeBy(60_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size, "no claim after leaving") - assertEquals(true, "Message Stream re-claim cancelled (ADR-062)" in BleLogger.exportLog()) - assertNull(repo.ancChannelTaken.first(), "no hint either: the tab is not shown") + // ---- DECISIONS.md ADR-064/065 (`ai-sessions/0091`): the settings stream on the app's own channel — the two requests, the pushes, the cause rules ---- + + /** The Buds of `CAP-051`: every ReadSetting answered by the `CAP-036` sweep's frame except 13, answered by [read13]; the GetHardwareInfo as in `CAP-072`. */ + private fun answerReadsLikeCap051(transport: FakeBudsTransport, read13: String?) { + transport.onSent = { ch, frame -> + if (ch == Dlci.MAESTRO) { + val rpc = (PwRpc.decode((Hdlc.decode(frame) as BudsResult.Success).value.payload) as BudsResult.Success).value + if (rpc.methodId == Maestro.METHOD_READ_SETTING) { + val field = rpc.payload[1].toInt() + if (field == 13) read13?.let { transport.emit(Dlci.MAESTRO, hex(it)) } else Settings036.ANSWERS[field]?.let { transport.emit(Dlci.MAESTRO, hex(it)) } + } + if (rpc.methodId == Maestro.METHOD_GET_HARDWARE_INFO) transport.emit(Dlci.MAESTRO, hex(HardwareInfoFixtures.RESPONSE_CH21_CAP072_A7596_REDACTED)) + } else if (ch == Dlci.FAST_PAIR_MESSAGE_STREAM) { + autoAck(transport, ch, frame) + } } } + /** A verified repository (channel 21 announced) after its Connect sequence, the mode read as Adaptive (`CAP-051` 1225) at t = 4 s. */ + private suspend fun TestScope.connectedLikeCap051(read13: String? = SettingsStream.READ_13_RESP_ADAPTIVE_CH21_CAP051_1225): Pair { + val (repo, transport) = buildRepository() + answerReadsLikeCap051(transport, read13) + advanceTimeBy(4_000) + repo.launchInitialEqRead() + settle() + return repo to transport + } + + private fun FakeBudsTransport.maestroHex() = sent.filter { it.first == Dlci.MAESTRO }.map { it.second.toHex() } + + @Test + @DisplayName("ADR-064/065 item 1: Connect sends SubscribeToSettingsChanges and ReadSetting 4:13 after SubscribeRuntimeInfo and before GetHardwareInfo, byte-identical to CAP-051 1177/1178; 1225's Adaptive is a reading with its time, allowed; no claim on DLCI 0x04") + fun `Connect subscribes to the settings stream and reads the mode`() = runTest { + BleLogger.clear() + val (repo, transport) = connectedLikeCap051() + val maestro = transport.maestroHex() + assertEquals(5 + settingReads.size, maestro.size) + assertEquals("7e004b0310151dea71de7d5e2590821ee66654bfab7e", maestro[maestro.size - 4], "SubscribeRuntimeInfo (ADR-043)") + assertEquals(SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177, maestro[maestro.size - 3], "then the settings-changes subscription") + assertEquals(SettingsStream.READ_13_REQ_CH21_CAP051_1178, maestro[maestro.size - 2], "then the read of setting 13") + assertEquals(HardwareInfoFixtures.REQUEST_CH21_CAP072_A7593, maestro.last(), "GetHardwareInfo last (the maintainer's choice, Q6)") + assertEquals(1, maestro.count { it == SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177 }, "once per session") + assertEquals(1, maestro.count { it == SettingsStream.READ_13_REQ_CH21_CAP051_1178 }, "once per session") + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.READ, repo.ancModeCause.first(), "the first mode ever shown: a reading") + assertEquals(4_000L, repo.ancModeUpdatedAt.first()) + assertEquals(AncAvailability.ALLOWED, repo.ancAvailability.first(), "1–4 = a mode is settable (Q5)") + assertEquals(4_000L, repo.ancAvailabilityUpdatedAt.first()) + assertEquals(emptyList(), transport.openChannelCalls, "nothing claimed on DLCI 0x04 for it") + val log = BleLogger.exportLog() + assertEquals(true, "Settings stream subscribed (channel 21)" in log) + assertEquals(true, "Noise-control mode read (channel 21)" in log) + assertEquals(true, "Noise-control mode from the mode read (read)" in log) + } + @Test - @DisplayName("ADR-062 take it back: a session loss during the delay → no claim; the ADR-044 re-open's snapshot is kept as the hold, as today") - fun `take it back - a session loss during the delay claims nothing, the re-open's snapshot is the hold again`() = runTest { + @DisplayName("ADR-065 item 1 / M1: a session the Buds closed and ADR-044 re-opened subscribes again and reads 13 again — one subscription per MAESTRO session (CAP-051 1177 → DISC 1996 → 2229/2231)") + fun `the automatic re-open subscribes to the settings stream again`() = runTest { BleLogger.clear() - val machine = buildConnectionStateMachine() - val mode = MutableStateFlow(HoldReclaimMode.RECLAIM) - val (repo, transport) = buildRepository(connectionStateMachine = machine, holdReclaimMode = mode) - transport.onSent = { ch, frame -> if (frame.toHex().take(4) == "0811") transport.emit(ch, hex(Cap074.NOTIFY_ADAPTIVE_B15853)) } - repo.onAppVisible(true); repo.setAncTabShown(true); settle() - assertEquals(1, transport.openChannelCalls.size) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap074.CUT_DETAIL); settle() - advanceTimeBy(1_000); runCurrent() + val machine = buildConnectionStateMachine(driveToReady = false) + val (repo, transport) = buildRepository(connectionStateMachine = machine, verified = false, bondedDevice = stubBluetoothDevice()) + answerReadsLikeCap051(transport, SettingsStream.READ_13_RESP_ADAPTIVE_CH21_CAP051_1225) + fun subscribes() = transport.maestroHex().count { it == SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177 } + fun reads13() = transport.maestroHex().count { it == SettingsStream.READ_13_REQ_CH21_CAP051_1178 } + + assertEquals(BudsResult.Success(Unit), repo.connect()) + repo.onAppVisible(true) + repo.onAndroidLink(AndroidLink.CONNECTED) + assertEquals(ConnectionState.Ready, repo.connectionState.first()) + transport.emit(Dlci.MAESTRO, Cap061.announcementFrame()); settle() + assertEquals(1, subscribes()); assertEquals(1, reads13()) + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + // The Buds close the session themselves (CAP-051 1996, a wear change): the transport has closed every socket. + advanceTimeBy(30_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802)); settle() // 1993's change reached the app as a stream packet + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncMode.TRANSPARENT, repo.currentAncMode().first(), "the tile and the notification read the same flow (connected, this session)") transport.emitConnectionLost(ConnectionLoss(channelId = Dlci.MAESTRO, detail = "bt socket closed, read return: -1")) - transport.openChannels.clear() - settle() + transport.openChannels.clear(); settle() assertEquals(ConnectionState.Disconnected, repo.connectionState.first()) - advanceTimeBy(60_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size, "no claim by itself after the loss") - assertNull(repo.ancChannelTaken.first()) + assertEquals(1, subscribes(), "nothing re-sent on the dead session") + + advanceTimeBy(SessionReopener.LOSS_REOPEN_DELAY_MS + 100); settle() // ADR-044 (a): the re-open 1.5 s after the loss, the link still connected + assertEquals(ConnectionState.Ready, repo.connectionState.first(), "re-opened by itself") + transport.emit(Dlci.MAESTRO, Cap061.announcementFrame()); settle() // the new session's announcement (ADR-034) + assertEquals(2, subscribes(), "M1: the re-open subscribes again") + assertEquals(2, reads13(), "and reads 13 again") + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first(), "the re-open's read (1225's bytes here)") + assertEquals(AncModeCause.CHANGED_SINCE_LAST_READ, repo.ancModeCause.first(), "it differs from the mode last shown: changed while not connected (ADR-065 item 4)") + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + assertEquals(2, BleLogger.exportLog().lines().count { "Settings stream subscribed (channel 21)" in it }) + } - // The new session (driven directly — connect() needs a BluetoothDevice): its first claim is kept as the hold, and a cut of it starts a fresh chain. - transport.connected = true - machine.onConnectRequested(); machine.onLinkEstablished(); machine.onReady() - assertEquals(BudsResult.Success(AncMode.ADAPTIVE), repo.refreshAncMode()) - assertEquals(2, transport.openChannelCalls.size) - advanceTimeBy(10_000); runCurrent() - assertEquals(emptyList(), transport.closeChannelCalls, "held again") - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap074.CUT_DETAIL); settle() - advanceTimeBy(3_000); settle() - assertEquals(3, transport.openChannelCalls.size, "the first step of a new chain, 3 s") + @Test + @DisplayName("ADR-065 item 1: a session lost at the subscription — its send fails, so do the read of 13 and GetHardwareInfo; nothing is retried within 10 s; the next session's sequence subscribes") + fun `a lost session during the subscription is not retried`() = runTest { + BleLogger.clear() + val (repo, transport) = buildRepository() + answerReadsLikeCap051(transport, SettingsStream.READ_13_RESP_ADAPTIVE_CH21_CAP051_1225) + val answers = transport.onSent!! + transport.onSent = { ch, frame -> + answers(ch, frame) + val rpc = (PwRpc.decode((Hdlc.decode(frame) as BudsResult.Success).value.payload) as BudsResult.Success).value + if (rpc.methodId == Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO) transport.sendShouldFail = BudsError.ConnectionLost // the session dies before the next send + } + repo.launchInitialEqRead(); settle() + advanceTimeBy(10_000); settle() + val maestro = transport.maestroHex() + assertEquals(2 + settingReads.size, maestro.size, "the EQ read, the settings reads, SubscribeRuntimeInfo — then nothing reaches the wire") + assertEquals(0, maestro.count { it == SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177 }) + assertEquals(0, maestro.count { it == SettingsStream.READ_13_REQ_CH21_CAP051_1178 }) + assertEquals(0, maestro.count { it == HardwareInfoFixtures.REQUEST_CH21_CAP072_A7593 }) + val log = BleLogger.exportLog() + assertEquals(1, log.lines().count { "Settings stream not subscribed: " in it }, "said once, never retried") + assertEquals(1, log.lines().count { "Noise-control mode read failed: " in it }) + assertNull(repo.ancMode.first()) + + // The next session's sequence (the re-open runs the same one): the subscription, the read, the serials. + transport.sendShouldFail = null + answerReadsLikeCap051(transport, SettingsStream.READ_13_RESP_ADAPTIVE_CH21_CAP051_1225) + repo.launchInitialEqRead(); settle() + assertEquals(1, transport.maestroHex().count { it == SettingsStream.SUBSCRIBE_REQ_CH21_CAP051_1177 }) + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) } @Test - @DisplayName("ADR-062 take it back: a Disconnect during the delay → no claim, no hint") - fun `take it back - a Disconnect during the delay claims nothing`() = runTest { - val (repo, transport) = holdingRepository(getAnswer = Cap074.NOTIFY_ADAPTIVE_B15853, holdReclaimMode = MutableStateFlow(HoldReclaimMode.RECLAIM)) - cutAfterTheShownHold(repo, transport) - advanceTimeBy(1_000); runCurrent() - repo.disconnect(); settle() - advanceTimeBy(60_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size) - assertNull(repo.ancChannelTaken.first()) + @DisplayName("ADR-065 item 4: a stream packet (CAP-051 1717, 30 s after the read) shows its mode with its time as changed by the Buds — nothing sent, no claim; the tile's flow follows") + fun `a stream packet is changed by the Buds`() = runTest { + BleLogger.clear() + val (repo, transport) = connectedLikeCap051() + val sentBefore = transport.sent.size + advanceTimeBy(30_475) // CAP-051: the read's answer 1225 (21:43:13.0) → 1717 (21:43:43.479) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717)); settle() + assertEquals(AncMode.ACTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first(), "the line's time is the packet's receive time") + assertEquals(sentBefore, transport.sent.size, "nothing is sent in reaction to a stream packet") + assertEquals(emptyList(), transport.openChannelCalls, "no SABM on DLCI 0x04") + assertEquals(true, "Noise-control mode from the settings stream (changed_by_buds)" in BleLogger.exportLog()) + + // Two more pushes (1755, 1802): each applied with its time. + advanceTimeBy(12_525) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ADAPTIVE_CH21_CAP051_1755)); settle() + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + advanceTimeBy(9_146) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802)); settle() + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + + // The same mode again: the cause stays, the time moves. + advanceTimeBy(1_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802)); settle() + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) } @Test - @DisplayName("ADR-062 the switch: the repository follows a change at once — to hint only, a pending re-claim is cancelled and the hint is not back-filled; to take it back, the next cut re-claims") - fun `the switch - a change of the setting is followed at once`() = runTest { + @DisplayName("ADR-065 item 3 / M2: value 0 is never shown as a mode — the stream's 13:0 (CAP-045 2277) keeps the mode and sets not allowed; a Connect whose read answers 13:0 (CAP-036 1519) shows no mode") + fun `value 0 is no mode`() = runTest { BleLogger.clear() - val mode = MutableStateFlow(HoldReclaimMode.RECLAIM) - val (repo, transport) = holdingRepository(getAnswer = Cap074.NOTIFY_ADAPTIVE_B15853, holdReclaimMode = mode) - cutAfterTheShownHold(repo, transport) - advanceTimeBy(1_000); runCurrent() - mode.value = HoldReclaimMode.HINT_ONLY; settle() - advanceTimeBy(60_000); runCurrent() - assertEquals(1, transport.openChannelCalls.size, "the pending re-claim was cancelled") - assertEquals(true, "Message Stream re-claim cancelled (ADR-062)" in BleLogger.exportLog()) + val (repo, transport) = connectedLikeCap051() + advanceTimeBy(5_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_NONE_CH21_CAP045_2277)); settle() + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first(), "the mode stays") + assertEquals(4_000L, repo.ancModeUpdatedAt.first(), "and so does its time") + assertEquals(AncModeCause.READ, repo.ancModeCause.first()) + assertEquals(AncAvailability.NOT_ALLOWED, repo.ancAvailability.first(), "0 = Settable 00 (ADR-049)") + assertEquals(currentTime, repo.ancAvailabilityUpdatedAt.first()) + assertEquals(true, "Noise-control mode from the settings stream: no mode settable" in BleLogger.exportLog()) + // The stream's next 1–4 makes it allowed again. + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717)); settle() + assertEquals(AncAvailability.ALLOWED, repo.ancAvailability.first()) + assertEquals(AncMode.ACTIVE, repo.ancMode.first()) - assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ACTIVE)) // the user's tap holds again - assertEquals(2, transport.openChannelCalls.size) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap074.CUT_DETAIL); settle() - assertEquals(ChannelTaken(currentTime, 0), repo.ancChannelTaken.first(), "hint only now") - advanceTimeBy(60_000); runCurrent() - assertEquals(2, transport.openChannelCalls.size, "no re-claim in hint only") + val (repo2, _) = connectedLikeCap051(read13 = Settings036.READ_13_RESP) + assertNull(repo2.ancMode.first(), "13:0 at Connect: no mode") + assertEquals(AncAvailability.NOT_ALLOWED, repo2.ancAvailability.first()) + assertEquals(true, "Noise-control mode from the mode read: no mode settable" in BleLogger.exportLog()) + } + + @Test + @DisplayName("ADR-065 item 4: a read answer (ReadSetting 4:13) that differs from the shown mode is changed since the app last saw it; the same mode again is a reading; 13:0 after the Buds' DISC (CAP-051 2247) keeps the mode and its cause") + fun `a differing read of 13 is changed since the app last saw it`() = runTest { + val (repo, transport) = connectedLikeCap051() + advanceTimeBy(30_475) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717)); settle() + assertEquals(AncMode.ACTIVE, repo.ancMode.first()) + + // A later session's read (the sequence run again here) answers Adaptive (1225's bytes): changed while the app was not connected. + advanceTimeBy(10_000) + repo.launchInitialEqRead(); settle() + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_SINCE_LAST_READ, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + + advanceTimeBy(10_000) + repo.launchInitialEqRead(); settle() + assertEquals(AncModeCause.READ, repo.ancModeCause.first(), "the same mode read again is a reading") + + // CAP-051 1993 → DISC 1996 → the re-open's 2231 answered 2247 (13:0): the shown mode and its cause stay, nothing is settable. + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802)); settle() + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + answerReadsLikeCap051(transport, SettingsStream.READ_13_RESP_NONE_CH19_CAP051_2247) + advanceTimeBy(5_000) + repo.launchInitialEqRead(); settle() + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(AncAvailability.NOT_ALLOWED, repo.ancAvailability.first()) + } + + @Test + @DisplayName("ADR-065 item 4 / M3: the app's own Set — CAP-051's order (Set 1527 → ACK 1529 → Notify 1530/1533 → stream 1534, 269 ms): set by this app, never changed by the Buds") + fun `the stream packet after the app's own Set is set by this app`() = runTest { + val (repo, transport) = connectedLikeCap051() // the Get of a tap answered OFF (Cap065 6334), the Set ACKed (autoAck) + advanceTimeBy(15_000) + assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.TRANSPARENT)) + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first()) + advanceTimeBy(192) // ACK 1529 (28.219) → Notify 1530 (28.299) → 1533 (28.375) + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(SettingsStream.NOTIFY_TRANSPARENT_CAP051_1801)); settle() + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(SettingsStream.NOTIFY_TRANSPARENT_CAP051_1801)); settle() + advanceTimeBy(1) // 1534 (28.376) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802)); settle() + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first(), "the stream packet of the app's own change") + + // After ANC_SET_WINDOW_MS a stream packet with another mode is the Buds' again. + advanceTimeBy(BudsRepositoryImpl.ANC_SET_WINDOW_MS + 1) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717)); settle() + assertEquals(AncMode.ACTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + } + + @Test + @DisplayName("ADR-065 item 4 / M3: the stream packet before the ACK (the other order, CAP-056 3983/4001 ↔ 4025/4028) and after an answer cut off by the claim's close — both the app's own change") + fun `the stream packet before the ACK or after a cut-off answer is set by this app`() = runTest { + // (a) The Buds' stream packet arrives before their ACK. + val (repo, transport) = connectedLikeCap051() + advanceTimeBy(15_000) + transport.onSent = { ch, frame -> + when (frame.toHex().take(4)) { + "0811" -> transport.emit(ch, hex(Cap065.NOTIFY_E8_OFF_6334)) + "0812" -> { + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_TRANSPARENT_CH21_CAP051_1802)) // first the stream … + transport.emit(ch, hex(SettingsStream.ACK_TRANSPARENT_CAP051_1529)) // … then the ACK + } + } + } + assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.TRANSPARENT)) + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first()) + + // (b) The claim is closed under the Set (CAP-065 2640 → DISC 2649): the ACK never arrives, the mode is unconfirmed — the stream packet on the app's own + // channel then confirms it as the app's change within ANC_SET_WINDOW_MS. + advanceTimeBy(10_000) + val closed = "IOException: bt socket closed, read return: -1" + transport.onSent = { ch, frame -> + when (frame.toHex().take(4)) { + "0811" -> transport.emit(ch, hex(Cap065.NOTIFY_E8_OFF_6334)) + "0812" -> transport.emitChannelClosed(ch, closed) + } + } + val setAt = currentTime + val result = repo.setAncMode(AncMode.ACTIVE) + assertEquals(BudsError.AnswerCutOff(Dlci.FAST_PAIR_MESSAGE_STREAM, closed), (result as BudsResult.Failure).error) + assertEquals(AncMode.OFF, repo.ancMode.first(), "the Get's OFF; the requested mode is not applied") + assertEquals(setAt, repo.ancModeUnconfirmedAt.first(), "marked not confirmed, with the time of the change") + advanceTimeBy(SettingsStream.SET_TO_STREAM_MS) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH21_CAP051_1717)); settle() + assertEquals(AncMode.ACTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first(), "the Buds applied the app's Set: its stream packet, not a change of theirs") + assertNull(repo.ancModeUnconfirmedAt.first(), "confirmed by the Buds' own report") + } + + @Test + @DisplayName("CAP-056's wear changes (2259, 2324, 4025, 4028; a stored mode per wear state, PROTOCOL.md §6 🟡): the mode jumps to the stored value and back, each with its time, as changed by the Buds") + fun `the CAP-056 wear sequence from the stream`() = runTest { + val (repo, transport) = connectedLikeCap051() // Adaptive at t = 4 s + advanceTimeBy(150_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ADAPTIVE_CH19_CAP056_2259)); settle() // 17:33:46.727 — the same mode: a reading still + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.READ, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + advanceTimeBy(5_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH19_CAP038_2205)); settle() // = 2324, the other bud's stored mode + assertEquals(AncMode.ACTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + advanceTimeBy(100_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ADAPTIVE_CH19_CAP056_2259)); settle() // 4025 (17:35:31.569) + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + advanceTimeBy(20) // 4028 (31.589) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_13_ACTIVE_CH19_CAP038_2205)); settle() + assertEquals(AncMode.ACTIVE, repo.ancMode.first(), "the last value received wins") + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + } + + @Test + @DisplayName("ADR-065 item 2 / M5: the other fields follow the stream with the packet's time — the EQ (CAP-015 2116), in-ear detection (CAP-056 2175), the hold mode list (CAP-056 1696); the mirror of the app's own write keeps its mark (CAP-069 2492 → 2502), another value does not") + fun `other settings follow the stream`() = runTest { + val (repo, transport) = connectedLikeCap051() + assertEquals(0.3f, repo.eqProfile.first()!!.mid, 1e-4f) // CAP-036 1525 + advanceTimeBy(1_000) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_16_FLAT_CH19_CAP015_2116)); settle() + assertEquals(EqBandGains.FLAT, repo.eqProfile.first(), "M5: the EQ follows the stream") + assertEquals(currentTime, repo.eqProfileUpdatedAt.first()) + + advanceTimeBy(1_000) + assertEquals(SettingReading(true, 4_000), repo.settings.value.inEarDetection) // CAP-036 1447 at Connect + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_2_ON_CH19_CAP056_2175)); settle() + assertEquals(SettingReading(true, currentTime, changedByApp = false), repo.settings.value.inEarDetection, "the same value, the stream's time") + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_12_CH19_CAP056_1696)); settle() + assertEquals( + SettingReading(AncModeList(noiseCancellation = false, off = true, transparency = true, adaptive = true), currentTime), + repo.settings.value.ancModeList, + ) + + // The app writes head gestures off (CAP-069 2492, RESPONSE 2506); the Buds mirror it on the stream 300 ms later (2502): the mark stays. + answerWritesWith(transport, Settings074.ACK_CH21_2506) + advanceTimeBy(1_000) + assertEquals(BudsResult.Success(Unit), repo.setHeadGestures(false)) + assertEquals(SettingReading(false, currentTime, changedByApp = true), repo.settings.value.headGestures) + val writtenAt = currentTime + advanceTimeBy(300) + transport.emit(Dlci.MAESTRO, hex(SettingsStream.STREAM_29_OFF_CH21_CAP069_2502)); settle() + assertEquals(SettingReading(false, currentTime, changedByApp = true), repo.settings.value.headGestures, "the mirror of the app's own write keeps its mark") + assertEquals(true, currentTime > writtenAt) + // Another value from the stream (the official app on another phone, or the Buds): a plain reading with its time. + advanceTimeBy(1_000) + transport.emit(Dlci.MAESTRO, streamOf(Settings074.READ_29_ON_CH21_1226)); settle() + assertEquals(SettingReading(true, currentTime, changedByApp = false), repo.settings.value.headGestures) + assertEquals(emptyList(), transport.openChannelCalls) + } + + /** The real RESPONSE frame [readAnswerFrame] re-sealed as a SERVER_STREAM packet of the subscription — **synthetic**, labelled (AGENTS.md §11). */ + private fun streamOf(readAnswerFrame: String): ByteArray { + val hdlc = (Hdlc.decode(hex(readAnswerFrame)) as BudsResult.Success).value + val rpc = (PwRpc.decode(hdlc.payload) as BudsResult.Success).value + return Hdlc.encode( + hdlc.address, + hdlc.control, + PwRpc.encode(RpcPacket(PwRpc.TYPE_SERVER_STREAM, rpc.channelId, rpc.serviceId, Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, rpc.payload)), + ) + } + + @Test + @DisplayName("ADR-065 item 2 / M4: a stream packet for a field this app does not read is logged payload-free and ignored — not an UnidentifiedFrame, nothing shown, nothing sent") + fun `an unread field from the stream is logged and ignored`() = runTest { + BleLogger.clear() + val (repo, transport) = connectedLikeCap051() + val unidentified = mutableListOf() + backgroundScope.launch { repo.unidentifiedFrames.collect { unidentified += it } } + settle() + val settingsBefore = repo.settings.value + val sentBefore = transport.sent.size + // Synthetic, labelled: `4:{10:1}` in CAP-045 2277's envelope (channel 21), re-sealed — no capture holds a stream packet for a field outside the read set. + val inner = byteArrayOf(0x50, 0x01) + val payload = byteArrayOf(0x22, inner.size.toByte()) + inner + val frame = Hdlc.encode( + Cap061.CHANNEL_21_RESPONSE_ADDRESS, PW_HDLC_CONTROL_UI, + PwRpc.encode(RpcPacket(PwRpc.TYPE_SERVER_STREAM, 21, Maestro.SERVICE_ID, Maestro.METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, payload)), + ) + transport.emit(Dlci.MAESTRO, frame); settle() + assertEquals(settingsBefore, repo.settings.value, "nothing shown") + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(sentBefore, transport.sent.size, "nothing sent") + assertEquals(emptyList(), unidentified, "M4: not an UnidentifiedFrame") + val log = BleLogger.exportLog() + assertEquals(true, "Settings stream: field 10 not shown (not read by this app)" in log) + assertEquals(false, "5001" in log.lowercase().replace(" ", ""), "payload-free") + } + + @Test + @DisplayName("ADR-065 item 5: no hold, no re-claim — a claim's foreign close changes nothing and nothing claims by itself; a tap claims once and is released after the linger, whatever tab is shown") + fun `nothing is held or re-claimed`() = runTest { + BleLogger.clear() + val (repo, transport) = connectedLikeCap051() + repo.onAppVisible(true); settle() + advanceTimeBy(10_000); runCurrent() + assertEquals(emptyList(), transport.openChannelCalls, "nothing claims by itself") + + assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ACTIVE)) + assertEquals(listOf(Dlci.FAST_PAIR_MESSAGE_STREAM), transport.openChannelCalls, "one claim for the tap") + advanceTimeBy(1_400); runCurrent() + assertEquals(emptyList(), transport.closeChannelCalls, "not before the linger") + advanceTimeBy(200); runCurrent() + assertEquals(listOf(Dlci.FAST_PAIR_MESSAGE_STREAM), transport.closeChannelCalls, "released 1.5 s after the tap (ADR-032)") - mode.value = HoldReclaimMode.RECLAIM; settle() assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.OFF)) - assertEquals(3, transport.openChannelCalls.size) - transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, Cap074.CUT_DETAIL); settle() - advanceTimeBy(3_000); settle() - assertEquals(4, transport.openChannelCalls.size, "take it back again: the re-claim 3 s after the cut") + transport.emitChannelClosed(Dlci.FAST_PAIR_MESSAGE_STREAM, "IOException: bt socket closed, read return: -1"); settle() // Play's Fast Pair takes it + advanceTimeBy(60_000); runCurrent() + assertEquals(2, transport.openChannelCalls.size, "no re-claim after a foreign close") + val log = BleLogger.exportLog() + assertEquals(false, "re-claim" in log) + assertEquals(false, "hold" in log.lowercase().replace("threshold", ""), "no hold lines") } } diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStoreTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStoreTest.kt new file mode 100644 index 00000000..4da0eb7e --- /dev/null +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/DarkModeSettingsStoreTest.kt @@ -0,0 +1,74 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.settings + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * DECISIONS.md ADR-065 item 5 (`ai-sessions/0091`): the key `hold_reclaim_mode` that 1.2.1/1.2.2 wrote into the app's settings file (ADR-062) is ignored — + * a file holding it still reads and writes dark mode correctly, and the key is left exactly as it was (never migrated, never removed). On the JVM, against a + * temporary file (`PreferenceDataStoreFactory`, no Android context), read back by a **second** instance over the same file. + */ +class DarkModeSettingsStoreTest { + + @TempDir + lateinit var dir: File + + private val holdReclaimKey = stringPreferencesKey("hold_reclaim_mode") + private val darkModeKey = stringPreferencesKey("dark_mode") + + @Test + @DisplayName("a 1.2.2 settings file with hold_reclaim_mode = RECLAIM: dark mode reads and writes as before, the stored choice stays untouched and unread") + fun `a stored hold_reclaim_mode is ignored, never migrated`() = runBlocking { + val file = File(dir, "opencontrol_settings.preferences_pb") + val first = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val written = PreferenceDataStoreFactory.create(scope = first) { file } + written.edit { prefs -> + prefs[holdReclaimKey] = "RECLAIM" // 1.2.2's "This phone has Google Play services" + prefs[darkModeKey] = "ON" + } + first.cancel() // DataStore allows one active instance per file + + val second = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val dataStore = PreferenceDataStoreFactory.create(scope = second) { file } + val store = DarkModeSettingsStore(dataStore) + assertEquals(DarkMode.ON, store.darkMode.first(), "dark mode is read from the file") + store.setDarkMode(DarkMode.OFF) + assertEquals(DarkMode.OFF, store.darkMode.first()) + val prefs = dataStore.data.first() + assertEquals("RECLAIM", prefs[holdReclaimKey], "the old key is left as it was — ignored, not migrated, not removed") + assertEquals(setOf("hold_reclaim_mode", "dark_mode"), prefs.asMap().keys.map { it.name }.toSet(), "no new key") + second.cancel() + } +} diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt index 95d4e53e..37ba9d73 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt @@ -31,7 +31,6 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError -import io.github.tedsluis.opencontrolpixelbuds.domain.ChannelTaken import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import org.junit.Assert.assertEquals import org.junit.Rule @@ -60,8 +59,6 @@ class AncScreenTest { connectionState: ConnectionState = ConnectionState.Ready, sessionSince: Long? = at - 1_000, // the connection opened a second before the Buds reported the mode cause: AncModeCause? = null, - channelTaken: ChannelTaken? = null, - onReclaim: () -> Unit = {}, ) = compose.setContent { OpenControlTheme(darkTheme = false) { AncScreen( @@ -77,8 +74,6 @@ class AncScreenTest { ancModeUnconfirmedAt = unconfirmedAt, sessionSince = sessionSince, ancModeCause = cause, - channelTaken = channelTaken, - onReclaimAncChannel = onReclaim, ) } } @@ -151,7 +146,7 @@ class AncScreenTest { fun `a mode the Buds changed says so in the (i), with the mode's time`() { show(cause = AncModeCause.CHANGED_BY_BUDS) compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() // the (i) is plain: the mode is current - compose.onNodeWithText("Changed by the Buds at ${formatUpdatedAt(at)} (a press-and-hold on a bud, or the Buds' own change).").assertExists() + compose.onNodeWithText("Changed by the Buds at ${formatUpdatedAt(at)}: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves.").assertExists() compose.onNodeWithText("ANC mode: OFF (updated ${formatUpdatedAt(at)})").assertExists() } @@ -173,28 +168,32 @@ class AncScreenTest { @Test fun `the changed-by-the-Buds line leaves the time out when there is none, and needs a mode`() { - assertEquals("Changed by the Buds (a press-and-hold on a bud, or the Buds' own change).", ancChangedByBudsLine(null)) + assertEquals("Changed by the Buds: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves.", ancChangedByBudsLine(null)) assertEquals( - listOf("ANC mode: unknown", "Connection: Ready", ANC_HOLD_HINT_TEXT), + listOf("ANC mode: unknown", "Connection: Ready", ANC_STREAM_HINT_TEXT), ancDetailLines(ConnectionState.Ready, null, null, notAllowed = false, checkedAt = null, cause = AncModeCause.CHANGED_BY_BUDS), ) } @Test - fun `ADR-061 - the (i) ends with the hold sentence, the maintainer's text (ADR-062 Update 2), and Find keeps the brief-claim hint`() { + fun `ADR-065 - the (i) ends with the stream sentence, the maintainer's text (ai-sessions 0091) hedged as a hypothesis, and Find keeps the brief-claim hint`() { assertEquals( - // ADR-062 Update 2 (the maintainer's text in chat 2026-10-13, "ANC (i)"): the card's new name; no "cannot use it meanwhile" (CAP-074). - "While this tab is open the app keeps the Buds' noise-control channel, so a press-and-hold on a bud shows here; it is released 1.5 seconds " + - "after you leave the tab. On a phone with Google Play services, Play's Fast Pair takes the channel back about 5 seconds later; what this app " + - "does then is set on the Settings tab under Noise control with Google Play services.", - ANC_HOLD_HINT_TEXT, + // The maintainer's text in chat 2026-10-14 ("ANC (i)"): the wear-change part is 🟡 (PROTOCOL.md §6) and says so — never a fact in the UI. + "The Buds report every change of the mode on the app's own channel. A press-and-hold on a bud is one cause; on film the Buds also switched the " + + "mode by themselves when a bud was put in or taken out — that part is a hypothesis.", + ANC_STREAM_HINT_TEXT, ) - assertEquals(false, ANC_HOLD_HINT_TEXT.contains("cannot use it meanwhile")) - assertEquals(ANC_HOLD_HINT_TEXT, ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null).last()) + assertEquals(true, ANC_STREAM_HINT_TEXT.endsWith("that part is a hypothesis.")) + assertEquals(ANC_STREAM_HINT_TEXT, ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null).last()) assertEquals(false, ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null).contains(MESSAGE_STREAM_HINT_TEXT)) show(cause = AncModeCause.READ) compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() - compose.onNodeWithText(ANC_HOLD_HINT_TEXT).assertExists() + compose.onNodeWithText(ANC_STREAM_HINT_TEXT).assertExists() + // ADR-061/062/063 are gone (ADR-065 item 5): no hold sentence, no channel-taken line, no "Take it back again", no Play line. + compose.onNodeWithText("keeps the Buds' noise-control channel", substring = true).assertDoesNotExist() + compose.onNodeWithText("Another app took the noise-control channel", substring = true).assertDoesNotExist() + compose.onNodeWithText("Take it back again").assertDoesNotExist() + compose.onNodeWithText("Google Play services", substring = true).assertDoesNotExist() } @Test @@ -203,69 +202,31 @@ class AncScreenTest { assertEquals("ANC mode: unknown", ancModeLine(null, null, fromLastConnection = true)) } - // ---- DECISIONS.md ADR-062 Update 2 (`ai-sessions/0089`, the maintainer's texts in chat 2026-10-13): the channel-taken hint and its action ---- - - @Test - fun `ADR-062 - the hint-only line is the maintainer's text with the close's time, on the main surface and in the (i), before the Connection line`() { - val taken = ChannelTaken(at + 5_133, reclaimAttempts = 0) // CAP-074: the cut 5.133 s after the SABM - val time = formatUpdatedAt(at + 5_133) - val expected = "Another app took the noise-control channel at $time — usually Google Play services' Fast Pair. A press-and-hold on a bud is not " + - "shown until this app has the channel again: tap Take it back again, a mode, Refresh, or pull down." - assertEquals(expected, ancChannelTakenLine(taken)) - show(channelTaken = taken) - compose.onNodeWithText(expected).assertExists() // the main surface - compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() - compose.onAllNodesWithText(expected).assertCountEquals(2) // and the (i) - val lines = ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null, channelTaken = taken) - assertEquals(listOf(expected, "Connection: Ready", ANC_HOLD_HINT_TEXT), lines.takeLast(3)) - } + // ---- DECISIONS.md ADR-065 (`ai-sessions/0091`, the maintainer's texts in chat 2026-10-14, "Cause lines"): the two cause lines ---- @Test - fun `ADR-062 - after the cap the line says how often the app took the channel back, and no hint means no line`() { + fun `ADR-065 - a mode the Buds pushed says so in the (i) with the new wording, the mode's time, and the two triggers`() { val time = formatUpdatedAt(at) + val expected = "Changed by the Buds at $time: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves." + assertEquals(expected, ancChangedByBudsLine(at)) + show(cause = AncModeCause.CHANGED_BY_BUDS) + compose.onNodeWithText("Changed by the Buds", substring = true).assertDoesNotExist() // nothing on the main surface + compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() + compose.onNodeWithText(expected).assertExists() assertEquals( - "Another app took the noise-control channel at $time — usually Google Play services' Fast Pair; this app took it back 20 times and stopped. " + - "A press-and-hold on a bud is not shown until this app has the channel again: tap Take it back again, a mode, Refresh, or pull down.", - ancChannelTakenLine(ChannelTaken(at, reclaimAttempts = 20)), - ) - assertEquals( - "Another app took the noise-control channel at $time — usually Google Play services' Fast Pair; this app took it back once and stopped. " + - "A press-and-hold on a bud is not shown until this app has the channel again: tap Take it back again, a mode, Refresh, or pull down.", - ancChannelTakenLine(ChannelTaken(at, reclaimAttempts = 1)), + listOf("ANC mode: OFF (updated $time)", expected, "Connection: Ready", ANC_STREAM_HINT_TEXT), + ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null, cause = AncModeCause.CHANGED_BY_BUDS), ) - show() - compose.onNodeWithText("Another app took the noise-control channel", substring = true).assertDoesNotExist() - } - - @Test - fun `ADR-062 Update 2 - Take it back again is shown only with the hint, and a tap hands it over`() { - assertEquals("Take it back again", TAKE_IT_BACK_AGAIN_LABEL) - var taps = 0 - show(channelTaken = ChannelTaken(at, reclaimAttempts = 20), onReclaim = { taps++ }) - compose.onNodeWithText("Take it back again").assertIsEnabled().performClick() - assertEquals(1, taps) - } - - @Test - fun `ADR-062 Update 2 - no hint, no Take it back again`() { - show() - compose.onNodeWithText("Take it back again").assertDoesNotExist() - } - - @Test - fun `ADR-062 Update 2 - Take it back again is disabled while the session is not ready`() { - show(connectionState = ConnectionState.Disconnected, channelTaken = ChannelTaken(at, reclaimAttempts = 0)) - compose.onNodeWithText("Take it back again").assertIsNotEnabled() } @Test - fun `ADR-062 Update 2 - a mode the app's own Get found changed carries the changed-since-last-read line (CAP-075 2980)`() { + fun `ADR-065 - a mode a read found changed carries the changed-since-last-read line with the new wording (CAP-075 2980, CAP-051 2247)`() { val time = formatUpdatedAt(at) - val expected = "Changed since the app last saw it (read at $time): a press-and-hold on a bud or the Buds' own change, made while this app did not " + - "have the channel." + val expected = "Changed since the app last saw it (read at $time): a press-and-hold on a bud or the Buds' own change, made while this app was not " + + "connected." assertEquals(expected, ancChangedSinceLastReadLine(at)) assertEquals( - "Changed since the app last saw it: a press-and-hold on a bud or the Buds' own change, made while this app did not have the channel.", + "Changed since the app last saw it: a press-and-hold on a bud or the Buds' own change, made while this app was not connected.", ancChangedSinceLastReadLine(null), ) show(cause = AncModeCause.CHANGED_SINCE_LAST_READ) @@ -273,13 +234,13 @@ class AncScreenTest { compose.onNodeWithText(expected).assertExists() compose.onNodeWithText("Changed by the Buds", substring = true).assertDoesNotExist() assertEquals( - listOf("ANC mode: OFF (updated $time)", expected, "Connection: Ready", ANC_HOLD_HINT_TEXT), + listOf("ANC mode: OFF (updated $time)", expected, "Connection: Ready", ANC_STREAM_HINT_TEXT), ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null, cause = AncModeCause.CHANGED_SINCE_LAST_READ), ) } @Test - fun `ADR-062 Update 2 - a reading, the app's own Set and the Buds' change carry no changed-since-last-read line`() { + fun `ADR-065 - a reading, the app's own Set and the Buds' change carry no changed-since-last-read line`() { for (cause in listOf(AncModeCause.READ, AncModeCause.SET_BY_APP, AncModeCause.CHANGED_BY_BUDS, null)) { val lines = ancDetailLines(ConnectionState.Ready, AncMode.OFF, at, notAllowed = false, checkedAt = null, cause = cause) assertEquals("$cause", false, lines.any { it.startsWith("Changed since the app last saw it") }) diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt index 23e29a4b..562b73ef 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt @@ -55,8 +55,6 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceInfo import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceStatus import io.github.tedsluis.opencontrolpixelbuds.domain.FirmwareEntry -import io.github.tedsluis.opencontrolpixelbuds.domain.HoldReclaimMode -import io.github.tedsluis.opencontrolpixelbuds.domain.PlayServicesPresence import androidx.compose.ui.test.assertIsNotSelected import io.github.tedsluis.opencontrolpixelbuds.domain.PermissionState import io.github.tedsluis.opencontrolpixelbuds.domain.PermissionStatus @@ -392,132 +390,24 @@ class SettingsMenuTest { compose.onAllNodes(isToggleable()).assertCountEquals(0) } - // ---- DECISIONS.md ADR-062 (`ai-sessions/0087`) and its Update 2 (`ai-sessions/0089`, the maintainer's choices in chat 2026-10-13): the card ---- - - private val noPlay = "This phone has no Google Play services" - private val withPlay = "This phone has Google Play services" + // ---- DECISIONS.md ADR-065 (`ai-sessions/0091`): the "Noise control with Google Play services" card and the Play-services line are gone ---- @Test - fun `ADR-062 Update 2 - the card sits between Dark mode and Case sounds, shows the stored choice, and a tap hands the other one over`() { - val chosen = mutableListOf() + fun `ADR-065 - the Settings tab has no Noise control with Google Play services card and no Google Play services line, Case sounds follows Dark mode`() { compose.setContent { OpenControlTheme(darkTheme = false) { SettingsMenuScreen( DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, announced, connectionState = ConnectionState.Ready, settings = caseSoundsRead, - holdReclaimMode = HoldReclaimMode.HINT_ONLY, onHoldReclaimModeChanged = { chosen += it }, ) } } - // One scroll state for the three readings, unclipped (a performScrollTo between them would move every node; off-screen nodes clip to 0). + compose.onNodeWithText("Noise control with Google Play services", substring = true).assertDoesNotExist() + compose.onNodeWithText("This phone has", substring = true).assertDoesNotExist() + compose.onNodeWithText("Google Play services", substring = true).assertDoesNotExist() + compose.onNodeWithText("Take it back", substring = true).assertDoesNotExist() val darkMode = compose.onNodeWithText("Dark mode").getUnclippedBoundsInRoot().top - val card = compose.onNodeWithContentDescription("Noise control with Google Play services: $DETAILS_DESCRIPTION").getUnclippedBoundsInRoot().top val caseSounds = compose.onNodeWithContentDescription("Case sounds: $DETAILS_DESCRIPTION").getUnclippedBoundsInRoot().top - assertTrue("Dark mode < the card < Case sounds: $darkMode, $card, $caseSounds", darkMode < card && card < caseSounds) - - compose.onNodeWithText(HOLD_RECLAIM_INTRO).performScrollTo().assertExists() - compose.onNodeWithText(noPlay).performScrollTo().assertIsSelected() - compose.onNodeWithText(holdReclaimSupportingText(HoldReclaimMode.HINT_ONLY)).performScrollTo().assertExists() - compose.onNodeWithText(withPlay).performScrollTo().assertIsNotSelected().performClick() - assertEquals(listOf(HoldReclaimMode.RECLAIM), chosen) - compose.onNodeWithText("Google Play services:", substring = true).assertDoesNotExist() // no presence known: no line (ADR-063 item 4) - - compose.onNodeWithContentDescription("Noise control with Google Play services: $DETAILS_DESCRIPTION").performScrollTo().performClick() - HOLD_RECLAIM_DETAIL_LINES.forEach { compose.onNodeWithText(it).assertExists() } - compose.onNodeWithText("Close").performClick() - } - - @Test - fun `ADR-062 Update 2 - the card's texts are the maintainer's, and the stored names keep their meaning`() { - assertEquals("Noise control with Google Play services", HOLD_RECLAIM_TITLE) - assertEquals("The Buds report a press-and-hold on a bud to one app at a time. Choose what fits this phone:", HOLD_RECLAIM_INTRO) - assertEquals(noPlay, holdReclaimLabel(HoldReclaimMode.HINT_ONLY)) - assertEquals(withPlay, holdReclaimLabel(HoldReclaimMode.RECLAIM)) - assertEquals( - "Nothing else uses the noise-control channel. If another app takes it anyway, the noise-control tab says so.", - holdReclaimSupportingText(HoldReclaimMode.HINT_ONLY), - ) - assertEquals( - "While the noise-control tab is open, take the channel back from Play services' Fast Pair 3 seconds after each time it takes it, so a " + - "press-and-hold shows about half of the time.", - holdReclaimSupportingText(HoldReclaimMode.RECLAIM), - ) - assertEquals( - listOf( - "The Buds send a press-and-hold's noise-control change to one app at a time: the app that has their noise-control channel open. While the " + - "noise-control tab is open this app keeps that channel.", - "On a phone with Google Play services, Play's Fast Pair takes the channel back 5 to 6 seconds after it lost it to this app, every time " + - "(measured in October 2026 with one version of Play services; another version may differ).", - "No Google Play services: the noise-control tab shows every press-and-hold. If another app takes the channel anyway, the tab says so and " + - "when; Take it back again, a tap, Refresh or a pull-down takes it back.", - "Google Play services: 3 seconds after each time Play takes the channel this app takes it back, up to 20 times (about 3 minutes) per " + - "visit of the tab, so a press-and-hold shows about half of the time. It is a tug of war with Play: Android's own view of the Buds may " + - "lag meanwhile. After the 20th time the tab says so; Take it back again starts over.", - "Without Google Play services both choices behave the same.", - ), - HOLD_RECLAIM_DETAIL_LINES, - ) - // The stored names are 1.2.1's (a stored choice keeps its meaning after the rename — HoldReclaimSettingsStoreTest reads a 1.2.1 file). - assertEquals(listOf("HINT_ONLY", "RECLAIM"), HoldReclaimMode.entries.map { it.name }) - assertEquals(HoldReclaimMode.RECLAIM, HoldReclaimMode.fromStored("RECLAIM")) - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.fromStored("HINT_ONLY")) - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.fromStored(null)) - assertEquals(HoldReclaimMode.HINT_ONLY, HoldReclaimMode.fromStored("FIGHT")) - } - - @Test - fun `ADR-062 - with the Google Play services option stored, that row is the selected one`() { - compose.setContent { - OpenControlTheme(darkTheme = false) { - SettingsMenuScreen(DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, null, holdReclaimMode = HoldReclaimMode.RECLAIM) - } - } - compose.onNodeWithText(withPlay).performScrollTo().assertIsSelected() - compose.onNodeWithText(noPlay).performScrollTo().assertIsNotSelected() - } - - // ---- DECISIONS.md ADR-063 (`ai-sessions/0089`): whether Android finds Google Play services on this phone — information only ---- - - @Test - fun `ADR-063 - the presence line per answer, none without one`() { - assertEquals("Google Play services: found on this phone", playServicesPresenceLine(PlayServicesPresence.FOUND)) - assertEquals("Google Play services: found but disabled on this phone", playServicesPresenceLine(PlayServicesPresence.FOUND_DISABLED)) - assertEquals("Google Play services: not found on this phone", playServicesPresenceLine(PlayServicesPresence.NOT_FOUND)) - assertEquals(null, playServicesPresenceLine(null)) - } - - @Test - fun `ADR-063 - the card shows the presence line and the stored choice is left as it is`() { - val chosen = mutableListOf() - compose.setContent { - OpenControlTheme(darkTheme = false) { - SettingsMenuScreen( - DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, null, - holdReclaimMode = HoldReclaimMode.HINT_ONLY, onHoldReclaimModeChanged = { chosen += it }, - playServicesPresence = PlayServicesPresence.FOUND, - ) - } - } - compose.onNodeWithText("Google Play services: found on this phone").performScrollTo().assertExists() - compose.onNodeWithText(noPlay).performScrollTo().assertIsSelected() // information only: nothing switches by itself - compose.waitForIdle() - assertEquals(emptyList(), chosen) - } - - // ---- `ai-sessions/0069` A68-APP-05: "Use different Buds" on the Settings tab ---- - - @Test - fun `the Settings tab offers Use different Buds with what it does and what it leaves alone`() { - var taps = 0 - compose.setContent { - OpenControlTheme(darkTheme = false) { - SettingsMenuScreen(DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, null, onUseDifferentBuds = { taps++ }) - } - } - compose.onNodeWithText( - "Forgets which Buds this app controls (the Bluetooth pairing in Android stays). You then pick the Buds again with Pair a device.", - ).assertExists() - compose.onNode(hasText("Use different Buds") and hasClickAction()).performScrollTo().performClick() - assertEquals(1, taps) + assertTrue("Dark mode < Case sounds: $darkMode, $caseSounds", darkMode < caseSounds) } } diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/TabRestoreTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/TabRestoreTest.kt index 2f9f3e18..5bb53dc5 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/TabRestoreTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/TabRestoreTest.kt @@ -142,28 +142,4 @@ class TabRestoreTest { compose.onNodeWithText("Equalizer").assertExists() // back returns to Sound, as before the relaunch bottomItem("Sound").assertIsSelected() } - - /** - * DECISIONS.md ADR-061 (`ai-sessions/0084`, the maintainer's choice "Tab selected + app visible"): the nav host reports `true` while the noise-control tab is - * the selected tab and the settings menu is closed, `false` on any other tab or with the menu open — the repository holds the Message Stream claim only then. - */ - @Test - fun `ADR-061 - the noise-control tab is reported shown only while it is selected and the settings menu is closed`() { - val reports = mutableListOf() - compose.setContent { OpenControlTheme(darkTheme = false) { OpenControlNavHost(uiState(), noActions.copy(onAncTabShown = { reports += it })) } } - compose.waitForIdle() - assertEquals("the start tab is Connection", false, reports.last()) - - bottomItem("ANC").performClick(); compose.waitForIdle() - assertEquals(true, reports.last()) - - compose.onNodeWithContentDescription("Settings").performClick(); compose.waitForIdle() - assertEquals("the settings menu covers the tab", false, reports.last()) - - compose.onNodeWithContentDescription("Back").performClick(); compose.waitForIdle() - assertEquals("back on the noise-control tab", true, reports.last()) - - bottomItem("Sound").performClick(); compose.waitForIdle() - assertEquals(false, reports.last()) - } } From 8a72572b7cc27d3a30f004d58fe0caeaad2b9be7 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Wed, 14 Oct 2026 22:39:00 +0200 Subject: [PATCH 04/11] docs: ADR-065, 1.3.0, CAP-076 rewritten for the settings stream (ai-sessions/0091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DECISIONS.md: ADR-065 (maintainer-approved in chat 2026-10-14) supersedes ADR-061/062/063 and amends ADR-064 items 2 and 6; their Status lines and pointer Updates on ADR-032 and ADR-064; id_registry.csv accordingly. AGENTS.md §1: the approved pointer (the package query is gone). ARCHITECTURE.md §2, §2.4, §3.1, §3.2 (ANC_SET_WINDOW_MS; HOLD_RECLAIM_* gone), §5a, §6.0b, §7, §9. The CAP-076 skeleton, Group BL, its index and registry rows and APP_TESTPLAN.md §Y rewritten as the 1.3.0 run in both users (holds with the tab closed and open, the own tap, the wear-change re-home, Bluetooth off/on, taps while Play holds the channel, the counts for the verdict). CHANGELOG [1.3.0], README, PROJECT, the release-notes template, versionName 1.3.0 / versionCode 10300, TODO.md, INDEX, the session's RESULT. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 1 + APP_TESTPLAN.md | 55 ++-- ARCHITECTURE.md | 115 +++----- CAPTURE_BLUETOOTH_HCI_SNOOP.md | 44 +-- CHANGELOG.md | 29 ++ DECISIONS.md | 69 ++++- PROJECT.md | 2 +- README.md | 15 +- TODO.md | 33 ++- ai-sessions/0091_FEATURE_RESULT_2026_10_14.md | 279 ++++++++++++++++++ ai-sessions/INDEX.md | 2 +- android/app/build.gradle.kts | 4 +- .../CAP-076-EVENT-NOTES.md | 222 +++++++------- id_registry.csv | 11 +- scripts/release_notes.template | 2 +- 15 files changed, 635 insertions(+), 248 deletions(-) create mode 100644 ai-sessions/0091_FEATURE_RESULT_2026_10_14.md diff --git a/AGENTS.md b/AGENTS.md index d5a36f16..f34f5a8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ order: asking Android's `PackageManager` whether the package `com.google.android.gms` is installed (no import, no binding, nothing read from it) is not GMS use; the Settings card's information line relies on it. +- **Pointer (2026-10-14, maintainer-approved in chat, `DECISIONS.md` ADR-065, `ai-sessions/0091`):** superseded — the package query is gone; since 1.3.0 the app no longer asks Android whether `com.google.android.gms` is installed (no `` entry in the manifest). ## 2. GrapheneOS Compatibility & Permissions diff --git a/APP_TESTPLAN.md b/APP_TESTPLAN.md index 73777aac..46a3af0b 100644 --- a/APP_TESTPLAN.md +++ b/APP_TESTPLAN.md @@ -33,7 +33,10 @@ Google Play services Play's Fast Pair takes the held channel back ≈ 5 s later **Updated 2026-10-13 for 1.2.2** (`ai-sessions/0089`, `DECISIONS.md` ADR-062 Update 2 and ADR-063): the card is "Noise control with Google Play services" with options that say whether the phone has Google Play services and a line whether Android finds Play services; in the Play option the re-claim comes 3 s after every cut, up to 20 times, then the hint with "Take it back again"; a mode the app's own `Get` finds changed gets "Changed since the app last saw it" in the (i); -**section Y** (both users). +**section Y** (both users). **Updated 2026-10-14 for 1.3.0** (`ai-sessions/0091`, `DECISIONS.md` ADR-064 and ADR-065): the app subscribes to the Buds' settings +stream and reads the noise-control mode on its own channel at every Connect and re-open (fifteen MAESTRO requests: O8), so a press-and-hold shows on any tab +without a claim of the shared channel; the hold, the re-claim, the "Noise control with Google Play services" card and the Play line are gone (the F-rows no +longer hold the channel; the X/Y rows of 1.2.1/1.2.2 are history); the (i) texts of the noise-control card; **section Y rewritten** for the 1.3.0 run (both users). This is a *user-level* functional test of this project's own app; `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` is the separate catalogue of Buds/official-app behaviours, and the "Expected on the wire" column below only names what to look for in the HCI log afterwards (`ai-sessions/0046` RESULT §9 has the exact frames). @@ -413,29 +416,39 @@ measured 2026-10-12): recovery 5.2–5.7 s after it lost the channel, retry 2.2 | X11 | Hint only: the ANC tab; holds every ≈ 5 s for 30 s. Then "Take it back"; back to ANC; holds every ≈ 5 s for 30 s. | every hold shown in both positions; no hint | one `SABM` + `08 11` per tab entry; **no `DISC` while the tab is shown**; no re-claim line; 0 Play-services claims | ✅ | BK-16, BK-17 (`TOUCH-007`) — H2/H3/H5 (hint only) and H4/H6 (take it back): 41 of 41 shown, no `DISC` while the tab was shown, 0 re-claim lines, 0 Play claims | | X12 | Leave the tab (Controls); optional: the tile on the tab, Home/return, both buds into the case and out with the tab shown; back to "Show a hint"; Export. | Controls; (optional branches as in W6–W8) | the `DISC` ≈ 1.5 s after leaving; (optional: no second `SABM` for the tile; the `DISC` after Home; the re-open's snapshot kept) | ⚠️ | BK-18 … BK-21, BK-end2 — the `DISC` 1.50–1.51 s after leaving the tab ×6 ✓; the optional branches not run; the switch **not** set back (set 1 ends on "Take it back"); Export ✓ (228 lines) | -## Y. 1.2.2 (`ai-sessions/0089`: the constant re-claim step, "Take it back again", the cause line, the renamed card — ADR-062 Update 2, ADR-063 — in both users) +## Y. 1.3.0 (`ai-sessions/0091`: the settings stream on the app's own channel — ADR-064, ADR-065 — in both users) -In `CAP-076` as BL-1 … BL-24 (captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md, which has the reference frames per request from -`CAP-075`). Nothing new on the wire: the re-claim and "Take it back again" are the existing claim (`SABM` DLCI 0x04, `08 11 00 00`, the phone's `DISC`). The -timings expected below are `CAP-075`'s Play services (one version, measured 2026-10-13): Play's retry 2.1–2.8 s after a cut, its recovery 5.3–5.8 s after it lost -the channel — a cycle of ≈ 9.2–9.5 s with ≈ 4.6–4.9 s held; write down this run's own numbers. +In `CAP-076` as BL-1 … BL-25 (captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md, which has the reference frames per request). Two +new requests on the wire at every Connect and re-open — `SubscribeToSettingsChanges` and `ReadSetting 4:13`, the official app's bytes (`CAP-051` 1177/1178, +`CAP-056` 1451/1450); the Buds then push every change of the mode (and of every other setting the app reads) as a stream packet on the app's own channel, on any +tab, with or without Google Play services. The hold (W), the "Noise-control channel" / "Noise control with Google Play services" setting (X, the 1.2.2 Y) are +gone: a stored choice is ignored. Expected: every hold shown within a second; no `SABM` on DLCI 0x04 from the app except for a tap, the tile, Refresh, a pull +or a Connect's snapshot; the own tap's stream packet ≈ 0.3 s after the `Set`. | ID | Steps | Expected on screen | Expected on the wire | Result | Notes | |---|---|---|---|---|---| -| Y1 | **User 0 (Play).** Update over 1.2.1 (Debug mode on); open the app; Info; gear → Settings: the card and its (i). | "App: 1.2.2, build ", no "-dirty"; the card "Noise control with Google Play services" between Dark mode and Case sounds; "Google Play services: found on this phone"; **"This phone has Google Play services" selected** (1.2.1's "Take it back" kept); each option with its second line; the (i)'s five lines | the thirteen requests + `GetHardwareInfo`; the snapshot claim | | BL-1, BL-2 (`PAIR-003`, `BATT-004`, `FW-003`) | -| Y2 | Disconnect, Connect; the ANC tab. | ready; the mode; the hold | the thirteen requests; the hold | | BL-3, BL-4 | -| Y3 | **Google Play services option:** press and hold a bud every ≈ 4 s for 3½ minutes; touch nothing; count the holds shown; the system log at once after. | about half of the holds shown during ≈ 3 minutes; no hint meanwhile; after the 20th re-claim's cut the hint "Another app took the noise-control channel at HH:MM:SS — usually Google Play services' Fast Pair; this app took it back 20 times and stopped. …" with **Take it back again** | per cycle the cut, Play's retry, the export's "re-claim N/20 … 3000 ms later" **3.0 s** after each "closed" line (every step 3000 ms), attempt 1 refused, the app's `SABM` + `08 11`; after the 20th: "re-claim stopped after 20 re-claims — …"; no 21st | | BL-5, BL-6 (`TOUCH-007`) | -| Y4 | Open the ANC (i) after a re-claim that found another mode. | "Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app did not have the channel."; the last sentence names "Noise control with Google Play services" | the re-claim's `08 11` answered with another mode than the app's last report | | BL-7 (`TOUCH-007`) | -| Y5 | Tap **Take it back again**; two holds; wait 15 s. | the hint and the button gone; the holds in the window shown; a re-claim 3 s after the next cut | one claim: `08 11 00 00` only — **no `08 12`**, no DLCI 0x02 request; "Message Stream claim by the hint card's action (ADR-062 Update 2)"; after its cut "re-claim 1/20" | | BL-8 (`ANC-001`…`004`, `TOUCH-007`) | -| Y6 | Within 2 s of a cut: the Sound tab (then back); Home (then return). | no re-claim; a new claim on each return | "Message Stream re-claim cancelled (ADR-062)" ×2; no `SABM` 3 s after those cuts | | BL-9, BL-10 | -| Y7 | gear → Settings → **"This phone has no Google Play services"**; back to ANC; holds for 20 s. | the first hold in the claim shown, then the hint without "took it back" and with the button; no re-claim | "the hint is shown, no re-claim (ADR-062, hint only)"; no app `SABM` after the cut | | BL-11 (`TOUCH-007`) | -| Y8 | Tap **Take it back again** (no-Play option); wait 10 s. | the hint gone, back ≈ 5 s later; no re-claim | one claim with `08 11` only; its cut; no re-claim | | BL-12 (`ANC-001`…`004`) | -| Y9 | Back to **"This phone has Google Play services"**; ANC. | the row selected; a new claim | after its cut "re-claim 1/20" | | BL-13 | -| Y10 | Disconnect, Connect on the ANC tab; Export. | the hint gone at Connect; "Debug log saved (N lines)." | the thirteen requests; the chain from 1/20 | | BL-14, BL-end1 (`PAIR-003`) | -| Y11 | **User 10 (no Play).** Open the app; Info; Settings: the card. | the build; "Google Play services: not found on this phone"; "This phone has Google Play services" selected (1.2.1's stored choice; nothing switches by itself) | the Connect sequence; the snapshot claim released ≈ 1.5 s after its answer | | BL-15 (`PAIR-003`, `FW-003`, `BATT-004`) | -| Y12 | The ANC tab; holds every ≈ 5 s for 30 s. Then "This phone has no Google Play services"; back to ANC; holds every ≈ 5 s for 30 s. | every hold shown in both options; no hint, no button | one `SABM` + `08 11` per tab entry; **no `DISC` while the tab is shown**; no re-claim line; 0 Play-services claims | | BL-16, BL-17 (`TOUCH-007`) | -| Y13 | The ANC (i) after a hold; then the Connection tab, a hold there, back to ANC, the (i). | first "Changed by the Buds at …" (no changed-since line); after the hold away from the tab "Changed since the app last saw it (read at …)" | the return's `08 11` answered with another mode | | BL-18, BL-19 (`TOUCH-007`) | -| Y14 | Leave the tab (Controls); optional: the tile on the tab, Home/return, both buds into the case and out with the tab shown; Export. | Controls; (optional branches as in W6–W8) | the `DISC` ≈ 1.5 s after leaving; (optional: as X12) | | BL-20 … BL-24, BL-end2 | +| Y1 | **User 0 (Play).** Update over 1.2.2 (Debug mode on; the 1.2.2 card's choice on film first); open the app; Info; gear → Settings. | "App: 1.3.0, build ", no "-dirty"; Settings = Dark mode, Case sounds, Use different Buds — **no "Noise control with Google Play services" card, no "Google Play services: …" line** | the **fifteen** requests: `4:16`, the twelve settings, `SubscribeRuntimeInfo`, **`SubscribeToSettingsChanges`, `ReadSetting 4:13`**, `GetHardwareInfo`; the snapshot claim | | BL-1, BL-2 (`PAIR-003`, `BATT-004`, `FW-003`) | +| Y2 | Disconnect, Connect. | ready | the fifteen requests again — the subscription and the read once per session | | BL-3 (`PAIR-003`) | +| Y3 | **Connection tab**, both worn: 10 press-and-holds ≈ 6 s apart; touch nothing; then open ANC. | the mode follows every hold (the notification's line; ANC shows the last) | one `4:{13:n}` stream packet per hold on DLCI 0x02; **no app `SABM` on DLCI 0x04** | | BL-4 (`TOUCH-007`, `ANC-001`…`004`) | +| Y4 | **ANC tab**: 10 press-and-holds ≈ 6 s apart; the (i) after one. | **10 of 10** shown within a second; the (i) "Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves."; the last sentence "The Buds report every change of the mode on the app's own channel. … that part is a hypothesis." | one stream packet per hold; no app `SABM`; no hint, no "Take it back again" | | BL-5, BL-6 (`TOUCH-007`, `ANC-001`…`004`) | +| Y5 | Tap a mode; the (i). The ANC tile once. | the mode at once; no "Changed by the Buds" line for the own tap; the tile steps one mode and the app follows | one claim per tap/tile (`08 11`, `08 12`, ACK), the Buds' `Notify`, **the stream packet of the tapped mode ≈ 0.3 s after the `Set`**, the `DISC` ≈ 1.5 s after the ACK | | BL-7, BL-8 (`ANC-001`…`004`) | +| Y6 | One bud out (both worn), 10 s, back in, 10 s; read the mode after each. | either the loss and the re-open by itself with the mode read ("Changed since the app last saw it (read at …)" if it differs), or the Buds' own switch as "Changed by the Buds" (🟡) | the Buds' `DISC` → the re-open's fifteen requests (the subscription and the read again), or a stream packet without a `DISC` — say which | | BL-9 (`PAIR-003`, `TOUCH-007`) | +| Y7 | Bluetooth off, 5 s, on; wait. | "Bluetooth was switched off"; ready again by itself | the re-open's fifteen requests — the subscription again | | BL-10 (`PAIR-003`) | +| Y8 | Taps while Play holds the channel; tap again after a failed one. | the mode after at most two taps; **no hint, no re-claim, no "Take it back again"** | a refused attempt 1 with Play's `DISC` (the `CAP-075` §5 collision — the known issue); the app never re-claims by itself | | BL-11 (`ANC-001`…`004`) | +| Y9 | 5 more holds on the ANC tab. | 5 of 5 shown | 5 stream packets; no app `SABM` | | BL-12 (`TOUCH-007`) | +| Y10 | (optional) The official app on a second phone: an EQ slider; In-ear detection off/on. | the equalizer and the switch follow in OpenControl ("read HH:MM:SS", not "changed by this app") | the stream packets for field 16 and field 2 on OpenControl's channel | | BL-13 | +| Y11 | Export; the system log; switch to user 10. | "Debug log saved (N lines)." | — | | BL-end1 | +| Y12 | **User 10 (no Play).** Open the app; Info; Settings. | the build; no card, no Play line | the fifteen requests; the snapshot claim released ≈ 1.5 s after its answer | | BL-14 (`PAIR-003`, `FW-003`, `BATT-004`) | +| Y13 | Connection tab: 10 holds; then ANC: 10 holds; the (i). | every hold followed / shown; "Changed by the Buds at …" | 20 stream packets; no app `SABM`; 0 Play claims (positive control: part 1's count); **no `DISC` of DLCI 0x04 while the tab is shown — there is no claim** | | BL-15, BL-16 (`TOUCH-007`, `ANC-001`…`004`) | +| Y14 | A tap; the tile. | the mode; the app follows the tile; no "Changed by the Buds" for the own change | one claim each; the stream packet ≈ 0.3 s after the `Set`; the `DISC` ≈ 1.5 s after the ACK | | BL-17, BL-18 (`ANC-001`…`004`) | +| Y15 | One bud out / in (as Y6). | as Y6 | as Y6 | | BL-19 (`PAIR-003`, `TOUCH-007`) | +| Y16 | Bluetooth off / on. | ready again by itself | the re-open's requests — the subscription again | | BL-20 (`PAIR-003`) | +| Y17 | Controls tab: 5 holds; then ANC. | the last mode; "Changed by the Buds" | 5 packets; no app `SABM` | | BL-21 (`TOUCH-007`) | +| Y18 | Disconnect; two holds while disconnected; Connect; the ANC (i). | after the Connect the mode read; **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected."** | nothing while disconnected; the Connect's fifteen requests; the read's answer | | BL-22 (`PAIR-003`, `TOUCH-007`) | +| Y19 | Both buds into the case, 15 s, out, 15 s. | the session ends and re-opens by itself, or "The Buds don't allow changing noise control right now …" with the mode kept; the mode read once worn; **a value 0 never shown as a mode** | the loss and the re-open's requests, or a stream packet `4:{13:0}`; the read's answer 0 in the case, 1–4 worn | | BL-23 (`PAIR-003`) | +| Y20 | (optional) Home; two holds while hidden; return. | the holds' result on return | 2 packets while hidden; no `SABM` | | BL-24 (`TOUCH-007`) | +| Y21 | (optional) The official app on a second phone: an EQ slider; a switch. | OpenControl follows | the stream packets | | BL-25 | +| Y22 | Export; the system log; the APK pull. | "Debug log saved (N lines)." | — | | BL-end2 | ## After the run (within 1 minute of the last action) @@ -475,7 +488,7 @@ the channel — a cycle of ≈ 9.2–9.5 s with ≈ 4.6–4.9 s held; write down | V 1.2.0 build | 19 | 13 | 1 | 4 | 1 | | W 1.2.0 rebuilt | 12 | 3 | 0 | 6 | 3 | | X 1.2.1 | 12 | 6 | 0 | 5 | 1 | -| Y 1.2.2 | 14 | | | | | +| Y 1.3.0 | 22 | | | | | **Run `CAP-075` (2026-10-12 set 1, the user without Play; 2026-10-13 set 2, the Owner with Play; 1.2.1 `31ee183`; `ai-sessions/0088`, `CAP-075-FINDINGS.md`):** section X — ✅ X2, X5, X6, X7, X10, X11; ⚠️ X1 (set 2's card pre-set to "Take it back"), X3 (the hint ✓, no re-claim ✓, 0 of 9 holds shown — the claims lived 0.42 / 1.2 s), X4 (the (i) ✓; no mode tap), X9 (on the Connection tab), X12 (the switch not set back; the optional branches not run); not run X8. The X6 expectation "≈ 6 of 10" was 8 of 19 within a second (the 6/12-s steps lengthen the cycles). Verdict (the maintainer, chat 2026-10-13): no heavy issue — 1.2.1 released as built. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e0c7ebe7..21ebb97c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -118,9 +118,9 @@ enforced by the build graph, not just by convention: `BudsRepositoryImpl`, translating transport-level events from `:hardware` into domain models. Also owns local persistence — as actually built, this is the Debug Mode toggle via plain (not encrypted) AndroidX DataStore - Preferences (`DebugSettingsStore.kt`), — since `ai-sessions/0062` (F-6) — the dark-mode choice (`DarkModeSettingsStore.kt`, a second key in the - same file, `SettingsDataStore.kt`) and — since 1.2.1 (`ai-sessions/0087`, ADR-062) — the "Noise-control channel" choice (`HoldReclaimSettingsStore.kt`, a - third key in that file); see `DebugSettingsStore.kt`'s comment for why encryption was judged unnecessary for these non-sensitive + Preferences (`DebugSettingsStore.kt`) and — since `ai-sessions/0062` (F-6) — the dark-mode choice (`DarkModeSettingsStore.kt`, a second key in the + same file, `SettingsDataStore.kt`); 1.2.1/1.2.2's third key `hold_reclaim_mode` (ADR-062) is no longer read since 1.3.0 (ADR-065, `ai-sessions/0091`: ignored, + never migrated, never removed); see `DebugSettingsStore.kt`'s comment for why encryption was judged unnecessary for these non-sensitive values, and AGENTS.md §10 for the dependency-policy reasoning. No EQ-preset or battery persistence exists yet — this project's own state-reconciliation design (§3.1) treats the hardware as the sole source of truth for those, @@ -197,23 +197,14 @@ write per completed drag; since the 1.2.0 rebuild, `ai-sessions/0084`, the maint Buds; its (i) adds "These settings live on the case and are read when the app connects."), and "Conversation detection" is an own card on **Controls** after "In-ear detection" (the `0052` subtitle kept); "Sound"'s second card holds Balance and Mono audio. The Noise control card's (i) says "Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)." for a `Notify` the app did not provoke (`AncModeCause`) and — since the 1.2.0 rebuild - (ADR-061) — ends with "While this tab is open the app keeps the Buds' noise-control channel open, …" (**since 1.2.1**, ADR-062, `ai-sessions/0087`: - "… Another app that uses the channel (for example Google Play services' Fast Pair) loses it to this app and may take it back about 5 seconds later; what - the app does then is set on the Settings tab under Noise-control channel." — the earlier "cannot use it meanwhile" was wrong, `CAP-074`); **since 1.2.1** the - card also shows, below the mode buttons and in the (i), "The noise-control channel was taken by another app at HH:MM:SS (for example Google Play - services' Fast Pair)[; the app took it back N times and stopped]. A change made on a bud is not shown until you tap a mode, Refresh, or pull down." while - `ancChannelTaken` is set (ADR-062), and **gear → Settings** has the "Noise-control channel" card after Dark mode (the maintainer's order, chat 2026-10-12: - Dark mode · Noise-control channel · Case sounds · Use different Buds) — "When another app takes the channel:" with the two radio rows "Show a hint, do not - take it back" (the default) and "Take it back while the noise-control tab is open (may fight that app)" and an (i) with the numbers of `CAP-074`. **Since 1.2.2** - (`ai-sessions/0089`, ADR-062 Update 2 and ADR-063; the maintainer's texts in chat 2026-10-13): the card is **"Noise control with Google Play services"** — the - intro "The Buds report a press-and-hold on a bud to one app at a time. Choose what fits this phone:", the line "Google Play services: found / found but - disabled / not found on this phone" (`PlayServicesProbe` in `:app`, asked on every resume; information only; no line when the query fails), and two radio rows - with a supporting line each — "This phone has no Google Play services" (`HINT_ONLY`, the default) and "This phone has Google Play services" (`RECLAIM`); the - stored names are 1.2.1's; the (i) has five lines with `CAP-074`/`CAP-075`'s numbers. The noise-control hint reads "Another app took the noise-control channel at - HH:MM:SS — usually Google Play services' Fast Pair[; this app took it back N times and stopped]. A press-and-hold on a bud is not shown until this app has the - channel again: tap Take it back again, a mode, Refresh, or pull down." with a **Take it back again** text button under it (enabled while `Ready`; - `BudsRepository.reclaimAncChannel`: a claim with its `Get`, no `Set`); the ANC (i) ends "… what this app does then is set on the Settings tab under Noise control - with Google Play services." and carries "Changed since the app last saw it (read at HH:MM:SS): …" for `AncModeCause.CHANGED_SINCE_LAST_READ`; the battery card has the "probably + **(1.2.0 … 1.2.2, history — `DECISIONS.md` ADR-061/062/063, superseded by ADR-065 on 2026-10-14, `ai-sessions/0091`):** the ANC (i) ended with the hold sentence, + the card showed the channel-taken hint with "Take it back again" while `ancChannelTaken` was set, and gear → Settings had the "Noise-control channel" / + "Noise control with Google Play services" card with the Play-services line — all gone in 1.3.0. **Since 1.3.0 (ADR-064/065; the maintainer's texts in chat + 2026-10-14):** the ANC (i) ends with "The Buds report every change of the mode on the app's own channel. A press-and-hold on a bud is one cause; on film the + Buds also switched the mode by themselves when a bud was put in or taken out — that part is a hypothesis." (`ANC_STREAM_HINT_TEXT`); it carries "Changed by + the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves." for `AncModeCause.CHANGED_BY_BUDS` and + "Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected." for + `CHANGED_SINCE_LAST_READ`; gear → Settings is Dark mode · Case sounds · Use different Buds; the battery card has the "probably worn" line (ADR-059); Info lists the serial numbers (ADR-058). **Exception:** the press-and-hold action chips (`HoldRow`, `ControlsScreen.kt`) are enabled with the connection only; a chip tapped before the read writes that one field (`ai-sessions/0058` A58-ARCH-05). - **Justification for `navigation-compose`** (`AGENTS.md` §10's dependency-policy requirement): pure @@ -290,6 +281,16 @@ change state while this app is disconnected or backgrounded. (ANC mode, battery, EQ) rather than assuming the last-known `StateFlow` value still holds. Reads run regardless of firmware; the Startup Handshake (§8.1, ADR-042) gates **writes** only. +- **The Buds push every change of a setting on the app's own channel (since 1.3.0, `DECISIONS.md` ADR-064/065, `ai-sessions/0091`):** every MAESTRO session + (Connect and every ADR-044 re-open) ends its read sequence with one `SubscribeToSettingsChanges` and one `ReadSetting 4:13`; from then on a `SERVER_STREAM` + packet `4:{N: …}` for any field the app reads (2, 4, 7, 11, 12, **13 = the noise-control mode**, 15, 16, 17, 19, 22, 27, 28, 29) goes through the same decoders + as a read answer and replaces the value with the packet's receive time (`RoutedFrame.Setting(stream = true)`; `applySetting`, `applyAncState`); a field the + app does not read is logged payload-free and not shown (`RoutedFrame.UnreadSetting`); setting 13's value 0 is "no mode settable" (`AncAvailability.NOT_ALLOWED`), + never a mode. The last value received wins, whatever its channel (the Message Stream's `Notify` or the stream). The cause of the shown mode: an own `Set` + within `ANC_SET_WINDOW_MS` → `SET_BY_APP`; a differing push → `CHANGED_BY_BUDS`; a differing read answer (the `Get`'s `Notify`, the `4:13` RESPONSE) → + `CHANGED_SINCE_LAST_READ` — also across a Connect, since the kept mode of the ended session is the reference (the maintainer's choice, chat 2026-10-14, Q4; + before 1.3.0 the cause was reset at Connect). Nothing is written in reaction to a stream packet; the Message Stream is claimed only for a tap, the tile, + Refresh, a pull and the Connect's snapshot (ADR-032). - **One rule for "current" (built `ai-sessions/0069`, 1.0.1; `ai-sessions/0068` A68-APP-02; the maintainer's choice in chat 2026-10-03, "Keep the value, mark it"; recorded as **ADR-057** on 2026-10-07):** a value is current only while the session is `Ready` **and** the Buds reported it on this connection — `isCurrent(ready, valueAt, sessionSince)` in `:domain` (`ValueCurrency.kt`); `BudsRepository.sessionSince` is the time the current connection was requested. A value that is not @@ -364,9 +365,9 @@ is met by the value here. Check with `grep -rn "const val .*_MS" android/*/src/m | `ACK_WAIT_MS` | 1 000 ms | an ANC `Set` or a Ring waits for the Buds' ACK/NAK/Notify | `BudsRepositoryImpl` | ADR-032 | | `CUT_OFF_GRACE_MS` | 100 ms | after a claim's close, an answer already received may still pass the codec | `BudsRepositoryImpl` | `ai-sessions/0062` F-3 | | `BATTERY_BURST_WAIT_MS` | 2 000 ms | *Refresh battery* waits for the `03 03` burst on its fresh claim | `BudsRepositoryImpl` | ADR-031 | -| `MESSAGE_STREAM_LINGER_MS` | 1 500 ms | the Message Stream stays claimed after an action, then is released — or, while the noise-control tab is on screen, is held until that tab or the app is left and released this long after | `BudsRepositoryImpl` | ADR-032, ADR-061 | -| `HOLD_RECLAIM_STEP_MS` | 3 000 ms | in the Settings option "This phone has Google Play services" (`RECLAIM`) only: the delay before the re-claim after **every** foreign close of the held claim, measured from the close (1.2.1: `HOLD_RECLAIM_DELAYS_MS` = 3 000, 6 000, 12 000 ms); `CAP-075`'s Play services (one version): retry 2.1–2.8 s after the cut, recovery 5.3–5.8 s after its loss — a cycle of ≈ 9.3 s with ≈ 4.6–4.9 s held; never in the background; cancelled by leaving the tab or the app, a loss, a Disconnect or the setting | `BudsRepositoryImpl` | ADR-062 Update 2 | -| `HOLD_RECLAIM_CAP` | 20 re-claims | the re-claims per chain (≈ 3 min of 9.3-s cycles); then the hint with the count; a new chain at a tab entry, the app's return, a tap, Refresh, a pull, Take it back again or a Connect | `BudsRepositoryImpl` | ADR-062 Update 2 | +| `MESSAGE_STREAM_LINGER_MS` | 1 500 ms | the Message Stream stays claimed after an action, then is released (1.2.0 … 1.2.2 held it while the noise-control tab was on screen — ADR-061, gone since 1.3.0, ADR-065) | `BudsRepositoryImpl` | ADR-032 | +| `ANC_SET_WINDOW_MS` | 2 000 ms | how long after the app's own ANC `Set` a settings-stream `4:{13:n}` of that mode still counts as the app's change — not the Buds' (observed 269 ms from the Set, `CAP-051` 1527 → 1534, after the ACK and two Notifies; 55 ms after the second Notify, `CAP-056` 4001 → 4028); compared at the packet's arrival, not a timer. 1.2.1/1.2.2's `HOLD_RECLAIM_STEP_MS` (3 000 ms) and `HOLD_RECLAIM_CAP` (20) are gone with the re-claim | `BudsRepositoryImpl` | ADR-065 | +| — (no constant) | ≤ `SETTING_READ_TIMEOUT_MS` | the `SubscribeToSettingsChanges` request at every Connect and re-open: no wait, no retry (the stream pushes by itself); the `ReadSetting 4:13` after it waits for its answer like every settings read | `BudsRepositoryImpl` | ADR-064/065 | | `AVAILABILITY_PROVISIONAL_MS` | 2 000 ms | a Settable reading this soon after the open is provisional | `BudsRepositoryImpl` | ADR-049 | | `LOSS_REOPEN_DELAY_MS` | 1 500 ms | delay before the automatic re-open after a Buds-side close | `SessionReopener` | ADR-044 | | `CHAIN_GUARD_MS` | 10 000 ms | a loss this soon after an automatic re-open schedules no further one | `SessionReopener` | ADR-044 | @@ -506,7 +507,7 @@ summary), the current state is (every write below additionally passes the Safe-M | §4.5's other DLCI 0x02 settings (Touch & Hold, Head gestures, In-ear detection, Mono audio, Volume EQ, Volume Balance, Case sounds, Multipoint, Conversation Detection) | DLCI 0x02 | 🟢 FACT for several individual fields' number/semantic identity (ADR-019 and its Updates) | **ADR-036** — read-only `ReadSetting` for fields 2, 4, 7, 11, 15, 17, 19, 22, 27, 28 (no subscription); **ADR-045** (2026-09-26) — `WriteSetting` for 17, 19, 22, 4, 7; **ADR-046** (2026-09-28) — read + write of field 12; **ADR-047** (2026-09-28) — write of field 2 (both built `ai-sessions/0056`); **ADR-052** (2026-10-04) — read + write of field 29; **ADR-053** (2026-10-04) — write of 11; **ADR-054** (2026-10-06) — writes of 27, 28; **ADR-055** (2026-10-06) — write of 15 (all four built `ai-sessions/0074`) | **As built `ai-sessions/0052`:** reads of 2, 4, 7, 17, 19, 22 at Connect (`SettingsCodec`, fixtures `CAP-036` 1445…1540); **writes of 17, 19, 22, 4, 7 per ADR-045** (fixtures `CAP-019` 1720/1808, `CAP-020` 1741/1995, `CAP-021` 1895/3619/4315/4976, `CAP-022` 1621/1823/1922…2099, byte-identical); UI tabs Sound and Controls. **`ai-sessions/0056`:** field 12 read at Connect and written (ADR-046; fixtures `CAP-056` 1529/1531, 1689/1725/1786/1815/1843, OK 1697; channel 21 `CAP-041` 2176/2192/2198, `CAP-036` 1514/1516) and field 2 written (ADR-047; `CAP-056` 2173/4048, 2849/3627). **`ai-sessions/0074` (1.1.0):** fields 11, 15, 27, 28, 29 read at Connect and written — head gestures 1 = off / 2 = on (`SettingValue.HeadGestures`), the others 0/1; fixtures byte-identical: `CAP-069` 3161/3212 (11, ch 21), 2492/2564 (29, ch 21), `CAP-024` 1988/2023/2053/2084 and `CAP-058` 5623/5643/5680/5697 (28/27, both channels), `CAP-022` 1871/1895 (15, ch 19), `CAP-041` 2461 (15 off, ch 21); reads `CAP-036` 1471…1561 and their ch-19 counterparts in `CAP-024`/`CAP-069`. The 0052 settings were hardware-verified in `CAP-063`; 12 and 2 in `CAP-064` (ADR-046/047 Updates); **11, 15, 27, 28, 29 in `CAP-070`** (1.1.0, build `0323849`: 89 of 89 writes byte-identical and ACKed on both channels, read back on the next connection — `CAP-070-FINDINGS.md` §2–§3). *(Updated 2026-10-07, `ai-sessions/0075`, maintainer-approved in chat 2026-10-07, `AskUserQuestion` "Docs":)* the three forms not seen before — 11 and 29 on channel 19, `15:1` on channel 21 — are on the wire in `CAP-070` (A3668/A3679, A3614/A3685, A4800), byte-identical to the derived frames; since `ai-sessions/0076` (2026-10-07) these bytes are the unit-test fixtures (`Settings070`), the labelled structural tests and ADR-055's `// TODO(verify)` are gone (ADR-055 Update of 2026-10-07). | | Serial numbers (`GetHardwareInfo` field 7) | DLCI 0x02, pw_rpc `maestro_pw.Maestro` `GetHardwareInfo` | 🟢 FACT (method identity `PROTOCOL.md` §6 2026-09-24; the component order 7.1 Case / 7.2 Right / 7.3 Left, 2026-10-09) | **ADR-058** (2026-10-09) — one request per Connect, a read, not gated | **Implemented `ai-sessions/0082` (1.2.0)**: `Maestro.getHardwareInfoRequest`, `HardwareInfo`, `RoutedFrame.HardwareInfo`, `BudsRepositoryImpl.readHardwareInfo`; fixtures `CAP-036` 1415/1423 (ch 21) and `CAP-024` 801/832 (ch 19) — the answers with the serials redacted (`HardwareInfoFixtures`); the Info tab lines. **Hardware-verified in `CAP-072`** (30 requests byte-identical, one per session, 29 answered, the labels on the Info tab — ADR-058 Update of 2026-10-10) and again in `CAP-073` (11 of 11, 1.2.0 `5b4d5db`). | | Worn indicator ("probably worn") | — (no request; the Settable byte of DLCI 0x04, field 2 and the runtime-info charging flags) | 🟡 ADR-049 item 3 | **ADR-059** (2026-10-09) — a user-visible interpretation of a hypothesis, labelled as such | **Implemented `ai-sessions/0082` (1.2.0)**: `WornReading`/`wornReading()` (`:domain`), `BudsRepository.wornReading`, the battery card's line and (i); fixtures `CAP-065` 5465/6334, `CAP-064` 10394 (+ the field-2 write 9922), `CAP-062` 4845/2782. **Hardware-tested in `CAP-072`** (every reading followed the rule; `e8` on a table at 42 s, the documented limit — ADR-059 Update of 2026-10-10); the "in-ear detection is off" reading not yet seen on a phone (`TODO.md` §2). | -| "Changed by the Buds" (ANC-mode cause) | DLCI 0x04 `Notify` (existing) | 🟢 FACT for the `Notify` (`PROTOCOL.md` §4.1) | none needed (UI + state, no wire change; `CAP-069` §12 item 8); **ADR-061** (2026-10-10) — the claim held while the noise-control tab is on screen | **Hardware-verified `CAP-075` (`ai-sessions/0088`): the (i) line on film after a press-and-hold (17:27:31/37); a mode learned by a re-claim's own read shows no cause line — `CAP-075-FINDINGS.md` §9 P4.** **1.2.2 (`ai-sessions/0089`, ADR-062 Update 2): `AncModeCause.CHANGED_SINCE_LAST_READ` for a `Get` answer that differs from this connection's last report, its (i) line; the re-claim 3 s after every cut up to 20; Take it back again; fixtures `CAP-075` H2 2735/2980, 3006, 4893/5102, 5361, 3538/3964 (`Cap075Fixtures`); not hardware-verified — `CAP-076` (Group BL).** **Implemented `ai-sessions/0082` (1.2.0)**: `AncModeCause`, `BudsRepository.ancModeCause`, the (i) line; fixtures `CAP-045` 609/612/1583/1755/1818 (press-and-hold changes on a bud). `CAP-072`: shown inside a claim (A 13519, 17:51:10), never for a press-and-hold between claims (§5). **The hold, `ai-sessions/0084` (the 1.2.0 rebuild):** `setAncTabShown`, `releaseOrHold`; fixtures `CAP-072` A 13515/13519, B 825. **Hardware-verified for its main path in `CAP-073`** (`ai-sessions/0085`, 1.2.0 `5b4d5db`): the claim held 31 s on the tab, four unprovoked `Notify` shown without a tap, four taps on the held channel without a second `SABM`, the `DISC` 1.509 s after leaving the tab (`CAP-073-FINDINGS.md` §4); the tile, Home, a loss or re-open while the tab is shown and the (i) line not run. **In a user with Google Play services (`CAP-074`, 2026-10-12, `ai-sessions/0086`): the hold is cut 4.7–5.1 s after its `SABM` (12 of 12) by Play's Fast Pair recovery of the channel (5.2–5.7 s after it lost it), and the app does not re-claim — only the first press-and-hold after a Connect is shown there; verified as designed, the user-visible limit of 1.2.0 in that user (ADR-061 Update of 2026-10-12).** **1.2.1 (`ai-sessions/0087`, ADR-062):** the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default: the card's hint with the close's time, `ancChannelTaken`) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close of one visit, then the hint; the existing claim with its `Get`, nothing new on the wire); fixtures `CAP-074` B 15853/15860, the cut 5.133 s after `SABM` B 15822 (`Cap074Fixtures`); **not hardware-verified** — the run `CAP-075` (Group BK) in both users. | +| "Changed by the Buds" (ANC-mode cause) | DLCI 0x04 `Notify` (existing) | 🟢 FACT for the `Notify` (`PROTOCOL.md` §4.1) | none needed (UI + state, no wire change; `CAP-069` §12 item 8); **ADR-061** (2026-10-10) — the claim held while the noise-control tab is on screen | **Hardware-verified `CAP-075` (`ai-sessions/0088`): the (i) line on film after a press-and-hold (17:27:31/37); a mode learned by a re-claim's own read shows no cause line — `CAP-075-FINDINGS.md` §9 P4.** **1.2.2 (`ai-sessions/0089`, ADR-062 Update 2): `AncModeCause.CHANGED_SINCE_LAST_READ` for a `Get` answer that differs from this connection's last report, its (i) line; the re-claim 3 s after every cut up to 20; Take it back again; fixtures `CAP-075` H2 2735/2980, 3006, 4893/5102, 5361, 3538/3964 (`Cap075Fixtures`); not hardware-verified — `CAP-076` (Group BL).** **Implemented `ai-sessions/0082` (1.2.0)**: `AncModeCause`, `BudsRepository.ancModeCause`, the (i) line; fixtures `CAP-045` 609/612/1583/1755/1818 (press-and-hold changes on a bud). `CAP-072`: shown inside a claim (A 13519, 17:51:10), never for a press-and-hold between claims (§5). **The hold, `ai-sessions/0084` (the 1.2.0 rebuild):** `setAncTabShown`, `releaseOrHold`; fixtures `CAP-072` A 13515/13519, B 825. **Hardware-verified for its main path in `CAP-073`** (`ai-sessions/0085`, 1.2.0 `5b4d5db`): the claim held 31 s on the tab, four unprovoked `Notify` shown without a tap, four taps on the held channel without a second `SABM`, the `DISC` 1.509 s after leaving the tab (`CAP-073-FINDINGS.md` §4); the tile, Home, a loss or re-open while the tab is shown and the (i) line not run. **In a user with Google Play services (`CAP-074`, 2026-10-12, `ai-sessions/0086`): the hold is cut 4.7–5.1 s after its `SABM` (12 of 12) by Play's Fast Pair recovery of the channel (5.2–5.7 s after it lost it), and the app does not re-claim — only the first press-and-hold after a Connect is shown there; verified as designed, the user-visible limit of 1.2.0 in that user (ADR-061 Update of 2026-10-12).** **1.2.1 (`ai-sessions/0087`, ADR-062):** the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default: the card's hint with the close's time, `ancChannelTaken`) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close of one visit, then the hint; the existing claim with its `Get`, nothing new on the wire); fixtures `CAP-074` B 15853/15860, the cut 5.133 s after `SABM` B 15822 (`Cap074Fixtures`); hardware-verified `CAP-075`. **1.3.0 (`ai-sessions/0091`, ADR-064/065): the cause comes from the Buds' settings stream on DLCI 0x02 (`4:{13:n}`, `SettingValue.AncState`), on any tab, without a claim — the hold, the re-claim, the setting and the Play line removed; `AncModeCause` unchanged, the rules in §3.1; fixtures `CAP-051` 1177/1178/1225/1717/1755/1802/1527…1534, `CAP-056` 1451/1450/1468/2259/4025/4028/1696/2175, `CAP-038` 2170/2205, `CAP-045` 2033/2277, `CAP-021` 1089/1842, `CAP-027` 923, `CAP-069` 2502, `CAP-015` 2116, `CAP-036` 1517/1519 (`SettingsStreamFixtures`); not hardware-verified — `CAP-076` (Group BL, rewritten).** | | Safe Mode / Startup Handshake | DLCI 0x02 announcement + DLCI 0x04 Model ID | 🟢 FACT (firmware string ADR-012/034; Model ID `PROTOCOL.md` §0.1) | **ADR-042** | **Implemented** (`SafeModeGate`, `:data`; Safe Mode card, `:ui`) | | Find My Buds Case / "ring both" | — | — | ADR-027 (premise narrowed 2026-09-24: spec `0x03` "ring both" untested) | **Out of scope**; sending `0x03` needs its own ADR | @@ -627,42 +628,22 @@ stack, not a new architectural choice (no `DECISIONS.md` entry; nothing here cha DLCI 0x04 (ANC mode, battery) are only as fresh as the last claim. Play services' recovery of its own socket took 2.7–5.0 s in three observed cases, so a claim is kept short. This supersedes the "evaluated and not adopted" note `ai-sessions/0039` had recorded here. - **Since the 1.2.0 rebuild (`DECISIONS.md` ADR-061, `ai-sessions/0084`; the maintainer's choices in chat 2026-10-10):** while the **noise-control tab is on - screen** — the selected tab with the settings menu closed (`OpenControlNavHost` → `OpenControlActions.onAncTabShown` → `BudsRepository.setAncTabShown`) - **and** the app visible (`onAppVisible`: resume … stop; an (i) dialog, Quick Settings and a rotation keep it) — the claim is **held**: entering the tab claims - once with the existing claim code and its `Get` (or keeps a claim still lingering), a tap, a pull or the tile reuse the open channel, and `withMessageStream`'s - `finally` (`releaseOrHold`) schedules no release. Leaving the tab or the app ends the hold and the usual release follows `MESSAGE_STREAM_LINGER_MS` later. - A session loss or a Disconnect ends it with the session; the Buds (or another client) closing DLCI 0x04 ends it **without a re-claim** (in the default mode of - the Settings tab — see ADR-062 below) — the next tap, pull or a - re-entry of the tab claims again; an ADR-044 re-open's snapshot claim is kept as the hold while the tab is shown. Each start and end is logged (always on, - payload-free, with the reason). Nothing new on the wire: the same `SABM`/`08 11`/`DISC` of ADR-032, only the moment of the `DISC` changes. On a phone with - Google Play services, Play's Fast Pair loses DLCI 0x04 to this app when the tab is opened (ADR-061 Consequences) — for ≈ 5 s only, see below; the ANC (i) says so. - **Observed 2026-10-12 (`CAP-074`, ADR-061 and ADR-032 Updates):** that lockout lasts ≈ 5 s — Play's Fast Pair re-connects 5.2–5.7 s after losing the channel, the - stack closes the app's port on that collision (the `DISC` on the wire, `read return: -1` in the export) and, by ADR-061's design, nothing re-claims until the next tap, pull, - tab re-entry or Connect; Play retries 2.2–2.6 s after a collided recovery. In the user without Play services nothing contends (`CAP-073`). - **Since 1.2.1 (`DECISIONS.md` ADR-062, `ai-sessions/0087`; the maintainer's choices in chat 2026-10-12):** what follows such a foreign close of a *held* claim is - the Settings tab's **"Noise-control channel"** choice (`HoldReclaimSettingsStore`, DataStore key `hold_reclaim_mode`; `BudsRepositoryImpl` follows its flow at - once, as the Debug-mode switch). **"Show a hint, do not take it back"** (the default, `HoldReclaimMode.HINT_ONLY`): as above, plus the noise-control card's hint - with the close's time (`BudsRepository.ancChannelTaken`, cleared when the app holds the channel again, at Connect, Disconnect and a loss). **"Take it back while - the noise-control tab is open"** (`RECLAIM`): the `channelClosed` collector schedules **one** bounded delay — `HOLD_RECLAIM_DELAYS_MS`: 3 s after the first foreign - close of the chain, 6 s after the second, 12 s after the third — then, under the claim mutex and only if the tab is still shown, the app visible, the session - `Ready` and the channel still closed, the existing claim with its `Get` (`refreshAncMode`'s path; its `finally` keeps it as the hold); after the third re-claim the - hint, until a tap, Refresh, a pull, a re-entry of the tab, the app's return or a Connect starts a new chain (each also drops a pending re-claim, as do leaving the - tab or the app, a loss, a Disconnect and a switch to "hint only"). The 3 s let Play's own retry (2.2–2.6 s) succeed first, so the app's connect finds Play on the - channel (attempt 1 kills its socket, attempt 2 connects 0.4 s later) instead of racing the retry into a free channel; Play then recovers 5.2–5.7 s after its loss and - cuts the app again — the app holds ≈ 5 of every 8.5 s during the chain, three kills of Play's socket per visit, then nothing (`ai-sessions/0087` RESULT §A.3's - candidates). One always-on, payload-free line per re-claim ("Message Stream re-claim N/3 after a foreign close, D ms later (ADR-062)"), one when it stops, one - when it is cancelled, one in the hint-only mode. Nothing new on the wire; not a loop (ADR-032 item 2, ADR-044): every chain has its cap and ends with the tab, - the app's visibility, the session or the setting. **Hardware-verified by `CAP-075` (`ai-sessions/0088`, 2026-10-13; ADR-062 Update):** without Play both modes identical (41 of 41 holds shown); with Play the hint 2 of 2, three chains × three re-claims at 3 / 6 / 12 s scheduled (4.0–4.8 s on the wire — attempt 1 refused, Play's retry 2.1–2.8 s after the cut being on the channel; attempt 2 ≈ 0.9–1.5 s later), each hold cut 5.3–5.8 s after Play's loss, ≈ 43 % of a 44-s chain held then nothing (`CAP-075-FINDINGS.md` §4, §9 — the measured numbers replace the ≈ 5-of-8.5-s and 0.4-s estimates above). - **Since 1.2.2 (`DECISIONS.md` ADR-062 Update 2, `ai-sessions/0089`; the maintainer's choices in chat 2026-10-13):** the step is **the same after every cut** — - `HOLD_RECLAIM_STEP_MS` = 3 s, measured from the close as before — and the chain is capped at `HOLD_RECLAIM_CAP` = **20** re-claims; the log line keeps its form - ("Message Stream re-claim N/20 after a foreign close, 3000 ms later (ADR-062)"), the stop line names "Take it back again". Why: Play's timers did not change over - 15 cycles, so 1.2.1's 6/12-s steps only lengthened the dead time (51 / 39 / 26 % per cycle); at 3 s every cycle is `CAP-075`'s first one (≈ 9.3 s, ≈ 50 % held). - The hint's **Take it back again** (`reclaimAncChannel`) logs "Message Stream claim by the hint card's action (ADR-062 Update 2)", starts a new chain and makes - the existing claim with its `Get` — no `Set`, no battery claim, no DLCI 0x02 request; in `HINT_ONLY` the claim is held until the next cut and nothing re-claims. - The setting is renamed "Noise control with Google Play services" (its stored names and DataStore key unchanged); the card's Play-services line is ADR-063 - (a `PackageManager` query in `:app` with a `` entry for that one package — information only). A `Get` answer with a mode other than this connection's - last report is `AncModeCause.CHANGED_SINCE_LAST_READ` (`causeOfNotify`; the first report of a connection stays `READ`). Not hardware-verified: `CAP-076`. + **1.2.0 … 1.2.2 (history — `DECISIONS.md` ADR-061, ADR-062 with its Updates, ADR-063; superseded by ADR-065 on 2026-10-14, `ai-sessions/0091`):** the + claim was **held** while the noise-control tab was on screen (`setAncTabShown`, `releaseOrHold`), so a press-and-hold's `Notify` reached the app; in the user + with Google Play services Play's Fast Pair cut that hold 5.3–5.8 s after losing the channel (`CAP-074`, `CAP-075`), so 1.2.1 added the "Noise-control channel" + setting (a hint, or a re-claim 3/6/12 s after the cuts), 1.2.2 renamed it "Noise control with Google Play services" with a re-claim 3 s after every cut up to + 20 times, "Take it back again" and the Play-services line (`PlayServicesProbe`, a `` entry) — about half of the holds shown at best. The texts and + numbers of that design are in those ADRs and in `CAP-073`/`CAP-074`/`CAP-075`'s findings; nothing of it is in the code since 1.3.0. + **Since 1.3.0 (`DECISIONS.md` ADR-064/065, `ai-sessions/0091`; the maintainer's choices in chat 2026-10-14):** the mode comes from the Buds' **settings stream + on the app's own channel** (§3.1): one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session, after `SubscribeRuntimeInfo` and before + `GetHardwareInfo` (`launchInitialEqRead`: `subscribeToSettingsChanges`, `readAncSetting`), on every Connect and every ADR-044 re-open — never retried, never + polled; a `SERVER_STREAM` `4:{13:n}` is the mode with the packet's time (`applyAncState`), a `Notify` on a claim still counts (the last value received wins). + The Message Stream claim is ADR-032's again: a tap, the tile, Refresh, a pull and the Connect's snapshot claim it and `withMessageStream`'s `finally` schedules + the release `MESSAGE_STREAM_LINGER_MS` later; a foreign close (`channelClosed`) only drops the claim's buffer and Model ID — nothing re-claims by itself, no hint, + no setting. In the user with Google Play services a tap can still collide with Play's claim (`CAP-075-FINDINGS.md` §5: attempt 1 refused, attempt 2 ≈ 1 s + later, or the tap's own error) — the known issue of 1.3.0; the holds themselves no longer depend on the channel. Always-on, payload-free lines: "Settings + stream subscribed (channel N)", "Noise-control mode read (channel N)", "Noise-control mode from the settings stream ()" / "… from the mode read (…)", + "… : no mode settable", "Settings stream: field N not shown (not read by this app)", and the failures with their reason. Not hardware-verified: `CAP-076`. - **Observing Android's own state is read-only and visibility-bound (`ai-sessions/0041`).** The Connection screen mirrors what Android's Bluetooth settings show (paired / connected to this phone) via `OsConnectionObserver`: public APIs only — `BluetoothProfile.getConnectedDevices()` @@ -794,10 +775,9 @@ the router has no exception handler and would otherwise stop for the life of the converts a `SecurityException` into `PermissionDenied` and any other `RuntimeException` into `ChannelLost` with its text, on write, read and close (A68-APP-10). The UI texts are English only; they are Kotlin literals, not string resources (A68-APP-15, `TODO.md`). -**No new `BudsError` for a taken channel (1.2.1, ADR-062):** a held claim closed by another client is not a failure of anything the user asked for, so it is a -state, not an error — `:domain`'s `ChannelTaken(atMillis, reclaimAttempts)` on `BudsRepository.ancChannelTaken`, rendered by the noise-control card as its -hint line (§2.4) and cleared when the app holds the channel again. A claim the user *does* ask for and that fails keeps its own errors (`ChannelUnavailable`, -`AnswerCutOff`, …). +**No new `BudsError` for a taken channel (1.2.1 … 1.2.2, ADR-062 — history since 1.3.0, ADR-065):** a held claim closed by another client was a state, not an +error (`:domain`'s `ChannelTaken` on `BudsRepository.ancChannelTaken`, the card's hint line); both are gone with the hold. A claim the user *does* ask for and +that fails keeps its own errors (`ChannelUnavailable`, `AnswerCutOff`, …); a foreign close of a lingering claim is still no error (`channelClosed`). **`UnidentifiedFrame` is deliberately not part of `BudsError`.** A frame with an unrecognized Group/Code (typically on DLCI 0x08, §5) parsed successfully — @@ -884,10 +864,9 @@ of firmware versions this app has been verified against. | `FOREGROUND_SERVICE_CONNECTED_DEVICE` | `:hardware` | its type on API 34+ | The merged manifest adds AndroidX Core's private `DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` (signature level, this app only). No `INTERNET`. - **Package visibility (since 1.2.2, `DECISIONS.md` ADR-063, `ai-sessions/0089`):** `:app`'s manifest has one `` entry, ``, so `PlayServicesProbe` can ask `PackageManager` whether Google Play services is installed and enabled for this user — - the Settings card's information line. Not a permission; nothing of that package is imported, bound, started or read (`AGENTS.md` §1's dated note of - 2026-10-13). ADR-050's links still need no ``. + **Package visibility:** none. 1.2.2 had one `` entry for `com.google.android.gms` (`DECISIONS.md` ADR-063, `ai-sessions/0089`: `PlayServicesProbe` + asked `PackageManager` whether Google Play services was installed — information only); since 1.3.0 (ADR-065, `ai-sessions/0091`) the entry and the probe are + gone (`AGENTS.md` §1's pointer of 2026-10-14). ADR-050's links need no ``. - **Companion Device Manager (CDM):** initial pairing/discovery uses `CompanionDeviceManager` (API 26+) instead of custom BLE scanning — this delegates the scan UI to the OS and grants the app access only to the diff --git a/CAPTURE_BLUETOOTH_HCI_SNOOP.md b/CAPTURE_BLUETOOTH_HCI_SNOOP.md index 9f5a2f9c..b03a961c 100644 --- a/CAPTURE_BLUETOOTH_HCI_SNOOP.md +++ b/CAPTURE_BLUETOOTH_HCI_SNOOP.md @@ -1865,24 +1865,30 @@ exists yet: this run makes the first.** **Status:** 🔲 planned (skeleton written 2026-10-12 by `ai-sessions/0087`; the run follows `RELEASING.md` B1–C2 for 1.2.1). -#### Group BL — 1.2.2, the constant re-claim step, "Take it back again", the cause line and the renamed card (ADR-062 Update 2, ADR-063) in both GrapheneOS users (skeleton by `ai-sessions/0089`) - -Pixel 9a / GrapheneOS, **1.2.2** (versionCode 10202 — the follow-up of `CAP-075`, `ai-sessions/0089`), installed over the 1.2.1 `31ee183` (one APK serves both -users; the stored choice in both users is 1.2.1's "Take it back"). **Two parts, ≤ 20 min on film with sound** (the maintainer's choice in chat 2026-10-13, -"CAP-076" → "both users, as CAP-075"). The skeleton captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md holds: **part 1, the Owner user -(user 0) with Google Play services** — P0 with Play's *Nearby devices* permission state and version on film (`TODO.md` §3's standing item), the update, Info and -the card **"Noise control with Google Play services"** with its (i), "Google Play services: found on this phone" (ADR-063) and the 1.2.1 choice kept as "This phone -has Google Play services"; holds every ≈ 4 s for 3½ minutes — re-claims **3 s after every cut, up to 20** (≈ 9.3-s cycles, ≈ 50 % held, against `CAP-075`'s 43 % for -44 s), the stop and the hint "…; this app took it back 20 times and stopped. …" with **Take it back again**; the system log right after the chain (Play's "Retry -times remaining"); the (i) line "Changed since the app last saw it (read at HH:MM:SS) …" after a re-claim's changed read; Take it back again → one claim with its -`Get`, **no `Set`**, a new chain (1/20); a tab change, Home and the switch to "This phone has no Google Play services" within 2 s of a cut (`CAP-075`'s untested -BK-11 … BK-13); the APK pull. **Part 2, the secondary user (user 10) without Google Play** — "not found on this phone", the `CAP-073` §4 regression in both options -(every hold shown, no re-claim, no hint), the (i) after a hold made while the app had no claim; optional: the tile, Home/return, a loss with the tab shown -(`TODO.md` §2). Test-IDs: [`PAIR-003`], [`BATT-004`], [`FW-003`], [`ANC-001`]…[`ANC-004`], [`TOUCH-007`]. Reference frames re-derived from `CAP-075` (the Connect -reads on channels 19/21, the cut H2 2369, Play's retry 2401, the refused attempt 2476 and the re-claim's `SABM` 2502, the shown hold 3006, a re-claim's changed -read 2968 → 2980; the hold without Play H1 1192 … 1389) — in the skeleton's table. - -**Status:** 🔲 planned (skeleton written 2026-10-13 by `ai-sessions/0089`; the run follows `RELEASING.md` B1–C2 for 1.2.2). +#### Group BL — 1.3.0, the settings stream on the app's own channel (ADR-064, ADR-065) in both GrapheneOS users (skeleton by `ai-sessions/0089`, rewritten by `ai-sessions/0091`) + +Pixel 9a / GrapheneOS, **1.3.0** (versionCode 10300 — `ai-sessions/0091`; the 1.2.2 skeleton of `ai-sessions/0089` is replaced, 1.2.2 having been released +without this run), installed over 1.2.2 `bfd7891` (one APK serves both users). **Two parts, the extended tier, screen recordings with sound and a camera on +the ears and the phone** for the hold steps. The skeleton captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md holds: **part 1, +the Owner user (user 0) with Google Play services** — P0 with Play's *Nearby devices* permission state and version on film (`TODO.md` §3's standing item), the +update, Info and a Settings tab **without** the "Noise control with Google Play services" card and the Play line; the Connect's **fifteen** MAESTRO requests +(`SubscribeToSettingsChanges` and `ReadSetting 4:13` after `SubscribeRuntimeInfo`, byte-identical to `CAP-051` 1177/1178 on channel 21 or `CAP-056` 1451/1450 on +19); **ten press-and-holds with the noise-control tab closed and ten with it open** (≥ 10 each) — every hold one `4:{13:n}` stream packet on DLCI 0x02 and the +mode on screen within a second, **no `SABM` on DLCI 0x04 from the app**; the (i) "Changed by the Buds at HH:MM:SS: …"; a tap on the tab and the tile (one claim +each, the stream packet of the own `Set` ≈ 0.3 s later, released ≈ 1.5 s after the ACK); a wear-change re-home (one bud out with both worn: the Buds' `DISC` → +the ADR-044 re-open → the subscription and the read again, the mode before/after, "Changed since the app last saw it (read at HH:MM:SS)" if it differs); +Bluetooth off/on (the re-open subscribes again); taps while Play holds the channel (the `CAP-075` §5 collision — the known issue, nothing re-claims); optionally +the EQ and a switch changed in the official app on a second phone (the stream packets for field 16 / 2 reach OpenControl) — skipped and said so if no second +phone; the APK pull. **Part 2, the secondary user (user 10) without Google Play** — the same holds (tab closed, tab open), the tap, the tile, the wear-change +re-home, Bluetooth off/on, a Disconnect with two holds made while disconnected and the Connect's read ("Changed since the app last saw it"), both buds into the +case and out (a value 0 never shown as a mode); optional Home with holds while hidden. **Counts for the verdict:** OpenControl's `SABM`s on DLCI 0x04 vs the +taps/tile/pulls/Connects made; holds shown vs made; stream packets vs `Notify`s; the first read's value after every Connect/re-open. Test-IDs: [`PAIR-003`], +[`BATT-004`], [`FW-003`], [`ANC-001`]…[`ANC-004`], [`TOUCH-007`]. Reference frames (the official app's bytes, re-derived by `ai-sessions/0091`: the two requests, +the `4:{13:n}` answers and stream packets per channel and value, the other fields' packets, the own tap's order `CAP-051` 1527 → 1534) and the `tshark` lines +to find them — in the skeleton's table. + +**Status:** 🔲 planned (skeleton written 2026-10-13 by `ai-sessions/0089` for 1.2.2; rewritten 2026-10-14 by `ai-sessions/0091` for 1.3.0; the run follows +`RELEASING.md` B1–C2 for 1.3.0). ### 4.3 Hardware Actions (either phone) @@ -2287,7 +2293,7 @@ is how the 2026-08-18 `CAP-005`/`CAP-007`/`CAP-010` ID-reuse incident (see | `CAP-073` | 2026-10-10 | Pixel 9a (secondary user 10 without Google Play) | 17 (`CP3A.261005.005`) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.2.0** rebuilt, build `5b4d5db`, release-signed (Info tab on film, no -dirty; installed APK SHA-256 = B1's), an update over the `ec6d163` 1.2.0 | BI (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004`, `TOUCH-007`, `CASE-004`, `CASE-005`, `INEAR-006`, `AUDIO-003`; `AUDIO-002`, `INEAR-004` not run | The re-test of the rebuilt 1.2.0 (`ai-sessions/0085`): 11 sessions with the thirteen requests byte-identical to `CAP-072`; the ADR-061 hold held DLCI 0x04 31 s on the noise-control tab, four unprovoked `Notify` shown without a tap, four taps on the held channel (no `SABM`), released 1.509 s after leaving the tab; balance one write per release, "Right 4" on the third; 6 writes and 4 `Set`s answered; no camera, no audio; verdict OK — ready to release | — (one `bugreportz` archive, not kept; the raw logs) | `CAP-073-btsnoop_hci.log` (08:22:51–08:35:07, the run), `CAP-073-btsnoop_hci.log.last` (2026-10-09 18:32 – 2026-10-10 08:22, before the run, the Owner user); one export, the app logcat, `CAP-073-logcat-extract.txt`, the shell log, `CAP-073-screen.mp4` (blurred) | analyzed | | `CAP-074` | 2026-10-12 | Pixel 9a (**the Owner user 0 with Google Play services**) | 17 (`CP3A.261005.005`) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.2.0**, build `5b4d5db`, release-signed (the published release; Info tab on film, no -dirty; the installed APK not pulled) | BJ (new) | `PAIR-003`, `ANC-001`–`ANC-004`, `TOUCH-007`, `BATT-004`, `FW-003`; `AUDIO-002` not run | The maintainer's ad-hoc run in the user with Play services (`ai-sessions/0086`): nine Disconnect/Connect pairs, after each the ANC tab and press-and-holds; 10 sessions byte-identical to `CAP-073`; **every ADR-061 hold cut 4.7–5.1 s after its `SABM` by Play services' Fast Pair recovery** (5.2–5.7 s after it lost the channel, 20 of 20) — 9 holds shown, 35 reached Play services: the cause of the first-hold-only pattern (`CAP-074-FINDINGS.md` §4, §8); fix: a Settings switch, the maintainer's choice | — (`bugreportz` by the maintainer, not kept; the raw logs) | `CAP-074-btsnoop_hci.log` (2026-10-11 19:50 – 2026-10-12 06:52: the run and a 06:30–06:31 window of an earlier process), `CAP-074-btsnoop_hci.log.last` (2026-10-11); one export (06:38–07:03), the app logcat, `CAP-074-logcat-extract.txt`, `CAP-074-recording.mp4` (with audio, as is), `CAP-074-sha256sum.txt` | analyzed | | `CAP-075` | 2026-10-12 (set 1, 17:22:31–17:28:43 on film) and 2026-10-13 (set 2, 06:29:04–06:34:32 on film) | Pixel 9a (**set 1: the secondary user without Google Play; set 2: the Owner user 0 with Google Play services**) | 17 (`CP3A.261005.005` in `CAP-074`; not re-read) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.2.1** (versionCode 10201), build `31ee183`, release-signed (Info tab on film in both users, no -dirty; the installed APK not pulled), updated over 1.2.0 `5b4d5db` on film | BK (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004` (no mode tap: shown from `Notify` only), `TOUCH-007`; `AUDIO-002`, `CASE-004`/`005` not observed | The hardware run of the "Noise-control channel" switch (`DECISIONS.md` ADR-062): without Play both positions identical, 41 of 41 holds shown, no foreign close; with Play "Show a hint" = the hint with the cut's time, 0 of 9 holds shown; "Take it back" = three chains × three re-claims (3/6/12 s scheduled, 4.0–4.8 s on the wire), every re-claim colliding with Play's retry, every hold cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 holds shown; the cut's stack path 15 of 15 in the system log (`CAP-075-FINDINGS.md` §4, §5, §8); verdict no heavy issue — 1.2.1 released as built; proposals P1/P2/P4 to the next FEATURE prompt | — (`bugreportz` 06:35:41 and the GrapheneOS log viewer's full log, both local only) | `CAP-075-btsnoop_hci1.log.last` + `CAP-075-btsnoop_hci1.log` (set 1, split at the Bluetooth off/on 17:23:04/08), `CAP-075-btsnoop_hci2.log.last` (2026-10-12 18:13 – 2026-10-13 06:29, incl. the Owner's unfilmed evening session) + `CAP-075-btsnoop_hci2.log` (06:29–06:36); two exports, two app logcats, `CAP-075-logcat-extract.txt` (set 2's system log, redacted), `CAP-075-recording1.mp4` / `CAP-075-recording2.mp4` (with audio, as is), `CAP-075-sha256sum.txt` | analyzed | -| `CAP-076` | TBD | Pixel 9a (**part 1: the Owner user 0 with Google Play services; part 2: the secondary user 10 without**) | 17 | `release_5.203` expected | OpenControl for Pixel Buds Pro 2 **1.2.2** (versionCode 10202), the release APK of `scripts/release.sh 1.2.2`, installed over 1.2.1 `31ee183` — build hash on the Info tab on film, the installed APK pulled | BL (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004`, `TOUCH-007` | The hardware run of ADR-062 Update 2 and ADR-063 (`ai-sessions/0089`): in the Play user the re-claim 3 s after every cut up to 20 (≈ 50 % held for ≈ 3 min), the hint with "20 times" and Take it back again (one claim, no `Set`, a new chain), the changed-since-last-read (i) line, the cancel paths, the renamed card with "found on this phone"; in the user without Play the `CAP-073` §4 regression in both options and "not found on this phone" | — | TBD | planned | +| `CAP-076` | TBD | Pixel 9a (**part 1: the Owner user 0 with Google Play services; part 2: the secondary user 10 without**) | 17 | `release_5.203` expected | OpenControl for Pixel Buds Pro 2 **1.3.0** (versionCode 10300), the release APK of `scripts/release.sh 1.3.0`, installed over 1.2.2 `bfd7891` — build hash on the Info tab on film, the installed APK pulled | BL (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004`, `TOUCH-007` | The hardware run of ADR-064/ADR-065 (`ai-sessions/0091`, 1.3.0): the two new requests once per MAESTRO session; press-and-holds with the noise-control tab closed and open shown in both users through the settings stream, no `SABM` on DLCI 0x04 from the app; the own tap's stream packet; the wear-change re-home and the re-open's read; Bluetooth off/on; taps while Play holds the channel; optionally the EQ/a switch from the official app on a second phone; the card and the Play line gone | — | TBD | planned | **Column notes:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 566898ec..243005c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,35 @@ build commit `bfd7891`, `ai-sessions/0089`; tested by the maintainer on the phon ## [Unreleased] +## [1.3.0] + +Prepared in `ai-sessions/0091` (2026-10-14) after `ai-sessions/0090`'s cross-check of the official app (`DECISIONS.md` ADR-064, ADR-065): the Buds report every +change of the noise-control mode — and of every other setting — on the app's own channel, once the app asks for it. Two new requests on the wire at every +Connect, both byte-identical to the official app's. + +### Changed + +- **A press-and-hold on a bud now shows in the app whatever tab is open, with or without Google Play services on the phone.** The Buds push the new mode on the + app's own channel; the noise-control (i) says "Changed by the Buds at HH:MM:SS". The channel shared with Google Play services is claimed only for a tap, as in + 1.1.x. +- **Every setting the app shows follows a change made elsewhere** (the official app on another phone, the Buds themselves): the equalizer, the switches, the + balance, the press-and-hold actions and modes — each with the time it came in. +- The noise-control mode is read at every Connect and re-open: a change made while the app was not connected shows at once ("Changed since the app last saw + it"). + +### Removed + +- The setting "Noise control with Google Play services", its card, the "Google Play services: found / not found" line, the hold of the channel while the + noise-control tab is open, the re-claims and "Take it back again" (`DECISIONS.md` ADR-061, ADR-062, ADR-063 — superseded by ADR-065). A choice stored by 1.2.1 + or 1.2.2 is ignored. The app no longer asks Android whether Google Play services is installed (the manifest's `` entry is gone). + +### Known issues + +- A tap on a noise-control mode claims the shared channel for about 1.5 seconds; on a phone with Google Play services, Play's Fast Pair may be on it at that + moment (about 5 seconds after a claim) — the tap then fails with a message; tap again. +- When the Buds close the session themselves (one bud out while both are worn — 12 of 77 such changes in the captures), the app re-opens it and reads the mode + again; in between, the mode shown can be a few seconds old. + ## [1.2.2] - 2026-10-14 Prepared in `ai-sessions/0089` (2026-10-13) after `CAP-075` (`ai-sessions/0088`): with Google Play services, Play's Fast Pair takes the noise-control channel diff --git a/DECISIONS.md b/DECISIONS.md index f1025530..c0f6c885 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1787,6 +1787,10 @@ motivated this). this text in the preview):** (i)'s "Play services' recovery of its own socket took 2.7–5.0 s" reads **5.2–5.7 s in `CAP-074`** (20 of 20 recoveries, the Owner user with Google Play services), with a retry 2.2–2.6 s after a recovery that collided; with the ADR-061 hold the recovery cuts the app's claim (`CAP-074-FINDINGS.md` §4 items 3–5). The decision is unchanged. +- **Update (2026-10-14, `ai-sessions/0091`, pointer only — maintainer-approved in chat 2026-10-14, `AskUserQuestion` "ADR-065", option *"Accept as drafted + (Recommended)"*):** the hold exception of the 2026-10-10 Update is gone — **ADR-065** removes ADR-061's hold and ADR-062's re-claim; item 3's "released after + a short linger" holds again for every claim, and the claim per tap (with the Connect's snapshot claim) is again the only Message Stream behaviour. The mode + reaches the app without a claim, on DLCI 0x02 (ADR-064/065). The other items are unchanged. ## ADR-033 — Battery Option B: `Group 0x03 Code 0x03` decoder on DLCI 0x04 unblocked for implementation (percentage regime only) @@ -2693,7 +2697,8 @@ motivated this). ## ADR-061 — Hold the Message Stream claim while the noise-control tab is on screen - **Date**: 2026-10-10 -- **Status**: Accepted (maintainer, chat 2026-10-10, `ai-sessions/0083`) — not yet built +- **Status**: Accepted (maintainer, chat 2026-10-10, `ai-sessions/0083`) — built in the 1.2.0 rebuild (`ai-sessions/0084`). **Superseded by ADR-065 (2026-10-14, + `ai-sessions/0091`): the hold is removed in 1.3.0 — the mode comes from the Buds' settings stream on the app's own channel.** The text below is kept as written. - **Note on process**: drafted by an AI agent (`ai-sessions/0083`); the decision is the maintainer's, given in the chat of 2026-10-10 (`AskUserQuestion` "Hold result", option *"Fix first: hold 0x04 on ANC tab"*, and "Hold ADR", option *"Accept as ADR-061 now (Recommended)"*, with this text in the preview), per `AGENTS.md` §6. @@ -2729,8 +2734,9 @@ motivated this). - **Date**: 2026-10-12 - **Status**: Accepted (maintainer, chat 2026-10-12, `ai-sessions/0087`) — supersedes ADR-061's clause "the Buds (or another client) closing DLCI 0x04 ends it - without a re-claim" for the switch's mode "take it back" only; ADR-061's hold and ADR-032's claim model stand. Built in 1.2.1 (`ai-sessions/0087`); **not yet - hardware-verified** — the run `CAP-075` (Group BK). + without a re-claim" for the switch's mode "take it back" only; ADR-061's hold and ADR-032's claim model stand. Built in 1.2.1 (`ai-sessions/0087`); + hardware-verified by `CAP-075` (its Update of 2026-10-13). **Superseded by ADR-065 (2026-10-14, `ai-sessions/0091`), with its Updates: the re-claim, the + setting, its card and "Take it back again" are removed in 1.3.0; a stored `hold_reclaim_mode` is ignored.** The text below is kept as written. - **Note on process**: drafted by an AI agent (`ai-sessions/0087`); the switch with its two modes is the maintainer's choice in chat 2026-10-12 (`ai-sessions/0086` Checkpoint D, verbatim there); the delay/back-off/cap, the default, the texts and this text approved in the chat of 2026-10-12 (`AskUserQuestion` "Re-claim delay" → *"3 s, 6 s, 12 s, then stop (Recommended)"*, "Default mode" → *"Hint only (Recommended)"*, "Switch card", "Hint texts", "ADR-062" → *"Accept ADR-062 as @@ -2803,7 +2809,9 @@ motivated this). ## ADR-063 — The Settings card may say whether Google Play services is installed on this phone (information only) - **Date**: 2026-10-13 -- **Status**: Accepted (maintainer, chat 2026-10-13, `ai-sessions/0089`) — built in 1.2.2; not hardware-verified (`CAP-076`, Group BL) +- **Status**: Accepted (maintainer, chat 2026-10-13, `ai-sessions/0089`) — built in 1.2.2; never hardware-verified (1.2.2 was released without `CAP-076`). + **Superseded by ADR-065 (2026-10-14, `ai-sessions/0091`): the Play-services line, `PlayServicesProbe` and the `` entry are removed in 1.3.0.** The + text below is kept as written. - **Note on process**: drafted by an AI agent (`ai-sessions/0089`); the information line is the maintainer's choice in chat 2026-10-13 (`AskUserQuestion` "Play check" → *"Ja, als informatieregel"*, against the agent's recommendation "no"), this text approved in the same chat (`AskUserQuestion` "ADR-063" → *"ADR-063 zoals getoond (Recommended)"*, with this text in the preview), and the dated note in `AGENTS.md` §1 likewise ("AGENTS.md" → *"Ja, gedateerde notitie in §1"*), @@ -2858,6 +2866,59 @@ motivated this). - **Consequences**: the codec gains the subscription request and a field-13 decoder (real `CAP-051`/`CAP-056` bytes as fixtures, fuzzed); `BudsRepositoryImpl`'s Connect sequence gains two requests; `PROTOCOL.md` §4.1 Update of 2026-10-14 is its evidence. A FEATURE session builds it with its hardware run (`TODO.md` §5). +- **Update (2026-10-14, `ai-sessions/0091`, pointer only — maintainer-approved in chat 2026-10-14, `AskUserQuestion` "ADR-065", option *"Accept as drafted + (Recommended)"*):** built in 1.3.0. **ADR-065** amends item 2 (every field the app reads is decoded from the stream, not only 13) and item 6 (ADR-061/062/063 + are retired now, not after the hardware run); items 1, 3, 4 and 5 are unchanged. The hardware run is `CAP-076` (Group BL, rewritten for 1.3.0). + +## ADR-065 — The settings stream (DLCI 0x02) replaces the hold and the re-claim of the Message Stream: every setting the app reads follows the Buds' push; the Play-services setting, card and package query are removed + +- **Date**: 2026-10-14 +- **Status**: Accepted (maintainer, chat 2026-10-14, `ai-sessions/0091`) — built in 1.3.0; hardware run `CAP-076` (Group BL) +- **Supersedes**: ADR-061 (the hold), ADR-062 and its Updates of 2026-10-13 (the re-claim, the setting, "Take it back again", the card), ADR-063 (the + Play-services line and the `` entry). **Amends** ADR-064 item 2 (every read field is decoded from the stream, not only 13) and item 6 (the retirement + happens now, not after the hardware run). ADR-032's claim per tap, with its linger and release, is again the only Message Stream behaviour; the Connect-time + snapshot claim stays. +- **Note on process**: drafted by an AI agent (`ai-sessions/0091`); the decision is the maintainer's, given in the chat of 2026-10-14 (`AskUserQuestion` + "ADR-065", option *"Accept as drafted (Recommended)"*, with this text in the preview), per `AGENTS.md` §6. It rests on ADR-064 and the `PROTOCOL.md` §4.1 + Update of 2026-10-14 (🟢 setting 13 = the mode). The same chat settled, each with its own question: the (i) text and the two cause lines (Q3, Q10), one + rule for every read answer also across a Connect (Q4), the availability fed by setting 13 (Q5), the order of the requests (Q6), the snapshot claim kept + (Q7), the files deleted (Q8), `ReadSetting 4:8/9/10` not in this run (Q9), the release texts (Q11) — verbatim in `ai-sessions/0091`'s RESULT §B. +- **Context**: ADR-064 decided the subscription and the read of setting 13 and left ADR-061/062/063 in place until the hardware run. The maintainer's feedback + on `ai-sessions/0090` (the FEATURE prompt of 0091, §3): the hold and the re-claim exist only to catch a press-and-hold on the shared channel; with the stream + the Buds report it on the app's own channel (`CAP-051` 1717/1755/1802, `CAP-038` 2170/2205 with no Message Stream open), so the three ADRs, the setting and the + package query lose their reason and go now. The stream also carries every other setting the app shows (`CAP-056` 1696 field 12, 2175 field 2; `CAP-069` 2502 + field 29; `CAP-015` 2116 field 16). +- **Options considered**: (a) ADR-064 item 6 as written — keep the hold and the re-claim until `CAP-076`; (b) this decision; (c) keep the setting as a fallback — + rejected: two mechanisms for one value, and the Play-user branch stays the hardest to test. +- **Decision**: + 1. The wire as ADR-064 item 1: once per MAESTRO session (Connect and every ADR-044 re-open), after `SubscribeRuntimeInfo`, one `SubscribeToSettingsChanges` + REQUEST and one `ReadSetting 4:13`, byte-identical to `CAP-051` 1177/1178 (channel 21) and `CAP-056` 1451/1450 (channel 19), on the announced channel with + its ADR-034 address; no retry, no polling; a send failure ends the sequence (the re-open subscribes again). + 2. (amends ADR-064 item 2) Every field the app reads — 2, 4, 7, 11, 12, 13, 15, 16, 17, 19, 22, 27, 28, 29 — is decoded from a `SERVER_STREAM` packet + through the same decoders as a `ReadSetting` answer and applied with its receive time (field 18 is read but not shown, as today). A stream packet for any + other field is logged payload-free ("Settings stream: field N not shown") and not interpreted — not an `UnidentifiedFrame`. Nothing is written in reaction + to a stream packet. + 3. Setting 13: 1 = Off, 2 = Noise cancellation, 3 = Transparency, 4 = Adaptive; 0 = no mode settable (ADR-049's Settable `00`) — never shown as a mode. The + last value received wins, whatever its channel. + 4. The cause: a stream value equal to the mode of the app's own last `Set` within `ANC_SET_WINDOW_MS` (2 s; `CAP-051` 1527 → 1534 = 269 ms, `CAP-056` 4001 → + 4028 = 55 ms) is the app's own change; a stream value that differs from the shown mode with no such `Set` is "Changed by the Buds (HH:MM:SS)" — the push + is the cause, whatever triggered it (a press-and-hold is one trigger; the Buds' own switch on a wear change is 🟡, `PROTOCOL.md` §6 item of 2026-10-14); + a read answer (`Notify` to the app's `Get`, or the RESPONSE to `ReadSetting 4:13`) that differs from the last shown mode is "Changed since the app last saw + it (read at HH:MM:SS)". + 5. Removed from the app: the hold of the claim while the noise-control tab is shown (ADR-061); the re-claim chain, the setting "Noise control with Google Play + services", its card and "Take it back again" (ADR-062); the "Google Play services: found / not found" line, `PlayServicesProbe` and the manifest's + `` entry (ADR-063). The stored key `hold_reclaim_mode` is no longer read: a stored value is ignored, never migrated. `AGENTS.md` §1's ADR-063 note + gets a dated pointer. + 6. `WriteSetting 4:{13:n}` stays gated (ADR-064 item 4; `TODO.md` §4's experiment E-01). +- **Verification**: `CAP-076` (Group BL, rewritten for 1.3.0): both GrapheneOS users; press-and-holds with the noise-control tab closed and open (≥ 10 each per + user, ears on film) — every hold shown, one stream packet per hold, no `SABM` on DLCI 0x04 from OpenControl after the Connect's snapshot; a wear-change + re-home (the Buds' `DISC` → re-open → subscribe + read); Bluetooth off/on; a tap on the tab and on the tile with Play present (one claim each, released within + the linger); the EQ and a switch changed in the official app on another phone if available. +- **Consequences**: `SettingsCodec` gains field 13 and the stream-vs-answer flag; `CodecRouter` the `UnreadSetting` frame; `BudsRepositoryImpl` loses the + hold/re-claim code and gains the two requests and the cause rules; `:domain` loses `HoldReclaimMode`, `ChannelTaken`, `PlayServicesPresence`; `:app` loses the + store, the probe and the `` entry; `ARCHITECTURE.md` §2.4, §3.1, §3.2, §5a, §6.0b updated; version 1.3.0 (`CHANGELOG.md`). +- **Note (as built, `ai-sessions/0091`)**: item 1's "a send failure ends the sequence" is implemented as every other step of the Connect sequence — each request + fails on its own and is never retried; a lost session fails them all (the test "a lost session during the subscription is not retried"). --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/DECISIONS.md - https://tedsluis.github.io/opencontrolpixelbudspro2/DECISIONS diff --git a/PROJECT.md b/PROJECT.md index e98da612..a385d81d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -148,7 +148,7 @@ verdict: release build `86a6fb3` (the maintainer, chat 2026-10-08). **1.2.0** (prepared in `ai-sessions/0082`, 2026-10-09; published 2026-10-10, build `5b4d5db`): "Changed by the Buds" in the noise-control details, the component serial numbers on the Info tab (ADR-058), the "probably worn" indicator on the battery card (ADR-059), the Case sounds switches moved to gear → Settings and Conversation detection to Controls; volume-level notifications studied, not built (a draft ADR-060, a 1.3.0 candidate). Its hardware run `CAP-072` (`ai-sessions/0083`, 2026-10-09) found no heavy defect — every request answered and byte-identical, the serials and the worn line as specified — but showed that a press-and-hold is never shown (no claim open between actions, ADR-032); verdict **fix first** (the maintainer, chat 2026-10-10): ADR-061 holds the noise-control channel while that tab is on screen, then a short re-test. **Rebuilt** as the same version in `ai-sessions/0084` (2026-10-10): the hold built (`setAncTabShown`), the serial fixtures from OpenControl's own `CAP-072` exchange, a finer balance slider (the finger's value while dragging, steps of 1 near the centre); the re-test `CAP-073` (Group BI, `ai-sessions/0085`, 2026-10-10) found no heavy defect — the noise-control channel held on its tab, four press-and-holds shown without a tap, every request byte-identical to `CAP-072`; verdict **OK — ready to release** (the maintainer, chat 2026-10-10). -**`CAP-074`** (`ai-sessions/0086`, 2026-10-12, the released 1.2.0 in the GrapheneOS user **with** Google Play services) found the limit of that user: Play's Fast Pair takes the held noise-control channel back ≈ 5 s later and the app did not re-claim, so only the first press-and-hold after a Connect was shown. **1.2.1** (prepared in `ai-sessions/0087`, 2026-10-12; published 2026-10-13, build `31ee183`): the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close, then the hint) — ADR-062; its hardware run `CAP-075` (Group BK, `ai-sessions/0088`, 2026-10-12/13, both users) passed: without Play services both choices are identical and every press-and-hold on the tab is shown (41 of 41); with Play services the hint shows the cut's time and "Take it back" holds the channel about 43 % of a 44-s chain (Play's recovery 5.3–5.8 s, retry 2.1–2.8 s) — verdict "no heavy issue — release as built" (the maintainer, chat 2026-10-13); the next step is a constant re-claim step with a longer chain (`TODO.md` §5). **1.2.2** (prepared in `ai-sessions/0089`, 2026-10-13; not released): the setting renamed "Noise control with Google Play services" with options that say whether the phone has Google Play services and a line whether Android finds it (ADR-063), the re-claim 3 s after every cut up to 20 times, "Take it back again" on the hint, and a cause line for a mode the app's own read found changed (ADR-062 Update 2); its hardware run is `CAP-076` (Group BL, skeleton). +**`CAP-074`** (`ai-sessions/0086`, 2026-10-12, the released 1.2.0 in the GrapheneOS user **with** Google Play services) found the limit of that user: Play's Fast Pair takes the held noise-control channel back ≈ 5 s later and the app did not re-claim, so only the first press-and-hold after a Connect was shown. **1.2.1** (prepared in `ai-sessions/0087`, 2026-10-12; published 2026-10-13, build `31ee183`): the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close, then the hint) — ADR-062; its hardware run `CAP-075` (Group BK, `ai-sessions/0088`, 2026-10-12/13, both users) passed: without Play services both choices are identical and every press-and-hold on the tab is shown (41 of 41); with Play services the hint shows the cut's time and "Take it back" holds the channel about 43 % of a 44-s chain (Play's recovery 5.3–5.8 s, retry 2.1–2.8 s) — verdict "no heavy issue — release as built" (the maintainer, chat 2026-10-13); the next step is a constant re-claim step with a longer chain (`TODO.md` §5). **1.2.2** (prepared in `ai-sessions/0089`, 2026-10-13; not released): the setting renamed "Noise control with Google Play services" with options that say whether the phone has Google Play services and a line whether Android finds it (ADR-063), the re-claim 3 s after every cut up to 20 times, "Take it back again" on the hint, and a cause line for a mode the app's own read found changed (ADR-062 Update 2); released 2026-10-14 (build `bfd7891`) without a hardware run. **1.3.0** (prepared in `ai-sessions/0091`, 2026-10-14; not released): the app subscribes to the Buds' settings stream and reads the noise-control mode on its own channel (ADR-064), so a press-and-hold on a bud shows on any tab and every setting follows a change made elsewhere; the hold, the re-claim, the "Noise control with Google Play services" setting and the Play-services line are removed (ADR-065); its hardware run is `CAP-076` (Group BL, rewritten). --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/PROJECT.md - https://tedsluis.github.io/opencontrolpixelbudspro2/PROJECT diff --git a/README.md b/README.md index 28cca76c..7dffec04 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,13 @@ noise-control tab, so a press-and-hold is shown about half of the time for the w (no mode written); and the noise-control (i) says when a mode the app read had changed while it did not have the channel. Expected numbers are `CAP-075`'s (one Play version), not measured with 1.2.2. +**1.3.0** (prepared 2026-10-14 in `ai-sessions/0091` after `ai-sessions/0090`'s cross-check of the official app; not released): the app subscribes to the Buds' +settings stream and reads the noise-control mode on its own channel at every Connect and re-open (ADR-064), so **a press-and-hold on a bud shows on any tab, with +or without Google Play services**, and every setting the app shows follows a change made elsewhere; the hold, the re-claim, the "Noise control with Google Play +services" setting and the Play-services line are removed (ADR-065; a stored choice is ignored). Its hardware run is `CAP-076` (Group BL, rewritten). Known issues +of 1.3.0 (also in [`CHANGELOG.md`](./CHANGELOG.md)): a tap can still collide with Play's claim of the shared channel (then tap again); after the Buds' own close +of the session (one bud out while both are worn) the mode shown can be a few seconds old until the re-open's read. + **Known issue since 1.0.1** (also in [`CHANGELOG.md`](./CHANGELOG.md)): - After you stop a ring by touching the bud, the app keeps saying it is ringing until you tap Stop. @@ -166,21 +173,21 @@ the Pixel Buds Pro 2 first has to be reconstructed through Bluetooth traffic analysis and reverse engineering of the Android APK. That knowledge is then used to design, implement, test, and document a native Android app. -## Current state (2026-10-13) +## Current state (2026-10-14) -- **Captures:** 76 registered sessions (`CAP-001`–`CAP-076`): 71 analyzed (the latest, `CAP-075` of 2026-10-12/13: the 1.2.1 release build in both GrapheneOS users — without Google Play services every press-and-hold on the tab shown, with Play services the hint and the re-claims measured against Play's timers, `ai-sessions/0088`), 3 planned (among them `CAP-076`, the 1.2.2 run, skeleton by `ai-sessions/0089`), 2 withdrawn (`CAP-052`, +- **Captures:** 76 registered sessions (`CAP-001`–`CAP-076`): 71 analyzed (the latest, `CAP-075` of 2026-10-12/13: the 1.2.1 release build in both GrapheneOS users — without Google Play services every press-and-hold on the tab shown, with Play services the hint and the re-claims measured against Play's timers, `ai-sessions/0088`), 3 planned (among them `CAP-076`, the 1.3.0 run, skeleton by `ai-sessions/0089`, rewritten for 1.3.0 by `ai-sessions/0091`), 2 withdrawn (`CAP-052`, `CAP-057`) — see `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9 and `id_registry.csv`. `CAP-059`–`CAP-068` and `CAP-070`–`CAP-075` are captures of this project's own app (`CAP-067`: the 1.0.0 release APK, `CAP-068`: the 1.0.1 release APK, `CAP-070`: the 1.1.0 release APK, `CAP-071`: the 1.1.1 release APK, `CAP-072`: the 1.2.0 release APK, `CAP-073`: the rebuilt 1.2.0 release APK, all without Google Play services; `CAP-074`: the released 1.2.0 in the Owner user with Play services; `CAP-075`: the 1.2.1 release APK in both users); the Safe-Mode fix of `ai-sessions/0046` was hardware-verified in `CAP-062`/`CAP-063`. - **APK analysis:** one companion-app version fully pulled, decompiled, and analyzed (`v1.0.955078536-10253511`) — see `reverse-engineering/APK_VERSIONS.md`. DLCI 0x04/0x08's transport code is not in it (ADR-025): both channels are implemented independently, from wire-capture evidence (and, for DLCI 0x04, the public Fast Pair spec). -- **Decisions:** 62 accepted ADRs (ADR-063, 2026-10-13, the card's "Google Play services: found / not found" line of 1.2.2, `ai-sessions/0089`; ADR-062, 2026-10-12, the Noise-control channel switch of 1.2.1, `ai-sessions/0087`, hardware-verified in `CAP-075` — its Update of 2026-10-13, `ai-sessions/0088`, and its Update 2 for 1.2.2, `ai-sessions/0089`; ADR-061, 2026-10-10, built in the 1.2.0 rebuild, `ai-sessions/0084`, its main path hardware-verified in `CAP-073`) and one labelled draft (ADR-060, proposed, not accepted) in `DECISIONS.md`; every 🟢 FACT in `PROTOCOL.md` has a recorded maintainer sign-off, except ADR-009, whose +- **Decisions:** 63 accepted ADRs (ADR-065, 2026-10-14, the settings stream replaces the hold and the re-claim — built in 1.3.0, `ai-sessions/0091`; ADR-064, 2026-10-14, the subscription and the read of setting 13, `ai-sessions/0090`; ADR-061/062/063 superseded by ADR-065) and one labelled draft (ADR-060, proposed, not accepted) in `DECISIONS.md`; every 🟢 FACT in `PROTOCOL.md` has a recorded maintainer sign-off, except ADR-009, whose approval was given but whose record is missing (the ADR says so). - **Implemented in the app:** ANC/Transparency/Adaptive (DLCI 0x04, ADR-009), Find My Buds Left/Right (ADR-011), EQ read and write (DLCI 0x02 pw_rpc, ADR-020/034), battery Left/Right with charging (ADR-033) and the Case (DLCI 0x02 `SubscribeRuntimeInfo`, ADR-043), the settings reads (ADR-036) and writes (touch controls, press and hold, conversation detection, balance, mono, in-ear detection — ADR-045/046/047; from 1.1.0 head gestures, Multipoint, case sounds and Volume EQ — ADR-052…055), from 1.2.0 the serial numbers (one `GetHardwareInfo` per connection, ADR-058), - the "probably worn" line (ADR-059) and "Changed by the Buds" (`ai-sessions/0082`) with the noise-control channel held on its tab (ADR-061, `ai-sessions/0084`), from 1.2.1 the Noise-control channel setting (hint or re-claim after another app takes the channel — ADR-062, `ai-sessions/0087`; in 1.2.2 (2026-10-14) renamed "Noise control with Google Play services", the re-claim 3 s after every cut up to 20 times, "Take it back again" — ADR-062 Update 2, ADR-063, `ai-sessions/0089`), + the "probably worn" line (ADR-059) and "Changed by the Buds" (`ai-sessions/0082`; 1.2.0 … 1.2.2 held the noise-control channel on its tab and re-claimed it from Play services — ADR-061/062/063, retired), from 1.3.0 the Buds' settings stream on the app's own channel (`SubscribeToSettingsChanges` and `ReadSetting 4:13` at every Connect and re-open: a press-and-hold shows on any tab and every setting follows a change made elsewhere — ADR-064/065, `ai-sessions/0091`), firmware line, ANC Quick Settings tile, Safe Mode (ADR-042), the session re-open while visible (ADR-044). - **Protocol-known but not built:** the "Volume level notifications" switch (`qhr` field 21, 🟢 since 2026-10-06) and the notification behind it — studied in `ai-sessions/0082` (the phone decides, from the `Dosimeter` daily doses; draft ADR-060, a 1.3.0 candidate, needs `WELL-002` first); the BLE battery advertisement diff --git a/TODO.md b/TODO.md index 7e72765c..78c9e337 100644 --- a/TODO.md +++ b/TODO.md @@ -18,9 +18,10 @@ Legend: 🔴 open question · 🟡 hypothesis to test · **M** = needs the maint installed on the Pixel 9a with Obtainium (the maintainer, chat 2026-10-14: "Ik heb opencontrol for buds middels obtainium geupdate naar 1.2.2 op mijn pixel 9a telefoon"); the published APK's SHA-256 = the tested build's (`d750551a…8a6b`, re-checked with `gh release download`). E1–E4: PR #52. (1.2.1: D4 done 2026-10-13 with Obtainium; E1–E4 by PR #45.) -- [ ] `CAP-076` (Group BL, skeleton by `ai-sessions/0089`) stays planned but is **not** required for 1.2.2. `ai-sessions/0090` answered the setting-13 route - (🟢 `PROTOCOL.md` §4.1 Update of 2026-10-14; ADR-064 accepted): the next hardware run is ADR-064's, with its build (§5) — whether `CAP-076` is still run - is **M**. +- [ ] `CAP-076` (Group BL) is **the 1.3.0 run** since `ai-sessions/0091` (2026-10-14): the skeleton of `ai-sessions/0089` (the 1.2.2 run, never made) is rewritten + for ADR-064/065 — both users, holds with the noise-control tab closed and open, the own tap, the wear-change re-home, Bluetooth off/on, taps while Play + holds the channel, optionally the EQ/a switch from the official app on a second phone; the counts for the verdict in its analysis checklist. Runs after + `RELEASING.md` B1–B3 for 1.3.0 (a CAPTURE session analyses it, C3/C4). - The Buds' state after `CAP-074`/`CAP-075`: a custom EQ `[−3.5, 0.5, 1.0, −1.0, 2.5]`, all four press-and-hold modes, balance Centre (`CAP-074-FINDINGS.md` §0; unchanged by `CAP-075`: 0 writes) — restore if wanted (**M**). The Owner user's stored "Noise-control channel" choice is "Take it back" and the secondary user's too (`CAP-075-FINDINGS.md` §4) — the default "Show a hint" is not restored. @@ -41,7 +42,8 @@ ADR-061, chat 2026-10-10). **`CAP-073`** (the rebuilt 1.2.0, analysed in `ai-sessions/0085`, verdict OK — ready to release) settled the ADR-061 hold's main path and balance "Right 4" (both removed here); the maintainer tested only the changed functionality, so T11, C12, S12, H5, Volume EQ and the worn line with in-ear detection off stay open, with the hold's untested branches below. **Next app run:** none planned — these items go into the hardware run of the next release (`RELEASING.md` §11a). -- [ ] The ADR-061 hold's other branches on hardware (`CAP-073-FINDINGS.md` §4): the tile with the noise-control tab shown (no second `SABM`, no `DISC`); Home and +- [x] ~~The ADR-061 hold's other branches on hardware~~ — **moot since 1.3.0** (`ai-sessions/0091`, ADR-065: the hold and the re-claim are removed; the tile, Home, + a loss and the re-open with the tab shown are in `CAP-076` as BL-8/BL-9/BL-10/BL-24 for the stream instead). History: (`CAP-073-FINDINGS.md` §4): the tile with the noise-control tab shown (no second `SABM`, no `DISC`); Home and return (the `DISC` ≈ 1.5 s after Home, a new claim on return); a session loss and the ADR-044 re-open while the tab is shown (the snapshot kept); and the noise-control (i) after a press-and-hold ("Changed by the Buds at HH:MM:SS …" and the ADR-061 sentence) — not run in `CAP-073`. **`CAP-074` (the Owner user with Play services, `ai-sessions/0086`) ran the "another client closes DLCI 0x04 while the tab is shown" branch twelve times:** the hold ended "the channel was closed, @@ -73,7 +75,7 @@ with the hold's untested branches below. **Next app run:** none planned — thes |---|---|---|---| | `CAP-055` | AQ | Nod/Shake head gestures with an active call or notification (needs a second phone) | Pixel 7a, official app | | `CAP-030` | Q #19–20 | Loud Noise Protection / Adaptive Audio (firmware naming never reconciled with `release_5.203`, `PROTOCOL.md` §0.1) | Pixel 7a, official app | -| `CAP-076` | BL | (not required for 1.2.2, released 2026-10-14 without it — see §1) The 1.2.2 run (`ai-sessions/0089`, ADR-062 Update 2, ADR-063): the re-claim 3 s after every cut up to 20, Take it back again, the cause line, the renamed card — both users | Pixel 9a, OpenControl 1.2.2 | +| `CAP-076` | BL | The 1.3.0 run (`ai-sessions/0091`, ADR-064/ADR-065; the 1.2.2 skeleton of `ai-sessions/0089` replaced): the two new requests once per session, holds shown on any tab through the settings stream, the own tap, the wear-change re-home, Bluetooth off/on, taps while Play holds the channel — both users | Pixel 9a, OpenControl 1.3.0 | From `ai-sessions/0072` (`CAP-053`/`054`/`058`, analysed 2026-10-06) — procedures for a next attempt, no skeleton written: @@ -103,8 +105,8 @@ From `ai-sessions/0072` (`CAP-053`/`054`/`058`, analysed 2026-10-06) — procedu in the background, after an `AT+BIEV` — the buds into/out of the case off screen? a run with the case moves on camera (`CASE-004`/`005`); (2) what Play's `RfcommEventStreamMedium` does when "Retry times remaining" reaches 0 (seen 5 → 4 once, back at 5 next time); (3) the Google app's (uid 10302) DLCI 0x08/0x0a traffic on the Buds' ACL in the Owner user (63 frames, the firmware string in its answers) — what it reads; (4) answered by `ai-sessions/0090`: setting 13 - (`ReadSetting 4:13`, and `SubscribeToSettingsChanges` for every change) — nothing moved at the 31 holds because OpenControl never subscribed; built next - under ADR-064 (§5). + (`ReadSetting 4:13`, and `SubscribeToSettingsChanges` for every change) — nothing moved at the 31 holds because OpenControl never subscribed; **built in + 1.3.0** (`ai-sessions/0091`, ADR-064/065), its hardware run `CAP-076`. The five switches the maintainer chose (Multipoint, head gestures, the two case sounds, Volume EQ; ADR-052 … ADR-055) and the screen-reader text are built in 1.1.0 (`ai-sessions/0074`) and passed their hardware run `CAP-070` (`ai-sessions/0075`); since `ai-sessions/0076` their tests use the `CAP-070` bytes. @@ -199,15 +201,18 @@ Open questions (each with where it is described): ## 5. App: later work -- [ ] **Next: `ai-sessions/0091_FEATURE_PROMPT_2026_10_14.md`** (written 2026-10-14 from the maintainer's feedback on 0090) — build ADR-064 as **1.3.0**, the stream feeding every read setting, ADR-061/062/063 replaced by one new ADR (the hold, the re-claim, the card and the Play line removed), the cause line rewritten, `CAP-076` rewritten for 1.3.0. The earlier outline (accepted 2026-10-14, `ai-sessions/0090`): one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` +- [x] **Done 2026-10-14 (`ai-sessions/0091`, branch `feature/settings-stream`): ADR-064 built as 1.3.0** — the stream feeding every read setting, ADR-061/062/063 + superseded by ADR-065 (the hold, the re-claim, the card and the Play line removed), the cause line rewritten, `CAP-076` rewritten for 1.3.0; mutation + checks M1–M5 caught. **Next:** the maintainer's push, PR and CI; then `RELEASING.md` B1–B3 for 1.3.0 and `CAP-076` (C1–C2), analysed by a CAPTURE + session (C3/C4). Deferred documentation of 0091: none beyond the items in this file. The prompt's earlier outline (accepted 2026-10-14, `ai-sessions/0090`): one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session, field 13 only, the mode shown with "Changed by the Buds" without a Message Stream claim; fixtures from `CAP-051` 1177/1755 and `CAP-056` 1451; then its hardware run (skeleton written by that session — the maintainer's choice, chat 2026-10-14): both users, holds with the noise-control tab - closed, a wear-change re-home and the re-open's read; optionally the read-only `ReadSetting 4:8/9/10` (§4). If holds show without DLCI 0x04, a later ADR may - retire ADR-061/062's hold and re-claim (**M**). -- [ ] **The noise-control channel in the Play user — after `ai-sessions/0089` (1.2.2: P1 + P2 + P4 built, ADR-062 Update 2):** still later — P3 a shorter - attempt-2 latency for the on-demand channel (`CAP-075-FINDINGS.md` §9: the re-claim's `SABM` 0.7–1.0 s after attempt 1's refusal); P5 the mode on the app's - own channel — now ADR-064 (above). Rejected on evidence: a re-claim before Play's retry (§9 P6). Whether 20 re-claims in a row drive Play's "Retry times remaining" to 0 - and what Play does then is ❓ (§4; `CAP-076` P6 watches it). + closed, a wear-change re-home and the re-open's read; optionally the read-only `ReadSetting 4:8/9/10` (§4 — **not** in the 1.3.0 run, the maintainer's + choice in chat 2026-10-14; a later session may add it). The retirement of ADR-061/062's hold and re-claim is ADR-065 (done). +- [ ] **The noise-control channel in the Play user — after 1.3.0 (ADR-065: the re-claim is gone, the holds no longer depend on the channel):** what remains is + the per-tap collision with Play's claim (`CAP-075-FINDINGS.md` §5; 1.3.0's known issue) — P3 a shorter attempt-2 latency for the on-demand channel (§9: + attempt 2 0.7–1.0 s after attempt 1's refusal) is still open (**M**). Whether repeated collisions drive Play's "Retry times remaining" to 0 and what Play + does then stays ❓ (§4; `CAP-076` BL-11 counts the collided taps). - [ ] **After the toolchain upgrade of `ai-sessions/0078`** (Gradle 9.7.1, AGP 9.3.3, Kotlin 2.4.20, BOM 2026.09.00, compileSdk 37): (a) hardware-tested in `CAP-071` (`ai-sessions/0080`): no behaviour change found — the same reads, request bytes, answers, re-opens and screens as 1.1.0; diff --git a/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md b/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md new file mode 100644 index 00000000..b4862bd5 --- /dev/null +++ b/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md @@ -0,0 +1,279 @@ +# 0091_FEATURE_RESULT_2026_10_14.md — Build ADR-064 as version 1.3.0: the settings stream on DLCI 0x02 replaces the hold, the re-claim and the Play-services card + +**Number:** 0091 +**Category:** FEATURE +**Date:** 2026-10-14 +**Prompt:** `ai-sessions/0091_FEATURE_PROMPT_2026_10_14.md` +**Status:** complete (not committed — awaiting the maintainer's yes in chat) + +## Progress + +- Phase 0: branch `feature/settings-stream` created from `main` at `c1d898b` (Merge PR #55); `git fetch` showed no commits in `HEAD..origin/main`; + `git status --short` showed only `?? android/domain/bin/` (build output, never staged). Gate before any change (`cd android && ./gradlew assembleDebug testDebugUnitTest test lint --max-workers=2`): BUILD SUCCESSFUL, exit 0; JUnit XML test counts (debug + release variants summed): `:data` 3344, `:hardware` 128, `:domain` 42, `:ui` 204, `:app` 0 — i.e. per variant `:data` 1672, `:hardware` 64, `:domain` 42 (JVM, one variant), `:ui` 102, the same as `ai-sessions/0089`'s final gate. `python3 scripts/lint_docs.py`: only this new RESULT's missing footer (ensure_footers in Phase F). Reading of the mandatory documents: done (see "Files read"). +- Phase A: done — evidence re-derived (section A.1), design (A.2–A.6), drafts and questions (A.7–A.8). +- Phase B: done — eleven questions, all answered with the recommended option (section B). +- Phase C: done (section C). +- Phase D: done — the gate green, M1–M5 caught, compliance checked (section D). +- Phase E: done (section E). +- Phase F: done (section F); ensure_footers and lint_docs run (see the chat summary). +- Phase G: the summary and the commit/push question in chat (section G). + +## A. Evidence and design (Phase A) + +### A.1 Evidence re-derived from the captures + +Every frame below was listed with `tshark -r -Y 'frame.number==N' -T fields -E separator='|' -e frame.number -e frame.time -e frame.p2p_dir +-e bthci_acl.chandle -e btrfcomm.dlci -e btrfcomm.frame_type -e data.data` (exit 0) after scoping to the Buds' connection handle (the Connection Complete for +the Buds' address: `CAP-015` `0x0004`, `CAP-021` `0x0002`, `CAP-027` `0x0002`, `CAP-038` `0x0001` + `0x0004`, `CAP-045` `0x0002`, `CAP-051` `0x0002`, +`CAP-056` `0x0005`, `CAP-069` `0x0001` + `0x0002`), and decoded with `python3 scripts/pwrpc_decode.py `; multi-frame RFCOMM payloads were split on `7e` +and every HDLC frame's CRC-32 verified. The logs are real files (no LFS pointers). Positive control: `pwrpc_decode.py` lists the subscribe REQUEST once per +MAESTRO open in the official-app captures (`CAP-051` 1177 on channel 21, 2229 on channel 19; `CAP-056` 1451) and 0 times in every OpenControl log +(`ai-sessions/0090` E-02). + +| What | Log, frame (time) | RFCOMM payload (hex, flags included) | +|---|---|---| +| `SubscribeToSettingsChanges` REQUEST, channel 21 | `CAP-051` 1177 (21:43:13.004) | `7e004b0310151dea71de7d5e25f5ad2128019754b07e` | +| the same, channel 19 | `CAP-056` 1451 (17:31:15.770); `CAP-051` 2229 | `7e003b0310131dea71de7d5e25f5ad212807ee8eb27e` | +| `ReadSetting 4:13` REQUEST, channel 21 | `CAP-051` 1178; `CAP-045` 2021 | `7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e` | +| the same, channel 19 | `CAP-056` 1450; `CAP-021` 1069; `CAP-027` 910; `CAP-051` 2231 | `7e003b0310131dea71de7d5e2551aed0ae2a02200d24c239477e` | +| RESPONSE `4:{13:4}` ch 21 | `CAP-051` 1225 (2nd HDLC frame of 5) | `7e00a5032a0422026804080110151dea71de7d5e2551aed0ae067d5e749e7e` | +| RESPONSE `4:{13:2}` ch 19 | `CAP-056` 1468 | `7e80a3032a0422026802080110131dea71de7d5e2551aed0ae57085e2d7e` | +| RESPONSE `4:{13:3}` ch 19 | `CAP-027` 923 (4th of 5) | `7e80a3032a0422026803080110131dea71de7d5e2551aed0aebfd3a5947e` | +| RESPONSE `4:{13:1}` ch 19 | `CAP-021` 1089 (2nd of 4) | `7e80a3032a0422026801080110131dea71de7d5e2551aed0ae2e62233c7e` | +| RESPONSE `4:{13:0}` ch 21 | `CAP-045` 2033 (1st of 3) | `7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e` | +| RESPONSE `4:{13:0}` ch 19 | `CAP-051` 2247 (2nd of 2) | `7e80a3032a0422026800080110131dea71de7d5e2551aed0aec6b9d8857e` | +| SERVER_STREAM `4:{13:2}` ch 21 | `CAP-051` 1717 (21:43:43.479) | `7e00a5032a0422026802080710151dea71de7d5e25f5ad2128cbb358d57e` | +| SERVER_STREAM `4:{13:4}` ch 21 | `CAP-051` 1755 (21:43:56.004) | `7e00a5032a0422026804080710151dea71de7d5e25f5ad21283967a2f77e` | +| SERVER_STREAM `4:{13:3}` ch 21 | `CAP-051` 1802 (21:44:05.150), 1534 | `7e00a5032a0422026803080710151dea71de7d5e25f5ad21282368a36c7e` | +| SERVER_STREAM `4:{13:3}` ch 19 | `CAP-038` 2170 (06:50:50.589; DLCI 0x03, no Message Stream open) | `7e80a3032a0422026803080710131dea71de7d5e25f5ad212880ca73fd7e` | +| SERVER_STREAM `4:{13:2}` ch 19 | `CAP-038` 2205; `CAP-056` 2324, 4028 (1st frame) | `7e80a3032a0422026802080710131dea71de7d5e25f5ad2128681188447e` | +| SERVER_STREAM `4:{13:0}` ch 21 | `CAP-045` 2277; `CAP-038` 2479 | `7e00a5032a0422026800080710151dea71de7d5e25f5ad21285a02de7d5d7e` | +| SERVER_STREAM `4:{13:1}` ch 19 | `CAP-021` 1842 | `7e80a3032a0422026801080710131dea71de7d5e25f5ad2128117bf5557e` | +| SERVER_STREAM `4:{13:4}` ch 19 | `CAP-056` 2259, 4025 | `7e80a3032a0422026804080710131dea71de7d5e25f5ad21289ac572667e` | +| SERVER_STREAM `4:{12:{1:0 2:1 3:1 4:1}}` ch 19 | `CAP-056` 1696 | `7e80a3032a0c220a62080800100118012001080710131dea71de7d5e25f5ad21281fbcad167e` | +| SERVER_STREAM `4:{2:1}` ch 19 | `CAP-056` 2175 | `7e80a3032a0422021001080710131dea71de7d5e25f5ad2128475780f97e` | +| SERVER_STREAM `4:{29:1}` ch 21 (mirror of the write 2492; RESPONSE 2506) | `CAP-069` 2502 | `7e00a5032a052203e80101080710151dea71de7d5e25f5ad2128cff7296f7e` | +| SERVER_STREAM field 16, all-zero quintet, ch 19 | `CAP-015` **2116** (the prompt wrote 2117; 2117 is the WriteSetting ACK) | `7e80a3032a1e221c8201190d0000000015000000001d0000000025000000002d00000000080710131dea71de7d5e25f5ad2128b1cbb10b7e` | + +Timing (`CAP-051`, channel 21): Notify 1707 (`0813000401e8e808`, 21:43:43.336) → stream 1717 (+144 ms, 13:2); 1754 (`…e8e840`) → 1755 (+2 ms, 13:4); +1801 (`…e8e880`) → 1802 (+48 ms, 13:3). Own tap: 1527 Set (`0812001401e8e880…`, 21:43:28.107) → 1529 ACK `ff010006081201e8e880` (28.219) → 1530 Notify +`0813000401e8e880` (28.299) → 1533 Notify (28.375) → 1534 stream 13:3 (28.376) — 269 ms from the Set. `CAP-056` wear sequence: 2257 Notify `…e8e840` +(17:33:46.716) → 2259 stream 13:4 (+11 ms); 2322 `…e8e808` → 2324 13:2 (+1 ms); 3983 `…40` (17:35:31.366) and 4001 `…08` (31.534) ↔ 4025 13:4 (31.569) and +4028 13:2 (31.589) — the stream after the second Notify, 55 ms after it. `DISC` case: `CAP-051` 1993 Notify `0813000401e8e880` (21:45:45.603) → 1996 the +Buds' `DISC` DLCI 0x02 (45.608) → 1998 `DISC` DLCI 0x04 (45.612) → 2229 the re-open's subscribe on channel 19 (21:45:50.057) → 2231 `ReadSetting 4:13` → 2247 +answer 13:0 (50.260). Note (no finding): in `CAP-038` the stream packet 2479 is on channel 21 while 2170/2205 are on channel 19 within the same connection. + +### A.2 Design — the wire + +- `Maestro.subscribeToSettingsChangesRequest(channelId)`: `RpcPacket(REQUEST, channelId, SERVICE_ID, METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES, empty payload)`, + sealed with the announced channel's ADR-034 request address — asserted byte-identical to `CAP-051` 1177 (ch 21) and `CAP-056` 1451 (ch 19). +- `Maestro.READABLE_FIELDS` gains 13; `Maestro.readSettingRequest(channelId, 13)` is then byte-identical to `CAP-051` 1178 / `CAP-056` 1450. +- Connect sequence (`launchInitialEqRead`, the same function for Connect and every ADR-044 re-open since both run `openSession`): `readEq` → `readSettings` + (12 fields) → `subscribeRuntimeInfo` → **`subscribeToSettingsChanges`** → **`readAncSetting`** (`ReadSetting 4:13`, waits ≤ `SETTING_READ_TIMEOUT_MS` + 2 000 ms, never retried) → `readHardwareInfo` (checkpoint question: GetHardwareInfo last, as proposed, or before the two new requests). A failed send ends the + sequence (the session is going away; the re-open subscribes again). Nothing else new on the wire; nothing written in reaction to a stream packet. + +### A.3 Design — routing and decoding + +- `SettingsCodec.FIELD_ANC_STATE = 13`; `SettingValue.AncState(mode: AncMode?)`: 1 → OFF, 2 → ACTIVE, 3 → TRANSPARENT, 4 → ADAPTIVE (`PROTOCOL.md` §4.1 + Update of 2026-10-14), 0 → `null` = no mode settable; any other value → the decoder returns `null` (not interpreted). Never encodable (no write path). +- `RoutedFrame.Setting` gains `stream: Boolean` (`SERVER_STREAM` = true, `RESPONSE` = false), so the repository can tell a push from a read answer. +- `routeMaestro`: a `SERVER_STREAM` of `SubscribeToSettingsChanges` whose payload is `4:{N:…}` with N outside `READABLE_FIELDS` (18 among them) → the new + `RoutedFrame.UnreadSetting(field)` → one always-on, payload-free log line ("Settings stream: field N not shown (not read by this app)") — not an + `UnidentifiedFrame`. A stream packet that does not parse as `4:{N:…}` stays an `UnidentifiedFrame` (Debug UI). A field-16 stream packet is an EQ frame + (`CAP-015` 2116) and updates the equalizer like a read answer. +- Fuzz: `OversizedLengthTest`'s structured fuzz and `CodecRouterTest` gain server-stream packets of the subscribe method (random, truncated, oversized + lengths, unknown fields, field 13 with the values 0–255), asserting no throw and only `Setting` / `UnreadSetting` / `UnidentifiedFrame` / `MalformedFrame`. + +### A.4 Design — state and cause + +- A stream `4:{13:n}` (n = 1…4): the mode is shown with the packet's receive time. Cause: equal to the mode of the app's own last `Set` within + `ANC_SET_WINDOW_MS` → `SET_BY_APP` (the stream can come after the ACK ended the Set's wait, `CAP-051` 1534 at +269 ms, or between two Notifies, `CAP-056` + 4028); otherwise, if it differs from the shown mode → `CHANGED_BY_BUDS` ("Changed by the Buds (HH:MM:SS)"); the same mode again → the cause stays, the time + moves. The repository keeps `(lastSetMode, lastSetAt)` from `ancSetOnClaim` besides today's `setPendingMode`. +- `ANC_SET_WINDOW_MS = 2 000` (new constant, `ARCHITECTURE.md` §3.2): both observed orders fit (269 ms, 55 ms) with margin; a hold to the mode just set + changes nothing, so a wrong `SET_BY_APP` inside the window has no visible effect. +- A stream `4:{13:0}` or a RESPONSE `4:{13:0}`: never shown as a mode; `AncAvailability` → `NOT_ALLOWED` (checkpoint question A.8-Q5: whether the stream and + the read also feed `AncAvailability` — recommended yes: 0 ↔ Settable `00`, ADR-049; 1–4 → `ALLOWED`, with the receive time). +- The RESPONSE to `ReadSetting 4:13` (Connect and re-open): a mode that differs from the last shown mode → `CHANGED_SINCE_LAST_READ` ("Changed since the app + last saw it (read at HH:MM:SS)"); the first mode ever shown, or the same mode → `READ`. Checkpoint question Q4: whether a new Connect's first read that + differs from the previous session's mode is `CHANGED_SINCE_LAST_READ` (one rule, recommended) or stays `READ` (0089's "a session's first read stays a + reading", which `_ancModeCause = null` at Connect implements today). Collisions: the last value received wins, whatever its channel (Notify or stream). +- Other fields from the stream: applied through `applySetting` with the receive time. `changedByApp` stays `true` only when the current reading has the same + value and was the app's own write (the mirror `CAP-069` 2502 of the app's write); otherwise `false` ("read HH:MM:SS"). Field 16 → `updateEqProfile`. +- Tile and notification: `currentAncMode()` on the repository flow — they follow the stream without a claim; the tile's own claim is only its Set. +- Logging (always on, payload-free): "Settings stream subscribed (channel N)", "Noise-control mode read (channel N)" / "… : no mode settable", "Noise-control + mode from the settings stream ()", "Settings stream: field N not shown (not read by this app)"; failures via `eqErrorLogText`. + +### A.5 Design — the removal (§3 item 2; executed file by file after the checkpoint's approval of this list) + +Deleted files: `android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/HoldReclaimMode.kt` (with `ChannelTaken`, +`PlayServicesPresence`); `android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStore.kt` and +`android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/settings/HoldReclaimSettingsStoreTest.kt`; +`android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/PlayServicesProbe.kt`; `android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/Cap074Fixtures.kt` +if no remaining test uses it. Edited: `BudsRepositoryImpl` (the ctor param, the hold, the chain, `setAncTabShown`, `ancChannelTaken`, `reclaimAncChannel`, +`HOLD_RECLAIM_*`; `withMessageStream` releases after `MESSAGE_STREAM_LINGER_MS` again), `BudsRepository` (three members), `RepositoryModule`, `MainActivity` +(store, probe, three callbacks), `AndroidManifest.xml` (`` + comment), `OpenControlNavHost`, `AncScreen` (the line, the button, the hint text), +`SettingsMenu` (the card, the Play line), the tests of 0084/0087/0089 (deleted or rewritten, not skipped), `TabRestoreTest` (the ADR-061 test), +`Cap075Fixtures.kt` (the Notify frames stay for the cause tests). The key `hold_reclaim_mode` is no longer read (one test: a preferences file with that key +and `dark_mode` — dark mode still reads, the key is untouched). + +### A.6 Design — tests and mutations + +Codec: the subscription's bytes on both channels; every fixture of A.1 decodes (type, field, value); value 0; a value 5 is not interpreted; `CAP-015` 2116 is +an EQ frame; `CAP-056` 1696 / 2175 / `CAP-069` 2502 are `Setting(stream = true)`; a stream `4:{18:…}` (synthetic, labelled) is `UnreadSetting(18)`; fuzz. +Repository (`FakeBudsTransport`, `runCurrent()`/`settle()`): Connect sends subscribe + `4:13` after `SubscribeRuntimeInfo`; the re-open sends both again +(the M1 test: a stub `android.bluetooth.BluetoothDevice` from a same-package test helper — the stub's constructor is package-private in the SDK stub; the test +then runs `connect()` → loss → ADR-044 re-open end to end; if the stub cannot be loaded the fallback is an `internal` re-open entry point); a push updates +the mode with its time; value 0; double pushes; Notify-then-stream and stream-then-Notify around an own Set (`SET_BY_APP` both); a push with no Set +("Changed by the Buds"); loss during the subscription (no retry); the `CAP-056` wear sequence (4 → 2 → 4 → 2); an EQ and a switch stream packet update +their values with the time; an unread field logged and ignored; the removal tests. UI: the ANC (i) texts, no card, no hint line, no Play line, the tile. +Mutations, one at a time with `--max-workers=2`: M1 the subscription skipped after a re-open; M2 value 0 shown as a mode; M3 a stream value equal to the own +Set classed "Changed by the Buds"; M4 the stream routed to `UnidentifiedFrame`; M5 an EQ stream packet ignored. + +### A.7 Drafts for the checkpoint (the exact texts are in the questions' previews) + +The new ADR is **ADR-065** (ADR-064 is the highest). It supersedes ADR-061, ADR-062 (with its Updates) and ADR-063 and amends ADR-064 items 2 and 6; its +text, the `AGENTS.md` §1 pointer, the ANC (i) text, the two cause lines, the `CHANGELOG.md` `[1.3.0]` block, the README/PROJECT status lines and the +release-notes feature line are the previews of Phase B. + +### A.8 Checkpoint questions + +Q1 ADR-065 text; Q2 `AGENTS.md` §1 pointer; Q3 the ANC (i) text; Q4 a new Connect's first differing read; Q5 the stream feeding `AncAvailability`; Q6 the +position of `GetHardwareInfo`; Q7 the Connect-time snapshot claim on DLCI 0x04 (keep — it brings the battery burst and the Settable byte; §2's "0 `SABM`" +count then starts after the Connect's snapshot); Q8 the deletion list (A.5); Q9 the optional `ReadSetting 4:8/9/10` in `CAP-076` (not part, as the prompt +says); Q10 the cause lines; Q11 the `CHANGELOG.md`/README/release-notes texts. + +## B. Checkpoint (Phase B) — the maintainer's answers in chat, 2026-10-14, verbatim + +Stated back once: §3's four decisions (1.3.0 / 10300; one new ADR = ADR-065 superseding ADR-061/062/063 and removing the setting, the hold, the chain, the +card, the hint, the Play line, `PlayServicesProbe`, ``; the stream feeding every read setting; the rewritten cause line and the hedged (i) text). + +| # | Question (`AskUserQuestion`) | Answer | +|---|---|---| +| Q1 | ADR-065 text | "Accept as drafted (Recommended)" — the text of the preview (written into `DECISIONS.md` in Phase F unchanged) | +| Q2 | `AGENTS.md` §1 pointer | "Yes, add the pointer (Recommended)" — "- **Pointer (2026-10-14, maintainer-approved in chat, `DECISIONS.md` ADR-065, `ai-sessions/0091`):** superseded — the package query is gone; since 1.3.0 the app no longer asks Android whether `com.google.android.gms` is installed (no `` entry in the manifest)." | +| Q3 | ANC (i) text | "The prompt's text (Recommended)" — "The Buds report every change of the mode on the app's own channel. A press-and-hold on a bud is one cause; on film the Buds also switched the mode by themselves when a bud was put in or taken out — that part is a hypothesis." | +| Q4 | a new Connect's first differing read | "Changed since the app last saw it (Recommended)" — one rule for every read answer; `_ancModeCause` no longer reset at Connect; the kept mode of the ended session is the reference; the first mode ever shown this run stays READ | +| Q5 | the stream feeding `AncAvailability` | "Yes, feed it (Recommended)" — 0 → NOT_ALLOWED, 1–4 → ALLOWED, with the receive time; the Notify's Settable byte as today (last report wins) | +| Q6 | order | "Before GetHardwareInfo (Recommended)" — readEq → readSettings → SubscribeRuntimeInfo → SubscribeToSettingsChanges → ReadSetting 4:13 → GetHardwareInfo | +| Q7 | the Connect-time snapshot claim | "Keep it (Recommended)" — one `SABM` on DLCI 0x04 per Connect/re-open is expected; `CAP-076`'s count starts after it | +| Q8 | the deletion list | "Approve the list (Recommended)" — the four files of A.5, plus Cap074Fixtures.kt only if unused | +| Q9 | `ReadSetting 4:8/9/10` | "Not part of this run (Recommended)" | +| Q10 | the cause lines | "Rewritten as proposed (Recommended)" — "Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves." / "Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected." | +| Q11 | `CHANGELOG.md` `[1.3.0]`, README/PROJECT lines, release-notes feature line | "Accept as drafted (Recommended)" — the texts of the preview | + +## C. Build (Phase C) — as built + +**Plain-language summary.** Since this build the app asks the Buds, at every Connect and every automatic re-open, to report every change of their settings on +the app's own channel, and reads the noise-control mode once right after. A press-and-hold on a bud therefore shows in the app within a second on any tab, with +or without Google Play services on the phone, and every setting the app shows follows a change made elsewhere. The hold of the shared channel, the re-claim +chain, the "Noise control with Google Play services" setting with its card, "Take it back again" and the Play-services line are gone; the shared channel is +claimed only for a tap, as before 1.2.0. + +**Wire.** `Maestro.subscribeToSettingsChangesRequest(channelId)` (empty payload, method `0x2821adf5`) and `Maestro.readSettingRequest(channelId, 13)` +(`READABLE_FIELDS` += 13) — byte-identical to `CAP-051` 1177/1178 (channel 21) and `CAP-056` 1451/1450 (channel 19), asserted in `SettingsStreamCodecTest`. +`launchInitialEqRead()` (Connect and re-open, through `openSession`): `readEq` → `readSettings` → `subscribeRuntimeInfo` → `subscribeToSettingsChanges` → +`readAncSetting` → `readHardwareInfo` (Q6). Each step fails on its own, never retried (the ADR-065 "as built" note). Nothing else new on the wire; nothing +written in reaction to a stream packet (`git diff` shows exactly two new `transport.send` call sites, both DLCI 0x02 requests). + +**Codec.** `SettingValue.AncState(mode: AncMode?)` (field 13: 0 → `null`, 1–4 → the mode, else not interpreted, never encodable); `RoutedFrame.Setting` +carries `stream: Boolean`; `RoutedFrame.UnreadSetting(field)` for a `SERVER_STREAM` `4:{N: …}` with N outside `READABLE_FIELDS` (`SettingsCodec.fieldOf`); +a stream packet that does not parse stays an `UnidentifiedFrame`; field 16 from the stream is an EQ frame (as before), field 18 an EQ frame with `persist`. + +**State and cause** (`BudsRepositoryImpl`). `applyAncState(state, stream)`: the availability from the value (0 → `NOT_ALLOWED`, 1–4 → `ALLOWED`, Q5), the mode +with the packet's time; `causeOfStream` (own `Set` pending or within `ANC_SET_WINDOW_MS` = 2 000 ms → `SET_BY_APP`; differing → `CHANGED_BY_BUDS`; first ever → +`READ`; same → unchanged), `causeOfReadAnswer` (differing → `CHANGED_SINCE_LAST_READ`, else `READ`) used by both the `4:13` RESPONSE and the `Get`'s `Notify`; +`_ancModeCause` no longer reset at Connect (Q4); `lastSetMode`/`lastSetAt` recorded in `ancSetOnClaim`. Other fields: `applySetting` with the receive time, +`keepsOwnMark` keeps `changedByApp` for the mirror of the app's own write. `currentAncMode()` (tile, notification) follows the same flow. + +**Removal** (Q8 list, executed file by file after a fresh listing): deleted `HoldReclaimMode.kt` (with `ChannelTaken`, `PlayServicesPresence`), +`HoldReclaimSettingsStore.kt` + its test, `PlayServicesProbe.kt`, `Cap074Fixtures.kt` (unused after the hold tests went); edited `BudsRepositoryImpl` (the +ctor param, the hold, the chain, `setAncTabShown`, `ancChannelTaken`, `reclaimAncChannel`, `HOLD_RECLAIM_*`; `withMessageStream` releases after the linger +again), `BudsRepository`, `RepositoryModule`, `MainActivity`, `AndroidManifest.xml` (`` + comment gone), `OpenControlNavHost`, `AncScreen` +(`ANC_STREAM_HINT_TEXT`, the two cause lines — Q3/Q10), `SettingsMenu` (the card and the Play line), `DarkModeSettingsStore` (a `DataStore` constructor for the +JVM test; `hold_reclaim_mode` never read). Always-on, payload-free log lines: "Settings stream subscribed (channel N)", "Noise-control mode read (channel N)", +"Noise-control mode from the settings stream ()" / "… from the mode read (…)", "… : no mode settable", "Settings stream: field N not shown (not read by +this app)", and the failures with `eqErrorLogText`. + +**Tests.** New: `SettingsStreamFixtures.kt` (the real frames of §A.1 with file and frame), `SettingsStreamCodecTest` (the requests on both channels; every +RESPONSE and stream packet; value 0; the other fields; field 13 with the values 0–255; unread fields 8/10/20/31 → `UnreadSetting`, the same as a RESPONSE → +`RpcResult`; field 18; a fuzz over mutated/truncated/oversized/random-field stream packets), `DarkModeSettingsStoreTest` (a 1.2.2 file with +`hold_reclaim_mode` — read, written, the key untouched), `android/bluetooth/StubBluetoothDevice.kt` (the SDK stub's package-private constructor, so +`connect()` and the ADR-044 re-open run end to end against the fake transport), the ADR-064/065 section of `BudsRepositoryImplTest` (the Connect sequence's +bytes and order; the re-open subscribing again — M1; a lost session at the subscription not retried; a push as changed by the Buds with its time, nothing sent, +no claim; value 0 — M2; a differing read of 13 and the `CAP-051` 2247 case; the own Set in `CAP-051`'s order, before the ACK and after a cut-off answer — M3; +the `CAP-056` wear sequence; the other fields — M5, with the own-write mirror; an unread field — M4; nothing held or re-claimed). Rewritten: the 0089 cause +tests (a session's first differing read is now "changed since", the Connect keeps the cause), `AncScreenTest` (the (i) text, the two cause lines, no card/hint/ +button/Play line), `SettingsMenuTest` (no card, no Play line), `TabRestoreTest` (the ADR-061 test removed), the Connect-sequence counts (15 requests), +`PwRpcTest`/`SettingsCodecTest` (13 readable, the stream flag), `Settings036` gains `CAP-036` 1517/1519 (`ReadSetting 4:13` → `4:{13:0}`) so the official +sweep's answer set is complete. + +## D. Gate, mutations, compliance (Phase D) + +- `cd android && ./gradlew assembleDebug testDebugUnitTest test lint --max-workers=2`: **BUILD SUCCESSFUL in 2m 29s, exit 0**. JUnit XML (debug + release + summed): `:data` 3344 (1672 per variant: the hold/re-claim tests and `HoldReclaimSettingsStoreTest` out, the stream tests in), `:hardware` 128, `:domain` 42, + `:ui` 184 (92 per variant; 102 before — the ADR-061/062/063 screen tests out, two in), `:app` 0; 0 failures. Lint: 7 warnings (6 `:app`, 1 `:ui`), no error. +- Mutation checks, one at a time (`--max-workers=2`, the script in the scratchpad; the sources restored and verified byte-identical afterwards): + M1 (the subscription sent once per process, not per session) → 2 tests fail (the re-open test, the lost-session test); M2 (0 decoded as Off) → 4 fail; + M3 (`causeOfStream` without its `SET_BY_APP` branches) → 1 fails (the stream before the ACK / after a cut-off answer); M4 (no `UnreadSetting`) → 2 fail; + M5 (an EQ frame only from a RESPONSE) → 6 fail. All five caught. +- Compliance: the manifest's permissions unchanged (`BLUETOOTH_CONNECT`, `BLUETOOTH_SCAN`, `POST_NOTIFICATIONS`, `FOREGROUND_SERVICE`, + `FOREGROUND_SERVICE_CONNECTED_DEVICE`; the merged manifest adds only AndroidX Core's private receiver permission), **no ``**, no `INTERNET`; + `git diff HEAD -- '*.kts' gradle/libs.versions.toml` empty (no dependency change), `./gradlew :app:dependencies --configuration releaseRuntimeClasspath` + run (892 lines, no gms/firebase/sentry/crashlytics/analytics/okhttp/retrofit); the AGPL header on every new and changed Kotlin file; no hidden API, no + reflection; no MAC or serial in any new log line (all payload-free; the unread-field test asserts the payload bytes are not in the log); the only new wire + bytes are the two requests. + +## E. `CAP-076` (Phase E) + +The skeleton captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md rewritten for 1.3.0 (both users, the extended tier, screen +recordings with sound and a camera on the ears; BL-1 … BL-25 with the expected bytes of the two requests, the `4:{13:n}` answers and stream packets per channel +and value, the other fields' packets, the own tap's order, the `tshark` lines; the counts for the verdict in the analysis checklist); Group BL and the index +row in `CAPTURE_BLUETOOTH_HCI_SNOOP.md`; the registry row (status planned); `APP_TESTPLAN.md` §Y (Y1 … Y22) and its header note; the Summary row. + +## F. Documents (Phase F) + +`DECISIONS.md`: ADR-065 (the approved text, plus an "as built" note on item 1's sequence convention), the Status lines of ADR-061/062/063 ("Superseded by +ADR-065"; texts kept), pointer Updates on ADR-032 (the hold exception gone) and ADR-064 (built; items 2 and 6 amended). `id_registry.csv`: ADR-065 added, +ADR-061/062/063 → superseded, ADR-064 and `CAP-076` updated. `AGENTS.md` §1: the approved pointer. `ARCHITECTURE.md`: §2 (the store line), §2.4 (the (i) and +cause texts; the 1.2.x card as history), §3.1 (the stream rule), §3.2 (`ANC_SET_WINDOW_MS`; `HOLD_RECLAIM_*` gone; the subscription row), §5a (the cause row), +§6.0b (the hold/re-claim paragraphs as history with a pointer; the 1.3.0 paragraph), §7 (`ChannelTaken` as history), §9 (no package visibility). `PROTOCOL.md`: +unchanged (its §4.1 Update of 2026-10-14 is still right). `TODO.md`: §1 (`CAP-076` = the 1.3.0 run), §2 (the hold branches moot), §3 (the row), §4 (item 4 +built), §5 ("Next" done; the Play-user item reduced to the per-tap collision; E-01 stays). `PROJECT.md`, `README.md` (the 1.3.0 paragraph, Current state), +`CHANGELOG.md` `[1.3.0]`, `scripts/release_notes.template`, `android/app/build.gradle.kts` 1.3.0 / 10300, `ai-sessions/INDEX.md`. Then `ensure_footers.py` +and `lint_docs.py` (below). + +## G. Files read, deferred documentation, commits + +**Files read in full:** `AGENTS.md`, `PROJECT_RULES.md`, `PROJECT.md`, `ARCHITECTURE.md`, `DECISIONS.md` (ADR-001 … ADR-064 with every Update), `TODO.md`, +`AI_SESSION_LOG_PROCEDURE.md`, `ai-sessions/INDEX.md`, `RELEASING.md`, the prompt, `ai-sessions/0090_CROSSCHECK_PROMPT`/`RESULT`, the `0087` and `0089` +RESULTs, `DESKRESEARCH_FINDINGS.md` entry 2026-10-14, `REVERSE_ENGINEERING.md`'s `qhr` Update of 2026-10-14, `CAP-051-FINDINGS.md` §2–§3, `CAP-038-FINDINGS.md` +§3/§5, `CAP-056-FINDINGS.md` §4, `CAP-075-FINDINGS.md` §1/§4/§5/§9, `CAP-073-FINDINGS.md` §4, `CAP-076-EVENT-NOTES.md` (the 1.2.2 skeleton), `APP_TESTPLAN.md` +§W/§X/§Y/Summary, `CHANGELOG.md`'s head and `[1.2.2]`/`[1.2.1]`/`[1.2.0]`, README's Status and Current state, `scripts/release_notes.template`, +`id_registry.csv`'s tail; Kotlin in full: `BudsRepositoryImpl.kt`, `SessionReopener.kt`, `codec/Maestro.kt`, `CodecRouter.kt`, `SettingFrame.kt`, `PwRpc.kt`, +`AncFrame.kt`, `RuntimeInfo.kt`, `EqFrame.kt`, `EqFrameDecoder.kt`, `HoldReclaimSettingsStore.kt`, `DarkModeSettingsStore.kt`, `SettingsDataStore.kt`, +`BudsRepository.kt`, `AncModeCause.kt`, `HoldReclaimMode.kt`, `DeviceInfo.kt`, `AncMode.kt`, `AncTile.kt`, `BudsSettings.kt`, `ValueCurrency.kt`, +`MainActivity.kt`, `AncTileService.kt`, `PlayServicesProbe.kt`, `RepositoryModule.kt`, `AndroidManifest.xml`, `app/build.gradle.kts`, `data/build.gradle.kts`, +`OpenControlNavHost.kt`, `AncScreen.kt`, `ConnectionBanner.kt`, `SettingsMenu.kt`, `BudsTransport.kt`, `FakeBudsTransport.kt`, `Cap074Fixtures.kt`, +`Cap075Fixtures.kt`, `Cap061Fixtures.kt`, `HoldReclaimSettingsStoreTest.kt`, `SessionReopenerTest.kt`, `CodecRouterTest.kt`, `SettingsFixtures.kt`, +`SettingsCodecTest.kt`, `OversizedLengthTest.kt`, `AncScreenTest.kt`, `SettingsMenuTest.kt`, `TabRestoreTest.kt`. **In part:** `PROTOCOL.md` (§2.2a, §4.1 with +its 2026-10-14 Update, §4.3 Option F, §4.5, §6, §8's last rows), `BudsRepositoryImplTest.kt` (lines 1–720, 1008–1060, 1180–1345, 1750–1835, 2470–2800, +2975–3634; the rest by its index of test names), `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the rows of `ANC-001`, `HOLD-005`, `FW-003`, `TOUCH-007`, `BATT-004`, +`PAIR-003`), `CAPTURE_BLUETOOTH_HCI_SNOOP.md` (Group BL, the `CAP-076` row, the headings), `APP_TESTPLAN.md`'s header, `Details.kt` (the `CardTitle` API), +`PwRpcTest.kt` (the method ids and the readable-fields test), `OpenControlApplication.kt` (`currentAncMode()`), `Cap045`/`Cap065`/`Cap072` +fixture names, `Varint.kt`, `CaseBatteryFrame.kt` (`Proto.fields`). + +**Deferred documentation:** none beyond `TODO.md`'s open items (the maintainer's next steps below; `CAP-076`'s analysis will add an ADR-065 Update with the +numbers and may settle `PROTOCOL.md` §6's wear-change item). The capture logs' `tshark` positive control for "0 `SABM` from the app" belongs to that analysis. + +**Commits:** none yet — the maintainer's yes is asked first (Phase G). Proposed, per concern: `feat`: the subscription, the read of 13 and the stream in the +codec and the repository; `refactor`: the hold/re-claim/Play removal (code, manifest, UI); `test`: the fixtures and tests; `docs`: ADR-065 and the documents, +`CAP-076`, 1.3.0. + +**The maintainer's next steps (not executed by this session):** push `feature/settings-stream` and open the PR with the Release checklist; CI green; B1 +`scripts/release.sh 1.3.0`, B2, B3; C1–C2 the `CAP-076` run in both users (the skeleton); a CAPTURE session for C3/C4 and the verdict; only then the +release (never by this session: no tag, no `dist/`, no merge). + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/0091_FEATURE_RESULT_2026_10_14 diff --git a/ai-sessions/INDEX.md b/ai-sessions/INDEX.md index d59684f8..330dd7d5 100644 --- a/ai-sessions/INDEX.md +++ b/ai-sessions/INDEX.md @@ -97,7 +97,7 @@ a new session** — the same "check before assigning" discipline `id_registry.cs | 0088 | CAPTURE | 2026-10-12 | Full analysis of `CAP-075` (Group BK: 1.2.1 build `31ee183`, the "Noise-control channel" switch — **two sets**: the secondary user without Google Play (2026-10-12: one screen recording with audio, two HCI logs, one export, the app logcat; 1.2.0 → 1.2.1 updated on film) and the Owner with Google Play (2026-10-13: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`): the real events per set, the holds per switch position, the Play user's cut mechanism frame by frame, the four-way comparison `CAP-073`/`CAP-074`/`CAP-075`, the answer per user to "which works best", improvement proposals for the Play user, the release verdict proposal; no app change | complete (2026-10-13) — `CAP-075-EVENT-NOTES.md` (the two timelines, the steps as run, Test-IDs) and `CAP-075-FINDINGS.md` §0–§12: without Play both positions identical (41 of 41 holds shown, no foreign close); with Play the hint 2 of 2 and 0 of 9 holds shown, "Take it back" three chains × three re-claims at 3/6/12 s (4.0–4.8 s on the wire, every one colliding with Play's retry 2.1–2.8 s after the cut), holds cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 shown; the cut's stack path 15 of 15 in the system log; Play's timers = `CAP-074`'s. Checkpoint 0 + Phase D approved in chat: the folder/recording renames, modes, the logcat extract, the films as is, the ADR-062 Update, a 🟢 `PROTOCOL.md` §4.1 note, the CHANGELOG Known-issues text, the verdict "no heavy issue — 1.2.1 released as built", P1 + P2 + P4 → the next FEATURE prompt; the leftover worktree of 0087's prompt removed; no app change. **Continuation in chat 2026-10-13:** PR #44 merged, 1.2.1 published (tag `v1.2.1` on `31ee183`, the notes' Known-issues text measured), E1–E4 by the pull request of `release/1.2.1-after` (#45); D4 done — installed with Obtainium | | 0089 | FEATURE | 2026-10-13 | The noise-control channel in the Play user after `CAP-075` (`ai-sessions/0088`, the maintainer's choice "P1 + P2 + P4"): a constant 3-s re-claim step with a longer chain (capped), a "Take it back again" action on the hint card, a cause label for a mode learned by a re-claim's own read; the switch renamed (the maintainer's example "Channel control") with options that say whether the phone has Google Play services and plainer texts; real `CAP-075` bytes as fixtures, mutation checks, the `CAP-076` (Group BL) skeleton, the documents, the release preparation (no tag, no publication) | awaiting maintainer sign-off — built on `feature/channel-control` (gate green: `:data` 1672, `:domain` 42, `:hardware` 64, `:ui` 102; M1–M6 killed): the re-claim 3 s after every cut up to 20 (`HOLD_RECLAIM_STEP_MS`, `HOLD_RECLAIM_CAP`), "Take it back again" (`reclaimAncChannel`, no `Set`), `AncModeCause.CHANGED_SINCE_LAST_READ` and its (i) line, the card renamed "Noise control with Google Play services" with options that say whether the phone has Play services and ADR-063's "found / not found" line (a `` entry); ADR-062 Update 2, ADR-063 and the `AGENTS.md` §1 note approved in chat 2026-10-13; 1.2.2 (10202) prepared; `CAP-076` (Group BL) skeleton for both users, `APP_TESTPLAN.md` §Y; commits `e60fb7c` … `bfd7891`, PR #49 merged (`5b204ea`); **1.2.2 published 2026-10-14** (tag `v1.2.2` on `bfd7891`) after the maintainer's own test, without `CAP-076`; installed with Obtainium (D4, 2026-10-14) | | 0090 | CROSSCHECK | 2026-10-14 | Point-by-point check of the pbpctrl-derived findings list (E-01 … E-23: `qzed/pbpctrl`, MagicPodsCore's Pixel Buds branch, omarchy-pixelbuds at pinned commits) against this project's captures, APK notes and documents — led by the question whether the app can learn a press-and-hold's noise-control mode on DLCI 0x02 (Maestro setting 13, `SubscribeToSettingsChanges`) instead of the contested Message Stream; proposals only where the rules require the maintainer; no app code, no wire bytes | complete — the leading question answered yes: setting 13 = the mode on DLCI 0x02, pushed on `SubscribeToSettingsChanges` for 73/73 Buds-side and 39/39 app-driven changes (also with no Message Stream open), `ReadSetting 4:13` 359/382; OpenControl never subscribed (0/33 logs); the APK feeds its ANC state only from 13 (`fxb.smali:1237`). Approved in chat 2026-10-14 and applied: `PROTOCOL.md` §4.1 🟢 Update (+ §6, §2.2, §4.5.7, §0.1 notes), **ADR-064** (subscribe + read 13, read only; build next) with pointers on ADR-025/034/036, ADR-060 item (vii), the `qhr` register Update, a desk entry, `CAP-051-FINDINGS.md` §3, `AGENTS.md` §5 note, `ARCHITECTURE.md` §5a rule, `TODO.md`, `scripts/anc_setting13_crosscheck.py`; no app code; commits `7bfb078` … `0b1681a` on `research/pbpctrl-crosscheck`, pushed, PR #55. **Continuation in chat 2026-10-14:** the existing captures checked for the new findings — one-bud/two-bud stored mode 28 of 28 in the official app's captures (🟡 strong, `PROTOCOL.md` §6, `scripts/wear_mode_crosscheck.py`); settings 8/9/10 and `SubscribeToOobeActions` never on any wire; runtime-info 7.x = the charging flags in 96.9 % | -| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | planned — prompt written 2026-10-14 (chat, from the maintainer's feedback on `ai-sessions/0090`); not started | +| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; not yet pushed); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/INDEX.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/INDEX diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a135e4e5..28e6f9a8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -55,8 +55,8 @@ android { // above the previous release. 1.0.1 = the hotfix of `ai-sessions/0069` (the maintainer's choice in chat 2026-10-03); 1.1.1 = the toolchain of `ai-sessions/0078` // (`ai-sessions/0079`, the maintainer's choice in chat 2026-10-08); 1.2.1 = the Noise-control channel switch of `ai-sessions/0087` (chat 2026-10-12). // 1.2.2 = the constant re-claim step, "Take it back again" and the renamed card of `ai-sessions/0089` (chat 2026-10-13, "Version"). - versionCode = 10202 - versionName = "1.2.2" + versionCode = 10300 + versionName = "1.3.0" buildConfigField("String", "GIT_COMMIT", "\"$gitCommit\"") buildConfigField("String", "GIT_COMMIT_DATE", "\"$gitCommitDate\"") } diff --git a/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md b/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md index 900c4e87..16ba1fe4 100644 --- a/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md +++ b/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md @@ -1,24 +1,26 @@ -# Event Notes: OpenControl for Pixel Buds Pro 2 on Pixel 9a (GrapheneOS, the Owner user **with** Google Play services and the secondary user without) — Group BL, 1.2.2: the constant re-claim step, "Take it back again", the cause line and the renamed card (`CAP-076`) - -**Status:** 🔲 **Not yet captured — skeleton only** (written by `ai-sessions/0089`, 2026-10-13). The hardware run of **1.2.2** (versionCode 10202) — the follow-up -of `CAP-075` (`ai-sessions/0088`, the maintainer's choice "P1 + P2 + P4"; `DECISIONS.md` ADR-062 Update 2 and ADR-063): in the option **"This phone has Google -Play services"** (stored as `RECLAIM`, 1.2.1's "Take it back") the app re-claims the noise-control channel **3 s after every cut, up to 20 times** (no 6/12-s -back-off), then shows the hint with "this app took it back 20 times and stopped"; the hint has a **"Take it back again"** button (a claim with its `Get`, no -`Set`); a mode the app's own `Get` finds changed since it last saw it gets the (i) line **"Changed since the app last saw it (read at HH:MM:SS): …"**; the -Settings card is renamed **"Noise control with Google Play services"**, its options say whether the phone has Google Play services, and it shows whether Android -finds Play services on this phone (ADR-063). Nothing new on the wire: every claim is the existing one (`SABM` DLCI 0x04, `08 11 00 00`, the phone's `DISC`). -**Two parts, ≤ 20 minutes on film with sound** (the maintainer's choice in chat 2026-10-13, "CAP-076" → "both users, as CAP-075"): **part 1** the Owner user -(user 0) with Play services; **part 2** the secondary user (user 10) without Play — the `CAP-073` §4 regression in both options and the renamed card. The -maintainer films; a later CAPTURE session analyses it and gives the release verdict (`RELEASING.md` C3/C4). After the run: rename this folder to the screen -recordings' first/last phone times and the recordings to CAP-076-recording1.mp4 / CAP-076-recording2.mp4. - -**Expected numbers (from `CAP-075`, one Play version — `CAP-075-FINDINGS.md` §1, §4; `ai-sessions/0089` RESULT §A.1):** a cycle cut → 3.0 s → attempt 1 refused -(0.26–1.05 s; Play's retry, 2.1–2.8 s after the cut, is on the channel) → attempt 2 → the app's `SABM` ≈ 4.5–4.9 s after the cut → held 4.55–4.92 s → the next -cut ⇒ **≈ 9.2–9.5 s per cycle, ≈ 50 % held, for ≈ 3.1 minutes** (20 cycles), then the hint. A press-and-hold every ≈ 4 s lands in the app's window about half -of the time. If Play's timers differ in this run, say the measured ones. - -**Not in this run** (say so if asked): the hold's other untested branches of `TODO.md` §2 (the tile on the tab, Home/return, a loss with the tab shown) — -optional at the end of part 2 (BL-22 … BL-24); the case-sound experiment; the wear sequences. +# Event Notes: OpenControl for Pixel Buds Pro 2 on Pixel 9a (GrapheneOS, the Owner user **with** Google Play services and the secondary user without) — Group BL, 1.3.0: the settings stream on the app's own channel (`CAP-076`) + +**Status:** 🔲 **Not yet captured — skeleton only** (written by `ai-sessions/0089` for 1.2.2 on 2026-10-13; **rewritten by `ai-sessions/0091` for 1.3.0 on +2026-10-14** — 1.2.2 was released without this run, `TODO.md` §1). The hardware run of **1.3.0** (versionCode 10300; `DECISIONS.md` ADR-064 and ADR-065): at every +MAESTRO session (Connect and every ADR-044 re-open) the app sends one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` after `SubscribeRuntimeInfo`, so +the Buds push every change of the noise-control mode — a press-and-hold on a bud, their own switch, the app's own `Set` — as a `4:{13:n}` packet on the app's +own channel (DLCI 0x02), **whatever tab is shown and without a claim of the shared channel DLCI 0x04**; every other setting the app reads follows the same +stream. The hold of 1.2.0 (ADR-061), the re-claim with the setting "Noise control with Google Play services" (ADR-062) and the Play-services line with the +`` entry (ADR-063) are gone. **Two new requests on the wire, nothing else new**: the official app's bytes (`CAP-051` 1177/1178, `CAP-056` 1451/1450). +**Two parts, both GrapheneOS users, the extended tier** (`RELEASING.md` §11a): **part 1** the Owner user (user 0) with Play services — where 1.2.x showed a hold +about half of the time at best; **part 2** the secondary user (user 10) without Play. The maintainer films; a later CAPTURE session analyses it and gives the +release verdict (`RELEASING.md` C3/C4). After the run: rename this folder to the screen recordings' first/last phone times and the recordings to +CAP-076-recording1.mp4 / CAP-076-recording2.mp4; the camera films to CAP-076-camera1.mp4 / CAP-076-camera2.mp4. + +**Expected (from the official app's captures — `CAP-051-FINDINGS.md` §2–§3, `CAP-056-FINDINGS.md` §4, `CAP-038-FINDINGS.md` §3; `ai-sessions/0091` RESULT +§A.1):** the stream packet for a hold follows the Buds' `Notify` by 1–144 ms (`CAP-051` 1707 → 1717, 1754 → 1755, 1801 → 1802), also while no client holds the +Message Stream (`CAP-038` 2170, 2205); so **every hold shows on screen within a second, on any tab, in both users**, with "Changed by the Buds at HH:MM:SS" in +the noise-control (i). The app's own tap: `Set` → ACK → `Notify` → the stream packet 269 ms after the Set (`CAP-051` 1527 → 1534) — shown as the app's change. +A wear change the Buds answer with a `DISC` of DLCI 0x02 (12 of 77 in the captures, `DESKRESEARCH_FINDINGS.md` 2026-10-14): the ADR-044 re-open ≈ 1.5 s later +subscribes again and reads 13 — the mode on screen may be a few seconds old in between. + +**Not in this run** (say so if asked): `ReadSetting 4:8/9/10` (the maintainer's choice in chat 2026-10-14, "Not part of this run"); `WriteSetting 4:{13:n}` +(`TODO.md` §4 E-01, needs its own approval); the case-sound experiment. ## Log Metadata @@ -28,134 +30,138 @@ optional at the end of part 2 (BL-22 … BL-24); the case-sound experiment; the | Group(s) | BL (new) | | Date | TBD | | Phone | Pixel 9a, GrapheneOS (Android 17) — **part 1: the Owner user (user 0) with Google Play services** (`CAP-074`/`CAP-075` set 2's user); **part 2: the secondary user (user 10) without Google Play** (`CAP-073`/`CAP-075` set 1's user) | -| App under test | OpenControl for Pixel Buds Pro 2 **1.2.2** (versionCode 10202), the release APK of `scripts/release.sh 1.2.2` (B1), kept as `~/opencontrol-1.2.2-tested` (B3) — **Info tab on film in each user**: "App: 1.2.2, build ()", no "-dirty"; APK SHA-256 and certificate SHA-256 from the script's output; **the installed APK pulled** (P1 — not done in `CAP-074`/`CAP-075`) | -| Previous version in each user | 1.2.1 build `31ee183` (installed 2026-10-12/13); **the stored choice in both users is "Take it back" (`RECLAIM`)** (`TODO.md` §1, `CAP-075-FINDINGS.md` §4) — after the update it must read as **"This phone has Google Play services"** (ADR-062 Update 2: the stored names keep their meaning) | +| App under test | OpenControl for Pixel Buds Pro 2 **1.3.0** (versionCode 10300), the release APK of `scripts/release.sh 1.3.0` (B1), kept as `~/opencontrol-1.3.0-tested` (B3) — **Info tab on film in each user**: "App: 1.3.0, build ()", no "-dirty"; APK SHA-256 and certificate SHA-256 from the script's output; **the installed APK pulled** (P1) | +| Previous version in each user | 1.2.2 build `bfd7891` (installed 2026-10-14 with Obtainium in the Owner user; the secondary user's version on film) — **the stored "Noise control with Google Play services" choice of 1.2.1/1.2.2 is ignored by 1.3.0** (ADR-065 item 5: never read, never migrated); the card is gone | | Google Play services (part 1) | present and active (`com.google.android.gms`, uid 10205 in `CAP-074`/`CAP-075`); **its *Nearby devices* permission state and its version on film** (P0 — `TODO.md` §3's standing item, not done in `CAP-075`) | -| Official Pixel Buds app | not used | +| Official Pixel Buds app | not used on this phone; **optionally on a second phone** for BL-13 / BL-25 (the EQ or a switch changed there — the stream packet reaches OpenControl) — say if no second phone was available | | Buds | Pixel Buds Pro 2, firmware `release_5.203` expected (any other ⇒ Safe Mode: say so, stop) — both buds **worn** for every hold step (Settable `e8`; `00` means no bud worn, ADR-049) | -| Video files | TBD — the screen recording **with sound** per part (say each action and each hold aloud, as in `CAP-075`); no camera | -| Log files | TBD — the HCI snoop logs, the app's debug export per part (Debug mode on), the app logcat, **the system log with the GrapheneOS log viewer right after BL-9's chain** (P6 — its main buffer covered every cut in `CAP-075`), the P0/P1 outputs | +| Video files | TBD — the screen recording **with sound** per part (say each action and each hold aloud, as in `CAP-075`) **and a camera on the ears and the phone** for the hold steps (the hold on the bud and the screen in one frame; no address overlay — `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §3) | +| Log files | TBD — the HCI snoop logs, the app's debug export per part (Debug mode on), the app logcat, the system log with the GrapheneOS log viewer at the end of each part, the P0/P1 outputs | ## Preparation | # | Check | Done | |---|---|---| | P0 | **In each user**: `adb shell am get-current-user`; `adb shell pm list packages --user \| grep -i -E "gms\|vending"; echo "exit=$?"` (part 1: Play listed, exit 0; part 2: nothing, exit 1); positive control `grep opencontrol` (exit 0). **Part 1 on film:** Settings → Apps → Google Play services → Permissions → *Nearby devices* (allowed / not allowed) and the app's version line. Save the outputs **with the exit statuses** | ☐ | -| P1 | In the installed 1.2.1 (`31ee183`): Debug mode **on** (on film). `adb shell dumpsys package io.github.tedsluis.opencontrolpixelbuds \| grep -E "firstInstallTime\|lastUpdateTime\|versionCode"` → save. `adb install -r ~/opencontrol-1.2.2-tested/opencontrol-pixelbudspro2-1.2.2.apk` (one APK serves both users) — **no uninstall**; the same `dumpsys` → save. After the run: `adb shell pm path --user io.github.tedsluis.opencontrolpixelbuds` → `adb pull` → `sha256sum` against `~/opencontrol-1.2.2-tested` | ☐ | -| P2 | HCI snoop log on (set in the Owner; it stays on across the user switch) | ☐ | -| P3 | No camera; the buds and the phone within reach | ☐ | +| P1 | In the installed 1.2.2 (`bfd7891`): Debug mode **on** (on film); gear → Settings: the card "Noise control with Google Play services" and its selected option on film (what 1.3.0 will ignore). `adb shell dumpsys package io.github.tedsluis.opencontrolpixelbuds \| grep -E "firstInstallTime\|lastUpdateTime\|versionCode"` → save. `adb install -r ~/opencontrol-1.3.0-tested/opencontrol-pixelbudspro2-1.3.0.apk` (one APK serves both users) — **no uninstall**; the same `dumpsys` → save. After the run: `adb shell pm path --user io.github.tedsluis.opencontrolpixelbuds` → `adb pull` → `sha256sum` against `~/opencontrol-1.3.0-tested` | ☐ | +| P2 | HCI snoop log on (set in the Owner; it stays on across the user switch) — **Bluetooth off and on on film** after enabling it | ☐ | +| P3 | The camera on a stand: the ears and the phone screen in one frame for BL-5 … BL-8 and BL-18 … BL-21; the phone's clock with seconds visible, or the status bar at a minute change at the start and the end | ☐ | | P4 | Android's **screen recorder with sound** on for each part | ☐ | | P5 | The status bar across a minute change at the start and the end of each recording | ☐ | -| P6 | **The system log of part 1 right after BL-9's chain has stopped** (the GrapheneOS log viewer, as `CAP-075`'s L) — the Bluetooth process's `already at opened state` / `cleanup_rfc_slot` and Play's `RfcommEventStreamMedium … Retry times remaining N` lines of the 20 cycles; **watch "Retry times remaining"**: `CAP-075` saw 5 → 4 once; what Play does at 0 is ❓ (`TODO.md` §4). A second system log at the end of the run. `adb bugreportz` sends signal 3 to every Java process — not an app fault (`CAP-071-FINDINGS.md` §0) | ☐ | +| P6 | **The system log at the end of each part** (the GrapheneOS log viewer, as `CAP-075`'s L) — crashes, ANRs, `StrictMode`; in part 1 Play's `RfcommEventStreamMedium` lines around the taps of BL-10/BL-11. `adb bugreportz` sends signal 3 to every Java process — not an app fault (`CAP-071-FINDINGS.md` §0) | ☐ | | P7 | **Do Not Disturb on** | ☐ | | P8 | Buds charged, **both worn** before BL-4; Android shows them connected before the app is opened | ☐ | | P9 | **No app use before the film** — not opened between P1's install and BL-1 | ☐ | +| P10 | Optional: a second phone with the official Pixel Buds app paired to the same Buds (multipoint) for BL-13 / BL-25 — say on film if there is none | ☐ | -**Rhythm:** one action, then 5–10 s; say what you do ("left hold", "right hold", "tap", "switch", "take it back again"). Export the debug log at the end of each -part. Serials: never unredacted in any note (first 4 + last 2 characters). +**Rhythm:** one action, then 5–10 s; say what you do ("left hold", "right hold", "tap", "tile", "bud out", "Bluetooth off"). Export the debug log at the end +of each part. Serials: never unredacted in any note (first 4 + last 2 characters). ## Steps DLCIs are session-local (MAESTRO 0x02 or 0x03, Message Stream 0x04 or 0x05). Every MAESTRO request starts `7e 00 3b 03 10 13 1d ea 71 de 7d 5e 25` on -**channel 19** or `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25` on **channel 21** (Info → "Control channel"). **Reference frames** — "H2" = `CAP-075-btsnoop_hci2.log` -(the Owner user with Play, 1.2.1 `31ee183`, handle `0x000b`), "H1" = `CAP-075-btsnoop_hci1.log` (user 10 without Play, the same build), re-derived by -`ai-sessions/0089` with `tshark -r -Y "frame.number==N" -T fields -e frame.number -e frame.time -e frame.p2p_dir -e btrfcomm.dlci -e btrfcomm.frame_type --e data.data` (exit 0): +**channel 19** or `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25` on **channel 21** (Info → "Control channel"). **Reference frames** — the official app's bytes, +re-derived by `ai-sessions/0091` with `tshark -r -Y 'frame.number==N' -T fields -E separator='|' -e frame.number -e frame.time -e frame.p2p_dir +-e bthci_acl.chandle -e btrfcomm.dlci -e data.data` (exit 0) after scoping to the Buds' ACL handle, decoded with `python3 scripts/pwrpc_decode.py ` +(a payload holding several HDLC frames split on `7e`, each CRC-32 verified): -| Request / event | Reference | +| Request / event | Reference (whole pw_hdlc frame incl. CRC) | |---|---| -| The Connect: `SABM` DLCI 0x02, then `ReadSetting 4:16`, 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29, `SubscribeRuntimeInfo`, `GetHardwareInfo` | channel 19: H2 1193 … 1302 — `7e003b0310131dea71de7d5e2551aed0ae2a022010fdae3f247e` (4:16) … `7e003b0310131dea71de7d5e25e3a5ec28ff1ebbb77e` (`GetHardwareInfo`); channel 21: H1 762 … 841 (`7e004b0310151dea71de7d5e2551aed0ae2a02201047eeadcf7e` … `7e004b0310151dea71de7d5e25e3a5ec28f96761b57e`) — `CAP-075-FINDINGS.md` §2: byte-identical to `CAP-073`/`CAP-074` | -| **The cut** (Play's recovery connect refused; the stack closes the app's port) | the phone's `DISC` of the app's DLCI 0x04 without a "released" line — **H2 2369** 06:30:19.281 (the app's `SABM` 2298 06:30:14.565: 4.716 s); export "RFCOMM on-demand channel 0x04 closed (IOException: bt socket closed, read return: -1)" + "Message Stream hold ended: the channel was closed, not by this app"; system log 0.04–0.22 s before: `RFCOMM_CreateConnectionWithSecurity: already at opened state` + `cleanup_rfc_slot … app_uid: 10205` + `… app_uid: ` (`CAP-075-FINDINGS.md` §5) | -| Play's retry after the cut | **H2 2401** 06:30:21.799 (`SABM`, first message `03 08 00 02 01 25` at 2409) — 2.518 s after the cut (range 2.06–2.82 s) | -| **The re-claim** (3.0 s after the close line; attempt 1 refused; attempt 2) | export "Message Stream re-claim 1/**20** after a foreign close, 3000 ms later (ADR-062)" (1.2.1: "1/3") 3.0 s after the "closed" line; "connect failed after N ms (attempt 1/3)" and Play's `DISC` within 3 ms (**H2 2476** 06:30:22.867 — Play's socket killed by the refused connect); the app's **`SABM` H2 2502** 06:30:23.760 (4.479 s after the cut) + `08110000` (2512); held until the next cut **H2 2570** (4.749 s) | -| **The shown hold** (an unprovoked `Notify` on the app's re-claimed hold) | **H2 3006** 06:30:58.178 `0813000401e8e808` (ACTIVE) on re-claim 3/3 (`SABM` 2960) — on screen within a second (film 2 s114); the (i) "Changed by the Buds at …" | -| **A re-claim's changed read** (the app's own `Get` finds another mode) | **H2 2968** `08110000` → **2980** 06:30:54.437 `0813000401e8e880` (TRANSPARENT; the app's last report 2735 `…e8e840` ADAPTIVE) — in 1.2.1 no cause line (film 2 s265–269 for 5361); **1.2.2: the (i) line "Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app did not have the channel."** | -| A hold that reaches Play (not the app) | an unprovoked `Notify` on Play's claim — H2 2878, 2891, 2907 (Play #9, `SABM` 2805); nothing on screen | -| The hold in the user without Play (part 2) | H1 1192 `SABM` 17:26:06.611 … 1268 (14.40 s, four holds), 1289 … 1389 (33.62 s, nine holds) — every hold on the app's claim, shown within a second; **no `DISC` while the tab is shown**; the phone's `DISC` 1.50–1.51 s after leaving the tab | - -Anything else from the app on the wire (a request not in this table or in `CAP-075` §2/§3, a second `GetHardwareInfo` in a session, anything on DLCI 0x08/0x0a, -a `Set` from "Take it back again", a claim while the tab is **not** shown that is not a tap/pull/Connect) is a finding. - -### Part 1 — the Owner user (user 0) **with** Google Play services (screen recording with sound; `APP_TESTPLAN.md` Y1–Y10; `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`…`ANC-004`, `TOUCH-007`) +| The Connect: `SABM` DLCI 0x02, then `ReadSetting 4:16`, 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29, `SubscribeRuntimeInfo`, **`SubscribeToSettingsChanges`, `ReadSetting 4:13`**, `GetHardwareInfo` — **fifteen MAESTRO requests** | `CAP-075` H2 1193 … 1302 (channel 19) / H1 762 … 841 (channel 21) for the thirteen of 1.2.x (`CAP-075-FINDINGS.md` §2), then the two new ones below, then `GetHardwareInfo` (`7e003b0310131dea71de7d5e25e3a5ec28ff1ebbb77e` on 19, `7e004b0310151dea71de7d5e25e3a5ec28f96761b57e` on 21) | +| **`SubscribeToSettingsChanges` REQUEST** (method `0x2821adf5`, empty payload) | channel 21: `CAP-051` 1177 — `7e004b0310151dea71de7d5e25f5ad2128019754b07e`; channel 19: `CAP-056` 1451 / `CAP-051` 2229 — `7e003b0310131dea71de7d5e25f5ad212807ee8eb27e`. Find them: `tshark -r CAP-076-btsnoop_hci1.log -Y 'btrfcomm.dlci==2 \|\| btrfcomm.dlci==3' -T fields -e frame.number -e frame.time -e frame.p2p_dir -e data.data \| grep -i "25f5ad2128019754b0\\\|25f5ad212807ee8eb2"` — **one per MAESTRO session, direction 0 (sent)** | +| **`ReadSetting 4:13` REQUEST** | channel 21: `CAP-051` 1178 / `CAP-036` 1517 — `7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e`; channel 19: `CAP-056` 1450 / `CAP-021` 1069 — `7e003b0310131dea71de7d5e2551aed0ae2a02200d24c239477e`. Find: `… \| grep -i "2a02200d"` — one per MAESTRO session, right after the subscription | +| **Its RESPONSE `4:{13:n}`** (type 1; the first read's value after every Connect/re-open) | channel 19: `7e80a3032a04220268 0n 080110131dea71de7d5e2551aed0ae` + CRC — n = 0 `…c6b9d8857e` (`CAP-051` 2247, after the Buds' own `DISC`), 1 `…2e62233c7e` (`CAP-021` 1089), 2 `…57085e2d7e` (`CAP-056` 1468), 3 `…bfd3a5947e` (`CAP-027` 923); channel 21: n = 0 `7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e` (`CAP-045` 2033), 4 `…067d5e749e7e` (`CAP-051` 1225). 1 = Off, 2 = Noise cancellation, 3 = Transparency, 4 = Adaptive, **0 = no mode settable** (no bud worn) | +| **The stream packet `4:{13:n}`** (type 7 `SERVER_STREAM`, direction 1, the Buds → the phone) — **one per hold, per own tap and per wear-driven switch** | channel 19: `7e80a3032a04220268 0n 080710131dea71de7d5e25f5ad2128` + CRC — n = 1 `…117bf5557e` (`CAP-021` 1842), 2 `…681188447e` (`CAP-038` 2205, `CAP-056` 2324/4028), 3 `…80ca73fd7e` (`CAP-038` 2170), 4 `…9ac572667e` (`CAP-056` 2259/4025); channel 21: 0 `7e00a5032a0422026800080710151dea71de7d5e25f5ad21285a02de7d5d7e` (`CAP-045` 2277), 2 `…cbb358d57e` (`CAP-051` 1717), 3 `…2368a36c7e` (1802/1534), 4 `…3967a2f77e` (1755). Find: `… \| grep -i "0807101[35]1dea71de7d5e25f5ad2128"`; count them against the holds made | +| A stream packet for another setting (BL-13 / BL-25, the official app on a second phone) | field 16 (the EQ): `CAP-015` 2116 `7e80a3032a1e221c8201190d…080710131dea71de7d5e25f5ad2128…`; field 2: `CAP-056` 2175 `7e80a3032a0422021001080710131dea71de7d5e25f5ad2128475780f97e`; field 12: `CAP-056` 1696; field 29: `CAP-069` 2502 — the value on screen with the packet's time ("read HH:MM:SS" in the card's (i)) | +| The app's own tap (unchanged, ADR-032) | `SABM` DLCI 0x04 (or 0x05), `08 11 00 00`, `Notify`, `08 12 00 14 01 e8 e8 …`, ACK `ff 01 00 06 08 12 01 e8 e8 `, then the Buds' `Notify` and **the stream packet of that mode on DLCI 0x02** (`CAP-051` 1527 → 1529 → 1530/1533 → 1534: 269 ms); the phone's `DISC` of DLCI 0x04 ≈ 1.5 s after the answer (`MESSAGE_STREAM_LINGER_MS`) | +| Play's Fast Pair claim (part 1) | `SABM` DLCI 0x04, first message `03 08 00 02 01 25` (`CAP-075-FINDINGS.md` §1) — its presence is expected and changes nothing for the stream | +| The Buds' own `DISC` on a wear change (BL-9 / BL-22) | `DISC` DLCI 0x02 from the Buds (`CAP-051` 1996, 5 ms after the Notify 1993) → the app's loss line → ADR-044's re-open ≈ 1.5 s later: `SABM` DLCI 0x02, the fifteen requests, the subscription and the read again (`CAP-051` 2229/2231 → 2247) | + +Anything else from the app on the wire (a request not in this table or in `CAP-075` §2/§3, a second `SubscribeToSettingsChanges` or `ReadSetting 4:13` within one +session, a `WriteSetting 4:{13:n}`, anything on DLCI 0x08/0x0a, a `SABM` on DLCI 0x04 that is not a tap, the tile, Refresh, a pull or the Connect's snapshot) is +a finding. + +### Part 1 — the Owner user (user 0) **with** Google Play services (screen recording with sound + the camera; `APP_TESTPLAN.md` Y1–Y13; `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`…`ANC-004`, `TOUCH-007`) | Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | |---|---|---|---|---|---|---| -| BL-1 | P0–P9 done, user 0, both buds worn | open the app | the session opens by itself (ADR-044); "Probably worn"; Left/Right %, the Case | DLCI 0x02 `SABM`, the thirteen requests in order, `GetHardwareInfo` last, answered; the snapshot claim (attempt 1 refused if Play is on the channel, attempt 2 connects); `03 03` burst; `08 11` → `Notify` | `PAIR-003`, `BATT-004` | a crash; a request missing or out of order; Safe Mode | -| BL-2 | ready | gear → **Info**: 3 s on the build line, the firmware, the serials; **Settings**: the card **"Noise control with Google Play services"** — 3 s on it, open its **(i)**, read the five lines aloud or hold each 3 s, close | "App: 1.2.2, build ", no "-dirty"; `release_5.203` ×3; the card between Dark mode and Case sounds; the intro "The Buds report a press-and-hold on a bud to one app at a time. Choose what fits this phone:"; **"Google Play services: found on this phone"** (ADR-063); **"This phone has Google Play services" selected** (the 1.2.1 choice "Take it back" kept); each option with its second line | nothing | `FW-003` | "-dirty"; another hash; the card under its old name; "This phone has no Google Play services" selected (the stored choice lost); "not found" or no presence line in this user | -| BL-3 | back on Connection | **Disconnect**, wait 5 s, **Connect** | ready | the phone's `DISC` of DLCI 0x02; the thirteen requests again; the snapshot claim | `PAIR-003` | — | -| BL-4 | ready, the **Connection** tab | open the **ANC** tab | the mode | the hold (snapshot claim kept or one new `SABM` + `08 11`); "Message Stream hold started: the noise-control tab is on screen (ADR-061)" | `ANC-001`…`004` | no hold line | -| **BL-5** | the hold open, option **"This phone has Google Play services"**, both buds worn | **press and hold a bud every ≈ 4 s for 3½ minutes** (≈ 50 holds; alternate left/right); say each; **touch nothing on the phone**; count aloud the holds you see change on screen | during the chain: **about half** of the holds change the mode on screen within a second; the ANC card shows **no hint** for ≈ 3 minutes; **after the 20th re-claim's cut** (≈ 3.1 min after the first cut) the hint **"Another app took the noise-control channel at HH:MM:SS — usually Google Play services' Fast Pair; this app took it back 20 times and stopped. A press-and-hold on a bud is not shown until this app has the channel again: tap Take it back again, a mode, Refresh, or pull down."** and the **"Take it back again"** button under it; no further change on screen after that | per cycle: the cut ≈ 4.5–4.9 s after the app's `SABM`; Play's retry `SABM` 2.1–2.8 s after the cut; the export's **"Message Stream re-claim N/20 after a foreign close, 3000 ms later (ADR-062)"** 3.0 s after each "closed" line (N = 1 … 20, **every step 3000 ms**); attempt 1 refused + Play's `DISC` within 3 ms; the app's `SABM` + `08 11`; after the 20th re-claim's cut **"Message Stream re-claim stopped after 20 re-claims — Take it back again, a tap, a pull or re-entering the noise-control tab claims again (ADR-062)"** and no app `SABM` | `TOUCH-007`, `ANC-001`…`004` | a step other than 3000 ms in the export (a 6000/12000 = the old back-off); a 21st re-claim; the stop before 20; the hint missing, without "20 times", or without the button; a `Set` on the wire | -| BL-6 | right after BL-5, **P6 at once** | the GrapheneOS log viewer: save the system log | — | — (the stack lines of the 20 cycles; Play's "Retry times remaining" values) | — | the log not covering the chain | -| BL-7 | the hint with the button | open the ANC **(i)** at a moment the shown mode came from a re-claim's own read (if BL-5 showed one: the mode changed within ≈ 0.5 s after a re-claim, not after a hold you said), read it, close | the (i): the mode with its time, **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app did not have the channel."**, the hint line, the new last sentence "… what this app does then is set on the Settings tab under Noise control with Google Play services." | the re-claim's `08 11` answered by a `Notify` with another mode than the app's last report (as H2 2968 → 2980) | `TOUCH-007` | a changed read without the line; the line after an unprovoked hold (that is "Changed by the Buds") | -| **BL-8** | the hint with the button (the chain stopped) | **tap "Take it back again"**; then hold a bud twice ≈ 4 s apart; wait 15 s | the hint and the button gone at once; the holds inside the app's window shown; ≈ 5 s later the claim is cut and **re-claimed 3 s later** (no hint) | **one** claim: attempt 1 refused (Play on the channel), attempt 2 `SABM` + **`08 11 00 00` only — no `08 12`**, no `03 03`-wait battery claim, no `SubscribeRuntimeInfo` on DLCI 0x02; export **"Message Stream claim by the hint card's action (ADR-062 Update 2)"**; after its cut **"re-claim 1/20 …"** (the counter reset) | `ANC-001`…`004`, `TOUCH-007` | a `Set` (`08 12`) after the tap; a DLCI 0x02 request; no re-claim after the cut (the counter not reset); "re-claim 21/20" | -| BL-9 | the chain of BL-8 running | **tap the Sound tab within 2 s of a cut** (while the 3-s delay runs); wait 10 s; back to **ANC** | Sound; on return a new claim | no `SABM` 3 s after that cut ("Message Stream re-claim cancelled (ADR-062)"); on return a new `SABM` + `08 11` | — | a re-claim while Sound is shown | -| BL-10 | the ANC tab, the chain running | **Home** within 2 s of a cut; wait 10 s; return | the home screen; on return the ANC tab with a new claim | no `SABM` while hidden ("re-claim cancelled"); on return a new claim | — | a `SABM` while the app is hidden | -| BL-11 | the chain running | gear → Settings → **"This phone has no Google Play services"**; back to ANC; hold a bud every ≈ 4 s for 20 s | the row selected; on return: the first hold inside the claim shown, then the hint **without** "took it back …" and with the button; no re-claim | "re-claim cancelled" at the switch (if one was pending); the return's claim; its cut; **"Noise-control channel taken by another client: the hint is shown, no re-claim (ADR-062, hint only)"**; no app `SABM` after the cut | `TOUCH-007` | a re-claim in this option | -| BL-12 | the hint (no Play option) | **tap "Take it back again"**; wait 10 s | the hint gone; ≈ 5 s later the hint back (the claim cut, no re-claim) | one claim with `08 11` only; its cut; the hint-only line again; no re-claim | `ANC-001`…`004` | a `Set`; a re-claim | -| BL-13 | the ANC tab | gear → Settings → **"This phone has Google Play services"** (back to this user's right option); back to ANC | the row selected; a new claim on return | the `DISC` ≈ 1.5 s after the gear (if a claim was open), a new `SABM` + `08 11` on return; after its cut "re-claim 1/20" | — | the choice not kept | -| BL-14 | the ANC tab | **Disconnect**; wait 5 s; **Connect**; stay on the ANC tab | the hint gone at Connect | the thirteen requests; the snapshot claim; the chain from 1/20 | `PAIR-003` | the hint surviving the Connect | -| BL-end1 | — | Debug → **Export**; a second system log; the status bar across a minute change; stop the recording; **switch to user 10** | "Debug log saved (N lines)." | — | — | an empty file | - -### Part 2 — the secondary user (user 10) **without** Google Play (screen recording with sound; `APP_TESTPLAN.md` Y11–Y14; the `CAP-073` §4 regression) +| BL-1 | P0–P10 done, user 0, both buds worn | open the app | the session opens by itself (ADR-044); "Probably worn"; Left/Right %, the Case; the mode | DLCI 0x02 `SABM`; the **fifteen** requests in order — the subscription and `ReadSetting 4:13` after `SubscribeRuntimeInfo`, `GetHardwareInfo` last — answered; the snapshot claim (one `SABM` DLCI 0x04, `08 11` → `Notify`, `03 03` burst, released ≈ 1.5 s later) | `PAIR-003`, `BATT-004` | a crash; a request missing, doubled or out of order; Safe Mode | +| BL-2 | ready | gear → **Info**: 3 s on the build line, the firmware, the serials; **Settings**: 3 s on the whole tab | "App: 1.3.0, build ", no "-dirty"; `release_5.203` ×3; Settings shows **Dark mode, Case sounds, Use different Buds — no "Noise control with Google Play services" card, no "Google Play services: …" line** | nothing | `FW-003` | "-dirty"; another hash; the card or the line still there | +| BL-3 | back on Connection | **Disconnect**, wait 5 s, **Connect** | ready | the phone's `DISC` of DLCI 0x02; the fifteen requests again (the subscription and the read once more — one per session); the snapshot claim | `PAIR-003` | the subscription missing after the Connect | +| BL-4 | ready, stay on the **Connection** tab, both buds worn, the camera on the ears | **press and hold a bud 10 times, ≈ 6 s apart** (alternate left/right); say each; **touch nothing on the phone**; count aloud the mode changes you see on the Connection tab's noise-control line (if any) — then open the **ANC** tab once and read the mode | the mode in the app follows **every** hold within a second (the tab need not be open: the notification's mode line and, on opening ANC, the current mode) | per hold: **one stream packet `4:{13:n}` on DLCI 0x02** (direction 1), no `Notify` (no claim), **no `SABM` on DLCI 0x04 from the app**; Play's own claims may come and go | `TOUCH-007`, `ANC-001`…`004` | a hold without a stream packet; an app `SABM` on DLCI 0x04 during the holds; a mode shown that differs from the last packet's | +| **BL-5** | the **ANC** tab open, both buds worn, the camera on the ears | **press and hold a bud 10 times, ≈ 6 s apart**; say each; count aloud the holds you see change on screen | **10 of 10** holds change the mode on screen within a second; the (i) after one of them: **"Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves."**; the (i)'s last sentence "The Buds report every change of the mode on the app's own channel. …" | one stream packet per hold; **no app `SABM` on DLCI 0x04** (the ANC tab claims nothing by itself since 1.3.0) | `TOUCH-007`, `ANC-001`…`004` | a hold not shown; an app `SABM`; a hint or "Take it back again" anywhere | +| BL-6 | the ANC tab | open the ANC **(i)** right after a hold, read it, close | the mode with its time; the "Changed by the Buds at …" line; "Connection: Ready"; the last sentence with "that part is a hypothesis" | nothing | `TOUCH-007` | the old hold sentence; the channel-taken line | +| BL-7 | the ANC tab | **tap a mode** (one tap); wait 5 s; open the (i) | the tapped mode at once; the (i) without a "Changed by the Buds" line (the app's own change) | one `SABM` DLCI 0x04 + `08 11` + `08 12` + ACK; the Buds' `Notify`; **the stream packet of the tapped mode on DLCI 0x02 within ≈ 0.3 s of the `Set`**; the phone's `DISC` ≈ 1.5 s after the ACK; if Play is on the channel at that moment: attempt 1 refused, attempt 2 connects (`CAP-075` §5) — or the tap fails with its message: tap again, say so | `ANC-001`…`004` | the stream packet of the own Set shown as "Changed by the Buds"; a `DISC` later than 2 s after the ACK | +| BL-8 | the ANC tab | pull **Quick Settings** down; tap the **ANC tile** once; close; look at the app | the tile steps one mode; the app follows at once | as BL-7: one claim for the tile's `Set`; the stream packet; the `DISC` ≈ 1.5 s later | `ANC-001`…`004` | a second `SABM`; the app not following | +| **BL-9** | the ANC tab, **both buds worn**, the camera on the ears | **take one bud out** (say which); wait 10 s; **put it back**; wait 10 s; read the mode after each | if the Buds close the session (the loss line "… the Buds closed …", ready again ≈ 1.5 s later by itself): after the re-open the mode read (`ReadSetting 4:13`'s answer) — the (i) says **"Changed since the app last saw it (read at HH:MM:SS): …"** if it differs from the mode before, nothing if it is the same; if the Buds keep the session: the stream packet of their own switch, "Changed by the Buds" (the stored mode per wear state, 🟡 `PROTOCOL.md` §6) | either the Buds' `DISC` of DLCI 0x02 → the re-open's `SABM`, the fifteen requests, **the subscription and the read again** (the first read's value after the re-open), or a stream packet `4:{13:n}` without a `DISC` — say which happened | `PAIR-003`, `TOUCH-007` | a re-open without the subscription; a changed read without the line; the app sending anything on reaction to the packet | +| BL-10 | ready | **Bluetooth off** (Quick Settings); wait 5 s; **Bluetooth on**; wait 15 s | "Bluetooth was switched off" as the loss cause; ready again by itself once Android reconnects the Buds | the loss; the re-open's `SABM` DLCI 0x02, the fifteen requests — **the subscription again** (M1 on hardware) | `PAIR-003` | no subscription after the re-open | +| BL-11 | ready, the ANC tab | **tap a mode** while Play's claim is likely open (any moment — Play holds the channel most of the time in this user); if the tap fails ("… could not claim …"), wait 5 s and tap again | the mode after at most two taps; nothing else; **no hint, no "Take it back again"** | one claim per tap; a refused attempt 1 with Play's `DISC` is the `CAP-075` §5 collision — write down how many taps failed; **the app never re-claims by itself** | `ANC-001`…`004` | an app `SABM` not caused by a tap/tile/Refresh/pull/Connect | +| BL-12 | the ANC tab | hold a bud **5 more times** ≈ 6 s apart | 5 of 5 shown | 5 stream packets; no app `SABM` | `TOUCH-007` | a hold not shown | +| BL-13 (optional, P10) | the Sound tab | on the **second phone**, in the official Pixel Buds app: move one EQ slider, then switch **In-ear detection** off and on (Controls); watch OpenControl | the equalizer follows within a second ("read HH:MM:SS" in its (i), not "changed by this app"); the switch follows | the stream packets for field 16 and field 2 on OpenControl's DLCI 0x02 (the official app writes on its own session) | — | a value not following; a write from OpenControl in reaction | +| BL-end1 | — | Debug → **Export**; the system log; the status bar across a minute change; stop the recordings; **switch to user 10** | "Debug log saved (N lines)." | — | — | an empty file | + +### Part 2 — the secondary user (user 10) **without** Google Play (screen recording with sound + the camera; `APP_TESTPLAN.md` Y14–Y22) | Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | |---|---|---|---|---|---|---| -| BL-15 | user 10, P0 (no Play), both buds worn, the recording on | open the app; **Info** 3 s; **Settings**: the card, 3 s | the session opens by itself; "App: 1.2.2, build "; the card **"Noise control with Google Play services"**, **"Google Play services: not found on this phone"**, and — the stored 1.2.1 choice "Take it back" — **"This phone has Google Play services" selected** (information only: nothing switches by itself) | the Connect sequence (`CAP-075` H1's bytes); the snapshot claim released ≈ 1.5 s after its answer | `PAIR-003`, `FW-003`, `BATT-004` | Safe Mode; "-dirty"; "found" in this user; the option changed by itself | -| **BL-16** | ready, "This phone has Google Play services" (as stored) | open the **ANC** tab; **press and hold a bud every ≈ 5 s for 30 s**; touch nothing | **every** hold changes the mode on screen; **no hint, no button** | one `SABM` + `08 11`; **no `DISC` for the 30 s**; the unprovoked `Notify` of each hold on the app's claim; no "re-claim" line; 0 Play claims (`frame contains 03:08:00:02:01:25` → 0 in this part) | `TOUCH-007`, `ANC-001`…`004` | a `DISC` while the tab is shown; a hold not shown; a re-claim line; the hint | -| BL-17 | the hold open | gear → Settings → **"This phone has no Google Play services"** (this user's right option); back to **ANC**; hold every ≈ 5 s for 30 s | every hold shown; no hint | the `DISC` ≈ 1.5 s after the gear; one new `SABM` + `08 11` on return; no further `SABM`, no `DISC` for the 30 s | `TOUCH-007` | any extra `SABM`; a `DISC` while the tab is shown | -| BL-18 | the hold open | open the ANC **(i)** after a hold, read it, close | "Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)."; the last sentence with "Noise control with Google Play services"; **no** "Changed since the app last saw it" line (the app had the channel for every hold) | nothing new | `TOUCH-007` | the changed-since line here | -| BL-19 | the hold open | go to the **Connection** tab, wait 5 s (the claim released), **hold a bud once** there, then back to **ANC** | on return the mode read; the (i) shows **"Changed since the app last saw it (read at …)"** (the change was made while the app had no claim) | the return's `08 11` answered with another mode than the last report | `TOUCH-007` | no line after a changed read | -| BL-20 | the hold open | tap the **Controls** tab; wait 5 s | Controls | the phone's `DISC` ≈ 1.5 s after the tab change; nothing else | — | no `DISC`, or one before 1.3 s | -| BL-21 | — | gear → Settings: leave **"This phone has no Google Play services"** (this user's right option) | the row selected | — | — | — | -| BL-22 (optional, `TODO.md` §2) | back on ANC, the hold open | pull **Quick Settings** down; tap the **ANC tile** once; close | the tile steps one mode; the app follows | `08 11` → `Notify` → `Set` → ACK on the held channel, no `SABM`, no `DISC` | `ANC-001`…`004` | a second `SABM` | -| BL-23 (optional) | the hold open | **Home**; wait 5 s; return | the home screen; ANC again | the `DISC` ≈ 1.5 s after Home; a new `SABM` + `08 11` on return | — | no `DISC` after Home | -| BL-24 (optional) | the ANC tab shown, both worn | both buds **into the case**; wait 10 s; **out into the ears** | the session ends with its cause; ready again by itself | the loss; the ADR-044 re-open, its snapshot claim kept as the hold | `PAIR-003` | the snapshot released while the tab is shown | -| BL-end2 | — | Debug → **Export**; the status bar across a minute change; stop the recording; P1's APK pull | "Debug log saved (N lines)." | — | — | — | +| BL-14 | user 10, P0 (no Play), both buds worn, the recordings on | open the app; **Info** 3 s; **Settings** 3 s | the session opens by itself; "App: 1.3.0, build "; no Play card, no Play line | the Connect sequence with the **fifteen** requests (the subscription and the read); the snapshot claim released ≈ 1.5 s after its answer | `PAIR-003`, `FW-003`, `BATT-004` | Safe Mode; "-dirty"; the card | +| BL-15 | ready, the **Connection** tab | **hold a bud 10 times** ≈ 6 s apart; say each; then open ANC | every hold followed (the mode line / the notification; ANC shows the last) | 10 stream packets; **no app `SABM`** on DLCI 0x04; 0 Play claims (positive control: part 1's count) | `TOUCH-007`, `ANC-001`…`004` | a hold without a packet; an app `SABM` | +| **BL-16** | the **ANC** tab, both worn | **hold a bud 10 times** ≈ 6 s apart | 10 of 10 shown within a second; "Changed by the Buds at …" in the (i) | 10 stream packets; no app `SABM`; **no `DISC` of DLCI 0x04 while the tab is shown — because there is no claim to cut** | `TOUCH-007`, `ANC-001`…`004` | a hold not shown; an app `SABM` | +| BL-17 | the ANC tab | **tap a mode**; wait 5 s; the (i) | the mode; no "Changed by the Buds" line | one claim: `08 11`, `08 12`, ACK, the `Notify`, the stream packet ≈ 0.3 s after the Set, the `DISC` ≈ 1.5 s after the ACK | `ANC-001`…`004` | the own Set shown as the Buds' change | +| BL-18 | the ANC tab | the **ANC tile** once; close; the app | the tile steps one mode; the app follows | as BL-17 | `ANC-001`…`004` | — | +| BL-19 | the ANC tab, both worn | **one bud out**; wait 10 s; **back in**; wait 10 s | as BL-9 (the re-open's read with "Changed since the app last saw it" if it differs, or the Buds' own switch as "Changed by the Buds") | as BL-9 | `PAIR-003`, `TOUCH-007` | as BL-9 | +| BL-20 | ready | **Bluetooth off**; 5 s; **on**; wait 15 s | the loss named; ready again by itself | the re-open's fifteen requests — the subscription again | `PAIR-003` | no subscription after the re-open | +| BL-21 | the Controls tab | hold a bud 5 times ≈ 6 s apart; then ANC | the last mode on ANC; (i) "Changed by the Buds" | 5 packets, no app `SABM` | `TOUCH-007` | — | +| BL-22 | ready | **Disconnect**; wait 5 s; hold a bud twice (no session); **Connect**; the ANC tab, the (i) | after the Connect the mode read; the (i) **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected."** | the phone's `DISC`; nothing while disconnected; the Connect's fifteen requests; the read's answer with the new mode | `PAIR-003`, `TOUCH-007` | the line missing after a differing read; the line after a read of the same mode | +| BL-23 | the ANC tab | both buds **into the case**; wait 15 s; **out into the ears**; wait 15 s | the session ends with its cause (or the Buds report "no mode settable": the mode kept, "The Buds don't allow changing noise control right now …"); ready again by itself; the mode read | the loss (or a stream packet `4:{13:0}` — `CAP-045` 2277 — and no loss); the re-open's requests; the read's answer (0 while in the case, 1–4 once worn) | `PAIR-003` | a value 0 shown as a mode | +| BL-24 (optional) | the ANC tab | **Home**; wait 10 s; hold a bud twice; return | on return the holds' result shown (the stream arrived while hidden — the session stays open in the foreground service) | 2 packets while hidden; no `SABM` | `TOUCH-007` | — | +| BL-25 (optional, P10) | the Sound tab | the second phone's official app: an EQ slider; a switch | OpenControl follows | the stream packets for 16 / the switch's field | — | — | +| BL-end2 | — | Debug → **Export**; the system log; the status bar across a minute change; stop the recordings; P1's APK pull | "Debug log saved (N lines)." | — | — | — | ## Don'ts -- Do not uninstall the installed 1.2.1 and do not clear the app's data (P1 — the run is an update; the stored choice of each user must survive it). +- Do not uninstall the installed 1.2.2 and do not clear the app's data (P1 — the run is an update; the ignored 1.2.x choice must still be in the file). - Do not open the app before the film (P9). -- In BL-5, BL-11, BL-16 and BL-17 touch nothing on the phone while holding the buds; say each hold. -- Do not tap two things within 5 s of each other, except where a step says "within 2 s of a cut" (BL-9, BL-10). +- In BL-4, BL-5, BL-12, BL-15, BL-16 and BL-21 touch nothing on the phone while holding the buds; say each hold; keep the ears on camera. +- Do not tap two things within 5 s of each other. - Do not write a serial number unredacted anywhere in this folder (first 4 + last 2 characters); no Wi-Fi name, phone number, account name or e-mail in any - committed text (the system log is committed as an extract, as `CAP-075`). + committed text (the system log is committed as an extract, as `CAP-075`); the camera films carry no address overlay. ## After the run -Into this folder: the screen recordings, the system logs of part 1 (as extracts — the full logs and the originals to `~/opencontrol-capture-originals/CAP-076/`), -every debug export, the app logcats if saved, the HCI snoop logs, the P0/P1 outputs with their exit statuses, the pulled APK's `sha256sum`, `dist/1.2.2/`'s -`.sha256` and the script's certificate line; then `sha256sum *` into CAP-076-sha256sum.txt. Commit on the release branch after the build commit (`RELEASING.md` -C3) — never on `main`. +Into this folder: the screen recordings and the camera films, the system logs (as extracts — the full logs and the originals to +`~/opencontrol-capture-originals/CAP-076/`), every debug export, the app logcats if saved, the HCI snoop logs, the P0/P1 outputs with their exit statuses, the +pulled APK's `sha256sum`, `dist/1.3.0/`'s `.sha256` and the script's certificate line; then `sha256sum *` into CAP-076-sha256sum.txt. Commit on the release +branch after the build commit (`RELEASING.md` C3) — never on `main`. ## Analysis checklist - [ ] Every filter scoped to the Buds' ACL handle (the Connection Complete for the Buds' address with that handle shown); DLCIs by content; a negative with its command, its exit status and a positive control (`AGENTS.md` §13 step 8). - [ ] P0 (part 1: Play listed, the *Nearby devices* permission state and Play's version on film; part 2: no Play, exit 1 with the positive control), P1 (the - update: `firstInstallTime` unchanged, `lastUpdateTime` later, `versionCode=10202`); the Info frame's hash = B1's; the pulled APK's SHA-256 = B1's. -- [ ] **The stored choice after the update** (BL-2, BL-15): "This phone has Google Play services" selected in both users (1.2.1's `RECLAIM`); ADR-063's line - "found" in user 0, "not found" in user 10. -- [ ] **The claim inventory of DLCI 0x04/0x05 with both clients** (`CAP-075-FINDINGS.md` §1's method: the owner by the first phone message — `08 11` the app, - `03 08 00 02 01 25` Play). -- [ ] **BL-5:** per cycle the cut, Play's retry, the export's re-claim line **3 000 ms** after the "closed" line (N/20), attempt 1's refusal, the app's `SABM`, - the held time and the cycle length; **the coverage of the whole chain** (expected ≈ 50 % for ≈ 3.1 min, against `CAP-075`'s 43 % for 44 s); the stop after - the 20th and the hint with "20 times"; the holds shown vs lost; Play's timers of this version; the "Retry times remaining" values in the system log (P6). -- [ ] **BL-7 / BL-19:** every `Get` answer that differs from the app's last report of the session → the (i) line "Changed since the app last saw it (read at …)"; - unprovoked `Notify` → "Changed by the Buds"; a session's first read → no line. -- [ ] **BL-8 / BL-12:** "Take it back again" → one claim, `08 11 00 00` only (`data.data[0:2]==08:12` → 0 between the tap and the next user action, with a - positive control), no DLCI 0x02 request; in the Play option the next cut re-claimed as 1/20; in the no-Play option no re-claim. -- [ ] **BL-9 … BL-11:** no `SABM` after a tab change, Home or the switch to "no Google Play services" (the "re-claim cancelled" lines) — `TODO.md` §2's - BK-11 … BK-13 items. -- [ ] **Part 2 (BL-16 … BL-20):** 0 Play claims (positive control: part 1's count); one claim per tab entry, **no `DISC` while the tab is shown**, every hold - shown, no re-claim and no hint in either option; the `DISC` ≈ 1.5 s after leaving the tab. -- [ ] The optional BL-22 … BL-24 if run; the system log: crashes, ANRs, `StrictMode`; the `bugreportz` SIGQUITs discounted. + update: `firstInstallTime` unchanged, `lastUpdateTime` later, `versionCode=10300`); the Info frame's hash = B1's; the pulled APK's SHA-256 = B1's. +- [ ] **The two new requests, byte-identical** (the table above), **once per MAESTRO session**: count the `SubscribeToSettingsChanges` REQUESTs and the + `ReadSetting 4:13` REQUESTs against the `SABM`s of DLCI 0x02 (Connects + re-opens) in each log — equal; their position after `SubscribeRuntimeInfo` and + before `GetHardwareInfo`. +- [ ] **The counts for the verdict:** (1) OpenControl's `SABM`s on DLCI 0x04/0x05 (owner by the first phone message, `08 11` = the app, `03 08 00 02 01 25` = + Play — `CAP-075-FINDINGS.md` §1's method) **vs the taps, tile taps, pulls and Connects/re-opens made** — equal (no claim the user did not cause); + (2) **holds shown vs holds made** per step (BL-4, 5, 12, 15, 16, 21 — expected all); (3) **stream packets `4:{13:n}` vs `Notify`s** — one packet per + hold, per own tap and per wear-driven switch, with the `Notify`s only inside the app's or Play's claims; (4) **the first read's value after every + Connect/re-open** (the RESPONSE after each `ReadSetting 4:13`), its (i) line when it differs from the mode shown before. +- [ ] **BL-7 / BL-17:** the own tap's stream packet classed as the app's change (no "Changed by the Buds" line in the film); its delay after the `Set`. +- [ ] **BL-9 / BL-19 / BL-23:** which of the two happened (the Buds' `DISC` → re-open → read, or a stream packet without a `DISC`); the mode before and after; + the (i) line; a value 0 never shown as a mode (the "not allowed" line instead). +- [ ] **BL-10 / BL-20:** the subscription after the Bluetooth-off re-open (M1 on hardware). +- [ ] **BL-11:** the taps that collided with Play (`CAP-075` §5's refusal), how many, and that nothing re-claimed afterwards — the known issue of 1.3.0. +- [ ] **BL-13 / BL-25 if run:** the official app's writes on its own session, the stream packets on OpenControl's, the values on screen with the packet's time. +- [ ] The system logs: crashes, ANRs, `StrictMode`; the `bugreportz` SIGQUITs discounted. - [ ] One row per step — done / not done / done differently; `APP_TESTPLAN.md` section Y and the Summary; traceability (`AGENTS.md` §13 step 7): `PAIR-003` - (BL-1, BL-3, BL-14, BL-15, BL-24), `BATT-004` (BL-1, BL-15), `FW-003` (BL-2, BL-15), `ANC-001`…`ANC-004` (BL-4, BL-5, BL-8, BL-12, BL-16, BL-22), - `TOUCH-007` (BL-5, BL-7, BL-8, BL-11, BL-16, BL-17, BL-18, BL-19) — each referenced in the timeline or flagged "expected but not observed". -- [ ] Update `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9, `id_registry.csv`, `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the Group column: BL), `APP_TESTPLAN.md`, `TODO.md` §2/§3; - write the findings file (CAP-076-FINDINGS); an ADR-062 Update with the numbers; the release verdict is the analysing session's, with the maintainer - (`RELEASING.md` C4). + (BL-1, BL-3, BL-9, BL-10, BL-14, BL-19, BL-20, BL-22, BL-23), `BATT-004` (BL-1, BL-14), `FW-003` (BL-2, BL-14), `ANC-001`…`ANC-004` (BL-4, BL-5, BL-7, + BL-8, BL-11, BL-15 … BL-18), `TOUCH-007` (BL-4 … BL-6, BL-9, BL-12, BL-15, BL-16, BL-19, BL-21, BL-22, BL-24) — each referenced in the timeline or flagged + "expected but not observed". +- [ ] Update `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9, `id_registry.csv`, `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the Group column: BL), `APP_TESTPLAN.md`, `TODO.md` + §1/§2/§3; write the findings file (CAP-076-FINDINGS); an ADR-065 Update with the numbers; `PROTOCOL.md` §6's wear-change item if BL-9/BL-19/BL-23 settle + it (a promotion needs the maintainer's approval, `AGENTS.md` §6); the release verdict is the analysing session's, with the maintainer (`RELEASING.md` C4). --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md - https://tedsluis.github.io/opencontrolpixelbudspro2/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES diff --git a/id_registry.csv b/id_registry.csv index c056763c..fd0f1c0f 100644 --- a/id_registry.csv +++ b/id_registry.csv @@ -232,14 +232,15 @@ FIT-001,test,catalogued,"Eartip seal / fit test (maestro_pw.EartipFitTest) — c ADR-058,adr,accepted,"DLCI 0x02: one GetHardwareInfo request per Connect for the component serial numbers (field 7, read by position: 7.1 Case, 7.2 Right, 7.3 Left — the official app's reading), a read not gated by Safe Mode, serials never logged — accepted 2026-10-09 (ai-sessions/0082)",DECISIONS.md ADR-059,adr,accepted,"A 'probably worn' indicator derived from the Settable byte, the in-ear detection setting and the runtime-info charging flags — no new wire traffic; ADR-049 item 3 stays a hypothesis — accepted 2026-10-09 (ai-sessions/0082)",DECISIONS.md ADR-060,adr,proposed,"Volume-level notifications from maestro_pw.Dosimeter (SubscribeToLiveDb + FetchDailySummaries per Connect, the phone computes the 7-day dose, the field-21 switch) — DRAFT only, not accepted; a 1.3.0 candidate (ai-sessions/0082 RESULT §C.4, TODO.md §5)",DECISIONS.md -ADR-061,adr,accepted,"Hold the Message Stream claim (DLCI 0x04) while the noise-control tab is on screen, so a press-and-hold's Notify reaches the app; released 1.5 s after leaving the tab or the app (CAP-072 §5; maintainer, chat 2026-10-10, ai-sessions/0083) — built in the 1.2.0 rebuild (ai-sessions/0084); main path hardware-verified in CAP-073 (ai-sessions/0085)",DECISIONS.md -ADR-062,adr,accepted,"A user-selectable re-claim of the noise-control channel after a foreign close, with a back-off: the Settings tab's Noise-control channel choice — hint only (default; ADR-061 as written plus the card's hint) or take it back (3, 6, 12 s after the first, second and third foreign close of one visit of the tab, then stop; the existing claim, nothing new on the wire; never in the background) — supersedes ADR-061's no-re-claim clause for that mode only; accepted 2026-10-12 (ai-sessions/0087), designed around CAP-074's Play timers; hardware run CAP-075; Update 2 2026-10-13 (ai-sessions/0089): 3 s after every foreign close up to 20 re-claims, Take it back again, the card renamed Noise control with Google Play services, CHANGED_SINCE_LAST_READ; hardware run CAP-076",DECISIONS.md -ADR-063,adr,accepted,"The Settings card may say whether Google Play services is installed on this phone (information only): :app asks PackageManager on every resume whether com.google.android.gms is installed and enabled for this user (no import, nothing bound/started/read, no permission, no network); a manifest entry for that one package; the line found / found but disabled / not found; the stored choice never changes by itself; a failed query shows no line; accepted 2026-10-13 (ai-sessions/0089)",DECISIONS.md -ADR-064,adr,accepted,"DLCI 0x02: SubscribeToSettingsChanges and ReadSetting 4:13 once per MAESTRO session — the noise-control mode on the app's own channel, read only (only field 13 decoded; nothing written; amends ADR-034 item 2 / ADR-036 'no subscription'; ADR-061/062/063 unchanged until its hardware run) — maintainer-approved in chat 2026-10-14, ai-sessions/0090",DECISIONS.md +ADR-061,adr,superseded,"Hold the Message Stream claim (DLCI 0x04) while the noise-control tab is on screen, so a press-and-hold's Notify reaches the app; released 1.5 s after leaving the tab or the app (CAP-072 §5; maintainer, chat 2026-10-10, ai-sessions/0083) — built in the 1.2.0 rebuild (ai-sessions/0084); main path hardware-verified in CAP-073 (ai-sessions/0085); superseded by ADR-065 (2026-10-14, ai-sessions/0091): the hold removed in 1.3.0",DECISIONS.md +ADR-062,adr,superseded,"A user-selectable re-claim of the noise-control channel after a foreign close, with a back-off: the Settings tab's Noise-control channel choice — hint only (default; ADR-061 as written plus the card's hint) or take it back (3, 6, 12 s after the first, second and third foreign close of one visit of the tab, then stop; the existing claim, nothing new on the wire; never in the background) — supersedes ADR-061's no-re-claim clause for that mode only; accepted 2026-10-12 (ai-sessions/0087), designed around CAP-074's Play timers; hardware run CAP-075; Update 2 2026-10-13 (ai-sessions/0089): 3 s after every foreign close up to 20 re-claims, Take it back again, the card renamed Noise control with Google Play services, CHANGED_SINCE_LAST_READ; superseded by ADR-065 (2026-10-14, ai-sessions/0091): the re-claim, the setting, its card and Take it back again removed in 1.3.0, a stored hold_reclaim_mode ignored",DECISIONS.md +ADR-063,adr,superseded,"The Settings card may say whether Google Play services is installed on this phone (information only): :app asks PackageManager on every resume whether com.google.android.gms is installed and enabled for this user (no import, nothing bound/started/read, no permission, no network); a manifest entry for that one package; the line found / found but disabled / not found; the stored choice never changes by itself; a failed query shows no line; accepted 2026-10-13 (ai-sessions/0089); superseded by ADR-065 (2026-10-14, ai-sessions/0091): the line, PlayServicesProbe and the entry removed in 1.3.0",DECISIONS.md +ADR-064,adr,accepted,"DLCI 0x02: SubscribeToSettingsChanges and ReadSetting 4:13 once per MAESTRO session — the noise-control mode on the app's own channel, read only (only field 13 decoded; nothing written; amends ADR-034 item 2 / ADR-036 'no subscription'; ADR-061/062/063 unchanged until its hardware run) — maintainer-approved in chat 2026-10-14, ai-sessions/0090; built in 1.3.0 (ai-sessions/0091), items 2 and 6 amended by ADR-065",DECISIONS.md +ADR-065,adr,accepted,"The settings stream (DLCI 0x02) replaces the hold and the re-claim of the Message Stream: every setting the app reads (2, 4, 7, 11, 12, 13, 15, 16, 17, 19, 22, 27, 28, 29) follows the Buds' SubscribeToSettingsChanges push through the same decoders, with its receive time; setting 13 = the mode (0 = none settable, never a mode); the cause rules (own Set within ANC_SET_WINDOW_MS 2 s = the app's, a differing push = Changed by the Buds, a differing read answer = Changed since the app last saw it, also across a Connect); supersedes ADR-061/062/063 (the hold, the re-claim, the Noise control with Google Play services setting, card and Take it back again, the Play line, PlayServicesProbe and the entry removed; hold_reclaim_mode ignored), amends ADR-064 items 2 and 6; accepted 2026-10-14 (ai-sessions/0091), built in 1.3.0; hardware run CAP-076",DECISIONS.md CAP-072,capture,analyzed,"2026-10-09 17:27:26-18:23:39, Group BH (new) — the release-signed 1.2.0 APK (ec6d163, versionCode 10200; Info tab on film, no -dirty) installed over a 1.1.1 (86a6fb3) reinstalled at 17:24 in the GrapheneOS secondary user without Google Play: camera film (no sound) and screen recording (with sound), both blurred at the privacy ranges; four HCI logs, four exports, the app logcat, a system-log extract, the shell log. 31 sessions with one GetHardwareInfo each (29 answered, none retried); 60 writes, 5 ANC Sets, 4 Ring/Stop answered; L-1 4/4 and only-Right-out => 21 6/6; press-and-holds not shown (ADR-032) -> verdict fix first (ADR-061); film 2 not made (folded into the rebuild's run) (ai-sessions/0083)",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 CAP-073,capture,analyzed,"2026-10-10 08:23:35-08:33:24, Group BI (new) — the rebuilt 1.2.0 (5b4d5db, versionCode 10200; Info tab on film, no -dirty; the installed APK pulled = B1) installed over 1.2.0 ec6d163 in the GrapheneOS secondary user without Google Play: one screen recording without audio (Wi-Fi name blurred), two HCI logs, one export, the app logcat, a system-log extract, the shell log; only the changed functionality tested. 11 sessions with the thirteen requests byte-identical to CAP-072; the ADR-061 hold kept DLCI 0x04 31 s on the noise-control tab, four unprovoked Notify shown without a tap, four taps on the held channel, released 1.509 s after leaving; balance one write per release, Right 4 on the third; verdict OK — ready to release (ai-sessions/0085)",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 CAP-074,capture,analyzed,"2026-10-12 06:40:16-06:45:31, Group BJ (new) — the released 1.2.0 (5b4d5db, versionCode 10200; Info tab on film; APK not pulled) in the GrapheneOS Owner user with Google Play services: one screen recording with audio (as is), two HCI logs, one debug export, the app logcat, a system-log extract; the maintainer's ad-hoc run (nine Disconnect/Connect pairs, the ANC tab, press-and-holds). 10 sessions byte-identical to CAP-073; every ADR-061 hold cut 4.7-5.1 s after its SABM by Play services' Fast Pair recovery (5.2-5.7 s after it lost the channel, 20 of 20) — 9 holds shown, 35 reached Play services; the root cause of the first-hold-only pattern; fix: a Settings switch (hint only / re-claim with back-off), the maintainer's choice (ai-sessions/0086)",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 CAP-075,capture,analyzed,"2026-10-12 17:22:31 – 2026-10-13 06:34:32 (two sets), Group BK (new) — 1.2.1 (31ee183, versionCode 10201; Info tab on film in both users; APK not pulled), the Noise-control channel switch (ADR-062): set 1 the secondary user without Google Play (41 of 41 press-and-holds shown, both positions identical, no foreign close), set 2 the Owner with Google Play (hint only: the hint with the cut time, 0 of 9 holds shown; take it back: three chains of exactly three re-claims at 3/6/12 s, every re-claim colliding with Play's retry 2.1–2.8 s after the cut, every hold cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 holds shown; the stack path of the cut observed 15 of 15 in the system log); verdict: no heavy issue, 1.2.1 released as built; analysed by ai-sessions/0088",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 -CAP-076,capture,planned,"planned — Group BL (new): the hardware run of 1.2.2 (ADR-062 Update 2 and ADR-063) in both GrapheneOS users — part 1 the Owner user with Google Play services (the renamed card 'Noise control with Google Play services' with 'found on this phone' and the 1.2.1 choice kept as 'This phone has Google Play services'; holds every ≈ 4 s for 3½ min: re-claims 3 s after every cut up to 20, ≈ 50 % held, the stop and the hint with '20 times'; Take it back again — one claim with its Get, no Set, a new chain; the changed-since-last-read line; the cancel paths within 2 s of a cut; the system log right after the chain; the Nearby-devices permission on film; the APK pull), part 2 the secondary user without Play ('not found on this phone'; the CAP-073 §4 regression in both options); skeleton by ai-sessions/0089",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 +CAP-076,capture,planned,"planned — Group BL (new): the hardware run of 1.3.0 (ADR-064/ADR-065, ai-sessions/0091; the 1.2.2 skeleton of ai-sessions/0089 replaced, 1.2.2 released without it) in both GrapheneOS users — the two new requests (SubscribeToSettingsChanges, ReadSetting 4:13) once per MAESTRO session; press-and-holds with the noise-control tab closed and open shown through the settings stream, no SABM on DLCI 0x04 from the app; the own tap's stream packet; the wear-change re-home and the re-open's read; Bluetooth off/on; taps while Play holds the channel; optionally the EQ/a switch from the official app on a second phone; the Play card and line gone; skeleton rewritten 2026-10-14",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 INEAR-006,test,catalogued,"The app's 'probably worn' indicator (ADR-059) read against the ears on film: both out, one in, both in the case, in-ear detection off, buds straight from the case to the table watched 60 s — added 2026-10-09, ai-sessions/0082",TESTPLAN_BLUETOOTH_HCI_SNOOP.md WELL-002,test,catalogued,"maestro_pw.Dosimeter SubscribeToLiveDb values correlated with a sound-level meter on film (the 🟡 that blocks ADR-060) — candidate, no capture scenario yet (added 2026-10-09, ai-sessions/0082)",TESTPLAN_BLUETOOTH_HCI_SNOOP.md diff --git a/scripts/release_notes.template b/scripts/release_notes.template index 52dd1dc1..45583542 100644 --- a/scripts/release_notes.template +++ b/scripts/release_notes.template @@ -1,6 +1,6 @@ An independent, open-source Android app to control Google Pixel Buds Pro 2 — fully offline: no Google Play services needed, no `INTERNET` permission, no location permission, no account. -**Features:** noise control (Noise cancellation, Adaptive, Transparency, Off) and a Quick Settings tile — with "Changed by the Buds" in its details when a bud's press-and-hold or the Buds themselves changed the mode while the noise-control tab is open · equalizer with presets and Volume EQ · battery Left/Right (with charging), the Case and a "probably worn" line · Find My Buds (ring Left / Right) · touch controls, press and hold per bud and its noise-control modes · head gestures, Multipoint, conversation detection, in-ear detection · balance (finer near the centre), mono audio · case sounds and dark mode on gear → Settings · firmware and serial numbers on gear → Info. +**Features:** noise control (Noise cancellation, Adaptive, Transparency, Off) and a Quick Settings tile — a press-and-hold on a bud shows in the app at once, on any tab ("Changed by the Buds" in the details) · equalizer with presets and Volume EQ · battery Left/Right (with charging), the Case and a "probably worn" line · Find My Buds (ring Left / Right) · touch controls, press and hold per bud and its noise-control modes · head gestures, Multipoint, conversation detection, in-ear detection · balance (finer near the centre), mono audio · case sounds and dark mode on gear → Settings · firmware and serial numbers on gear → Info. **Not included:** ringing the Case or both buds, firmware updates, choosing the devices of a multipoint connection, anything that needs a Google account. From 391b0c5c88803b1f73dbf57eab48de9bd9abf51e Mon Sep 17 00:00:00 2001 From: tedsluis Date: Wed, 14 Oct 2026 22:39:34 +0200 Subject: [PATCH 05/11] docs: ai-sessions/0091 records its commits and the push Co-Authored-By: Claude Fable 5.1 --- ai-sessions/0091_FEATURE_RESULT_2026_10_14.md | 8 +++++--- ai-sessions/INDEX.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md b/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md index b4862bd5..f424eee8 100644 --- a/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md +++ b/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md @@ -267,9 +267,11 @@ fixture names, `Varint.kt`, `CaseBatteryFrame.kt` (`Proto.fields`). **Deferred documentation:** none beyond `TODO.md`'s open items (the maintainer's next steps below; `CAP-076`'s analysis will add an ADR-065 Update with the numbers and may settle `PROTOCOL.md` §6's wear-change item). The capture logs' `tshark` positive control for "0 `SABM` from the app" belongs to that analysis. -**Commits:** none yet — the maintainer's yes is asked first (Phase G). Proposed, per concern: `feat`: the subscription, the read of 13 and the stream in the -codec and the repository; `refactor`: the hold/re-claim/Play removal (code, manifest, UI); `test`: the fixtures and tests; `docs`: ADR-065 and the documents, -`CAP-076`, 1.3.0. +**Commits** (the maintainer's yes in chat 2026-10-14, `AskUserQuestion` "Commit+push" → *"Ja, commit en push"*), on `feature/settings-stream`, pushed +2026-10-14 after `git fetch` (origin/main unchanged, no rebase needed), no force: `bb7a843` feat(data): the subscription, the read of 13 and the stream in the +codec and the repository; `7756e55` refactor: the hold/re-claim/Play removal (code, manifest, UI); `152c5b3` test: the fixtures and tests; `8a72572` docs: +ADR-065 and the documents, `CAP-076`, 1.3.0. The split is by file and concern — the gate was run on the final tree, not on each intermediate commit +(`BudsRepositoryImpl.kt` carries both the feature and the removal in the first commit). A fifth docs commit records this paragraph. **The maintainer's next steps (not executed by this session):** push `feature/settings-stream` and open the PR with the Release checklist; CI green; B1 `scripts/release.sh 1.3.0`, B2, B3; C1–C2 the `CAP-076` run in both users (the skeleton); a CAPTURE session for C3/C4 and the verdict; only then the diff --git a/ai-sessions/INDEX.md b/ai-sessions/INDEX.md index 330dd7d5..e7c96d56 100644 --- a/ai-sessions/INDEX.md +++ b/ai-sessions/INDEX.md @@ -97,7 +97,7 @@ a new session** — the same "check before assigning" discipline `id_registry.cs | 0088 | CAPTURE | 2026-10-12 | Full analysis of `CAP-075` (Group BK: 1.2.1 build `31ee183`, the "Noise-control channel" switch — **two sets**: the secondary user without Google Play (2026-10-12: one screen recording with audio, two HCI logs, one export, the app logcat; 1.2.0 → 1.2.1 updated on film) and the Owner with Google Play (2026-10-13: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`): the real events per set, the holds per switch position, the Play user's cut mechanism frame by frame, the four-way comparison `CAP-073`/`CAP-074`/`CAP-075`, the answer per user to "which works best", improvement proposals for the Play user, the release verdict proposal; no app change | complete (2026-10-13) — `CAP-075-EVENT-NOTES.md` (the two timelines, the steps as run, Test-IDs) and `CAP-075-FINDINGS.md` §0–§12: without Play both positions identical (41 of 41 holds shown, no foreign close); with Play the hint 2 of 2 and 0 of 9 holds shown, "Take it back" three chains × three re-claims at 3/6/12 s (4.0–4.8 s on the wire, every one colliding with Play's retry 2.1–2.8 s after the cut), holds cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 shown; the cut's stack path 15 of 15 in the system log; Play's timers = `CAP-074`'s. Checkpoint 0 + Phase D approved in chat: the folder/recording renames, modes, the logcat extract, the films as is, the ADR-062 Update, a 🟢 `PROTOCOL.md` §4.1 note, the CHANGELOG Known-issues text, the verdict "no heavy issue — 1.2.1 released as built", P1 + P2 + P4 → the next FEATURE prompt; the leftover worktree of 0087's prompt removed; no app change. **Continuation in chat 2026-10-13:** PR #44 merged, 1.2.1 published (tag `v1.2.1` on `31ee183`, the notes' Known-issues text measured), E1–E4 by the pull request of `release/1.2.1-after` (#45); D4 done — installed with Obtainium | | 0089 | FEATURE | 2026-10-13 | The noise-control channel in the Play user after `CAP-075` (`ai-sessions/0088`, the maintainer's choice "P1 + P2 + P4"): a constant 3-s re-claim step with a longer chain (capped), a "Take it back again" action on the hint card, a cause label for a mode learned by a re-claim's own read; the switch renamed (the maintainer's example "Channel control") with options that say whether the phone has Google Play services and plainer texts; real `CAP-075` bytes as fixtures, mutation checks, the `CAP-076` (Group BL) skeleton, the documents, the release preparation (no tag, no publication) | awaiting maintainer sign-off — built on `feature/channel-control` (gate green: `:data` 1672, `:domain` 42, `:hardware` 64, `:ui` 102; M1–M6 killed): the re-claim 3 s after every cut up to 20 (`HOLD_RECLAIM_STEP_MS`, `HOLD_RECLAIM_CAP`), "Take it back again" (`reclaimAncChannel`, no `Set`), `AncModeCause.CHANGED_SINCE_LAST_READ` and its (i) line, the card renamed "Noise control with Google Play services" with options that say whether the phone has Play services and ADR-063's "found / not found" line (a `` entry); ADR-062 Update 2, ADR-063 and the `AGENTS.md` §1 note approved in chat 2026-10-13; 1.2.2 (10202) prepared; `CAP-076` (Group BL) skeleton for both users, `APP_TESTPLAN.md` §Y; commits `e60fb7c` … `bfd7891`, PR #49 merged (`5b204ea`); **1.2.2 published 2026-10-14** (tag `v1.2.2` on `bfd7891`) after the maintainer's own test, without `CAP-076`; installed with Obtainium (D4, 2026-10-14) | | 0090 | CROSSCHECK | 2026-10-14 | Point-by-point check of the pbpctrl-derived findings list (E-01 … E-23: `qzed/pbpctrl`, MagicPodsCore's Pixel Buds branch, omarchy-pixelbuds at pinned commits) against this project's captures, APK notes and documents — led by the question whether the app can learn a press-and-hold's noise-control mode on DLCI 0x02 (Maestro setting 13, `SubscribeToSettingsChanges`) instead of the contested Message Stream; proposals only where the rules require the maintainer; no app code, no wire bytes | complete — the leading question answered yes: setting 13 = the mode on DLCI 0x02, pushed on `SubscribeToSettingsChanges` for 73/73 Buds-side and 39/39 app-driven changes (also with no Message Stream open), `ReadSetting 4:13` 359/382; OpenControl never subscribed (0/33 logs); the APK feeds its ANC state only from 13 (`fxb.smali:1237`). Approved in chat 2026-10-14 and applied: `PROTOCOL.md` §4.1 🟢 Update (+ §6, §2.2, §4.5.7, §0.1 notes), **ADR-064** (subscribe + read 13, read only; build next) with pointers on ADR-025/034/036, ADR-060 item (vii), the `qhr` register Update, a desk entry, `CAP-051-FINDINGS.md` §3, `AGENTS.md` §5 note, `ARCHITECTURE.md` §5a rule, `TODO.md`, `scripts/anc_setting13_crosscheck.py`; no app code; commits `7bfb078` … `0b1681a` on `research/pbpctrl-crosscheck`, pushed, PR #55. **Continuation in chat 2026-10-14:** the existing captures checked for the new findings — one-bud/two-bud stored mode 28 of 28 in the official app's captures (🟡 strong, `PROTOCOL.md` §6, `scripts/wear_mode_crosscheck.py`); settings 8/9/10 and `SubscribeToOobeActions` never on any wire; runtime-info 7.x = the charging flags in 96.9 % | -| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; not yet pushed); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | +| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; pushed 2026-10-14, commits `bb7a843` … `8a72572`); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/INDEX.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/INDEX From ff27ee1d6258276c37d7df92494416099c1e2469 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Wed, 14 Oct 2026 22:41:33 +0200 Subject: [PATCH 06/11] docs: ai-sessions/0091 records pull request #56 Co-Authored-By: Claude Fable 5.1 --- ai-sessions/0091_FEATURE_RESULT_2026_10_14.md | 2 +- ai-sessions/INDEX.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md b/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md index f424eee8..38bdf222 100644 --- a/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md +++ b/ai-sessions/0091_FEATURE_RESULT_2026_10_14.md @@ -271,7 +271,7 @@ numbers and may settle `PROTOCOL.md` §6's wear-change item). The capture logs' 2026-10-14 after `git fetch` (origin/main unchanged, no rebase needed), no force: `bb7a843` feat(data): the subscription, the read of 13 and the stream in the codec and the repository; `7756e55` refactor: the hold/re-claim/Play removal (code, manifest, UI); `152c5b3` test: the fixtures and tests; `8a72572` docs: ADR-065 and the documents, `CAP-076`, 1.3.0. The split is by file and concern — the gate was run on the final tree, not on each intermediate commit -(`BudsRepositoryImpl.kt` carries both the feature and the removal in the first commit). A fifth docs commit records this paragraph. +(`BudsRepositoryImpl.kt` carries both the feature and the removal in the first commit). A fifth docs commit (`391b0c5`) records this paragraph. **Pull request #56** (feature/settings-stream → main) opened 2026-10-14 after the maintainer's yes in chat (`AskUserQuestion` "PR openen" → *"Ja, open de PR"*), its description = the Release checklist (A1–A5 ticked, A6 onwards the maintainer's); not merged by this session. **The maintainer's next steps (not executed by this session):** push `feature/settings-stream` and open the PR with the Release checklist; CI green; B1 `scripts/release.sh 1.3.0`, B2, B3; C1–C2 the `CAP-076` run in both users (the skeleton); a CAPTURE session for C3/C4 and the verdict; only then the diff --git a/ai-sessions/INDEX.md b/ai-sessions/INDEX.md index e7c96d56..61eb464d 100644 --- a/ai-sessions/INDEX.md +++ b/ai-sessions/INDEX.md @@ -97,7 +97,7 @@ a new session** — the same "check before assigning" discipline `id_registry.cs | 0088 | CAPTURE | 2026-10-12 | Full analysis of `CAP-075` (Group BK: 1.2.1 build `31ee183`, the "Noise-control channel" switch — **two sets**: the secondary user without Google Play (2026-10-12: one screen recording with audio, two HCI logs, one export, the app logcat; 1.2.0 → 1.2.1 updated on film) and the Owner with Google Play (2026-10-13: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`): the real events per set, the holds per switch position, the Play user's cut mechanism frame by frame, the four-way comparison `CAP-073`/`CAP-074`/`CAP-075`, the answer per user to "which works best", improvement proposals for the Play user, the release verdict proposal; no app change | complete (2026-10-13) — `CAP-075-EVENT-NOTES.md` (the two timelines, the steps as run, Test-IDs) and `CAP-075-FINDINGS.md` §0–§12: without Play both positions identical (41 of 41 holds shown, no foreign close); with Play the hint 2 of 2 and 0 of 9 holds shown, "Take it back" three chains × three re-claims at 3/6/12 s (4.0–4.8 s on the wire, every one colliding with Play's retry 2.1–2.8 s after the cut), holds cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 shown; the cut's stack path 15 of 15 in the system log; Play's timers = `CAP-074`'s. Checkpoint 0 + Phase D approved in chat: the folder/recording renames, modes, the logcat extract, the films as is, the ADR-062 Update, a 🟢 `PROTOCOL.md` §4.1 note, the CHANGELOG Known-issues text, the verdict "no heavy issue — 1.2.1 released as built", P1 + P2 + P4 → the next FEATURE prompt; the leftover worktree of 0087's prompt removed; no app change. **Continuation in chat 2026-10-13:** PR #44 merged, 1.2.1 published (tag `v1.2.1` on `31ee183`, the notes' Known-issues text measured), E1–E4 by the pull request of `release/1.2.1-after` (#45); D4 done — installed with Obtainium | | 0089 | FEATURE | 2026-10-13 | The noise-control channel in the Play user after `CAP-075` (`ai-sessions/0088`, the maintainer's choice "P1 + P2 + P4"): a constant 3-s re-claim step with a longer chain (capped), a "Take it back again" action on the hint card, a cause label for a mode learned by a re-claim's own read; the switch renamed (the maintainer's example "Channel control") with options that say whether the phone has Google Play services and plainer texts; real `CAP-075` bytes as fixtures, mutation checks, the `CAP-076` (Group BL) skeleton, the documents, the release preparation (no tag, no publication) | awaiting maintainer sign-off — built on `feature/channel-control` (gate green: `:data` 1672, `:domain` 42, `:hardware` 64, `:ui` 102; M1–M6 killed): the re-claim 3 s after every cut up to 20 (`HOLD_RECLAIM_STEP_MS`, `HOLD_RECLAIM_CAP`), "Take it back again" (`reclaimAncChannel`, no `Set`), `AncModeCause.CHANGED_SINCE_LAST_READ` and its (i) line, the card renamed "Noise control with Google Play services" with options that say whether the phone has Play services and ADR-063's "found / not found" line (a `` entry); ADR-062 Update 2, ADR-063 and the `AGENTS.md` §1 note approved in chat 2026-10-13; 1.2.2 (10202) prepared; `CAP-076` (Group BL) skeleton for both users, `APP_TESTPLAN.md` §Y; commits `e60fb7c` … `bfd7891`, PR #49 merged (`5b204ea`); **1.2.2 published 2026-10-14** (tag `v1.2.2` on `bfd7891`) after the maintainer's own test, without `CAP-076`; installed with Obtainium (D4, 2026-10-14) | | 0090 | CROSSCHECK | 2026-10-14 | Point-by-point check of the pbpctrl-derived findings list (E-01 … E-23: `qzed/pbpctrl`, MagicPodsCore's Pixel Buds branch, omarchy-pixelbuds at pinned commits) against this project's captures, APK notes and documents — led by the question whether the app can learn a press-and-hold's noise-control mode on DLCI 0x02 (Maestro setting 13, `SubscribeToSettingsChanges`) instead of the contested Message Stream; proposals only where the rules require the maintainer; no app code, no wire bytes | complete — the leading question answered yes: setting 13 = the mode on DLCI 0x02, pushed on `SubscribeToSettingsChanges` for 73/73 Buds-side and 39/39 app-driven changes (also with no Message Stream open), `ReadSetting 4:13` 359/382; OpenControl never subscribed (0/33 logs); the APK feeds its ANC state only from 13 (`fxb.smali:1237`). Approved in chat 2026-10-14 and applied: `PROTOCOL.md` §4.1 🟢 Update (+ §6, §2.2, §4.5.7, §0.1 notes), **ADR-064** (subscribe + read 13, read only; build next) with pointers on ADR-025/034/036, ADR-060 item (vii), the `qhr` register Update, a desk entry, `CAP-051-FINDINGS.md` §3, `AGENTS.md` §5 note, `ARCHITECTURE.md` §5a rule, `TODO.md`, `scripts/anc_setting13_crosscheck.py`; no app code; commits `7bfb078` … `0b1681a` on `research/pbpctrl-crosscheck`, pushed, PR #55. **Continuation in chat 2026-10-14:** the existing captures checked for the new findings — one-bud/two-bud stored mode 28 of 28 in the official app's captures (🟡 strong, `PROTOCOL.md` §6, `scripts/wear_mode_crosscheck.py`); settings 8/9/10 and `SubscribeToOobeActions` never on any wire; runtime-info 7.x = the charging flags in 96.9 % | -| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; pushed 2026-10-14, commits `bb7a843` … `8a72572`); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | +| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; pushed 2026-10-14, commits `bb7a843` … `391b0c5`, PR #56 open); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/INDEX.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/INDEX From f41f970bd03b33304cda330b3bc5f004e83abff6 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Thu, 15 Oct 2026 07:41:42 +0200 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20ai-sessions/0092=20CAPTURE=20prom?= =?UTF-8?q?pt=20=E2=80=94=20the=20full=20analysis=20of=20CAP-076=20(the=20?= =?UTF-8?q?1.3.0=20run=20in=20both=20users)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer ran scripts/release.sh 1.3.0 (build commit ff27ee1), installed the APK over 1.2.2 in both GrapheneOS users and recorded CAP-076 in part (only the changed functionality). This prompt lets a fresh session analyse the two recordings, four HCI logs, two exports, two app logcats, the system log, the full logcat and the adb shell log per the project's rules (reading first, resumable phases, no sampling, evidence traceability, approvals in chat), and propose the release verdict (RELEASING.md C3/C4). Why a separate session: the analysis is a CAPTURE task of its own, and this session's context is spent. Co-Authored-By: Claude Fable 5.1 --- ai-sessions/0092_CAPTURE_PROMPT_2026_10_15.md | 373 ++++++++++++++++++ ai-sessions/INDEX.md | 1 + 2 files changed, 374 insertions(+) create mode 100644 ai-sessions/0092_CAPTURE_PROMPT_2026_10_15.md diff --git a/ai-sessions/0092_CAPTURE_PROMPT_2026_10_15.md b/ai-sessions/0092_CAPTURE_PROMPT_2026_10_15.md new file mode 100644 index 00000000..6c3db4c6 --- /dev/null +++ b/ai-sessions/0092_CAPTURE_PROMPT_2026_10_15.md @@ -0,0 +1,373 @@ +# 0092_CAPTURE_PROMPT_2026_10_15.md — Full analysis of CAP-076 (Group BL: the 1.3.0 build `ff27ee1`, the settings stream on the app's own channel — ADR-064/065 — in the Owner user with Google Play services and the secondary user without) + +**Number:** 0092 +**Category:** CAPTURE +**Date:** 2026-10-15 +**Title:** Fully analyse `CAP-076`: **two** Android screen recordings, **four** HCI snoop logs, **two** app debug exports, **two** app logcats, one system log, +one full `logcat -b all` and one adb shell log, recorded on 2026-10-15 with the release-signed **1.3.0 build `ff27ee1`** (`ai-sessions/0091`, PR #56; +`scripts/release.sh 1.3.0` run by the maintainer on 2026-10-15, APK SHA-256 `03272c6f954316d76c3ef6d1d020abd8bdb97755d7f8e165c84d3f16a9c6a96c`, certificate +`a7530f5ceadcdfc9cddcbb0e9889e7699961e8a4ef18898c4ec7738cc79d8dcb`) on a Pixel 9a with GrapheneOS (Android 17) — **part 1** in the Owner user **with** Google +Play services (06:35–06:52, the update 1.2.2 → 1.3.0 on film) and **part 2** in the secondary user **without** Google Play (07:01–07:08). The task: establish +from the films, the wire and the logs what happened in each part; whether the two new requests went out once per MAESTRO session and whether every +press-and-hold reached the app through the settings stream without a claim of DLCI 0x04; the own tap's stream packet; the re-opens and their reads; the +collisions with Play; compare with `CAP-075` (the same two users, 1.2.1); record the findings; propose the release verdict (`RELEASING.md` C3/C4). +- Record the real events in the existing skeleton **CAP-076-EVENT-NOTES.md** (rewritten for 1.3.0 by `ai-sessions/0091`; the timeline is added to it, the + planned procedure stays as the appendix, as `CAP-073`/`CAP-075` did) and the analysis in a new **CAP-076-FINDINGS.md**. +- **No change to the app** in this session (improvements are **proposed**, with the evidence, for a later FEATURE session), **no new capture skeleton**, + **no release step** (no tag, no `dist/` change, no merge): the analysis, its documentation and the verdict proposal for the maintainer. + +--- + +## 0. How to use this prompt + +You are an expert software architect, reverse engineer and technical auditor. This prompt is for a **fresh** Claude Code session in this repository. Run the +phases in §4 **strictly in order**; do not start a phase before the previous one is done and recorded. + +**Reading first (mandatory, `AGENTS.md` §0.1, `AI_SESSION_LOG_PROCEDURE.md` §8/§9).** Take the time to read the core files first, in full and in this order, to +understand the ground rules before analysing anything: +- `AGENTS.md` and `PROJECT_RULES.md`. +- `PROJECT.md` (the Definition of done; the status paragraph ending with 1.3.0). +- `ARCHITECTURE.md`, in particular **§3.1** (the new bullet "The Buds push every change of a setting on the app's own channel": the fields, the cause rules, + `UnreadSetting`, value 0), **§3.2** (`ANC_SET_WINDOW_MS`, `MESSAGE_STREAM_LINGER_MS`, `SETTING_READ_TIMEOUT_MS`, `LOSS_REOPEN_DELAY_MS`, `CHAIN_GUARD_MS`), + §5a (the "Changed by the Buds" row — 1.3.0 "not hardware-verified — `CAP-076`"), **§6.0b** (the 1.2.0 … 1.2.2 history paragraph and the "Since 1.3.0" + paragraph: the claim per tap, the foreign close that changes nothing, the known collision, the always-on log lines), §7, §9, §12, §13. +- `PROTOCOL.md`, in particular §2.2a and §2.3 (the three channels; DLCI 0x04 **or 0x05**; channel 19 vs 21 and the ADR-034 addresses), **§4.1 and its + Update of 2026-10-14** (setting 13 = the mode, pushed on the settings stream for every reported change; `ReadSetting 4:13`; the 12-of-77 `DISC` case; the + Notify goes to the one client holding DLCI 0x04; the 2026-10-13 note on Play's timers), §4.5, **§6** (the 🟡 items of 2026-10-14: the Buds' own switch on a + wear change — the stored mode per wear state; one-bud / two-bud mode), §8 (the rows of 2026-10-12/13/14). +- `DECISIONS.md`: **every** ADR, ADR-001 … ADR-065, with every dated Update, and the draft ADR-060 (not a decision). In particular **ADR-064** and its + 2026-10-14 Update, **ADR-065** (items 1–6, the "as built" note, "Verification" — this run), ADR-032 (its three Updates), ADR-044 (the re-open), ADR-049 + (Settable `00`), ADR-057 (what is "current"), ADR-061/062/063 (superseded — what 1.2.x did, for the comparison with `CAP-075`). +- `TODO.md`, §1 (`CAP-076` = the 1.3.0 run), §2, §3 (the *Nearby devices* permission item), §4 (item 4 built; E-01 `WriteSetting 4:{13:n}` — never sent), + §5 (the Play-user item: the per-tap collision). + +These are the ground rules; do not analyse or change anything before they are read. Then, per task: + +- **Procedure and registers:** `AI_SESSION_LOG_PROCEDURE.md` (in full, incl. §4b, §5 and §9), `ai-sessions/INDEX.md`, `id_registry.csv` (the `CAP-075`, + `CAP-076`, ADR-064, ADR-065 rows and every Test-ID named in §3 item 12), `RELEASING.md` (the Release checklist, "Repository rules", §6, §11a, §13). +- **The build under test and its skeleton — read in full:** `ai-sessions/0091_FEATURE_PROMPT_2026_10_14.md` and `…/0091_FEATURE_RESULT_2026_10_14.md` + (**§A.1 the reference frames re-derived** — every byte string the wire is compared against, §A.2–§A.4 the design, **§B the checkpoint answers Q1–Q11**, §C the + build as made — the log strings, §D the gate and the mutations, §E the skeleton, §G the commits and PR #56); **`CAP-076-EVENT-NOTES.md`** (the skeleton: the + two parts, P0–P10, the reference-frame table with the `tshark` lines, BL-1 … BL-25, the analysis checklist with the **counts for the verdict**); + `CHANGELOG.md` `[1.3.0]` (the two Known issues); `APP_TESTPLAN.md` §Y (Y1 … Y22) and the Summary. +- **The "before" — read in full:** `captures/CAP-075-2026-10-12_17-22-31_2026-10-13_06-34-32-Group_BK/CAP-075-FINDINGS.md` (**§0 the users' evidence, §1 the claim + inventory of both clients and the Play-services marker `03 08 00 02 01 25`, §2 the Connect reads byte for byte, §4 the holds per position, §5 the cut's + stack path, §9 the proposals P1–P6**) and `CAP-075-EVENT-NOTES.md` (the method: the clock offset from the status bar, the audio segments, the claim-owner + rule, the system-log extract and its command); `captures/CAP-074-…-Group_BJ/CAP-074-FINDINGS.md` §8 (one RFCOMM connection per server channel — a second + connect kills the incumbent); the official app's evidence for the stream: `captures/CAP-051-…/CAP-051-FINDINGS.md` §2–§3, `CAP-056-FINDINGS.md` §4, + `CAP-038-FINDINGS.md` §3, `DESKRESEARCH_FINDINGS.md` entry 2026-10-14 (the census: 73/73, 39/39, the 12 `DISC` misses); the maintainer's named examples + `captures/CAP-071-2026-10-08_17-51-29_18-27-28-Group_BG/CAP-071-EVENT-NOTES.md` and `…/CAP-071-FINDINGS.md` (the layout: metadata table, P0/P1 outputs, + pre-flight, review method and privacy, one timeline row per event, step mapping, Test-ID traceability, the extract command for a system log); + `ai-sessions/0088_CAPTURE_RESULT_2026_10_12.md` (the method and the scratch scripts — re-create them, they are not in the repository). +- **Every Kotlin source a finding touches, in full,** before you write about it — at least: `BudsRepositoryImpl.kt` (`launchInitialEqRead`, + `subscribeToSettingsChanges`, `readAncSetting`, `applyAncState`, `causeOfStream`, `causeOfReadAnswer`, `causeOfNotify`, `applySetting`/`keepsOwnMark`, + `handleRoutedFrame`'s `Setting`/`UnreadSetting` branches, `openSession` (the cause is no longer reset), `withMessageStream`/`scheduleRelease`, the + `channelClosed` and `connectionLost` collectors, **every log string — each export line is matched against them**), `codec/CodecRouter.kt` (`routeMaestro`), + `codec/SettingFrame.kt` (`SettingValue.AncState`, `fieldOf`), `codec/Maestro.kt`, `RfcommBudsTransport.kt` (the reader's `read return: -1` path, the + session-loss vs on-demand-close reporting, the connect retry), `SessionReopener.kt` (`LOSS_REOPEN_DELAY_MS`, `CHAIN_GUARD_MS`, the skip line), + `SessionDiagnostics.kt`, `AncScreen.kt` (`ANC_STREAM_HINT_TEXT`, `ancChangedByBudsLine`, `ancChangedSinceLastReadLine`, `ancNotAllowedLine`), + `SettingsMenu.kt`, `MainActivity.kt`, `domain/ValueCurrency.kt` (`currentAncMode`), `domain/AncModeCause.kt`. + +Say in the RESULT which files you read in full and which only partly. + +**Resuming after a rate or token limit (`AI_SESSION_LOG_PROCEDURE.md` §5) — you must be able to continue automatically.** +- Create **ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md** at the very start, with `**Status:** partial — resumed` and a **Progress** block. +- Update that block at the end of every phase **and** after every substantial step: every minute of each recording reviewed, every HCI log and channel + decoded, every log file read, every checkpoint answer. Say what is done and what is next, which files are touched but not yet verified, and where the + intermediate results live (the scratchpad: scripts, contact sheets, decoded tables, notes — re-create them if it is gone). +- A resumed session reads this prompt, then the RESULT's Progress block, re-reads the files the unfinished step touched, and continues from there. It never + redoes a finished, recorded step and never assumes an unrecorded one was done. The prompt keeps its number and date; the RESULT grows in the same file. + +**Memory and the machine.** The laptop has 15 GB and `/tmp` is a 7.7 GB tmpfs. Run `tshark`/`ffmpeg` one job at a time; extract contact sheets and crops in +ranges, never a whole film into memory (recording 1 is 248 MB and 981 s); the system log (12 MB, 94,423 lines) and the full logcat (30 MB, 213,298 lines) — +`grep`/`awk` them, do not read them whole into context. No Gradle build is needed. Never `git worktree add` a commit (it checks out `captures/`, gigabytes). +Scratch files go into a fresh `mktemp -d` under `/tmp/claude-1000/`; write their names in documents without backticks (the docs lint flags backticked names +that do not exist). `python3 scripts/pwrpc_decode.py ` decodes the pw_rpc frames (run it with `-I` only when the log is untrusted input you did not +record — here the maintainer's own logs, so a plain run is fine). + +**Nothing is taken on trust, including this prompt, the maintainer's statements, the 0091 RESULT and the memory notes.** Every claim and every number +measured in §2 and §3 is re-derived from the recordings, the HCI logs, the other logs, the code and official documentation. + +--- + +## 1. What the maintainer asked for (chat, 2026-10-15, translated from Dutch) + +1. "Make a new prompt for Claude Code, in English, per the project's rules in `ai-sessions/`, that can be run in a new session. You are an expert software + architect, reverse engineer and technical auditor. Your task is a comprehensive analysis of `CAP-076` (Group BL) of the OpenControl app, recording the + findings. Take the time to read the core files first (`AGENTS.md`, `PROJECT_RULES.md`, `PROTOCOL.md`, `ARCHITECTURE.md`, `DECISIONS.md`) to understand the + ground rules before auditing the rest. Run everything in phases, sequentially; when you hit a token limit you must be able to continue automatically + later." +2. **The maintainer's statements about the run (verify each; label each as the maintainer's statement until verified):** + - "I ran `scripts/release.sh 1.3.0`" (its output: build commit `ff27ee1`, the APK and certificate SHA-256 above), "did `cp -a dist/1.3.0 + ~/opencontrol-1.3.0-tested` and installed the APK over 1.2.2. Then I ran `CAP-076` (in part)." + - "**I did not keep to the test plan. I tested only the changed functionality.**" — *map every skeleton step BL-1 … BL-25 as done / not done / done + differently; a step not done is "expected but not observed" in the Test-ID traceability, not a failure.* + - "The app was version 1.2.2 (of yesterday) at the start of the video. During the first video (CAP-076-recording1.mp4) I did the upgrade to 1.3.0." — + *the Info tab on film, the app logcats' `package: …:10300`, the `dumpsys` lines in the shell log (versionCode 10202 at 06:23:50 → 10300 at 06:38:17, + `lastUpdateTime=2026-10-15 06:36:57`) and the full logcat's `am_proc_start` lines (06:35:42 PID 10952, 06:36:59 PID 11800) decide.* + - "The capture was made on a Pixel 9a with GrapheneOS Android 17." + - "I spoke only very little during the video." + - "**The ANC modes during playback were good! When I did a long press on a bud, the ANC mode in the app changed too!**" — *the verdict's core claim: + count every press-and-hold (the stream packets `4:{13:n}`, the audio, the ears if on camera, the mode buttons on film) against the mode shown within a + second, in both users; say how many were shown, how many not, and why for each miss.* +3. **The analysis steps the maintainer named (§4 follows them):** first both recordings → `CAP-076-EVENT-NOTES.md` with every event and action and its time; + all `CAP-076-btsnoop_hci*` logs with `tshark`; both debug exports; both app logcats; the full logcat; the adb shell log; the system log; then correlate the + timeline with every log (`CAP-071-EVENT-NOTES.md` as the example); a comprehensive CAP-076-FINDINGS.md (`CAP-071-FINDINGS.md` as the example). +4. Strict rules (the maintainer's): no assumptions — verify everything; validate claims externally against official documentation where needed (Android + Developer Docs, the Bluetooth Core Specification, the Fast Pair specification); **no sampling** — a 100 % complete review of the relevant files; evidence + traceability (every claim links to a frame, a log line or a film time) and structural integrity. +5. Standing practice: no commit or push without the maintainer's "yes" in chat; no release step; `main` takes no direct push (`RELEASING.md`). + +--- + +## 2. The evidence (measured 2026-10-15 07:20 by the session that wrote this prompt — re-derive every number) + +**State of the repository:** branch `feature/settings-stream` at **`ff27ee1`** (= the build commit of B1; the four commits of 0091 and two docs commits), +pushed; **PR #56** open (A1–A6, B1, B2 ticked; B3, C1 done by the maintainer per chat — tick them in the PR body only after this session's checks); `main` at +`c1d898b`. Per `RELEASING.md` C3 this session's commits go **on the same branch, after the build commit** — they change no app file, so the tested APK stays +the build of B1. Untracked in the repository: the fourteen capture files below (the skeleton file is committed), `android/domain/bin/` (build output — never +staged) and an **empty file `test` in the repository root** (0 bytes, 07:17 — ask the maintainer at Checkpoint 0 whether it is an accident to delete). + +The folder `captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/` (the skeleton's placeholder name) holds, per `ls -la` and `sha256sum` of 2026-10-15 07:20 +(the files the maintainer listed, with the first eight hex digits of each SHA-256): + +**Shared:** + +| File | Measured | Notes | +|---|---|---| +| CAP-076-EVENT-NOTES.md | 29,329 B, committed (`b1669687…`), rewritten 2026-10-14 22:28 | **exists**: add the timeline to it, keep the planned procedure as the appendix | +| CAP-076-adb-shell.log | 1,473 B (`307f948a…`), 06:21–06:38, mode 644 | P0 for both users and P1's `dumpsys` before/after — see below | + +**Part 1 — the Owner user (user 0) with Google Play (06:35–06:52):** + +| File | Measured | Notes | +|---|---|---| +| CAP-076-recording1.mp4 | 248 MB, **980.97 s**, 1080 × 2424, 60 fps nominal, **an audio track**; `creation_time` 2026-10-15T04:52:11Z (= 06:52:11 local, likely the end); mode 750; `e008aa17…` | if the stamp is the end the film starts ≈ 06:35:50 — **derive it from the status bar**; it should cover the 1.2.2 → 1.3.0 update (install 06:36:57, the 1.3.0 process start 06:36:59) | +| CAP-076-btsnoop_hci1.log | 607,389 B, **10,0xx packets**, mode 644, `b171e3ca…` | Buds ACLs (Connection Complete for `04:00:6e:…`, handle `0x000b`): **672** 06:36:19.895 → Disconnection Complete **4558** 06:42:23.192 (reason `0x13`); **4680** 06:42:46.059 → open at the log's end (06:55). Count the packets and the time span exactly (`capinfos -c -a -e`) | +| CAP-076-btsnoop_hci1.log.last | 833,776 B, 7,684 packets, `489c44ce…` | ends with a Disconnection Complete of an LE handle `0x0041`, reason **`0x16`**, at 06:35:34.729 — a Bluetooth **off** before the run (P2's toggle?); no Buds Connection Complete found by the `bthci_evt.code == 0x03` filter — verify the span and whether any Buds frame is in it (the P2 "off and on on film") | +| CAP-076-opencontrol-debug-20261015-065105.txt | **1,061 lines**, 06:37:00.476 – 06:50:55.279 (local); mode 750; `e455b902…` | one process from "Permissions (start)" at 06:37:00 (the 1.3.0 process, PID 11800); **8 "Settings stream subscribed", 8 "Noise-control mode read (", 86 "from the settings stream (changed_by_buds)", 22 "(set_by_app)", 5 "from the mode read (read)", 4 "no mode settable", 0 "changed_since", 0 "not shown (not read"**, 7 "Automatic re-open" (1 LINK_BACK 06:37:00, 6 AFTER_LOSS), 1 "re-open skipped … 928 ms after", 1 "Session ended by the user's Disconnect tap" 06:39:41, **7 "Session lost: channel 0x02 closed"** (06:39:53, 06:41:31, 06:42:23, 06:42:53, 06:42:57, 06:47:30, 06:47:44) against **one** ACL disconnect (06:42:23) — the others are RFCOMM-level closes by the Buds (`DISC` of DLCI 0x02 — the wear-change re-homes? the film), **4 "RFCOMM on-demand channel 0x04 closed"** 06:45:58 – 06:46:28 (taps colliding with Play? BL-11); 0 "re-claim"/"hold" lines (positive control: `CAP-075`'s export 2); no raw hex lines | +| CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-a1c4370c8dcb.txt | 731 lines; header `osVersion: google/tegu/tegu:17/CP3A.261005.005/2026100601:user/release-keys`, **no `userType:` line** (the Owner, as `CAP-074`/`CAP-075` set 2), `package: …:10300, targetSdk 34`; UTC 04:35:43 – 04:51:15; mode 750; `6e7fcc66…` | the app logcat of part 1 (App info → View logs) | +| CAP-076-System-log-b5e49395adf6.txt | 12.2 MB, **94,423 lines**; header `type: logcat … buffers: main,system,crash,events,kernel`; spans 10-08 19:47 – **10-15 04:51:37 (UTC)**; 893 lines name the app, 1,482 name `com.google.android.gms`; **2,709 lines match a broad privacy pattern** (`gmail|@|ssid`); mode 750; `71b39d49…` | Android's log viewer, "System log" (all apps) — part 1 only | +| CAP-076-logcat-all-1.txt | 30.1 MB, **213,298 lines**; `adb logcat -b all -v threadtime` (no header); spans 10-08 21:47 – **10-15 06:56:48 (local)**; 985 lines name the app; `am_switch_user: 10` at 06:08:16 and `am_switch_user: 0` at 06:13:05 (before the run), `am_proc_start` of the app in user 10 at 06:08:16 (PID 4104, bound-service), in user 0 at **06:35:42** (PID 10952 — 1.2.2) and **06:36:59** (PID 11800 — 1.3.0); 10,011 lines match the privacy pattern; mode 644; `3f8e5ef7…` | the same phone log in local time, longer buffer — part 1 only (it ends before part 2); neither system file is committed in full (privacy): an extract, as `CAP-075`'s CAP-075-logcat-extract.txt | + +**Part 2 — the secondary user (user 10) without Google Play (07:01–07:08):** + +| File | Measured | Notes | +|---|---|---| +| CAP-076-recording2.mp4 | 111 MB, **348.94 s**, 1080 × 2424; an audio track; `creation_time` 2026-10-15T05:08:20Z (= 07:08:20 local); mode 750; `fd4e51ec…` | if the stamp is the end the film starts ≈ 07:02:31 — the export starts 07:01:03 and shows a Bluetooth off/on at 07:02:39/07:02:44: derive the offset from the status bar and say what the film does not cover | +| CAP-076-btsnoop_hci2.log | 133,422 B, 3,070 packets, `d3674b9f…` | Buds ACLs: **151** 07:02:45.890 → **2666** 07:08:33.441 (`0x13`); **2678** 07:10:20.388 → open at the end (07:11) — after the film | +| CAP-076-btsnoop_hci2.log.last | 71,981 B, 1,303 packets, `9c332bc7…` | holds the **start of part 2**: Buds ACLs **311** 07:01:07.655 → **489** 07:01:08.972 (`0x13`), **605** 07:01:13.785 → **1294** 07:02:39.649 (**`0x16`**, with an LE handle `0x0040` at 1295 — the Bluetooth **off** of BL-20); hci2.log starts 07:02:45 with Bluetooth on. One subscription, one read of 13, one `SABM` on DLCI 0x04 in it | +| CAP-076-opencontrol-debug-20261015-070741.txt | **461 lines**, 07:01:03.577 – 07:07:33.969; mode 750; `8b3ff719…` | "Permissions (start)" 07:01:03; Bluetooth adapter OFF → ON at 07:01:05 (!), LINK_BACK re-open 07:01:14; **4 subscribed, 4 read, 24 changed_by_buds, 8 set_by_app, 2 "from the settings stream (read)", 2 "from the mode read (read)", 2 "no mode settable", 0 changed_since**; Bluetooth ON → OFF 07:02:39 ("Session loss cause: Bluetooth was switched off on this phone"), OFF → ON 07:02:44, LINK_BACK 07:02:46; a session loss 07:03:00 + AFTER_LOSS 07:03:01; "Session ended by the user's Disconnect tap" 07:04:12 | +| CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-41fc9acad7b3.txt | 585 lines; the same header **with `userType: full.secondary`**, `package: …:10300`; UTC 04:08:17 – 05:07:57; mode 750; `66ade0a5…` | the app logcat of part 2 (its buffer reaches back to 06:08 local — the user-10 bound-service start) | + +**The adb shell log (P0/P1), verbatim content to re-read:** `am get-current-user` → `0` (both times — the commands were run from the Owner, with +`--user 0` and `--user 10`); `pm list packages --user 0 | grep -i -E "gms|vending"` lists `app.grapheneos.gmscompat.config`, `…gmscompat.lib`, +`…gmscompat`, `com.android.vending`, **`com.google.android.gms`** (exit 0); `--user 10` lists only the three `app.grapheneos.gmscompat*` packages (exit 0 — +**the `grep` matched GrapheneOS's compatibility layer, so exit 0 is not a positive for Play in user 10; `com.google.android.gms` is absent there** — say it +with the lines); `dumpsys package` at 06:23:50: `versionCode=10202`, `lastUpdateTime=2026-10-14 18:35:00`, two `firstInstallTime` lines (2026-10-09 18:33:54 +and 17:24:41 — two users); at 06:38:17: `versionCode=10300`, `lastUpdateTime=2026-10-15 06:36:57`, the same `firstInstallTime` lines (P1: an update, no +reinstall). **Not in the log:** Play's *Nearby devices* permission and version (P0 on film?), the APK pull and its `sha256sum` (the Info tab's hash on film is +the fallback), a `sha256sum` file, camera films (P3 — the ears; the audio and the stream packets must carry the hold timing instead). + +**First look, to be re-derived (leads, not findings) — `tshark` on the Buds' handle `0x000b`, exit 0 each:** +- **The two new requests:** hci1.log **8** `SubscribeToSettingsChanges` REQUESTs sent (`frame contains 25:f5:ad:21:28:01:97:54:b0` or `…07:ee:8e:b2`) and **8** + `ReadSetting 4:13` (`frame contains 2a:02:20:0d`), hci2.log **3 + 3**, hci2.log.last **1 + 1**, hci1.log.last 0 + 0 — against the exports' 8 and 4 + "subscribed" lines and the sessions (one Connect/re-open each): **one per MAESTRO session?** List every pair with its session and channel (19 or 21). +- **The stream:** **109** `4:{13:n}` SERVER_STREAM packets received in hci1.log (`frame contains 68:0n:08:07`), **34** in hci2.log; the exports' 86 + 22 and + 24 + 8 + 2 "from the settings stream" lines — reconcile packet by packet (a packet per line? **the `set_by_app` lines come in pairs ≈ 25–40 ms apart** + (07:04:49.836/.861, 07:04:57.607/.646): two packets per own Set — the Buds echo the mode twice? one per bud? a `13` and another field? decode each). +- **Notifies and Sets:** hci1.log **110** `Notify` (`08 13 00 04 01`) and **11** `Set` (`08 12 00 14`); hci2.log 16 and 4. In user 0 the Notifies include + Play's claims (Play receives them while it holds the channel): attribute each Notify to the claim it arrived on (`CAP-075` §1's owner rule). +- **Claims on DLCI 0x04/0x05:** hci1.log **40** phone-sent `SABM`s (the app's and Play's together — split them by the first phone message: `08 11` the app, + `03 08 00 02 01 25` Play; **18 Play markers**), hci2.log 10 (0 Play markers — the positive control is hci1.log's 18). The verdict count: **the app's `SABM`s + vs the taps/tile/pulls/Connects/re-opens** (the Connect's snapshot claim is expected once per session — ADR-065's Q7). +- **Sessions:** user 0: a Disconnect tap 06:39:41, then **seven session losses between 06:39:53 and 06:47:44 with only one ACL disconnect (06:42:23, `0x13`)** — + the Buds' own `DISC` of DLCI 0x02 (the 12-of-77 case of `PROTOCOL.md` §4.1; a bud out/in — BL-9? the film), each followed by an AFTER_LOSS re-open 1.5 s + later (one skipped by `CHAIN_GUARD_MS` at 06:42:57); the re-opens' reads answered **"no mode settable"** 4 times and "read" 5 times, **never + "changed_since"** — was the mode ever different after a re-home, or always the same / 0? user 10: a Bluetooth off/on 07:02:39/44 (BL-20), a loss 07:03:00, + a Disconnect 07:04:12. +- **The on-demand closes:** 4 "RFCOMM on-demand channel 0x04 closed" in user 0 at 06:45:58 – 06:46:28 — the app's own tap claims cut by Play's recovery + (`CAP-075` §5; the known issue, BL-11): establish per tap whether the Set got its ACK before the cut and whether the stream packet still confirmed it. +- **User 10's first mode came from the stream** ("from the settings stream (read)" ×2): the first mode ever shown in that process arrived as a push before any + read answered (the read answered 0 — no bud worn?) — check against `causeOfStream`. + +--- + +## 3. Leads and context — verify each before relying on it + +1. **Which user, which part** (🟡 until shown): part 1 = user 0 with Play (no `userType:` line in its app logcat; 18 Play markers; `am_proc_start … [0,11800,…]`; + the shell log's `--user 0` list with `com.google.android.gms`); part 2 = user 10 without Play (`userType: full.secondary`; 0 Play markers; the `--user 10` + list without `com.google.android.gms`). Confirm on film (the launcher, the wallpaper, the user icon in Quick Settings) and from the shell log; note that + `am get-current-user` read 0 both times (run from the Owner before the switch) and that `logcat-all-1` ends 06:56 — part 2's user switch is not in it. +2. **The build on film:** part 1 starts on **1.2.2 build `bfd7891`** (the Info tab; the Settings tab **with** the "Noise control with Google Play services" card + and its selected option — P1 asks for it on film) and updates to **1.3.0 build `ff27ee1`** at 06:36:57 (the shell log's `lastUpdateTime`; the full logcat's + process starts; the Info tab afterwards, the card gone). Say which frames, claims and holds belong to 1.2.2 (the process of 06:35:42) and which to 1.3.0; + only 1.3.0's count for the verdict. Part 2: 1.3.0 throughout? (its app logcat's `:10300` from 04:08 UTC — the bound-service start of 06:08 was already + 1.3.0? impossible — 1.3.0 was installed 06:36: re-read the header's meaning; `CAP-075` §0 explains the `package:` line as the current version at export time). +3. **The clock offsets:** both films' status bars (the minute changes at 10 fps) and a shared event with an export line (the update's restart at 06:36:59; the + Bluetooth off at 07:02:39 — the Quick Settings tile on film); settle start/end for both `creation_time` stamps before any correlation. +4. **The audio tracks:** "I spoke only very little" — `silencedetect`/RMS per 100 ms, tabulate every non-silent segment with its film time, align each with a + stream packet (a hold) or a tap; you cannot listen: label the words as the maintainer's statement, the timing as measured. Playback (AVDTP `START`/`SUSPEND`, + AVRCP) — "during playback": say it with evidence. +5. **The press-and-hold evidence without a camera:** a hold is the Buds' change with no app `Set` before it: the stream packet `4:{13:n}` (direction 1) with no + `08 12` in the 2 s before and no `Notify`-answered `08 11` of the app; the mode button on film within a second (the screen recording). Count per part: + holds made (packets) vs shown (film) vs the mode list in force (`ReadSetting 12`'s answer at Connect: which modes a hold cycles through) and the cause line + in the (i) when opened. The `80 ↔ 08` alternation of `CAP-075` lead 5 (a left vs right bud?) — record each packet with its time and the preceding mode; + do not invent a rule. +6. **The own tap:** for each `Set` (11 + 4): `08 11` → `Notify` → `08 12` → ACK → the Buds' `Notify` → **the stream packet(s)** — the delay Set → stream + (`CAP-051`: 269 ms), whether the export classed it `set_by_app` (the `ANC_SET_WINDOW_MS` rule), and the two-packets-per-Set lead of §2. A tap cut by Play + (the four on-demand closes): the `AnswerCutOff` path and whether the stream packet still confirmed the mode (`ai-sessions/0091`'s test "after a cut-off + answer"). +7. **The re-homes:** for each "Session lost: channel 0x02 closed" without an ACL disconnect: the Buds' `DISC` of DLCI 0x02 on the wire (sender, time), what + happened on the Buds (a bud out/in on film? a stream packet or a Notify just before it — `CAP-051` 1993 → 1996), the re-open 1.5 s later (`SABM`, the + fifteen requests, the subscription and the read again — M1 on hardware), the read's value (0 = no bud worn? 1–4) and the (i) line if opened. The one + `CHAIN_GUARD_MS` skip (06:42:57, 928 ms after a re-open) — what reconnected the session afterwards (the ACL at 06:42:46 … the next LINK_BACK?). +8. **Play's claims and the app's:** the claim inventory of DLCI 0x04/0x05 with both clients (`CAP-075` §1's method), Play's recovery/retry timers in this run + vs `CAP-075`'s (5.3–5.8 s / 2.1–2.8 s), and — the point of 1.3.0 — **whether any hold was missed because of Play**: with the stream, none should be; if a + hold is missing on screen, find its packet and the app's state at that moment (not visible? the session lost? the mode equal?). +9. **"No mode settable" at Connect/re-open** (4 + 2): the Settable `00` reading — both buds out of the ear at that moment (the film), the (i) "The Buds don't + allow changing noise control right now …" line, a value 0 never shown as a mode (M2 on hardware), and the next 1–4 packet making it allowed again. +10. **Nothing held, nothing re-claimed:** 0 "hold"/"re-claim" lines in both exports (positive control: `CAP-075`'s export 2 has them); no `SABM` on DLCI 0x04 + from the app that is not a tap/tile/Refresh/pull/Connect snapshot (the verdict count); no `WriteSetting 4:{13:n}` anywhere (`frame contains 2a:…:68:` + on a sent frame → 0, with a positive control on a received one). +11. **The other settings from the stream:** any stream packet for a field other than 13 (the official app on a second phone was not used — say so; the Buds + may still push 16/12/2 after the app's own writes); any "not shown (not read by this app)" line (0 in both exports — positive control: the unit test). +12. **Test-IDs** (`TESTPLAN_BLUETOOTH_HCI_SNOOP.md`; `AGENTS.md` §13 step 7): `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`…`ANC-004`, `TOUCH-007` (the skeleton's + list); `CASE-004`/`CASE-005`/`INEAR-002`/`INEAR-003` if buds went in/out on film. Re-derive from what the run actually did; "expected but not observed" for + each that was not — and the skeleton's step mapping BL-1 … BL-25 (done / not done / done differently), part 1 and part 2 separately. The maintainer said + the plan was not followed: expect many "not done". +13. **Privacy:** the recordings' audio carries the maintainer's voice; the screens may show the Wi-Fi name in Quick Settings and the serial numbers on the Info + tab (allowed on film, never unredacted in text — first 4 + last 2); the logcats print `XX:XX:XX:XX:6E:07`-style addresses; the system log and the full + logcat match the privacy pattern thousands of times — **commit an extract only** (the `awk` of `CAP-071`/`CAP-075`: the time window of part 1, the app's + and the Bluetooth process's PIDs, every line naming the app, `com.android.bluetooth` or `NearbyDiscovery`/`gms`), keep the full files local + (`~/opencontrol-capture-originals/CAP-076/`), name the extract CAP-076-logcat-extract.txt. +14. **External validation** where a claim rests on a specification or platform behaviour (fetch, quote with the date): Android's user types (`UserManager`, + "full.secondary"), `BluetoothSocket`'s one-connection-per-channel behaviour (AOSP), the Bluetooth Core Specification for the `0x13`/`0x16` reasons and + RFCOMM's `DISC` semantics, the Fast Pair Message Stream page, Pigweed's pw_rpc packet types (`SERVER_STREAM` = 7) — re-fetch only if a new claim needs them. + +--- + +## 4. Tasks + +### Phase 0 — set-up, privacy and registration (ask before any move, rename, mode change, delete or `git add`) + +1. Create the RESULT (§0). Record `git log -1`, `git status --short`, `git fetch`, `git log --oneline HEAD..origin/main`, the open PR #56. Work on + **`feature/settings-stream`** (`git switch feature/settings-stream && git pull --ff-only`); never on `main`; never rebase or amend the build commit `ff27ee1`. +2. List the folder fresh (`ls -la`, `sha256sum`), diff it against §2's tables and say every difference. +3. **Checkpoint 0 (`AskUserQuestion`, one question per decision, options with pros and cons, "(Recommended)" on one, the exact commands in the preview):** + the users of part 1 and part 2 (the logs' reading); the folder rename to the recordings' first/last phone times (one day: `CAP-076-2026-10-15_HH-mm-ss_HH-mm-ss-Group_BL`) + and whether the recordings keep their names; `chmod 644` on the files with mode 750; a `sha256sum` file; the privacy treatment of the recordings (blur + ranges or as is; the audio stays) and of the two system-log files (an extract committed, the full files local); the empty `test` file in the repository + root; whether the phone can be attached for the APK pull (`adb shell pm path --user …`, `adb pull`, `sha256sum` against `~/opencontrol-1.3.0-tested`) + and for Play's *Nearby devices* permission state (`TODO.md` §3); whether the originals go to `~/opencontrol-capture-originals/CAP-076/`. +4. Update the `CAP-076` row of `id_registry.csv` (it exists with status `planned`; `analyzed` in Phase D) and prepare the Group BL status line of + `CAPTURE_BLUETOOTH_HCI_SNOOP.md` (written in Phase D). + +### Phase A — both screen recordings, in full + +5. Decode each recording at 1 frame per second into the scratchpad in ranges (part 1: 981 frames; part 2: 349); compare each second with the previous one; view + **every** second with a change on labelled contact sheets; narrow transitions at 10 fps where a claim depends on them (the mode buttons after a hold, the + (i) dialogs, the connection state and the loss line, the "Updaten…" screen, the Settings tab before and after the update, the Quick Settings Bluetooth + tile). No sampling: every second is either viewed or proved unchanged. +6. The audio tracks: extract each (`ffmpeg -vn`), derive the non-silent segments with `silencedetect`/RMS per 100 ms and tabulate every segment with its film + time; align them with the stream packets and the taps. Detect music playback the same way and with AVDTP/AVRCP. +7. **Measure both clock offsets** (status-bar minute changes at 10 fps; a shared event with an export line) with their uncertainty, and settle start/end for + both `creation_time` stamps before any correlation. +8. Write the timeline into the existing CAP-076-EVENT-NOTES.md: phone time · what happened · step BL-n / Test-ID · evidence — in `CAP-071`/`CAP-075`'s layout + (the status line becomes "captured 2026-10-15 … and analyzed …", Log Metadata filled in for both parts, the capture-integrity pre-flight incl. the + `.last`/current boundaries, the review method and privacy, the Event Timeline — part 1 then part 2, each incl. "before the recording" and "after the + recording" — the step mapping BL-1 … BL-25 done / not done / done differently, Test-IDs per `AGENTS.md` §13 step 7 with "expected but not observed", the + analysis checklist ticked; the planned procedure kept below as the appendix). + +### Phase B — the HCI logs and the other logs, in full + +9. All four HCI logs: Connection Complete events and the Buds' handle (by address; `bluetooth.addr` may match nothing — show the filter matching a known + frame), the boundaries between `.last` and current, the RFCOMM inventory per DLCI and direction, **every pw_hdlc frame CRC-32-checked** (`scripts/pwrpc_decode.py`, + with its positive control; a payload holding several HDLC frames split on `7e`), every Message Stream message decoded, **the full claim inventory of DLCI + 0x04/0x05 with the sender of every `SABM`/`UA`/`DISC`/`DM` and the owner of every claim** (the app's vs Play's), the Play marker counts with the positive + control, **every `4:{13:n}` stream packet with its time, value, channel and what preceded it** (a `Set`, a `Notify`, nothing), every `ReadSetting 4:13` + REQUEST/RESPONSE pair per session, the fifteen Connect requests byte for byte against `ai-sessions/0091` RESULT §A.1 / `CAP-075` §2 (order, channel, once + per session), every write (expected: the Connect reads, the subscriptions, the Sets — nothing else), AVDTP/AVRCP state. Commands and exit statuses in + FINDINGS (`PROJECT_RULES.md` rule 4a; `AGENTS.md` §13 step 8 for every negative). +10. Both exports and both app logcats: every line read (1,061 + 461 + 731 + 585) — every export line matched to the code's log string (`BudsRepositoryImpl.kt`, + `RfcommBudsTransport.kt`, `SessionReopener.kt`, `SessionDiagnostics.kt`, `OsConnectionObserver.kt`, `MainActivity.kt`): the subscriptions and reads per + session, every "from the settings stream ()" line against its packet and the cause rule, the "no mode settable" lines, the session losses and + re-opens with their delays against `LOSS_REOPEN_DELAY_MS`/`CHAIN_GUARD_MS`, the on-demand closes, the claim lifetimes (`SABM` → `DISC`) against + `MESSAGE_STREAM_LINGER_MS`, the connect failures and retries; the logcats' `events` lines (process start/stop, `wm_*`), the `BluetoothSocket` close + lines, crashes/ANRs/StrictMode (`grep -c` with a positive control). +11. The adb shell log (every line; P0/P1's meaning as in §2), the system log and the full logcat (part 1 only): `grep`/`awk` by time window and PID — the + app's process starts and user, the update at 06:36:57 (`PackageManager`/`installd` lines), Play services' `NearbyDiscovery`/Fast Pair lines around every + on-demand close and every app `SABM` (the collision's stack path — `CAP-075` §5's lines), the Buds' `DISC`s of DLCI 0x02 (the stack's socket close lines), + any crash. Write the extract command and its line count; commit the extract only (Checkpoint 0). + +### Phase C — correlation, the findings, the comparison + +12. Correlate every timeline row with its frames and log lines. Write CAP-076-FINDINGS.md in `CAP-071`/`CAP-075`'s structure: §0 build (1.2.2 → 1.3.0 on + film; the shell log's `dumpsys`; the Info tab's hash = `ff27ee1`), install, **users** (the evidence per part side by side), logs, privacy; §1 ACLs and the + claim inventory per part (both clients in part 1); **§2 the Connect sequence byte for byte: the fifteen requests, the two new ones once per MAESTRO + session (Connects and re-opens), their position, channel 19/21**; §3 every write (the Sets; no `WriteSetting 4:13`); **§4 the holds per part: every + stream packet, the mode on screen within a second (yes/no, the film time), the (i) line, the mode list in force, the misses and their cause** — the + maintainer's core claim verified or refuted; **§5 the own taps: Set → ACK → Notify → stream packet(s), the delay, the cause classed, the four taps cut by + Play**; **§6 the re-homes and re-opens: each Buds-side `DISC`, the re-open's subscription and read (M1 on hardware), the read's value and the (i) line, the + `CHAIN_GUARD_MS` skip**; §7 Bluetooth off/on in part 2 (BL-20); §8 playback; **§9 `CAP-075` (1.2.1, the same users) vs `CAP-076` (1.3.0)** — a table per + aspect (holds made / shown / missed per user, the app's `SABM`s per user, Play's claims and timers, the first-hold-only pattern gone?, the session losses); + **§10 the counts for the verdict** (the skeleton's analysis checklist: the app's `SABM`s vs the user's actions; holds shown vs made; stream packets vs + Notifies; the first read's value after every Connect/re-open); §11 the skeleton's "Refuted if" applied literally per step run; **§12 the verdict table + (`RELEASING.md` C4)** — what the run verifies for 1.3.0 (the stream in both users; the removal), what it does not (the steps not done: the EQ/switch from a + second phone, the tile?, the camera), heavy/minor/OK, and the analysing session's view — the decision is the maintainer's; §13 open questions and + proposals (the two-packets-per-Set lead; `PROTOCOL.md` §6's wear-change item: does this run settle the 🟡 "stored mode per wear state"? — a promotion + needs the maintainer's approval; the per-tap collision). FACT / HYPOTHESIS / ASSUMPTION / OPEN QUESTION on every conclusion (`PROJECT_RULES.md` §1); every + claim with a frame, a log line or a film time. +13. Validate externally (§3 item 14): fetch, quote with the date. + +### Phase D — checkpoint, documentation, finish + +14. **Checkpoint (`AskUserQuestion`):** the users' final reading (if not settled at Checkpoint 0); **the verdict** (with the pros and cons: `RELEASING.md` C4, + `PROJECT.md`'s Definition of done, `CHANGELOG.md`'s Known-issues lines — keep, reword or add); **the ADR-065 Update** (the numbers of both parts, + "hardware-verified: …" — a draft in the preview; `AGENTS.md` §6: nothing written without the maintainer's answer in this chat) and any `PROTOCOL.md` note + (§4.1: the stream in OpenControl's own logs; §6: the wear-change item — promotion or not); whether a FEATURE prompt follows (the two-packets lead; the + per-tap collision — a draft title in the preview); the privacy treatment; whether the folder's untracked files are committed now (Git LFS, + `.gitattributes` covers `captures/**` `.log`, `.log.last`, `.mp4`, `.txt`) on `feature/settings-stream` after the build commit. Record the answers + verbatim in the RESULT. +15. With the answers: `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9 (the `CAP-076` row: the real times, both users, analyzed) and the Group BL status line; + `id_registry.csv`; `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the Group column: BL on the Test-IDs exercised); `APP_TESTPLAN.md` §Y (the Result column per row, + the Summary row, a run note like §X's); `TODO.md` §1/§2/§3/§4/§5; `ARCHITECTURE.md` §5a (the "Changed by the Buds" row: what `CAP-076` verified) and + §6.0b (one sentence, as approved); `DECISIONS.md` ADR-065 Update (as approved); `PROJECT.md`/`README.md`/`CHANGELOG.md` only as approved; the PR #56 + body's C2/C3 lines (`gh pr edit`) as approved. `PYTHONDONTWRITEBYTECODE=1 python3 scripts/ensure_footers.py` and `python3 scripts/lint_docs.py` (exit 0). +16. The RESULT: plain-language summary first (what happened on the phone in each user, whether every hold showed and why not where it did not, what the two + requests did on the wire, what the maintainer decides next), the phases, the files read in full / in part, "Deferred documentation" (each also in + `TODO.md`), "Commits"; the `ai-sessions/INDEX.md` row (0092; the 0091 row's Status gets the back-fill "hardware run analysed by 0092"). +17. Show a short summary and **ask whether to commit and push**. Only after a yes: commits per concern (Conventional Commits: `docs(capture)`, `docs`), each + with its *why* and the attribution line; Git LFS for the capture files; on `feature/settings-stream` after `ff27ee1`; `git fetch`; push without force; + never merge; the release steps D1–D4 are the maintainer's. + +--- + +## 5. Guardrails (binding, in addition to `AGENTS.md`) + +- **Scope:** analysis and documentation only. No change under `android/` (improvements are proposals), no new skeleton, no release step, no `dist/` change, no + `scripts/release.sh`, no tag, no merge, no rebase of the build commit. +- **Approvals only in this chat** (`AGENTS.md` §6): no 🟢 FACT promotion, no new ADR or ADR Update, no verdict recorded as the maintainer's without their + answer here; ADR-060 stays a draft. "Every hold showed" is answered per user with the evidence of this run — never by the maintainer's impression. +- **Evidence:** every claim cites a frame (log and number), a log line or a film time; a negative needs its command, its exit status and a positive control + (the Play marker's positive control is hci1.log's 18; the "re-claim"/"hold" lines' positive control is `CAP-075`'s export 2); a hypothesis names its + settling experiment. The maintainer's statements are statements until verified — including which user each part was made in, "1.2.2 at the start" and + "the ANC modes were good". +- **No sampling:** every second of both recordings, every packet of all four HCI logs on the Buds' handle, every line of both exports, both app logcats and the + shell log — reviewed or covered by a stated, positive-controlled filter; the system log and the full logcat by stated filters over the full time window of + part 1. +- **Privacy:** no serial unredacted, no MAC, no phone number, no account name or e-mail, no Wi-Fi name in any committed text; the system-log files are + committed as an extract only; the recordings' treatment and the originals' place are Checkpoint 0's. +- **Files:** before moving, renaming or deleting anything, diff a fresh listing; never wildcard deletes; ask first. `android/domain/bin/` is never staged. +- **Subagents** may only read and report; every write is done and verified in the main session. + +## 6. Deliverables + +- CAP-076-EVENT-NOTES.md (the timeline of both parts added to the skeleton) and CAP-076-FINDINGS.md (the users settled, the two requests per session, every + hold against the screen, the own taps, the re-homes and re-opens, the `CAP-075` comparison, the counts, the verdict table), with the folder and files as + approved in Checkpoint 0 (incl. the system-log extract). +- The registry, index, test-plan, `TODO.md`, architecture, ADR-065 Update, PR-body and status updates as approved; a FEATURE-prompt proposal if one follows. +- ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md and the `ai-sessions/INDEX.md` row. + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/0092_CAPTURE_PROMPT_2026_10_15.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/0092_CAPTURE_PROMPT_2026_10_15 diff --git a/ai-sessions/INDEX.md b/ai-sessions/INDEX.md index 61eb464d..fa23c4b9 100644 --- a/ai-sessions/INDEX.md +++ b/ai-sessions/INDEX.md @@ -98,6 +98,7 @@ a new session** — the same "check before assigning" discipline `id_registry.cs | 0089 | FEATURE | 2026-10-13 | The noise-control channel in the Play user after `CAP-075` (`ai-sessions/0088`, the maintainer's choice "P1 + P2 + P4"): a constant 3-s re-claim step with a longer chain (capped), a "Take it back again" action on the hint card, a cause label for a mode learned by a re-claim's own read; the switch renamed (the maintainer's example "Channel control") with options that say whether the phone has Google Play services and plainer texts; real `CAP-075` bytes as fixtures, mutation checks, the `CAP-076` (Group BL) skeleton, the documents, the release preparation (no tag, no publication) | awaiting maintainer sign-off — built on `feature/channel-control` (gate green: `:data` 1672, `:domain` 42, `:hardware` 64, `:ui` 102; M1–M6 killed): the re-claim 3 s after every cut up to 20 (`HOLD_RECLAIM_STEP_MS`, `HOLD_RECLAIM_CAP`), "Take it back again" (`reclaimAncChannel`, no `Set`), `AncModeCause.CHANGED_SINCE_LAST_READ` and its (i) line, the card renamed "Noise control with Google Play services" with options that say whether the phone has Play services and ADR-063's "found / not found" line (a `` entry); ADR-062 Update 2, ADR-063 and the `AGENTS.md` §1 note approved in chat 2026-10-13; 1.2.2 (10202) prepared; `CAP-076` (Group BL) skeleton for both users, `APP_TESTPLAN.md` §Y; commits `e60fb7c` … `bfd7891`, PR #49 merged (`5b204ea`); **1.2.2 published 2026-10-14** (tag `v1.2.2` on `bfd7891`) after the maintainer's own test, without `CAP-076`; installed with Obtainium (D4, 2026-10-14) | | 0090 | CROSSCHECK | 2026-10-14 | Point-by-point check of the pbpctrl-derived findings list (E-01 … E-23: `qzed/pbpctrl`, MagicPodsCore's Pixel Buds branch, omarchy-pixelbuds at pinned commits) against this project's captures, APK notes and documents — led by the question whether the app can learn a press-and-hold's noise-control mode on DLCI 0x02 (Maestro setting 13, `SubscribeToSettingsChanges`) instead of the contested Message Stream; proposals only where the rules require the maintainer; no app code, no wire bytes | complete — the leading question answered yes: setting 13 = the mode on DLCI 0x02, pushed on `SubscribeToSettingsChanges` for 73/73 Buds-side and 39/39 app-driven changes (also with no Message Stream open), `ReadSetting 4:13` 359/382; OpenControl never subscribed (0/33 logs); the APK feeds its ANC state only from 13 (`fxb.smali:1237`). Approved in chat 2026-10-14 and applied: `PROTOCOL.md` §4.1 🟢 Update (+ §6, §2.2, §4.5.7, §0.1 notes), **ADR-064** (subscribe + read 13, read only; build next) with pointers on ADR-025/034/036, ADR-060 item (vii), the `qhr` register Update, a desk entry, `CAP-051-FINDINGS.md` §3, `AGENTS.md` §5 note, `ARCHITECTURE.md` §5a rule, `TODO.md`, `scripts/anc_setting13_crosscheck.py`; no app code; commits `7bfb078` … `0b1681a` on `research/pbpctrl-crosscheck`, pushed, PR #55. **Continuation in chat 2026-10-14:** the existing captures checked for the new findings — one-bud/two-bud stored mode 28 of 28 in the official app's captures (🟡 strong, `PROTOCOL.md` §6, `scripts/wear_mode_crosscheck.py`); settings 8/9/10 and `SubscribeToOobeActions` never on any wire; runtime-info 7.x = the charging flags in 96.9 % | | 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; pushed 2026-10-14, commits `bb7a843` … `391b0c5`, PR #56 open); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | +| 0092 | CAPTURE | 2026-10-15 | Full analysis of `CAP-076` (Group BL: the 1.3.0 build `ff27ee1`, the settings stream on the app's own channel — ADR-064/065 — **two parts**: the Owner user with Google Play (06:35–06:52, the update 1.2.2 → 1.3.0 on film: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`, the adb shell log) and the secondary user without Google Play (07:01–07:08: one screen recording with audio, two HCI logs, one export, the app logcat)): the real events per part, the two new requests once per MAESTRO session, every press-and-hold against the screen, the own taps' stream packets, the re-homes and re-opens, the comparison with `CAP-075`, the counts for the verdict, the release verdict proposal (`RELEASING.md` C3/C4); no app change | planned — prompt written 2026-10-15 (chat, after the maintainer's partial run of `CAP-076`); not started | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/INDEX.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/INDEX From c32b2974f687e7a20d214de6ca2f3d5697b8f994 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Thu, 15 Oct 2026 08:59:00 +0200 Subject: [PATCH 08/11] =?UTF-8?q?docs(capture):=20CAP-076=20=E2=80=94=20th?= =?UTF-8?q?e=201.3.0=20run=20in=20both=20GrapheneOS=20users,=20analysed=20?= =?UTF-8?q?(ai-sessions/0092)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: RELEASING.md C3 — the hardware run of the release build ff27ee1 must be analysed and committed on the release branch after the build commit before the verdict (C4). The run shows the settings stream of ADR-064/065 working in both users: every press-and-hold on the noise-control tab shown within 0.1 s (29/29 with Google Play services holding the shared channel, 12/12 without), the two new requests once per MAESTRO session, byte-identical. The folder renamed to the films' first and last phone times; the films, HCI logs, exports and app logcats as delivered (Git LFS); the two phone-wide logs kept local and committed as the redacted extract (serials removed); the read-only adb checks of 07:52 (the installed APK's SHA-256 = the tested build's) and a sha256sum of the data files. EVENT-NOTES gets the timeline of both parts (the skeleton kept as the appendix); FINDINGS is new. Co-Authored-By: Claude Opus 5.5 --- .../CAP-076-EVENT-NOTES.md | 448 ++++++++++++++++++ .../CAP-076-FINDINGS.md | 414 ++++++++++++++++ ...-for-Pixel-Buds-Pro-2-log-41fc9acad7b3.txt | 3 + ...-for-Pixel-Buds-Pro-2-log-a1c4370c8dcb.txt | 3 + .../CAP-076-adb-checks-0092.log | 3 + .../CAP-076-adb-shell.log | 3 + .../CAP-076-btsnoop_hci1.log | 3 + .../CAP-076-btsnoop_hci1.log.last | 3 + .../CAP-076-btsnoop_hci2.log | 3 + .../CAP-076-btsnoop_hci2.log.last | 3 + .../CAP-076-logcat-extract.txt | 3 + ...-076-opencontrol-debug-20261015-065105.txt | 3 + ...-076-opencontrol-debug-20261015-070741.txt | 3 + .../CAP-076-recording1.mp4 | 3 + .../CAP-076-recording2.mp4 | 3 + .../CAP-076-sha256sum.txt | 3 + .../CAP-076-EVENT-NOTES.md | 167 ------- 17 files changed, 904 insertions(+), 167 deletions(-) create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS.md create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-41fc9acad7b3.txt create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-a1c4370c8dcb.txt create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-checks-0092.log create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-shell.log create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log.last create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log.last create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-logcat-extract.txt create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-065105.txt create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-070741.txt create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording1.mp4 create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording2.mp4 create mode 100644 captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-sha256sum.txt delete mode 100644 captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md new file mode 100644 index 00000000..ed7ae91d --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md @@ -0,0 +1,448 @@ +# Event Notes: OpenControl for Pixel Buds Pro 2 on Pixel 9a (GrapheneOS, the Owner user **with** Google Play services and the secondary user without) — Group BL, 1.3.0: the settings stream on the app's own channel (`CAP-076`) + +**Status:** ✅ **captured 2026-10-15** (part 1 in the Owner user with Google Play services, film 06:35:26.3–06:51:47.3; part 2 in the secondary user without +Google Play, film 07:02:31.5–07:08:20.4) **and analyzed 2026-10-15 by `ai-sessions/0092`** (CAP-076-FINDINGS.md). The release-signed **1.3.0 build `ff27ee1`** +(versionCode 10300) was installed over 1.2.2 `bfd7891` **on film 1** (Android's "Updaten…" at 06:36:58–59). The maintainer's statement: "I did not keep to the +test plan. I tested only the changed functionality." — the steps as run are mapped to BL-1 … BL-25 below ("The maintainer's steps, as run"); the skeleton +of `ai-sessions/0091` is kept as written as the appendix. **After the run (Checkpoint 0 of `ai-sessions/0092`, chat 2026-10-15):** the seven mode-750 files +set to 644; the two phone-wide logs (CAP-076-System-log-b5e49395adf6.txt, CAP-076-logcat-all-1.txt) kept local in ~/opencontrol-capture-originals/CAP-076/ and +committed as the redacted extract CAP-076-logcat-extract.txt; the films committed as is (audio kept); the read-only adb checks of 07:52–07:53 saved as +CAP-076-adb-checks-0092.log; `sha256sum` of the folder's 13 data files (not the two notes, which later sessions may edit) in CAP-076-sha256sum.txt; the folder renamed to the films' first and last phone times (CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL). + +Labels per `PROJECT_RULES.md` §1: 🟢 FACT, 🟡 HYPOTHESIS, ⚪ ASSUMPTION, ❓ OPEN QUESTION. Frame numbers are per log: **H1** = CAP-076-btsnoop_hci1.log, **H1L** = +…hci1.log.last, **H2** = …hci2.log, **H2L** = …hci2.log.last; **E1** / **E2** = the two debug exports; **A1** / **A2** = the two app logcats (UTC); **L** = the full +logcat (local time; the committed extract carries the same lines); **s** = a film second (film 1: phone time = 06:35:26.3 + s; film 2: 07:02:31.5 + s). Every +`tshark` filter was run on the Buds' ACL handle `0x000b` (the Connection Complete for the Buds' address with that handle: H1 672 and 4680, H2L 311 and 605, +H2 151 and 2678; `bluetooth.addr == 04:00:6e:cf:6e:07` matches 0 frames in all four logs, `bthci_evt.bd_addr == 04:00:6e:cf:6e:07` matches each of those +Connection Completes). Modes: Message Stream byte `20` Off, `08` Noise cancellation (ACTIVE), `80` Transparency, `40` Adaptive; setting 13: 1 Off, 2 Noise +cancellation, 3 Transparency, 4 Adaptive, 0 no mode settable. + +## Log Metadata + +| Field | Value | +|---|---| +| Capture ID | `CAP-076` | +| Group(s) | BL | +| Date | 2026-10-15 — part 1 06:35–06:52, part 2 07:01–07:08 (local, CEST) | +| Phone | Pixel 9a "tegu", GrapheneOS, Android 17 — `osVersion: google/tegu/tegu:17/CP3A.261005.005/2026100601:user/release-keys` (A1/A2 line 2); "17 (CP3A.261005.005)" in Quick Settings (film 1 s595) | +| Users | **part 1 = the Owner (user 0) with Google Play services**: A1 has no `userType:` line; L `am_proc_start: [0,11800,10354,io.github.tedsluis.opencontrolpixelbuds,…]` 06:36:59.954; the shell log's `pm list packages --user 0` lists `com.google.android.gms` (exit 0); 18 Play claims in H1. **Part 2 = the secondary user (user 10) without Google Play**: A2 line 3 `userType: full.secondary`; the `--user 10` list holds only `app.grapheneos.gmscompat*` (the `grep` matched GrapheneOS's compatibility layer — exit 0 is not a positive for Play); `dumpsys package com.google.android.gms` 07:53: `User 10: … installed=false`; 0 Play markers in H2L/H2 (positive control H1: 18); "Alle 20 apps zien" without a Sandboxed Google Play entry on film 2 (s332) against film 1's Apps screen with "Sandboxed Google Play" (s956). Confirmed by the maintainer at Checkpoint 0 | +| App under test | OpenControl 1.3.0 (10300), build `ff27ee1` — on film "App: 1.3.0, build ff27ee1 (2026-10-14)", no "-dirty" (film 1 s101–107, film 2 s81–86, s321); **the installed APK pulled at 07:52 has SHA-256 `03272c6f954316d76c3ef6d1d020abd8bdb97755d7f8e165c84d3f16a9c6a96c` = the tested build's** (`~/opencontrol-1.3.0-tested`, CAP-076-adb-checks-0092.log); `lastUpdateTime=2026-10-15 06:36:57` unchanged at 07:52 (no install after the run) | +| Previous version | 1.2.2 `bfd7891` (10202) — film 1 s62–73 "App: 1.2.2, build bfd7891 (2026-10-13)"; the shell log 06:23:50 `versionCode=10202`, `lastUpdateTime=2026-10-14 18:35:00`; after the update 06:38:17 `versionCode=10300`, `lastUpdateTime=2026-10-15 06:36:57`, the two `firstInstallTime` lines unchanged (an update, no reinstall) | +| Google Play services (part 1) | present: `versionName=26.32.34 (260400-968093310)`; `User 0: installed=true`; *Nearby devices* group `BLUETOOTH_SCAN`/`BLUETOOTH_CONNECT`/`BLUETOOTH_ADVERTISE` `granted=true` — **read at 07:53, an hour after part 1** (⚪ the same during the run); Play's Fast Pair client = uid 10205, the persistent process 18871 (L) | +| Official Pixel Buds app | not used; no second phone (BL-13/BL-25 not run) | +| Buds | Pixel Buds Pro 2, `release_5.203` ×3 (Info on film; the `GetSoftwareInfo` announcements, e.g. H2 428) — no Safe Mode | +| Video files | CAP-076-recording1.mp4 (980.97 s, 1080 × 2424, video + **AAC audio**, `creation_time` 04:52:11Z = 24 s after the film's end), CAP-076-recording2.mp4 (348.94 s, **video only — no audio stream**, "Scherm opnemen", `creation_time` 05:08:20Z = the film's end); no camera films | +| Log files | four HCI snoop logs (H1L, H1, H2L, H2), two exports (E1 1,062 lines, E2 462 — the files end without a newline, `wc -l` gives 1,061/461; the toasts said "1062" / "462" lines), two app logcats (A1 731 lines, A2 585), the GrapheneOS "System log" (part 1, 94,423 lines), `adb logcat -b all` (part 1, 213,298 lines), CAP-076-adb-shell.log (P0/P1) | + +## Capture-integrity pre-flight + +| File | Measured (2026-10-15 07:20 / 07:45) | Covers | +|---|---|---| +| H1L | 7,684 packets, 06:13:09.488 – 06:35:34.956 (`capinfos -c -a -e`); **0 frames on handle `0x000b`, 0 RFCOMM frames** (positive control H1: 3,670 / 2,556); ends with LE handle `0x0041` Disconnection Complete reason `0x16` at 06:35:34.729 (7647) | before film 1 and its first 8 s: the Buds were not connected (film 1 s4–7: "Opgeslagen"); the Bluetooth **off** of film 1 s8 rotated the log | +| H1 | 10,617 packets, 06:36:11.035 – 06:54:29.229 | part 1 from the Bluetooth on (film s44, 06:36:10.3): ACL 672 06:36:19.895 → Disconnection Complete 4558 06:42:23.192 (`0x13`); ACL 4680 06:42:46.059 → open at the end | +| H2L | 1,303 packets, 07:01:04.767 – 07:02:39.805 | part 2 before film 2: ACL 311 07:01:07.655 → 489 07:01:08.972 (`0x13`, 1.3 s, no RFCOMM); ACL 605 07:01:13.785 → 1294 07:02:39.649 (`0x16`, the Bluetooth off; LE `0x0040` at 1295) | +| H2 | 3,070 packets, 07:02:44.098 – 07:11:10.545 | part 2 from the Bluetooth on: ACL 151 07:02:45.890 → 2666 07:08:33.441 (`0x13`, after the film); ACL 2678 07:10:20.388 → open (after the film) | +| E1 | 06:37:00.476 – 06:50:55.279; one process (A1's 11800) | the 1.3.0 process of part 1 from its first line | +| E2 | 07:01:03.577 – 07:07:33.969; process 18179 (A2) | part 2 from the process start | +| A1 / A2 | UTC 04:35:43 – 04:51:15 / 04:08:17 – 05:07:57 | A1: PID 10952 (1.2.2, 26 lines, 06:35:43–06:36:57) and 11800 (1.3.0, 578); A2: PID 4104 (1.2.2 in user 10, 20 lines, 06:08:17–06:13:05) and 18179 (1.3.0, 424). The header's `package: …:10300` is the version at export time, not that of the old processes | +| L | 10-08 21:47 – 10-15 06:56:48 (local) | part 1 (it ends before part 2: the user switch to 10 is not in it) | +| Shell log | 06:21:43 – 06:38:17 | P0 for both users (run from the Owner: `am get-current-user` 0 both times) and P1's `dumpsys` before/after; **the second `dumpsys` (06:38:17) falls inside film 1** (s171, in the unchanged stretch s159–254 — run from the laptop while the app sat on the Connection tab) | + +pw_hdlc frames on DLCI 0x02/0x03 of handle `0x000b`, reassembled per (DLCI, direction), split on `7e`, CRC-32 checked (`scripts/pwhdlc_crc_table.py`; positive +control: `CAP-051` 1177's bytes pass, the same bytes with one bit flipped fail): **H1 466, H2L 39, H2 153 — 0 CRC failures**; the same counts as +`python3 scripts/pwrpc_decode.py --handle 0x000b ` (466 / 39 / 153). No `DM` on DLCI 0x02 from the phone; one `DM` from the Buds (H1 5621, after their +`DISC` 5616). + +## Review method and privacy + +- **Films:** each decoded at 1 fps (film 1 981 frames, film 2 349, scaled to 360 px); every second compared with the one before (pixel |Δ| > 25 below the + top 30 px): **film 1 307 changed seconds, film 2 168 — all viewed** on labelled contact sheets of eight; the other 674 / 181 seconds are pixel-identical + below the status bar. Transitions narrowed at 10 fps where a claim depends on them: the status-bar minute flips (the clock), and the four mode buttons of the + noise-control card over every hold window (an automated read of which button is filled, `scripts/film_mode_buttons.py`, crops at full resolution — film 1 s460–530, s560–610, s792–852; + film 2 s132–252). +- **Clocks:** film 1's minute flips at s 33.8, 93.7, 153.7, 453.7, 933.7 (first 10-fps frame with the new minute) ⇒ phone time = 06:35:26.3 + s ± 0.1 s, no + drift; cross-check: the 1.3.0 relaunch on film (s94 = 06:37:00.3) against E1's first line 06:37:00.476. Film 2's flips at s 28.5, 88.5, 148.5, 208.5 ⇒ + 07:02:31.5 + s ± 0.1 s; cross-check: the Bluetooth panel's off on film (s8 = 07:02:39.5) against E2's `ON -> TURNING_OFF` 07:02:39.124 and H2L 1294 + 07:02:39.649. +- **Audio (film 1 only; film 2 has no audio stream):** `ffmpeg -vn` → 16 kHz mono; `silencedetect=noise=-40dB:d=0.3` gives **31 non-silent segments** + (95 at −50 dB, 20.9 s in all; median RMS −69 dB per 100 ms) — the maintainer's "I spoke only very little" (a statement; the words are not intelligible to the + analyst). Ten 0.5-s voiced segments 06:40:48.15, 52.63, 06:41:00.81, 09.30, 17.96, 27.57, 37.11, 44.18, 49.40, 52.56 (f0 200–420 Hz with harmonics, spectral + flatness 0.15–0.27 — speech-like), four lower ones 06:42:11.04–06:42:18.60 (110–220 Hz), the rest short clicks (≤ 0.34 s, flatness ≥ 0.47). None during the + holds on the ANC tab (06:43:14–06:44:11, 06:48:44–06:49:33). +- **Privacy:** film 1 shows the three serial numbers on Info (s62–73, s101–107) and the podcast's title in Quick Settings (s593–616, s951–952); film 2 shows + **the Wi-Fi network's name** in the Quick Settings tile (s0–4, s15, s327–328) and the serials (s81–86, s321–323). The audio carries the maintainer's voice. + No serial, Wi-Fi name, MAC, phone number or account name is written unredacted in this folder's text; the exports and app logcats print the Buds' address as + `XX:XX:XX:XX:6E:07`; **the exports' Debug-mode hex lines include the `GetHardwareInfo` answers, i.e. the serials** (E1 8 lines, E2 4; A1 2 more, truncated) — committed as is (as + `CAP-073`…`CAP-075`, the Phase-D answer); the extract has them redacted. + +## Event Timeline + +One row per event: phone time · film second · what happened · step / Test-ID · evidence. "Claim #n" = the n-th phone `SABM` on DLCI 0x04 in that log (`scripts/message_stream_claims.py`; owner by the first phone message: `08 11 00 00` the app, `03 08 00 02 01 25` Play); "S0 … S13" = the MAESTRO sessions. + +### Part 1 — before the recording + +| Time | What | Step / Test-ID | Evidence | +|---|---|---|---| +| 06:08:16 | the phone switched to user 10 and back to user 0 at 06:13:05; 1.2.2 ran in user 10 as PID 4104 (bound service of the tile), until the update killed it | — | L `am_switch_user: 10` 06:08:16.009, `am_proc_start: [10,4104,1010354,…,bound-service,{…AncTileService}]` 06:08:16.049, `am_switch_user: 0` 06:13:05.730; A2 4104 lines 04:08:17–04:13:05 UTC | +| 06:21:43–06:23:50 | P0 (both users' package lists) and P1's first `dumpsys` (10202) | P0, P1 | CAP-076-adb-shell.log | + +### Part 1 — film 1 (06:35:26.3 – 06:51:47.3), the Owner user with Google Play + +| Time | s | What happened | Step / Test-ID | Evidence | +|---|---|---|---|---| +| 06:35:26 | 0–7 | shade and the Bluetooth panel: the Buds "Opgeslagen" (not connected) | — | film | +| 06:35:34 | 8–10 | **Bluetooth off** ("Bluetooth staat uit") | P2 | H1L 7647 06:35:34.729 LE `0x0041` reason `0x16`; H1L ends 06:35:34.956 | +| 06:35:43 | 17–18 | 1.2.2 starts (PID 10952): "Bluetooth is disabled. [Enable Bluetooth]" | — | L `am_proc_start: [0,10952,…,next-top-activity,…]` 06:35:42.993 | +| 06:36:10 | 44–47 | **Bluetooth on**; the Bluetooth process restarts (PID 11130) | P2 | film; L `am_proc_start: [0,11130,1002,com.android.bluetooth,…]` 06:36:10.147; H1 starts 06:36:11.035 | +| 06:36:16–21 | 50–57 | 1.2.2: "Paired – not connected" → Connect → ready; L/R 100 % charging, Case 44 %, "Both buds in the case" | — | H1 672 Connection Complete 06:36:19.895; S0 (1.2.2, ch 21): `SABM` 760, `ReadSetting` 4:16 … 4:29 788–863, `SubscribeRuntimeInfo` 867, `GetHardwareInfo` 868 — **no subscription, no `4:13`** (1.2.2); claim #1 805–1013 (app, `Notify` 832 `e8 00 20`) | +| 06:36:23 | — | Play's first claim of the ACL (#2, 37.5 s) | — | H1 1121 `03 08 00 02 01 25` | +| 06:36:25–39 | 59–73 | 1.2.2 Settings: the card "Noise control with Google Play services", "Google Play services: found on this phone", the option "This phone has Google Play services" selected; Info "App: 1.2.2, build bfd7891 (2026-10-13)", firmware `release_5.203` ×3, control channel 21 | P1 | film | +| 06:36:57–59 | 92–93 | **the update to 1.3.0** — Android's "Updaten…" | P1 | L `am_kill: [10,4104,…,stop … due to killDueToPackageUpdate,…]` 06:36:57.930 and `[0,10952,…]` 06:36:57.941; H1 1586 phone `DISC` DLCI 0x02 06:36:57.987; L `PackageManager: installation completed for package:io.github.tedsluis.opencontrolpixelbuds` 06:36:58.015 (code path `~~Xm8mP1I_…` = the path pulled at 07:52) | +| 06:36:59–37:03 | 94–95 | **1.3.0 starts** (PID 11800) and re-opens the session by itself (LINK_BACK): S1 ch 21, the **seventeen** requests — `SubscribeToSettingsChanges` 1745 and `ReadSetting 4:13` 1746 after `SubscribeRuntimeInfo` 1744, `GetHardwareInfo` 1751 last; the read answers `4:{13:0}` (1750) — "no mode settable" (both buds in the case); the snapshot claim: attempt 1 refused (Play on the channel), attempt 2 = claim #3, released 06:37:03.04 | BL-1 (done differently: the update on film), `PAIR-003`, `BATT-004` | L `am_proc_start: [0,11800,…]` 06:36:59.954; E1 06:37:00.476 "Permissions (start)", 06:37:00.656 "Automatic re-open … LINK_BACK", 06:37:01.594 "Settings stream subscribed (channel 21)", 06:37:01.646 "… from the mode read: no mode settable", 06:37:00.821 "connect failed after 48 ms (attempt 1/3)"; L 06:37:00.818 `already at opened state` | +| 06:37:06 | — | Play's claim #4 (158.9 s) | — | H1 1789 | +| 06:37:26–52 | 100–135 | 1.3.0 Settings: Dark mode, Case sounds, Use different Buds — **no Play card, no Play line**; Info "App: 1.3.0, build ff27ee1 (2026-10-14)"; Debug: Debug mode on, "Unidentified frames (5)"; Battery (i); the ANC tab: "The Buds don't allow changing noise control right now …", Off; its (i): "ANC mode: OFF (updated 06:37:01)", "Connection: Ready", and the new last sentence "The Buds report every change of the mode on the app's own channel. … — that part is a hypothesis." | BL-2, `FW-003`; BL-6 (the stream sentence) | film | +| 06:37:43 | — | the Google app (uid 10302) closes its DLCI 0x08/0x0a | — | H1 2063/2064; L `cleanup_rfc_slot … scn: 4/5, app_uid: 10302` | +| 06:39:41 | 255 | **Disconnect** tap | BL-3 | E1 06:39:41.764 "Session ended by the user's Disconnect tap"; H1 2691 phone `DISC` DLCI 0x02 | +| 06:39:44–47 | 256–259 | **Connect**: S2 ch 21, the seventeen requests (subscription 2838, `4:13` 2839 → 2843 `4:{13:0}`); snapshot attempt 1 refused, #5 | BL-3, `PAIR-003` | E1 06:39:45.678 "Settings stream subscribed (channel 21)" | +| 06:39:53.97 | 268 | **the Buds close DLCI 0x02** (the Left bud out of the case: runtime info 06:39:57.99 Left not charging, Right charging); the loss line, "Android still showed the Buds connected … the Buds closed the channel" | BL-9 (a wear-change re-home), `CASE-004` | H1 2985 Buds `DISC`; E1 06:39:53.977 | +| 06:39:55.48–58.46 | 269–273 | **AFTER_LOSS re-open 1.50 s later**: S3 on **ch 19** — subscription 3120, `4:13` 3120 → 3124 `4:{13:1}` (Off, "(read)"); snapshot #7 (`Notify` 3090 `e8 e8 20`); "Probably worn (checked 06:39:56/58)" | BL-9, `PAIR-003` | E1 06:39:55.481 "… trigger: AFTER_LOSS", 06:39:57.890 "Settings stream subscribed (channel 19)", 06:39:58.031 "from the mode read (read)" | +| 06:39:59.6 | — | Play's claim #8 (91.7 s) — receives the `Notify` of every change until 06:41:31 | — | H1 3156; its `Notify` 3194 … 3971 | +| 06:40:02.27 | 276 | a change to Adaptive, **one stream packet on ch 19** (only the Left bud out of the case); the worn line's time moves | (BL-9: a Buds' own switch?) | H1 3233 `4:{13:4}`; E1 06:40:02.272 "(changed_by_buds)" | +| 06:40:10 | 284 | the Right bud out of the case (Battery (i): "not charging (06:40:10)") | `CASE-005` | H1 3280 runtime info both not charging | +| 06:40:46.8–06:41:44.8 | 320–378 | **the podcast plays on the Buds**: the Buds send AVRCP PLAY / PAUSE eleven times (in-ear detection resuming/pausing as buds go in and out of the ears) | `INEAR-002`/`INEAR-003` (which ear: not identifiable) | H1 3522 PLAY 06:40:46.806, 3603 PAUSE 06:40:53.995, 3626 PLAY 55.970, 3735 PAUSE 06:41:10.617, 3794 PLAY 14.434, 3852 PAUSE 19.327, 3919 PLAY 25.043, 3974 PAUSE 29.130, 4000 PLAY 31.589, 4272 PAUSE 41.315; AVDTP START 3569 06:40:51.362 … SUSPEND 4316 06:41:44.750; AVRCP "Playing" 3553 06:40:51.002 | +| 06:40:47.57–06:41:51.88 | 321–386 | **13 mode changes on the Connection tab** (that tab shows no mode): 3531 13:3, 3573 13:2, 3600 13:3, 3624 13:2, 3668 13:1, 3723 13:4, 3838 13:3, 3953 13:2, 3972 13:3, — the re-home — 4165 13:1, 4299 13:4, 4368 13:3, 4390 13:2 — **each as two stream packets, ch 19 and ch 21, 1–100 ms apart**; three of them within 2–7 ms of the Buds' AVRCP PAUSE/PLAY (3600↔3603, 3624↔3626, 3972↔3974: wear-driven switches); the worn line's "checked" time follows each (06:40:47, :51, :54, :56, :59, 06:41:08, :17, :26, :29, :36, :43, :51); ten short voiced audio segments 0.6–1.4 s after ten of them | BL-4 (done differently: holds and wear changes on the Connection tab), `TOUCH-007` | E1 26 lines "(changed_by_buds)" 06:40:47.575 … 06:41:51.946; claim #8 / #10 `Notify` 3530 … 4389 | +| 06:41:31.28 | 365 | **the Buds close DLCI 0x02** (a bud into an ear: AVRCP PLAY 0.31 s later) | BL-9 | H1 3994; E1 06:41:31.295 | +| 06:41:32.80–34.93 | 367–373 | AFTER_LOSS re-open 1.51 s later, S4 on **ch 21**; the snapshot's `Notify` 4094 `e8 e8 08` (06:41:33.418: Noise cancellation, the shown mode was Transparency) and `4:13` 4130 → 4135 `4:{13:2}` (06:41:34.165) → E1 "from the mode read (**read**)" | BL-9, `PAIR-003` | E1 06:41:32.803, 06:41:34.170; FINDINGS §6 (the "changed since" cause overwritten) | +| 06:41:36.6 | — | Play's claim #10 (to the ACL drop) | — | H1 4187 | +| 06:42:11.27/.64 | 405 | a change to Off (one packet, ch 21) and **`4:{13:0}` — no mode settable**: both buds into the case; "Not worn (checked 06:42:11)" | BL-23 (in part 1), `CASE-*` (into the case) | H1 4492, 4502; E1 06:42:11.277, 06:42:11.647 "… from the settings stream: no mode settable" | +| 06:42:23.19 | 417–419 | the ACL ends (`0x13`, the Buds); "Android no longer shows the Buds connected …" → "Paired – not connected" | BL-23 | H1 4558; E1 06:42:23.204, 06:42:23.616 "Android's link to the Buds went down around the loss" | +| 06:42:45.70–48.06 | 439–441 | **Connect** tap → the ACL (4680, 06:42:46.059) → S5 ch 21, subscription 4858, `4:13` 4859 → 4863 `4:{13:0}` ("no mode settable"); both buds in the case, then the Right out (runtime info 06:42:51) | BL-23, `PAIR-003`, `CASE-005` | E1 06:42:45.703, 06:42:47.354 | +| 06:42:48 | 442 | the ANC tab: the "don't allow changing noise control right now" line, Off kept — **value 0 never shown as a mode** | BL-23 (M2 on hardware) | film | +| 06:42:53.38 | 447 | the Buds close DLCI 0x02 | BL-9 | H1 5357; E1 06:42:53.387 | +| 06:42:54.89–57.11 | 449–451 | AFTER_LOSS re-open 1.50 s later, S6 ch 19 — **closed by the Buds again at 06:42:57.106 after 4:12** (no subscription, no `4:13`); "Automatic re-open skipped: the session was lost 928 ms after an automatic re-open" (`CHAIN_GUARD_MS`); "Still connecting…" / "Not connected to the Buds …" | BL-9 | H1 5528, 5564–5602, 5616 (+ `DM` 5621); E1 06:42:57.113 | +| 06:43:04.88–08.76 | 458–462 | **Connect** tap (nothing re-opened the session for 7.8 s) → S7 ch 21; the snapshot's `Notify` 5991 `e8 e8 08` (06:43:07.250; the shown mode was Off) and `4:13` → 6003 `4:{13:2}` (06:43:07.440) → "(read)"; the ANC tab: Noise cancellation | `PAIR-003` | E1 06:43:04.879, 06:43:07.444 | +| 06:43:11.6 | — | Play's claim #16 (84.4 s) — receives the `Notify` of the 17 holds | — | H1 6042; `Notify` 6107 … 6548 | +| 06:43:14.73–06:44:11.13 | 468–525 | **17 press-and-holds on the ANC tab**: Off, Adaptive, Transparency, NC, Off, Adaptive, Transparency, NC, Off, Adaptive, Transparency, NC, Off, Adaptive, Transparency, NC, Off — each two stream packets (6108/6117 … 6549/6553); **17 of 17 on screen within 0.1 s** of the first packet (10-fps button read: 06:43:14.8, 17.6, 19.8, 22.4, 24.6, 27.1, 29.1, 31.7, 38.7, 44.1, 48.8, 53.4, 57.4, 06:44:02.3, 05.8, 08.2, 11.2); no AVRCP, no audio, **no app `SABM`** | **BL-5**, `TOUCH-007` | E1 34 lines "(changed_by_buds)" 06:43:14.738 … 06:44:11.203 | +| 06:44:22 | 536 | the ANC (i): "ANC mode: OFF (updated 06:44:11) / **Changed by the Buds at 06:44:11: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves.** / Connection: Ready / The Buds report every change …" | **BL-6**, `TOUCH-007` | film | +| 06:44:35.96–39.06 | 551–553 | **tap Adaptive**: attempt 1 refused (Play on the channel), attempt 2 0.99 s later (claim #17); `Get`, `Notify` `e8 e8 20`, **`Set` 6724 `40`** 06:44:37.456 → ACK 6726 +91 ms → `Notify` 6727 → **stream 6731/6732 +177/+246 ms** → "(set_by_app)" ×2; the (i): "ADAPTIVE (updated 06:44:37)", no cause line; released 1.513 s after the ACK (`DISC` 6737) | **BL-7**, BL-11, `ANC-003` | E1 06:44:35.963, 06:44:37.055, 06:44:37.636/.708; L 06:44:35.961 `already at opened state` | +| 06:44:48–52 | 562–566 | **tap Transparency**: refused, attempt 2 (#19); `Set` 6956 `80` 06:44:50.476 → ACK +104 ms → stream +237/+238 ms; on screen at 06:44:50.6 (the ACK); (i) no cause line | BL-7, BL-11, `ANC-004` | E1 06:44:50.716/.717 | +| 06:44:58–45:02 | 572–576 | **tap Noise cancellation twice**: refused, attempt 2 (#21); `Get` 7124, `Set` 7137 `08` 06:45:00.538 → ACK +131 → stream +154/+155; the second tap on the open claim: `Get` 7143, `Set` 7151 `08` → ACK 7154, no stream (no change); (i) no cause line | BL-7, BL-11, `ANC-002` | E1 06:45:00.694/.695 | +| 06:45:19–42 | 593–616 | Quick Settings (the podcast card paused): **the ANC tile added** (edit tiles) | BL-8 | film | +| 06:45:44.90–48.70 | 618–621 | **tile** (shown Active): refused, attempt 2 (#23); `Set` 7498 `80` → ACK +83 → stream +160/+201; the tile "Transparent" | **BL-8**, BL-11, `ANC-004` | E1 06:45:44.902 "ANC tile tapped …" | +| 06:45:51.40–55.46 | 628 | tile (Transparent): refused, attempt 2 (#25); `Set` 7665 `40` → ACK +100 → stream +246/+248; "Adaptive" | BL-8, BL-11, `ANC-003` | E1 06:45:51.402 | +| 06:45:57.07–58.38 | 632–634 | tile (Adaptive): attempt 1 connects (#26); **Play's connect 06:45:58.170 is refused and the stack closes both sockets** — the `Get` unanswered: "ANC Get: … answer cut off, not retried"; toast "The channel was closed before the Buds' answer arrived (possibly by another app …)" (06:45:58–06:46:00) | **BL-11** (a collided tap) | E1 06:45:58.254 "RFCOMM on-demand channel 0x04 closed", 06:45:58.383; L 06:45:58.170 `already at opened state …`, `cleanup_rfc_slot … app_uid: 10205` / 06:45:58.247 `… app_uid: 10354`, NearbyDiscovery "Retry times remaining 5" | +| 06:46:03.40–06.72 | 639 | tile (Adaptive): refused, attempt 2 (#28); `Set` 7930 `20` → ACK +95 → stream +198/+199; "Off" | BL-8, BL-11, `ANC-001` | E1 06:46:03.395 | +| 06:46:08.96–09.95 | 640–646 | tile (Off): attempt 1 connects (#29); `Get` 8016 → `Notify` 8029 `e8 e8 20` 06:46:09.878 → **Play's connect refused at 06:46:09.879, the stack's `DISC` 8030 at .880** — **no `Set` sent, no failure line, no toast** (s640–646 pixel-identical) | **BL-11** (a collided tap, silent) | E1 06:46:09.952 "… closed"; L 06:46:09.879/.880/.950; FINDINGS §5 | +| 06:46:13.89–17.53 | 647–650 | tile (Off) again: refused, attempt 2 (#31); `Set` 8208 `08` → ACK +129 → stream +205/+242; "Active" | BL-8, BL-11, `ANC-002` | E1 06:46:13.886 | +| 06:46:19.56–23.79 | 653–656 | tile (Active): attempt 1 refused after 1,071 ms (Play's claim #32 06:46:20.045, killed by it), attempt 2 (#33); `Set` 8377 `80` → ACK +127 → stream +233/+300; "Transparent" | BL-8, BL-11 | E1 06:46:19.563, 06:46:20.636 | +| 06:46:23.87–26.25 | 657–659 | tile (Transparent): attempt 1 connects (#34); `Set` 8445 `40` → ACK +100 → stream +176/+218; then Play's connect (06:46:26.112) closes the claim in its linger; "Adaptive" | BL-8, BL-11 | E1 06:46:26.250 "… closed" | +| 06:46:26.98–28.55 | 660–662 | tile (Adaptive): attempt 1 connects (#35); `Set` 8527 `20` → ACK +129 → stream +195/+260; Play's retry 2.37 s after its refusal closes the claim (06:46:28.479; "Retry times remaining 4"); "Off" | BL-8, BL-11 | E1 06:46:28.549; L 06:46:28.483 | +| 06:46:33.0 | — | Play's claim #36 (57.5 s) | — | H1 8590 | +| 06:47:00–02 | 693–696 | Home and back to the app; the ANC tab: Off | — | E1 06:47:00.722 "observer stopped" / 06:47:01.681 "started" | +| 06:47:07.11, 06:47:18.66 | 701, 712 | two pushes of the **same** mode (Off, one packet each, ch 21) — E1 logs "(set_by_app)" (the kept cause, FINDINGS §4); the (i) "OFF (updated 06:47:07)" / "(updated 06:47:18)", no cause line | (BL-9?) | H1 8739, 8774; claim #36 `Notify` 8738, 8773 | +| 06:47:30.43–35.20 | 724–734 | the Buds close DLCI 0x02 (and Play's DLCI 0x04); AFTER_LOSS 1.50 s later, S8 ch 19: subscription 8959, `4:13` 8960 → 8968 `4:{13:1}` (read, the same); (i) "OFF (updated 06:47:35)" | BL-9, `PAIR-003` | H1 8827/8828; E1 06:47:30.447, 06:47:31.951, 06:47:35.152 | +| 06:47:44.72–49.51 | 738–748 | the Buds close DLCI 0x02 again; AFTER_LOSS 1.50 s later, S9 ch 21: subscription 9213, `4:13` 9214 → 9220 `4:{13:1}`; (i) "OFF (updated 06:47:49)" | BL-9, `PAIR-003` | H1 9077/9080; E1 06:47:44.722, 06:47:46.226 | +| 06:47:50.2 | — | Play's claim #40 (open to the log's end) — receives the 12 holds' `Notify` | — | H1 9250 | +| 06:48:44.23–06:49:33.02 | 798–847 | **12 press-and-holds on the ANC tab** (Adaptive, Transparency, NC, Off ×3), two packets each (9492/9493 … 9717/9720); **12 of 12 on screen within 0.1 s** (06:48:44.3, 46.6, 52.0, 57.1, 06:49:01.8, 06.5, 11.0, 14.6 under a volume panel, 21.2, 25.2, 29.6, 33.1); no app `SABM` | **BL-12** (12 instead of 5), `TOUCH-007` | E1 24 lines 06:48:44.234 … 06:49:33.091 | +| 06:49:44–50:11 | 858–885 | Sound: Upper treble 0 → 6.0; `WriteSetting 4:{16:…}` 9854 → RESPONSE 9860 OK; the stream's mirror 9856 (field 16); (i) "EQ updated: 06:50:04" | (BL-13 not run: no second phone) | E1 (no "Setting written" line for the EQ — it is the EQ path) | +| 06:50:21.80 | 895–905 | Controls: **In-ear detection off**: `4:{2:0}` 9925 → 9934 OK; mirror 9932; (i) "In-ear detection: changed 06:50:22" | `INEAR-001` | E1 06:50:22.351 "Setting 2 written (channel 21)" | +| 06:50:54.86 | 928–929 | In-ear detection on: `4:{2:1}` 10037 → 10042 OK; mirror 10040 | `INEAR-001` | H1 | +| 06:51:03–14 | 937–948 | Debug ("Unidentified frames (119)") → Export: "Debug log saved (1062 lines)." | BL-end1 | film | +| 06:51:19–42 | 953–976 | App info ("Rechten: Apparaten in de buurt, Meldingen en Sensors") → View logs → Save (A1); Show system log → Save (the System log) | BL-end1, P6 | film | +| 06:51:45–47 | 979–980 | the notification "Connected — ANC: OFF"; the film ends | — | film | + +### Part 1 — after the recording + +| Time | What | Evidence | +|---|---|---| +| 06:52:14.9 | AVRCP "Playing", AVDTP START (playback after the film) | H1 10255, 10257 | +| 06:54:29 | H1 ends with the ACL open; L ends 06:56:48 | `capinfos` | + +### Part 2 — before the recording (the secondary user without Google Play) + +| Time | What | Step / Test-ID | Evidence | +|---|---|---|---| +| 07:01:02.6–05.4 | 1.3.0 starts in user 10 (PID 18179); Bluetooth was off in this user and turns on | — | A2 `service_manager_slow` 05:01:02.560 UTC; E2 07:01:03.577 "Permissions (start)", 07:01:05.074 "Bluetooth adapter: OFF -> TURNING_ON", 07:01:05.355 "-> ON"; H2L starts 07:01:04.767 | +| 07:01:07.7–09.0 | a 1.3-s ACL without RFCOMM | — | H2L 311 → 489 (`0x13`) | +| 07:01:11.0–13.8 | the CDM pairing picker opened and cancelled (not on film) | — | E2 07:01:11.036 "Pairing: association requested", 07:01:13.845 "association failed (canceled)" | +| 07:01:13.8–15.9 | the ACL (605); LINK_BACK re-open: S10 ch 19 — subscription 848, `4:13` 849 → 853 `4:{13:0}` ("no mode settable", both buds in the case) | BL-14 (part), `PAIR-003` | E2 07:01:14.279, 07:01:15.114, 07:01:15.156 | +| 07:02:39.1–39.6 | **Bluetooth off** (the film's s8); "Session loss cause: Bluetooth was switched off on this phone" | **BL-20** | E2 07:02:39.124, 07:02:39.442; H2L 1294 (`0x16`) | + +### Part 2 — film 2 (07:02:31.5 – 07:08:20.4), the secondary user without Google Play + +| Time | s | What happened | Step / Test-ID | Evidence | +|---|---|---|---|---| +| 07:02:31.5 | 0–7 | shade, Quick Settings, the Bluetooth panel (the Buds "Actief. Batterijniveau 100%") | — | film | +| 07:02:39.5–44.5 | 8–13 | **Bluetooth off, then on** | **BL-20** | E2 07:02:44.391 "OFF -> TURNING_ON"; H2 starts 07:02:44.098 | +| 07:02:45.9–47.9 | 14–19 | the ACL (151); **LINK_BACK re-open 0.27 s after Android's link**: S11 ch 19 — subscription 540, `4:13` 541 → 546 `4:{13:0}` (no mode settable) — **the subscription again after the Bluetooth off (M1 on hardware)**; the app "ready", both buds in the case | **BL-20**, `PAIR-003`, `BATT-004` | E2 07:02:46.158, 07:02:47.053 "Settings stream subscribed (channel 19)" | +| 07:03:00.36 | 29 | the Buds close DLCI 0x02 (the Right bud out of the case: runtime info 07:03:04 Left charging, Right not) | BL-19 (a re-home), `CASE-005` | H2 641; E2 07:03:00.362 | +| 07:03:01.87–05.20 | 31–32 | AFTER_LOSS re-open 1.50 s later, S12 on **ch 21**; **a stream packet `4:{13:1}` at 07:03:02.647 (717) — before this session's subscription** (the ch-19 subscription of 07:02:47 still reports, now on ch 21) → E2 "from the settings stream (**read**)" (the first mode ever shown in this process); subscription 790, `4:13` 790 → 794 `4:{13:1}` "(read)"; "Probably worn (checked 07:03:03)" | BL-19, `PAIR-003` | E2 07:03:02.652, 07:03:04.511, 07:03:04.692 | +| 07:03:07.14 | 36 | the same mode again (Off, one packet, ch 21) — "(read)" (the kept cause); the Left bud out of the case at 07:03:14 | `CASE-004` | H2 839; Battery (i) s44 "not charging (07:03:14)" | +| 07:03:10–57 | 39–86 | Battery (i); the ANC tab (Off) and its (i) "OFF (updated 07:03:07)", no cause line, the stream sentence; Sound; Controls; Settings: **no Play card**; Debug "Unidentified frames (17)"; Info "App: 1.3.0, build ff27ee1 (2026-10-14)", firmware 07:03:02, channel 21, serials 07:03:04 | **BL-14**, `FW-003` | film | +| 07:04:12.26 | 101 | **Disconnect** tap | BL-22 | E2 07:04:12.264; H2 1017 | +| 07:04:15.35–17.43 | 104–107 | **Connect**: S13 ch 21 — subscription 1131, `4:13` 1132 → 1137 `4:{13:1}` (the same as before: "(read)", no cause line) | BL-22 (done differently: no hold while disconnected), `PAIR-003`, `BATT-004` | E2 07:04:15.346, 07:04:16.641 | +| 07:04:32.0–33.9 | 121–122 | **Refresh battery**: `SubscribeRuntimeInfo` again (1205) and a fresh claim; "Probably worn (checked 07:04:32)" | — | E2 07:04:32.023 "Runtime info re-requested on Refresh" | +| 07:04:49.7–51.3 | 139–144 | **tap Noise cancellation** on the ANC tab: claim at attempt 1 (no Play); `Set` 1372 `08` 07:04:49.772 → ACK +25 ms → stream 1381/1384 +60/+85 ms → "(set_by_app)" ×2; on screen at 07:04:49.8; (i) "ACTIVE (updated 07:04:49)", **no cause line** | **BL-17**, `ANC-002` | E2 07:04:49.699, 07:04:49.836/.861 | +| 07:04:57.4–59.1 | 145–149 | tap Adaptive: `Set` 1470 `40` → ACK +77 → stream +81/+121; (i) no cause line | BL-17, `ANC-003` | E2 07:04:57.607/.646 | +| 07:05:04.8–06.4 | 152–156 | tap Transparency: `Set` 1558 `80` → ACK +50 → stream +55/+93; (i) no cause line | BL-17, `ANC-004` | E2 07:05:04.927/.966 | +| 07:05:13.6–15.3 | 162–168 | tap Off: `Set` 1650 `20` → ACK +82 → stream +94/+125; (i) no cause line | BL-17, `ANC-001` | E2 07:05:13.842/.868 | +| 07:05:24.11–07:06:38.28 | 172–247 | **12 press-and-holds on the ANC tab** (Adaptive, Transparency, NC, Off ×3), two packets each (1726/1729 … 2014/2017); **12 of 12 on screen within 0.1 s** (07:05:24.1 under a volume panel, 28.7, 35.3, 41.5, 47.0, 51.6, 56.2, 07:06:03.2, 13.1 under a volume panel, 22.1, 31.3, 38.3); the (i) five times: "Changed by the Buds at 07:06:03 / 07:06:13 / 07:06:22 / 07:06:31 / 07:06:38: …"; no claim, no Play | **BL-16** (12 instead of 10), `TOUCH-007` | E2 24 lines "(changed_by_buds)" 07:05:24.113 … 07:06:38.320 | +| 07:06:44–49 | 253–259 | Sound: Upper treble 6.0 → 1.7; `WriteSetting 4:{16:…}` 2018 → 2021 OK; mirror 2020 (1.69) | — | H2 | +| 07:06:56.25 | 265–267 | Controls: **In-ear detection off** (left off): `4:{2:0}` 2081 → 2088 OK; mirror 2087; (i) "changed 07:06:56" | `INEAR-001` | E2 07:06:56.363 "Setting 2 written (channel 21)" | +| 07:07:15–19 | 284–288 | the Find tab and its (i): "Each action briefly claims the Buds' Message Stream channel. …" | — | film | +| 07:07:25.8–27.6 | 293–295 | **Ring Left**: claim #9, `04 01 00 01 02` → ACK `ff 01 00 03 04 01 00` and the Buds' `04 01 00 01 02`; "Ringing: Left earbud — tap Stop to end it." | `FIND-001` | E2 07:07:25.925 … 07:07:27.559 | +| 07:07:32.3–34.0 | 301 | **Stop**: claim #10, `04 01 00 01 00` → ACK; the ringing line gone | `FIND-001` | E2 07:07:32.344 … 07:07:33.969 | +| 07:07:38–44 | 307–313 | Debug → Export: "Debug log saved (462 lines)." | BL-end2 | film | +| 07:07:48–57 | 317–326 | Debug, Info (serials) | — | film | +| 07:08:00–16 | 329–345 | App info → View logs → Save (A2); the log view has no "Show system log" in this user | BL-end2 | film | +| 07:08:19–20 | 348 | the notification "Connected — ANC: OFF"; the film ends | — | film | + +### Part 2 — after the recording + +| Time | What | Evidence | +|---|---|---| +| 07:08:21.6 | AVRCP "Playing" | H2 2633 | +| 07:08:33.44 | the ACL ends (`0x13`) — the session lost while the app was in the background (no export after 07:07:33) | H2 2666 | +| 07:10:20.4 | a new ACL; the Buds open RFCOMM (DLCI 0x00/0x08), no OpenControl session | H2 2678, 2877, 2884 | + +## The maintainer's steps, as run (mapped to BL-1 … BL-25) + +| Step | Done? | How (evidence) | +|---|---|---| +| P0 | partly | the package lists of both users with exit statuses (shell log); not on film: Play's *Nearby devices* state and version — read by `ai-sessions/0092` at 07:53 instead (CAP-076-adb-checks-0092.log) | +| P1 | yes, differently | the 1.2.2 Settings card and Info on film (s59–73); both `dumpsys` saved; **the install on film** (s92–93); Debug mode was already on; the APK pulled by `ai-sessions/0092` (07:52) — SHA-256 = the tested build's | +| P2 | yes | Bluetooth off (s8) and on (s44) on film; H1L → H1 | +| P3 (camera) | **no** | no camera films; the holds are the maintainer's statement plus the wire (stream packets without an app `Set`, no AVRCP) | +| P4 | part 1 yes; part 2 screen only | film 2 has no audio stream | +| P5 | yes | minute flips in both films | +| P6 | part 1 yes | the System log saved at s975; user 10 offers no system log | +| P7 | yes | "Modi: Niet storen is actief" (s955) | +| P8 | differently | the buds came out of the case during the run (CASE-004/005), not worn before BL-4 | +| P9 | yes | the app opened first on film (s17) | +| P10 | no | no second phone | +| BL-1 | yes, differently | the update on film; the LINK_BACK re-open at the 1.3.0 start (S1), the seventeen requests | +| BL-2 | yes | Info and Settings of 1.3.0 on film (s100–112) | +| BL-3 | yes | Disconnect 06:39:41, Connect 06:39:44 (S2) | +| BL-4 | differently | 13 changes on the Connection tab 06:40:47–06:41:51, mixed with wear changes (AVRCP), during podcast playback; that tab shows no mode | +| BL-5 | yes (17 holds) | 17 of 17 within 0.1 s | +| BL-6 | yes | the (i) at 06:44:22 with "Changed by the Buds at 06:44:11 …" | +| BL-7 | yes | four taps (Adaptive, Transparency, NC ×2), each collided at attempt 1, each succeeded at attempt 2 | +| BL-8 | yes (9 tile taps) | the tile added on film, 9 taps | +| BL-9 | yes (several) | the Buds' `DISC`s at 06:39:53, 06:41:31, 06:42:53, 06:42:57, 06:47:30, 06:47:44 and their re-opens; three wear-driven switches | +| BL-10 | **no** | no Bluetooth off/on with 1.3.0 in part 1 (the one on film was under 1.2.2, before the session) | +| BL-11 | yes | 13 taps with Play present, in 12 claims: 8 claims (9 taps) refused at attempt 1 and done at attempt 2, 2 done at attempt 1 and cut in their linger, 1 cut before the `Get`'s answer (toast), 1 cut after it (silent) | +| BL-12 | yes (12 holds) | 12 of 12 within 0.1 s | +| BL-13 | no | no second phone | +| BL-end1 | yes | export, app logcat, system log | +| BL-14 | yes | S10/S11/S12; Info, Settings | +| BL-15 | **no** | no holds on the Connection tab in part 2 | +| BL-16 | yes (12 holds) | 12 of 12 within 0.1 s | +| BL-17 | yes (4 taps) | each at attempt 1 | +| BL-18 | **no** | no tile in part 2 | +| BL-19 | yes, differently | the Buds' `DISC` 07:03:00 and the re-open (a bud out of the case, not out of an ear) | +| BL-20 | yes | Bluetooth off/on 07:02:39/44, the re-open subscribes again (S11) | +| BL-21 | **no** | | +| BL-22 | differently | Disconnect / Connect 07:04:12/15 without holds in between — the read equal, no line expected | +| BL-23 | in part 1 | both buds into the case 06:42:11 (`4:{13:0}`), the ACL drop, the Connect with "no mode settable" | +| BL-24 | **no** | | +| BL-25 | no | no second phone | +| BL-end2 | yes | export, app logcat; no system log in user 10 | + +## Test-IDs (`AGENTS.md` §13 step 7; `TESTPLAN_BLUETOOTH_HCI_SNOOP.md`) + +| Test-ID | Where in the timeline | Result | +|---|---|---| +| `PAIR-003` | S1–S9 (part 1), S10–S13 (part 2): Connects, LINK_BACK and AFTER_LOSS re-opens | every session opened; the subscription and the read in every full session (12 of 12) | +| `BATT-004` | every snapshot claim's `03 03` burst (e.g. H1 7128/7132); the runtime-info stream per session | L/R 100 %, Case 44 %/43 % on screen | +| `FW-003` | Info, part 1 s101–107; part 2 s81–86, s321 | `release_5.203` ×3, three serials | +| `ANC-001`…`ANC-004` | the 15 `Set`s (part 1 11, part 2 4) | all four modes; 15 ACKed, 0 NAK | +| `TOUCH-007` | part 1: 13 changes on the Connection tab, 17 + 12 holds on the ANC tab; part 2: 12 holds | 29 + 12 of 29 + 12 on screen within 0.1 s | +| `CASE-004` / `CASE-005` | 06:39:53 (the Left out), 06:40:10 (the Right out), 06:42:46–51 (out again); 07:03:00 (the Right out), 07:03:14 (the Left out) — from the runtime-info charging flags, not on film | the Buds' `DISC`s and re-opens | +| `INEAR-002` / `INEAR-003` | the Buds' AVRCP PLAY/PAUSE 06:40:46–06:41:41 | which ear: not identifiable | +| `INEAR-001` | 06:50:22 off / 06:50:55 on; 07:06:56 off | written, acknowledged, mirrored on the stream | +| `FIND-001` | 07:07:25 Ring Left, 07:07:32 Stop | ACKed | +| `PAIR-003` BL-10 (Bluetooth off/on in part 1), `TOUCH-007` BL-15/BL-21/BL-24 | — | **expected but not observed** (not run) | + +## Analysis checklist + +- [x] Every filter scoped to the Buds' ACL handle; DLCIs by content; every negative with its command, exit status and positive control (CAP-076-FINDINGS.md). +- [x] P0/P1: Play listed in user 0 only; the update with `firstInstallTime` unchanged; the Info hash `ff27ee1`; the pulled APK's SHA-256 = B1's. +- [x] The two new requests byte-identical, once per MAESTRO session: 12 subscriptions and 12 `ReadSetting 4:13` in 12 full 1.3.0 sessions; the 13th (S6) + was closed by the Buds before them; after `SubscribeRuntimeInfo`, before `GetHardwareInfo` (FINDINGS §2). +- [x] The counts for the verdict (FINDINGS §10). +- [x] BL-7 / BL-17: the own tap classed as the app's change; the stream 55–246 ms after the `Set`. +- [x] BL-9 / BL-19 / BL-23: the Buds' `DISC` → re-open → read in every case; one stream packet before a subscription (07:03:02.647); value 0 never shown as a mode. +- [x] BL-10 / BL-20: BL-20 yes (S11 subscribed again); BL-10 not run. +- [x] BL-11: 13 taps with Play present; nothing re-claimed by itself. +- [ ] BL-13 / BL-25: not run. +- [x] The system log: crashes, ANRs, StrictMode — FINDINGS §0. +- [x] One row per step; Test-IDs with "expected but not observed". +- [x] Registers, the ADR-065 Update and the verdict: Phase D of `ai-sessions/0092` (with the maintainer). + +## Appendix — the planned procedure (the skeleton of `ai-sessions/0091`, 2026-10-14, as written) + +**Status:** 🔲 **Not yet captured — skeleton only** (written by `ai-sessions/0089` for 1.2.2 on 2026-10-13; **rewritten by `ai-sessions/0091` for 1.3.0 on +2026-10-14** — 1.2.2 was released without this run, `TODO.md` §1). The hardware run of **1.3.0** (versionCode 10300; `DECISIONS.md` ADR-064 and ADR-065): at every +MAESTRO session (Connect and every ADR-044 re-open) the app sends one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` after `SubscribeRuntimeInfo`, so +the Buds push every change of the noise-control mode — a press-and-hold on a bud, their own switch, the app's own `Set` — as a `4:{13:n}` packet on the app's +own channel (DLCI 0x02), **whatever tab is shown and without a claim of the shared channel DLCI 0x04**; every other setting the app reads follows the same +stream. The hold of 1.2.0 (ADR-061), the re-claim with the setting "Noise control with Google Play services" (ADR-062) and the Play-services line with the +`` entry (ADR-063) are gone. **Two new requests on the wire, nothing else new**: the official app's bytes (`CAP-051` 1177/1178, `CAP-056` 1451/1450). +**Two parts, both GrapheneOS users, the extended tier** (`RELEASING.md` §11a): **part 1** the Owner user (user 0) with Play services — where 1.2.x showed a hold +about half of the time at best; **part 2** the secondary user (user 10) without Play. The maintainer films; a later CAPTURE session analyses it and gives the +release verdict (`RELEASING.md` C3/C4). After the run: rename this folder to the screen recordings' first/last phone times and the recordings to +CAP-076-recording1.mp4 / CAP-076-recording2.mp4; the camera films to CAP-076-camera1.mp4 / CAP-076-camera2.mp4. + +**Expected (from the official app's captures — `CAP-051-FINDINGS.md` §2–§3, `CAP-056-FINDINGS.md` §4, `CAP-038-FINDINGS.md` §3; `ai-sessions/0091` RESULT +§A.1):** the stream packet for a hold follows the Buds' `Notify` by 1–144 ms (`CAP-051` 1707 → 1717, 1754 → 1755, 1801 → 1802), also while no client holds the +Message Stream (`CAP-038` 2170, 2205); so **every hold shows on screen within a second, on any tab, in both users**, with "Changed by the Buds at HH:MM:SS" in +the noise-control (i). The app's own tap: `Set` → ACK → `Notify` → the stream packet 269 ms after the Set (`CAP-051` 1527 → 1534) — shown as the app's change. +A wear change the Buds answer with a `DISC` of DLCI 0x02 (12 of 77 in the captures, `DESKRESEARCH_FINDINGS.md` 2026-10-14): the ADR-044 re-open ≈ 1.5 s later +subscribes again and reads 13 — the mode on screen may be a few seconds old in between. + +**Not in this run** (say so if asked): `ReadSetting 4:8/9/10` (the maintainer's choice in chat 2026-10-14, "Not part of this run"); `WriteSetting 4:{13:n}` +(`TODO.md` §4 E-01, needs its own approval); the case-sound experiment. + +### Log Metadata + +| Field | Value | +|---|---| +| Capture ID | `CAP-076` | +| Group(s) | BL (new) | +| Date | TBD | +| Phone | Pixel 9a, GrapheneOS (Android 17) — **part 1: the Owner user (user 0) with Google Play services** (`CAP-074`/`CAP-075` set 2's user); **part 2: the secondary user (user 10) without Google Play** (`CAP-073`/`CAP-075` set 1's user) | +| App under test | OpenControl for Pixel Buds Pro 2 **1.3.0** (versionCode 10300), the release APK of `scripts/release.sh 1.3.0` (B1), kept as `~/opencontrol-1.3.0-tested` (B3) — **Info tab on film in each user**: "App: 1.3.0, build ()", no "-dirty"; APK SHA-256 and certificate SHA-256 from the script's output; **the installed APK pulled** (P1) | +| Previous version in each user | 1.2.2 build `bfd7891` (installed 2026-10-14 with Obtainium in the Owner user; the secondary user's version on film) — **the stored "Noise control with Google Play services" choice of 1.2.1/1.2.2 is ignored by 1.3.0** (ADR-065 item 5: never read, never migrated); the card is gone | +| Google Play services (part 1) | present and active (`com.google.android.gms`, uid 10205 in `CAP-074`/`CAP-075`); **its *Nearby devices* permission state and its version on film** (P0 — `TODO.md` §3's standing item, not done in `CAP-075`) | +| Official Pixel Buds app | not used on this phone; **optionally on a second phone** for BL-13 / BL-25 (the EQ or a switch changed there — the stream packet reaches OpenControl) — say if no second phone was available | +| Buds | Pixel Buds Pro 2, firmware `release_5.203` expected (any other ⇒ Safe Mode: say so, stop) — both buds **worn** for every hold step (Settable `e8`; `00` means no bud worn, ADR-049) | +| Video files | TBD — the screen recording **with sound** per part (say each action and each hold aloud, as in `CAP-075`) **and a camera on the ears and the phone** for the hold steps (the hold on the bud and the screen in one frame; no address overlay — `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §3) | +| Log files | TBD — the HCI snoop logs, the app's debug export per part (Debug mode on), the app logcat, the system log with the GrapheneOS log viewer at the end of each part, the P0/P1 outputs | + +### Preparation + +| # | Check | Done | +|---|---|---| +| P0 | **In each user**: `adb shell am get-current-user`; `adb shell pm list packages --user \| grep -i -E "gms\|vending"; echo "exit=$?"` (part 1: Play listed, exit 0; part 2: nothing, exit 1); positive control `grep opencontrol` (exit 0). **Part 1 on film:** Settings → Apps → Google Play services → Permissions → *Nearby devices* (allowed / not allowed) and the app's version line. Save the outputs **with the exit statuses** | ☐ | +| P1 | In the installed 1.2.2 (`bfd7891`): Debug mode **on** (on film); gear → Settings: the card "Noise control with Google Play services" and its selected option on film (what 1.3.0 will ignore). `adb shell dumpsys package io.github.tedsluis.opencontrolpixelbuds \| grep -E "firstInstallTime\|lastUpdateTime\|versionCode"` → save. `adb install -r ~/opencontrol-1.3.0-tested/opencontrol-pixelbudspro2-1.3.0.apk` (one APK serves both users) — **no uninstall**; the same `dumpsys` → save. After the run: `adb shell pm path --user io.github.tedsluis.opencontrolpixelbuds` → `adb pull` → `sha256sum` against `~/opencontrol-1.3.0-tested` | ☐ | +| P2 | HCI snoop log on (set in the Owner; it stays on across the user switch) — **Bluetooth off and on on film** after enabling it | ☐ | +| P3 | The camera on a stand: the ears and the phone screen in one frame for BL-5 … BL-8 and BL-18 … BL-21; the phone's clock with seconds visible, or the status bar at a minute change at the start and the end | ☐ | +| P4 | Android's **screen recorder with sound** on for each part | ☐ | +| P5 | The status bar across a minute change at the start and the end of each recording | ☐ | +| P6 | **The system log at the end of each part** (the GrapheneOS log viewer, as `CAP-075`'s L) — crashes, ANRs, `StrictMode`; in part 1 Play's `RfcommEventStreamMedium` lines around the taps of BL-10/BL-11. `adb bugreportz` sends signal 3 to every Java process — not an app fault (`CAP-071-FINDINGS.md` §0) | ☐ | +| P7 | **Do Not Disturb on** | ☐ | +| P8 | Buds charged, **both worn** before BL-4; Android shows them connected before the app is opened | ☐ | +| P9 | **No app use before the film** — not opened between P1's install and BL-1 | ☐ | +| P10 | Optional: a second phone with the official Pixel Buds app paired to the same Buds (multipoint) for BL-13 / BL-25 — say on film if there is none | ☐ | + +**Rhythm:** one action, then 5–10 s; say what you do ("left hold", "right hold", "tap", "tile", "bud out", "Bluetooth off"). Export the debug log at the end +of each part. Serials: never unredacted in any note (first 4 + last 2 characters). + +### Steps + +DLCIs are session-local (MAESTRO 0x02 or 0x03, Message Stream 0x04 or 0x05). Every MAESTRO request starts `7e 00 3b 03 10 13 1d ea 71 de 7d 5e 25` on +**channel 19** or `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25` on **channel 21** (Info → "Control channel"). **Reference frames** — the official app's bytes, +re-derived by `ai-sessions/0091` with `tshark -r -Y 'frame.number==N' -T fields -E separator='|' -e frame.number -e frame.time -e frame.p2p_dir +-e bthci_acl.chandle -e btrfcomm.dlci -e data.data` (exit 0) after scoping to the Buds' ACL handle, decoded with `python3 scripts/pwrpc_decode.py ` +(a payload holding several HDLC frames split on `7e`, each CRC-32 verified): + +| Request / event | Reference (whole pw_hdlc frame incl. CRC) | +|---|---| +| The Connect: `SABM` DLCI 0x02, then `ReadSetting 4:16`, 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29, `SubscribeRuntimeInfo`, **`SubscribeToSettingsChanges`, `ReadSetting 4:13`**, `GetHardwareInfo` — **fifteen MAESTRO requests** | `CAP-075` H2 1193 … 1302 (channel 19) / H1 762 … 841 (channel 21) for the thirteen of 1.2.x (`CAP-075-FINDINGS.md` §2), then the two new ones below, then `GetHardwareInfo` (`7e003b0310131dea71de7d5e25e3a5ec28ff1ebbb77e` on 19, `7e004b0310151dea71de7d5e25e3a5ec28f96761b57e` on 21) | +| **`SubscribeToSettingsChanges` REQUEST** (method `0x2821adf5`, empty payload) | channel 21: `CAP-051` 1177 — `7e004b0310151dea71de7d5e25f5ad2128019754b07e`; channel 19: `CAP-056` 1451 / `CAP-051` 2229 — `7e003b0310131dea71de7d5e25f5ad212807ee8eb27e`. Find them: `tshark -r CAP-076-btsnoop_hci1.log -Y 'btrfcomm.dlci==2 \|\| btrfcomm.dlci==3' -T fields -e frame.number -e frame.time -e frame.p2p_dir -e data.data \| grep -i "25f5ad2128019754b0\\\|25f5ad212807ee8eb2"` — **one per MAESTRO session, direction 0 (sent)** | +| **`ReadSetting 4:13` REQUEST** | channel 21: `CAP-051` 1178 / `CAP-036` 1517 — `7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e`; channel 19: `CAP-056` 1450 / `CAP-021` 1069 — `7e003b0310131dea71de7d5e2551aed0ae2a02200d24c239477e`. Find: `… \| grep -i "2a02200d"` — one per MAESTRO session, right after the subscription | +| **Its RESPONSE `4:{13:n}`** (type 1; the first read's value after every Connect/re-open) | channel 19: `7e80a3032a04220268 0n 080110131dea71de7d5e2551aed0ae` + CRC — n = 0 `…c6b9d8857e` (`CAP-051` 2247, after the Buds' own `DISC`), 1 `…2e62233c7e` (`CAP-021` 1089), 2 `…57085e2d7e` (`CAP-056` 1468), 3 `…bfd3a5947e` (`CAP-027` 923); channel 21: n = 0 `7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e` (`CAP-045` 2033), 4 `…067d5e749e7e` (`CAP-051` 1225). 1 = Off, 2 = Noise cancellation, 3 = Transparency, 4 = Adaptive, **0 = no mode settable** (no bud worn) | +| **The stream packet `4:{13:n}`** (type 7 `SERVER_STREAM`, direction 1, the Buds → the phone) — **one per hold, per own tap and per wear-driven switch** | channel 19: `7e80a3032a04220268 0n 080710131dea71de7d5e25f5ad2128` + CRC — n = 1 `…117bf5557e` (`CAP-021` 1842), 2 `…681188447e` (`CAP-038` 2205, `CAP-056` 2324/4028), 3 `…80ca73fd7e` (`CAP-038` 2170), 4 `…9ac572667e` (`CAP-056` 2259/4025); channel 21: 0 `7e00a5032a0422026800080710151dea71de7d5e25f5ad21285a02de7d5d7e` (`CAP-045` 2277), 2 `…cbb358d57e` (`CAP-051` 1717), 3 `…2368a36c7e` (1802/1534), 4 `…3967a2f77e` (1755). Find: `… \| grep -i "0807101[35]1dea71de7d5e25f5ad2128"`; count them against the holds made | +| A stream packet for another setting (BL-13 / BL-25, the official app on a second phone) | field 16 (the EQ): `CAP-015` 2116 `7e80a3032a1e221c8201190d…080710131dea71de7d5e25f5ad2128…`; field 2: `CAP-056` 2175 `7e80a3032a0422021001080710131dea71de7d5e25f5ad2128475780f97e`; field 12: `CAP-056` 1696; field 29: `CAP-069` 2502 — the value on screen with the packet's time ("read HH:MM:SS" in the card's (i)) | +| The app's own tap (unchanged, ADR-032) | `SABM` DLCI 0x04 (or 0x05), `08 11 00 00`, `Notify`, `08 12 00 14 01 e8 e8 …`, ACK `ff 01 00 06 08 12 01 e8 e8 `, then the Buds' `Notify` and **the stream packet of that mode on DLCI 0x02** (`CAP-051` 1527 → 1529 → 1530/1533 → 1534: 269 ms); the phone's `DISC` of DLCI 0x04 ≈ 1.5 s after the answer (`MESSAGE_STREAM_LINGER_MS`) | +| Play's Fast Pair claim (part 1) | `SABM` DLCI 0x04, first message `03 08 00 02 01 25` (`CAP-075-FINDINGS.md` §1) — its presence is expected and changes nothing for the stream | +| The Buds' own `DISC` on a wear change (BL-9 / BL-22) | `DISC` DLCI 0x02 from the Buds (`CAP-051` 1996, 5 ms after the Notify 1993) → the app's loss line → ADR-044's re-open ≈ 1.5 s later: `SABM` DLCI 0x02, the fifteen requests, the subscription and the read again (`CAP-051` 2229/2231 → 2247) | + +Anything else from the app on the wire (a request not in this table or in `CAP-075` §2/§3, a second `SubscribeToSettingsChanges` or `ReadSetting 4:13` within one +session, a `WriteSetting 4:{13:n}`, anything on DLCI 0x08/0x0a, a `SABM` on DLCI 0x04 that is not a tap, the tile, Refresh, a pull or the Connect's snapshot) is +a finding. + +#### Part 1 — the Owner user (user 0) **with** Google Play services (screen recording with sound + the camera; `APP_TESTPLAN.md` Y1–Y13; `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`…`ANC-004`, `TOUCH-007`) + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BL-1 | P0–P10 done, user 0, both buds worn | open the app | the session opens by itself (ADR-044); "Probably worn"; Left/Right %, the Case; the mode | DLCI 0x02 `SABM`; the **fifteen** requests in order — the subscription and `ReadSetting 4:13` after `SubscribeRuntimeInfo`, `GetHardwareInfo` last — answered; the snapshot claim (one `SABM` DLCI 0x04, `08 11` → `Notify`, `03 03` burst, released ≈ 1.5 s later) | `PAIR-003`, `BATT-004` | a crash; a request missing, doubled or out of order; Safe Mode | +| BL-2 | ready | gear → **Info**: 3 s on the build line, the firmware, the serials; **Settings**: 3 s on the whole tab | "App: 1.3.0, build ", no "-dirty"; `release_5.203` ×3; Settings shows **Dark mode, Case sounds, Use different Buds — no "Noise control with Google Play services" card, no "Google Play services: …" line** | nothing | `FW-003` | "-dirty"; another hash; the card or the line still there | +| BL-3 | back on Connection | **Disconnect**, wait 5 s, **Connect** | ready | the phone's `DISC` of DLCI 0x02; the fifteen requests again (the subscription and the read once more — one per session); the snapshot claim | `PAIR-003` | the subscription missing after the Connect | +| BL-4 | ready, stay on the **Connection** tab, both buds worn, the camera on the ears | **press and hold a bud 10 times, ≈ 6 s apart** (alternate left/right); say each; **touch nothing on the phone**; count aloud the mode changes you see on the Connection tab's noise-control line (if any) — then open the **ANC** tab once and read the mode | the mode in the app follows **every** hold within a second (the tab need not be open: the notification's mode line and, on opening ANC, the current mode) | per hold: **one stream packet `4:{13:n}` on DLCI 0x02** (direction 1), no `Notify` (no claim), **no `SABM` on DLCI 0x04 from the app**; Play's own claims may come and go | `TOUCH-007`, `ANC-001`…`004` | a hold without a stream packet; an app `SABM` on DLCI 0x04 during the holds; a mode shown that differs from the last packet's | +| **BL-5** | the **ANC** tab open, both buds worn, the camera on the ears | **press and hold a bud 10 times, ≈ 6 s apart**; say each; count aloud the holds you see change on screen | **10 of 10** holds change the mode on screen within a second; the (i) after one of them: **"Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves."**; the (i)'s last sentence "The Buds report every change of the mode on the app's own channel. …" | one stream packet per hold; **no app `SABM` on DLCI 0x04** (the ANC tab claims nothing by itself since 1.3.0) | `TOUCH-007`, `ANC-001`…`004` | a hold not shown; an app `SABM`; a hint or "Take it back again" anywhere | +| BL-6 | the ANC tab | open the ANC **(i)** right after a hold, read it, close | the mode with its time; the "Changed by the Buds at …" line; "Connection: Ready"; the last sentence with "that part is a hypothesis" | nothing | `TOUCH-007` | the old hold sentence; the channel-taken line | +| BL-7 | the ANC tab | **tap a mode** (one tap); wait 5 s; open the (i) | the tapped mode at once; the (i) without a "Changed by the Buds" line (the app's own change) | one `SABM` DLCI 0x04 + `08 11` + `08 12` + ACK; the Buds' `Notify`; **the stream packet of the tapped mode on DLCI 0x02 within ≈ 0.3 s of the `Set`**; the phone's `DISC` ≈ 1.5 s after the ACK; if Play is on the channel at that moment: attempt 1 refused, attempt 2 connects (`CAP-075` §5) — or the tap fails with its message: tap again, say so | `ANC-001`…`004` | the stream packet of the own Set shown as "Changed by the Buds"; a `DISC` later than 2 s after the ACK | +| BL-8 | the ANC tab | pull **Quick Settings** down; tap the **ANC tile** once; close; look at the app | the tile steps one mode; the app follows at once | as BL-7: one claim for the tile's `Set`; the stream packet; the `DISC` ≈ 1.5 s later | `ANC-001`…`004` | a second `SABM`; the app not following | +| **BL-9** | the ANC tab, **both buds worn**, the camera on the ears | **take one bud out** (say which); wait 10 s; **put it back**; wait 10 s; read the mode after each | if the Buds close the session (the loss line "… the Buds closed …", ready again ≈ 1.5 s later by itself): after the re-open the mode read (`ReadSetting 4:13`'s answer) — the (i) says **"Changed since the app last saw it (read at HH:MM:SS): …"** if it differs from the mode before, nothing if it is the same; if the Buds keep the session: the stream packet of their own switch, "Changed by the Buds" (the stored mode per wear state, 🟡 `PROTOCOL.md` §6) | either the Buds' `DISC` of DLCI 0x02 → the re-open's `SABM`, the fifteen requests, **the subscription and the read again** (the first read's value after the re-open), or a stream packet `4:{13:n}` without a `DISC` — say which happened | `PAIR-003`, `TOUCH-007` | a re-open without the subscription; a changed read without the line; the app sending anything on reaction to the packet | +| BL-10 | ready | **Bluetooth off** (Quick Settings); wait 5 s; **Bluetooth on**; wait 15 s | "Bluetooth was switched off" as the loss cause; ready again by itself once Android reconnects the Buds | the loss; the re-open's `SABM` DLCI 0x02, the fifteen requests — **the subscription again** (M1 on hardware) | `PAIR-003` | no subscription after the re-open | +| BL-11 | ready, the ANC tab | **tap a mode** while Play's claim is likely open (any moment — Play holds the channel most of the time in this user); if the tap fails ("… could not claim …"), wait 5 s and tap again | the mode after at most two taps; nothing else; **no hint, no "Take it back again"** | one claim per tap; a refused attempt 1 with Play's `DISC` is the `CAP-075` §5 collision — write down how many taps failed; **the app never re-claims by itself** | `ANC-001`…`004` | an app `SABM` not caused by a tap/tile/Refresh/pull/Connect | +| BL-12 | the ANC tab | hold a bud **5 more times** ≈ 6 s apart | 5 of 5 shown | 5 stream packets; no app `SABM` | `TOUCH-007` | a hold not shown | +| BL-13 (optional, P10) | the Sound tab | on the **second phone**, in the official Pixel Buds app: move one EQ slider, then switch **In-ear detection** off and on (Controls); watch OpenControl | the equalizer follows within a second ("read HH:MM:SS" in its (i), not "changed by this app"); the switch follows | the stream packets for field 16 and field 2 on OpenControl's DLCI 0x02 (the official app writes on its own session) | — | a value not following; a write from OpenControl in reaction | +| BL-end1 | — | Debug → **Export**; the system log; the status bar across a minute change; stop the recordings; **switch to user 10** | "Debug log saved (N lines)." | — | — | an empty file | + +#### Part 2 — the secondary user (user 10) **without** Google Play (screen recording with sound + the camera; `APP_TESTPLAN.md` Y14–Y22) + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BL-14 | user 10, P0 (no Play), both buds worn, the recordings on | open the app; **Info** 3 s; **Settings** 3 s | the session opens by itself; "App: 1.3.0, build "; no Play card, no Play line | the Connect sequence with the **fifteen** requests (the subscription and the read); the snapshot claim released ≈ 1.5 s after its answer | `PAIR-003`, `FW-003`, `BATT-004` | Safe Mode; "-dirty"; the card | +| BL-15 | ready, the **Connection** tab | **hold a bud 10 times** ≈ 6 s apart; say each; then open ANC | every hold followed (the mode line / the notification; ANC shows the last) | 10 stream packets; **no app `SABM`** on DLCI 0x04; 0 Play claims (positive control: part 1's count) | `TOUCH-007`, `ANC-001`…`004` | a hold without a packet; an app `SABM` | +| **BL-16** | the **ANC** tab, both worn | **hold a bud 10 times** ≈ 6 s apart | 10 of 10 shown within a second; "Changed by the Buds at …" in the (i) | 10 stream packets; no app `SABM`; **no `DISC` of DLCI 0x04 while the tab is shown — because there is no claim to cut** | `TOUCH-007`, `ANC-001`…`004` | a hold not shown; an app `SABM` | +| BL-17 | the ANC tab | **tap a mode**; wait 5 s; the (i) | the mode; no "Changed by the Buds" line | one claim: `08 11`, `08 12`, ACK, the `Notify`, the stream packet ≈ 0.3 s after the Set, the `DISC` ≈ 1.5 s after the ACK | `ANC-001`…`004` | the own Set shown as the Buds' change | +| BL-18 | the ANC tab | the **ANC tile** once; close; the app | the tile steps one mode; the app follows | as BL-17 | `ANC-001`…`004` | — | +| BL-19 | the ANC tab, both worn | **one bud out**; wait 10 s; **back in**; wait 10 s | as BL-9 (the re-open's read with "Changed since the app last saw it" if it differs, or the Buds' own switch as "Changed by the Buds") | as BL-9 | `PAIR-003`, `TOUCH-007` | as BL-9 | +| BL-20 | ready | **Bluetooth off**; 5 s; **on**; wait 15 s | the loss named; ready again by itself | the re-open's fifteen requests — the subscription again | `PAIR-003` | no subscription after the re-open | +| BL-21 | the Controls tab | hold a bud 5 times ≈ 6 s apart; then ANC | the last mode on ANC; (i) "Changed by the Buds" | 5 packets, no app `SABM` | `TOUCH-007` | — | +| BL-22 | ready | **Disconnect**; wait 5 s; hold a bud twice (no session); **Connect**; the ANC tab, the (i) | after the Connect the mode read; the (i) **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected."** | the phone's `DISC`; nothing while disconnected; the Connect's fifteen requests; the read's answer with the new mode | `PAIR-003`, `TOUCH-007` | the line missing after a differing read; the line after a read of the same mode | +| BL-23 | the ANC tab | both buds **into the case**; wait 15 s; **out into the ears**; wait 15 s | the session ends with its cause (or the Buds report "no mode settable": the mode kept, "The Buds don't allow changing noise control right now …"); ready again by itself; the mode read | the loss (or a stream packet `4:{13:0}` — `CAP-045` 2277 — and no loss); the re-open's requests; the read's answer (0 while in the case, 1–4 once worn) | `PAIR-003` | a value 0 shown as a mode | +| BL-24 (optional) | the ANC tab | **Home**; wait 10 s; hold a bud twice; return | on return the holds' result shown (the stream arrived while hidden — the session stays open in the foreground service) | 2 packets while hidden; no `SABM` | `TOUCH-007` | — | +| BL-25 (optional, P10) | the Sound tab | the second phone's official app: an EQ slider; a switch | OpenControl follows | the stream packets for 16 / the switch's field | — | — | +| BL-end2 | — | Debug → **Export**; the system log; the status bar across a minute change; stop the recordings; P1's APK pull | "Debug log saved (N lines)." | — | — | — | + +### Don'ts + +- Do not uninstall the installed 1.2.2 and do not clear the app's data (P1 — the run is an update; the ignored 1.2.x choice must still be in the file). +- Do not open the app before the film (P9). +- In BL-4, BL-5, BL-12, BL-15, BL-16 and BL-21 touch nothing on the phone while holding the buds; say each hold; keep the ears on camera. +- Do not tap two things within 5 s of each other. +- Do not write a serial number unredacted anywhere in this folder (first 4 + last 2 characters); no Wi-Fi name, phone number, account name or e-mail in any + committed text (the system log is committed as an extract, as `CAP-075`); the camera films carry no address overlay. + +### After the run + +Into this folder: the screen recordings and the camera films, the system logs (as extracts — the full logs and the originals to +`~/opencontrol-capture-originals/CAP-076/`), every debug export, the app logcats if saved, the HCI snoop logs, the P0/P1 outputs with their exit statuses, the +pulled APK's `sha256sum`, `dist/1.3.0/`'s `.sha256` and the script's certificate line; then `sha256sum *` into CAP-076-sha256sum.txt. Commit on the release +branch after the build commit (`RELEASING.md` C3) — never on `main`. + +### Analysis checklist + +- [ ] Every filter scoped to the Buds' ACL handle (the Connection Complete for the Buds' address with that handle shown); DLCIs by content; a negative with its + command, its exit status and a positive control (`AGENTS.md` §13 step 8). +- [ ] P0 (part 1: Play listed, the *Nearby devices* permission state and Play's version on film; part 2: no Play, exit 1 with the positive control), P1 (the + update: `firstInstallTime` unchanged, `lastUpdateTime` later, `versionCode=10300`); the Info frame's hash = B1's; the pulled APK's SHA-256 = B1's. +- [ ] **The two new requests, byte-identical** (the table above), **once per MAESTRO session**: count the `SubscribeToSettingsChanges` REQUESTs and the + `ReadSetting 4:13` REQUESTs against the `SABM`s of DLCI 0x02 (Connects + re-opens) in each log — equal; their position after `SubscribeRuntimeInfo` and + before `GetHardwareInfo`. +- [ ] **The counts for the verdict:** (1) OpenControl's `SABM`s on DLCI 0x04/0x05 (owner by the first phone message, `08 11` = the app, `03 08 00 02 01 25` = + Play — `CAP-075-FINDINGS.md` §1's method) **vs the taps, tile taps, pulls and Connects/re-opens made** — equal (no claim the user did not cause); + (2) **holds shown vs holds made** per step (BL-4, 5, 12, 15, 16, 21 — expected all); (3) **stream packets `4:{13:n}` vs `Notify`s** — one packet per + hold, per own tap and per wear-driven switch, with the `Notify`s only inside the app's or Play's claims; (4) **the first read's value after every + Connect/re-open** (the RESPONSE after each `ReadSetting 4:13`), its (i) line when it differs from the mode shown before. +- [ ] **BL-7 / BL-17:** the own tap's stream packet classed as the app's change (no "Changed by the Buds" line in the film); its delay after the `Set`. +- [ ] **BL-9 / BL-19 / BL-23:** which of the two happened (the Buds' `DISC` → re-open → read, or a stream packet without a `DISC`); the mode before and after; + the (i) line; a value 0 never shown as a mode (the "not allowed" line instead). +- [ ] **BL-10 / BL-20:** the subscription after the Bluetooth-off re-open (M1 on hardware). +- [ ] **BL-11:** the taps that collided with Play (`CAP-075` §5's refusal), how many, and that nothing re-claimed afterwards — the known issue of 1.3.0. +- [ ] **BL-13 / BL-25 if run:** the official app's writes on its own session, the stream packets on OpenControl's, the values on screen with the packet's time. +- [ ] The system logs: crashes, ANRs, `StrictMode`; the `bugreportz` SIGQUITs discounted. +- [ ] One row per step — done / not done / done differently; `APP_TESTPLAN.md` section Y and the Summary; traceability (`AGENTS.md` §13 step 7): `PAIR-003` + (BL-1, BL-3, BL-9, BL-10, BL-14, BL-19, BL-20, BL-22, BL-23), `BATT-004` (BL-1, BL-14), `FW-003` (BL-2, BL-14), `ANC-001`…`ANC-004` (BL-4, BL-5, BL-7, + BL-8, BL-11, BL-15 … BL-18), `TOUCH-007` (BL-4 … BL-6, BL-9, BL-12, BL-15, BL-16, BL-19, BL-21, BL-22, BL-24) — each referenced in the timeline or flagged + "expected but not observed". +- [ ] Update `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9, `id_registry.csv`, `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the Group column: BL), `APP_TESTPLAN.md`, `TODO.md` + §1/§2/§3; write the findings file (CAP-076-FINDINGS); an ADR-065 Update with the numbers; `PROTOCOL.md` §6's wear-change item if BL-9/BL-19/BL-23 settle + it (a promotion needs the maintainer's approval, `AGENTS.md` §6); the release verdict is the analysing session's, with the maintainer (`RELEASING.md` C4). + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md - https://tedsluis.github.io/opencontrolpixelbudspro2/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS.md b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS.md new file mode 100644 index 00000000..4dcab594 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS.md @@ -0,0 +1,414 @@ +# Findings: `CAP-076` (Group BL — 1.3.0, build `ff27ee1`, the settings stream on the app's own channel (ADR-064/065) in both GrapheneOS users: the Owner user with Google Play services and the secondary user without, measured against `CAP-075`) + +**Analysed by `ai-sessions/0092` (2026-10-15).** Companion: `CAP-076-EVENT-NOTES.md` (the timeline, the steps as run, the Test-IDs). Labels per +`PROJECT_RULES.md` §1: 🟢 FACT (a frame, a log line or a film second), 🟡 HYPOTHESIS, ⚪ ASSUMPTION, ❓ OPEN QUESTION. Frame and log names as in the event +notes: H1L / H1 / H2L / H2 the HCI logs, E1 / E2 the exports, A1 / A2 the app logcats (UTC), L the full logcat (local time; CAP-076-logcat-extract.txt carries +the quoted lines), s a film second. Every `tshark` filter was run on the Buds' ACL handle (`bthci_acl.chandle==0x000b`; the Connection Completes for the +Buds' address: H1 672 / 4680, H2L 311 / 605, H2 151 / 2678 — `bthci_evt.bd_addr == 04:00:6e:cf:6e:07` matches them, `bluetooth.addr` matches nothing, exit 0 +both); every negative carries its command, its exit status and a positive control (`AGENTS.md` §13 step 8). The counts come from four scripts committed with this +analysis (`PROJECT_RULES.md` rule 4a): `scripts/pwhdlc_crc_table.py`, `scripts/message_stream_claims.py`, `scripts/stream_events.py`, +`scripts/film_mode_buttons.py` (their usage in each file's header). + +**In short (🟢 unless marked).** 1.3.0 did what ADR-064/065 say, in both users. Every MAESTRO session that got through its reads sent **one** +`SubscribeToSettingsChanges` and **one** `ReadSetting 4:13`, byte-identical to the official app's, after `SubscribeRuntimeInfo` and before `GetHardwareInfo` +(12 of 12 sessions; a 13th was closed by the Buds after 6 reads, before them). **Every press-and-hold made on the noise-control tab showed on screen within +0.1 s of the Buds' push — 29 of 29 in the user with Google Play services, 12 of 12 without — while Play's Fast Pair held the shared channel (DLCI 0x04) and +received the `Notify` frames**; the app sent nothing to get them. The maintainer's statement "when I did a long press on a bud, the ANC mode in the app +changed too" holds for every hold the film shows. Every own tap (15 `Set`s) was answered and classed as the app's own change. Nothing was held or re-claimed: +the app's 33 claims of DLCI 0x04 are 14 snapshots of Connects/re-opens and 19 user actions (13 noise-control taps in 12 claims of part 1; Refresh, 4 taps, Ring +and Stop of part 2). New on the wire: **every change arrived twice, on channel 19 and 21**, when both buds were out of the case (§4.3). Three defects of 1.3.0 +are found — none heavy: a re-open's "changed since the app last saw it" cause is overwritten by the equal `4:13` answer 0.2–0.7 s later (§6.2), a tile tap cut +right after its `Get`'s answer changed nothing and said nothing (§5.3), and a same-mode push is logged with the old cause (§4.4). The per-tap collision with +Play (1.3.0's known issue) is measured: 8 of 12 tap claims refused at attempt 1, 4 cut by Play's connect (§5.2). No crash, no wrong write, no Safe Mode; the +sessions lost were all ended by the Buds or by the ACL (§6). + +## 0. Build, install, users, logs, privacy, health + +- **Build.** "App: 1.3.0, build ff27ee1 (2026-10-14)", no "-dirty", on film in both users (film 1 s101–107; film 2 s81–86, s321). The installed APK, pulled + by this session at 07:52 (`adb shell pm path --user 0 …` and `--user 10` → the same `base.apk`, exit 0 each; `adb pull`), has SHA-256 + `03272c6f954316d76c3ef6d1d020abd8bdb97755d7f8e165c84d3f16a9c6a96c` = `~/opencontrol-1.3.0-tested/opencontrol-pixelbudspro2-1.3.0.apk.sha256` (B1's); + `lastUpdateTime=2026-10-15 06:36:57` unchanged since the run (CAP-076-adb-checks-0092.log). 🟢 The tested APK is the build of B1. +- **Install.** 1.2.2 `bfd7891` at the start of film 1 (s62–73) → the update on film (s92–93 "Updaten…"; L `PackageManager: … installation completed` + 06:36:58.015) → 1.3.0 (PID 11800, L `am_proc_start` 06:36:59.954). The update killed **both** users' processes (L `am_kill … killDueToPackageUpdate`: + `[10,4104,…]` 06:36:57.930, `[0,10952,…]` 06:36:57.941). The shell log: `versionCode=10202` (06:23:50) → `10300` (06:38:17), the two `firstInstallTime` + lines unchanged (an update, no reinstall). The 1.2.2 Settings card and its choice ("This phone has Google Play services", "Google Play services: found on + this phone", s59) are gone in 1.3.0 (s100, film 2 s73): 🟢 ADR-065 item 5 on hardware (the stored `hold_reclaim_mode` is not shown anywhere). +- **Users (Checkpoint 0, the maintainer: "Yes, as read").** Part 1 = the Owner (user 0) with Google Play services: A1 has no `userType:` line; L + `am_proc_start: [0,11800,10354,…]`; `pm list packages --user 0` lists `com.google.android.gms` (exit 0); `dumpsys package com.google.android.gms` (07:53): + `versionName=26.32.34 (260400-968093310)`, `User 0: installed=true`, `BLUETOOTH_SCAN`/`_CONNECT`/`_ADVERTISE` `granted=true` (the *Nearby devices* group; + ⚪ the same an hour earlier); 18 Play claims in H1 (`frame contains 03:08:00:02:01:25` → 18, exit 0). Part 2 = the secondary user (user 10) without Play: + A2 line 3 `userType: full.secondary` (source.android.com, *Multiple users*, as quoted in `CAP-075-FINDINGS.md` §0); `pm list packages --user 10 | grep -i -E + "gms|vending"` exit 0 **because it matched `app.grapheneos.gmscompat*`** — `com.google.android.gms` is not in the list, and `dumpsys` says `User 10: … + installed=false`; the same Play-marker filter → 0 in H2L and H2 (exit 0; positive control H1 18). +- **Logs.** Sizes, modes and SHA-256 as in the prompt's §2 (re-derived: all 14 files equal; the prompt's "empty `test` file" did not exist). H1 has **10,617** + packets (06:36:11.035–06:54:29.229), not "10,0xx". H1L holds no Buds frame (0 on handle `0x000b`, 0 RFCOMM; positive control H1 3,670 / 2,556) — it ends with + the Bluetooth off of film 1 s8. The exports end without a newline: 1,062 and 462 lines (the toasts' counts; `wc -l` gives 1,061 / 461) and **do carry the + Debug-mode hex lines** (E1 444 `DLCI 0x0N:` lines, E2 201) — the prompt's "no raw hex lines" is not so. **Film 2 has no audio stream** (`ffprobe + -show_entries stream=codec_type` → `video` only, exit 0; film 1 → `video`, `audio`). +- **Health (🟢).** A1/A2: `FATAL EXCEPTION` 0, `ANR in` 0, `StrictMode` 0 (`grep -c`, exit 1 each; positive control `grep -c OpenControlBuds` → 278 / 94, exit + 0). L over part 1: `am_crash`/`am_anr` for the app 0 (positive control `am_proc_start` for the app 3, exit 0). A1 carries **seven** `W System: A resource failed + to call close.` lines (the finalizer thread, 06:47:10 ×5 and 06:47:44 ×2, next to `BluetoothSocket: close() … Already closed`) — 🟡 CloseGuard reports for + sockets of the refused attempt-1 connects that were never `close()`d before collection; A2 0. pw_hdlc: 658 frames, 0 CRC-32 failures (§2). +- **Privacy (Checkpoint 0).** Films committed as is (audio kept): film 2 shows the Wi-Fi network's name (s0–4, s15, s327–328), both films the serials + (film 1 s62–73, s101–107; film 2 s81–86, s321–323). The exports' hex lines carry the serials (the `GetHardwareInfo` answers) — committed as is (as + `CAP-073`…`CAP-075`); the extract has them redacted (9 lines). The extract (14,121 lines: part 1 06:35–06:53, PIDs 10952/11800/6149/11130, Play's + Fast Pair lines, every line naming the app or `com.android.bluetooth`): 0 e-mail, 0 Wi-Fi name, 0 MAC other than `00:…`/`ff:…` (`grep -c`, exit 1 each; + positive control: `grep -c 'xx:xx:xx:xx:6e:07'` > 0). + +## 1. ACLs, the Message Stream claims of both clients, Play's timers + +**Method** (as `CAP-075-FINDINGS.md` §1): a claim = a phone `SABM` on DLCI 0x04 (never 0x05 in this run) to its `DISC`/`DM` or the ACL's end; the owner is +the first phone message (`08 11 00 00` = the app; `03 08 00 02 01 25` = Play; `04 01 …` = the app's Ring/Stop). Command: `python3 scripts/message_stream_claims.py 0x000b` ( `tshark -r -Y +"bthci_acl.chandle==0x000b && (btrfcomm.dlci==4 || btrfcomm.dlci==5)" -T fields -e frame.number -e frame.time_epoch -e frame.p2p_dir -e btrfcomm.dlci -e +btrfcomm.frame_type -e data.data`, exit 0), messages split as `[Group][Code][Len:2][Value]` per direction. + +| Log | ACLs (handle `0x000b`) | Claims | App | Play | `Notify` frames | +|---|---|---|---|---|---| +| H1 | 672 06:36:19.895 → 4558 06:42:23.192 (`0x13`); 4680 06:42:46.059 → open at the end | 40 | 22 (10 snapshots incl. S0 of 1.2.2, 12 taps' claims) | 18 | 110: 42 on the app's claims, 66 on Play's, 2 after a claim's `DISC` | +| H2L | 311 07:01:07.655 → 489 07:01:08.972 (`0x13`); 605 07:01:13.785 → 1294 07:02:39.649 (`0x16`) | 1 | 1 (snapshot) | 0 | 1 | +| H2 | 151 07:02:45.890 → 2666 07:08:33.441 (`0x13`); 2678 07:10:20.388 → open | 10 | 10 (3 snapshots, Refresh, 4 taps, Ring, Stop) | 0 | 16 | + +**Play's timers (🟢, Play 26.32.34, 2026-10-15, part 1)** — from L's `cleanup_rfc_slot … scn: 2, app_uid: 10205` (Play's loss) to Play's next connect (its +`SABM` on the wire or its own `already at opened state`): **recovery 5.13–5.97 s** (17 values: 5.16, 5.13, 5.60, 5.26, 5.77, 5.51, 5.59, 5.53, 5.67, 5.48, +5.75, 5.97, 5.63, 5.28, 5.49, 5.52, and 5.60 after the Buds' own `DISC` 06:39:53), **retry 2.37–2.50 s** after its connect was refused (06:45:58.170 → +06:46:00.559; 06:46:09.879 → 06:46:12.375; 06:46:26.112 → 06:46:28.479), and **4.49 s once** after two refusals within 2.4 s (06:46:28.479 → 06:46:32.965, +"Retry times remaining 4" at 06:46:28.483; all 20 other lines say 5). `CAP-075`: 5.33–5.82 / 2.06–2.82 / 4.56 once — 🟢 the same mechanism; the recovery +range is a little wider here (5.13 and 5.97 are new extremes). + +## 2. The Connect sequence — byte for byte, once per MAESTRO session (`PAIR-003`, `BATT-004`, `FW-003`) + +Command: `python3 scripts/pwhdlc_crc_table.py 0x000b` (the RFCOMM payloads of DLCI 2/3 per direction reassembled, split on `7e`, unescaped, `zlib.crc32` over address + control + data against +the little-endian trailer, the RpcPacket parsed with `scripts/pwrpc_decode.py`'s own functions) — **H1 466 frames, H2L 39, H2 153, 0 CRC failures**; positive +control: `CAP-051` 1177's bytes pass, the same bytes with one bit flipped fail; the counts equal `python3 scripts/pwrpc_decode.py --handle 0x000b | wc -l` +(466 / 39 / 153). + +| Session | Opened by | Channel | Requests | `SubscribeToSettingsChanges` → `ReadSetting 4:13` → answer | +|---|---|---|---|---| +| S0 (1.2.2) 06:36:20 | 1.2.2's Connect | 21 | 15 (13 reads, SRI, GHI) | — (1.2.2) | +| S1 06:37:00 | LINK_BACK | 21 | 17 | 1745 → 1746 → 1750 `4:{13:0}` | +| S2 06:39:44 | Connect | 21 | 17 | 2838 → 2839 → 2843 `4:{13:0}` | +| S3 06:39:56 | AFTER_LOSS | 19 | 17 | 3120 → 3120 → 3124 `4:{13:1}` | +| S4 06:41:33 | AFTER_LOSS | 21 | 17 | 4129 → 4130 → 4135 `4:{13:2}` | +| S5 06:42:46 | Connect | 21 | 17 | 4858 → 4859 → 4863 `4:{13:0}` | +| S6 06:42:56 | AFTER_LOSS | 19 | **6** (4:16 … 4:12; the Buds' `DISC` 5616 at 06:42:57.106) | — | +| S7 06:43:05 | Connect | 21 | 17 | 5995 → 5996 → 6003 `4:{13:2}` | +| S8 06:47:32 | AFTER_LOSS | 19 | 17 | 8959 → 8960 → 8968 `4:{13:1}` | +| S9 06:47:46 | AFTER_LOSS | 21 | 17 | 9213 → 9214 → 9220 `4:{13:1}` | +| S10 07:01:14 | LINK_BACK | 19 | 17 | H2L 848 → 849 → 853 `4:{13:0}` | +| S11 07:02:46 | LINK_BACK (after Bluetooth on) | 19 | 17 | H2 540 → 541 → 546 `4:{13:0}` | +| S12 07:03:02 | AFTER_LOSS | 21 | 17 | 790 → 790 → 794 `4:{13:1}` | +| S13 07:04:15 | Connect | 21 | 17 | 1131 → 1132 → 1137 `4:{13:1}` | + +- 🟢 **The two new requests, once per MAESTRO session:** 12 subscriptions and 12 `ReadSetting 4:13` in the 12 full 1.3.0 sessions, none in S0 (1.2.2) and + S6 (closed before them), no second one in any session; each right after `SubscribeRuntimeInfo` and before `GetHardwareInfo`. **Byte-identical** to the + references: channel 21 `7e004b0310151dea71de7d5e25f5ad2128019754b07e` (= `CAP-051` 1177) and `7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e` + (= `CAP-051` 1178); channel 19 `7e003b0310131dea71de7d5e25f5ad212807ee8eb27e` (= `CAP-056` 1451) and `7e003b0310131dea71de7d5e2551aed0ae2a02200d24c239477e` + (= `CAP-056` 1450). Command: `awk -F'\t' '$3==0 && $8 ~ /SubscribeToSettingsChanges/'` and `'$9=="4:13"'` over its tables. +- 🟢 **The other fifteen** (4:16, 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29, `SubscribeRuntimeInfo`, `GetHardwareInfo`): one byte form per field and channel + across all 14 sessions (e.g. 4:16 ch 21 `7e004b0310151dea71de7d5e2551aed0ae2a02201047eeadcf7e`, ch 19 `…2a022010fdae3f247e`; 4:2 ch 21 `…0220020f9f143c7e`) + — equal to `CAP-075-FINDINGS.md` §2's 1.2.1 bytes. **A full 1.3.0 session sends seventeen MAESTRO requests**; the skeleton's "fifteen" is 1.2.x's count + (`CAP-075` §2) and miscounts the list it gives. +- 🟢 The answers: the reads of `4:13` gave 0 (no mode settable) at S1, S2, S5, S10, S11 — each with both buds in the case (runtime info: both charging, e.g. + H1 4863's packet; film 1 s441, film 2 s19 "Both buds in the case") — 1 at S3, S8, S9, S12, S13 and 2 at S4, S7. Raw: H1 1750 (2nd HDLC frame) + `7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e` (= `CAP-045` 2033), H1 3124 `7e80a3032a0422026801080110131dea71de7d5e2551aed0ae2e62233c7e` + (= `CAP-021` 1089). Every `GetHardwareInfo` answered (the same three strings), every status OK. +- The snapshot claim (ADR-065 Q7) after every session; in part 1 three of the nine 1.3.0 snapshots were refused at attempt 1 (S1, S2, S7: Play on the + channel) and connected at attempt 2 ≈ 0.6–0.9 s later. + +## 3. Every write — byte for byte + +- DLCI 0x02 (🟢): part 1 `WriteSetting 4:{16:{-1, 0, 4, 2, 6}}` 9854 (06:50:04.010) → empty RESPONSE OK 9860, `4:{2:0}` 9925 → 9934, `4:{2:1}` 10037 → + 10042; part 2 `4:{16:{-1, 0, 4, 2, 1.69}}` 2018 → 2021, `4:{2:0}` 2081 → 2088 (raw 2081 `7e004b0310151dea71de7d5e251d9a8c9e2a0422021000a0a6cb947e`, the + same bytes as H1 9925). Each mirrored on the settings stream (H1 9856, 9932, 10040; H2 2020, 2087) and applied once (`keepsOwnMark`: E1 06:50:22.351 "Setting 2 + written", the (i) "changed 06:50:22"). **Nothing else written:** **0 `WriteSetting 4:{13:n}`** — `tshark -r -Y "bthci_acl.chandle==0x000b && + frame.p2p_dir==0 && frame contains 1d:9a:8c:9e:2a && frame contains 22:02:68"` → 0 in H1 and H2 (exit 0); positive controls: the method part + `frame contains 1d:9a:8c:9e:2a` on sent frames → 3 / 2 (the writes above), `frame contains 22:02:68` on received frames → 117 / 37. +- DLCI 0x04 (🟢): 15 `Set` frames (H1 11, H2 4: `frame.p2p_dir==0 && frame contains 08:12:00:14`, exit 0), Ring Left `04 01 00 01 02` (H2 claim #9) and Stop + `04 01 00 01 00` (#10) — each answered (§5). Nothing on DLCI 0x08/0x0a from the app: those channels were the Google app's (uid 10302, L `cleanup_rfc_slot … + scn: 4/5, app_uid: 10302`), as in `CAP-075`. +- **The Buds' state after the run (🟢):** EQ `[−1, 0, 4, 2, 1.69]` (Low bass … Upper treble), **in-ear detection off** (H2 2081 at 07:06:56, not switched on + again), the other settings as read (`TODO.md` §1, restore if wanted). + +## 4. The holds — every stream packet against the screen (`TOUCH-007`; ADR-064 item 3, ADR-065 items 3–4) + +### 4.1 Method + +The field-13 `SERVER_STREAM` packets from `scripts/pwhdlc_crc_table.py` (H1 117, H2L 0, H2 37); `scripts/stream_events.py` merges the two packets of one change (the same value within 0.4 s) and +marks the app's own `Set`s (§5). The screen: `scripts/film_mode_buttons.py` reads the four mode buttons of the noise-control card at 10 fps (the mean grey value of a patch inside each +button at full resolution; filled < 170) over film 1 s460–530, s560–610, s792–852 and film 2 s132–252, and prints each change of the selected button with its +phone time. "Shown within 0.1 s" = the first 10-fps frame with the packet's mode is ≤ 0.1 s after the packet (± 0.1 s for the clock offset). + +### 4.2 Per part (🟢) + +| Part | Where | Changes the app did not request | Their stream packets | On screen | +|---|---|---|---|---| +| 1 (Play) | the Connection tab, 06:40:02–06:41:51 | 14 (06:40:02 Adaptive on ch 19 only; 13 from 06:40:47, pairs) | 27 | **not visible** — the Connection tab shows no mode; E1 logs each ("changed_by_buds"), the battery card's "checked HH:MM:SS" follows each push (ADR-065 Q5: the stream feeds the availability) | +| 1 (Play) | the ANC tab, 06:43:14–06:44:11 | 17 holds | 34 | **17 of 17 within 0.1 s** (06:43:14.8 … 06:44:11.2, EVENT-NOTES) | +| 1 (Play) | the ANC tab, 06:48:44–06:49:33 | 12 holds | 24 | **12 of 12 within 0.1 s** (one under a volume panel) | +| 1 (Play) | the Connection tab, 06:42:11 | Off, then 0 (both buds into the case) | 2 | "Not worn (checked 06:42:11)"; 0 never shown as a mode (the ANC tab at 06:42:48: "The Buds don't allow changing noise control right now …", Off kept) | +| 2 (no Play) | the ANC tab, 07:05:24–07:06:38 | 12 holds | 24 | **12 of 12 within 0.1 s** (two under a volume panel) | + +- **The holds on the ANC tab: 29 of 29 in the user with Play, 12 of 12 without — 🟢 FACT for this run.** In part 1 the shared channel was Play's the whole time + (claims #16 06:43:11–06:44:35 and #40 from 06:47:50, carrying the `Notify` of every hold: 6107 … 6548, 9488 … 9716); the app had no claim open during any of + the 29 and sent nothing (no app `SABM` between 06:43:08.76 and 06:44:35.96, and between 06:47:49.51 and the end of the log). In `CAP-075` set 2 (1.2.1, the + same user) 8 of 31 were shown within a second (§9). +- **Which of these were press-and-holds** (🟡 for each, as in `CAP-073`…`CAP-075`): no camera; the 41 changes on the ANC tab came with no AVRCP from the Buds, + no audio and no app `Set`, in the Buds' list order (Noise cancellation → Off → Adaptive → Transparency, all four ticked: `4:{12:{1:1 2:1 3:1 4:1}}` at every + Connect), 2–7 s apart — the maintainer's holds (his statement) is the reading; a wear change is excluded for these by the absence of the Buds' PLAY/PAUSE that + in-ear detection sent for every bud in/out in part 1 (06:40:46–06:41:41, §8). +- **The Connection-tab stretch 06:40:47–06:41:51 is mixed** (🟢 for the coincidences): 3 of its 13 changes fall 2–7 ms from the Buds' AVRCP PAUSE/PLAY + (H1 3600 ↔ 3603, 3624 ↔ 3626, 3972 ↔ 3974) — wear-driven switches (🟡 `PROTOCOL.md` §6, "the stored mode per wear state"); the other 10 have no AVRCP within + 0.7 s; ten short voiced audio segments follow ten changes by 0.6–1.4 s (§0 of the event notes). The maintainer's statement for this stretch is not + separable into holds and wear changes from the evidence; it is not counted for the verdict's "shown" (the tab cannot show it). +- **The maintainer's "the ANC modes during playback were good":** the only playback of the run is part 1 06:40:51–06:41:44 (AVRCP "Playing" 3553, AVDTP + START 3569 … SUSPEND 4316, the podcast of the Quick Settings card) — the Connection-tab stretch; the mode changes there reached the app (E1) but not the + screen. The 41 holds on the ANC tab were made **without** playback (AVRCP "Paused"/"Stopped", no AVDTP START from 06:41:44.8 to 06:52:15 in H1; in H2 the + START/SUSPEND pairs of 3–5 s around taps are the phone's system sounds with AVRCP "Stopped" until 07:08:21). + +### 4.3 Two stream packets per change — channel 19 and 21 (🟢 for the observation, 🟡 for the cause) + +- **Every change of the mode with both buds out of the case arrived twice**, once with `channel_id` 21 (address `00 a5`) and once with 19 (`80 a3`), 1–200 ms + apart, the same value: **68 of 68** such changes (part 1: 13 on the Connection tab + 17 + 12 holds + 10 own `Set`s = 52; part 2: 12 holds + 4 own = 16), e.g. H1 6108 `7e00a5032a0422026801080710151dea71de7d5e25f5ad2128b2d925c47e` (06:43:14.732, ch 21) and 6117 + `7e80a3032a0422026801080710131dea71de7d5e25f5ad2128117bf5557e` (06:43:14.840, ch 19 = `CAP-021` 1842); H2 1726 / 1729 (ch 21 = `CAP-051` 1755, ch 19 = + `CAP-056` 2259). Command: `awk '$3=="SERVER_STREAM" && $5 ~ /13:/'` over the table of `scripts/pwhdlc_crc_table.py`, merged by `scripts/stream_events.py`. +- **A single packet came on the out bud's channel when one bud was in the case**: H1 3233 (06:40:02.267, ch 19 — runtime info 06:39:57.99: Left out, Right + charging), H1 4492 / 4502 (06:42:11, ch 21 — the buds going into the case), H2 717 and 839 (07:03:02.647 / 07:03:07.137, ch 21 — runtime info 07:03:04: + Left charging, Right out); and **two single packets of the same mode with both out** (H1 8739 06:47:07.108, 8774 06:47:18.657, ch 21). +- 🟡 **HYPOTHESIS H1:** each bud out of the case pushes its own packet, with its core's channel (`fux.java`: 19 = LEFT_BT_CORE, 21 = RIGHT_BT_CORE, + `PROTOCOL.md` §2.2a) — fits all 68 changes and the three one-bud cases; the two same-mode pushes of 06:47 are not explained. **Against it:** the official + app's captures show one packet per change (`python3 scripts/pwrpc_decode.py captures/CAP-051*/CAP-051-btsnoop_hci.log | grep 'SERVER_STREAM.*SubscribeToSettingsChanges'` + → ch 21 only, 4 packets; `CAP-056` → 4 on ch 19, 1 on ch 21, none paired; exit 0) although both buds were worn there (`PROTOCOL.md` §6, 2026-10-14: `CAP-056` + 2322 "both worn"). 🟡 **H2:** a subscription of an earlier session of the same ACL survives the Buds' `DISC` and is served on the other core's channel — + refuted for 06:48:44 (four earlier subscriptions in that ACL, two packets) and 07:03:07 (two subscriptions, one packet). ❓ Settling experiment: one Connect on a + fresh ACL (lid closed and opened), both buds worn, five holds — packets per hold with one subscription; then the same with the official app on the same ACL. +- **Effect on the app (🟢):** none visible — the second packet is "the same mode again" (`causeOfStream` → `null`); E1/E2 log it as a second "from the settings + stream ()" line (the prompt's "86 + 22" lines are 43 + 11 changes; E2's 24 + 8 are 12 + 4). The export's paired `set_by_app` lines (e.g. 07:04:49.836 / + .861) are these two packets, not two answers to one `Set`. + +### 4.4 The cause shown and logged (🟢) + +- The ANC (i) after a hold: "Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves." — + film 1 s536 (06:44:11), film 2 s216, s224, s233, s242, s249 (07:06:03 … 07:06:38): 🟢 ADR-065 item 4 / Q10 on hardware. +- **A same-mode push logs the kept cause** (`BudsRepositoryImpl.kt:419`, `(cause ?: _ancModeCause.value)`): H1 8739 / 8774 (Off again, 39 s and 50 s after the + tile's Off) → E1 06:47:07.121 / 06:47:18.664 "from the settings stream (**set_by_app**)"; H2 839 → E2 07:03:07.144 "(read)". The shown cause is right (the + (i) at s705/s716 has no cause line); only the log line reads as if the packet were the app's change (§13 P4). + +## 5. The own taps (`ANC-001`…`ANC-004`, ADR-032, ADR-065 item 4) + +### 5.1 Set → ACK → stream (🟢) + +| Part | `Set` (frame, mode) | ACK | the Buds' `Notify` | the stream (first, second) | E1/E2 cause | +|---|---|---|---|---|---| +| 1 | 6724 `40` 06:44:37.456 | 6726 +91 ms | 6727 +92 | 6731 +177, 6732 +246 | set_by_app ×2 | +| 1 | 6956 `80` 06:44:50.476 | +104 | +169 | +237, +238 | set_by_app ×2 | +| 1 | 7137 `08` 06:45:00.538 | +131 | +133 | +154, +155 | set_by_app ×2 | +| 1 | 7151 `08` (the second tap, same mode) | +75 | +74 | none (no change) | — | +| 1 | 7498 `80` (tile) | +83 | +84 | +160, +201 | set_by_app ×2 | +| 1 | 7665 `40` (tile) | +100 | +144 | +246, +248 | set_by_app ×2 | +| 1 | 7930 `20` (tile) | +95 | +96 | +198, +199 | set_by_app ×2 | +| 1 | 8208 `08` (tile) | +129 | +130 | +205, +242 | set_by_app ×2 | +| 1 | 8377 `80` (tile) | +127 | +128 | +233, +300 | set_by_app ×2 | +| 1 | 8445 `40` (tile) | +100 | +101 | +176, +218 | set_by_app ×2 | +| 1 | 8527 `20` (tile) | +129 | +131 | +195, +260 | set_by_app ×2 | +| 2 | 1372 `08` 07:04:49.772 | +25 | +26 | +60, +85 | set_by_app ×2 | +| 2 | 1470 `40` | +77 | +78 | +81, +121 | set_by_app ×2 | +| 2 | 1558 `80` | +50 | +52 | +55, +93 | set_by_app ×2 | +| 2 | 1650 `20` | +82 | +85 | +94, +125 | set_by_app ×2 | + +Raw (H1 6724 → 6726 → 6727): `0812001401e8e840` + 16 × `00` → `ff010006081201e8e840` → `0813000401e8e840`. 🟢 **15 `Set`s, 15 ACKed, 0 NAK**; the stream +packet 55–246 ms after the `Set` (`CAP-051`: 269 ms) — inside `ANC_SET_WINDOW_MS` (2 000 ms) with margin; **every own change classed as the app's** +(E1/E2 "set_by_app"; the (i) without a cause line on film: film 1 s553, s566, s576; film 2 s140, s148, s156, s166). On screen at the ACK (film 1 +06:44:50.6, 06:45:00.7; film 2 07:04:49.8, 57.6, 07:05:04.9, 13.8 — each the ACK's time + ≤ 0.1 s). + +### 5.2 The collisions with Play's claim — 1.3.0's known issue, measured (🟢) + +13 taps with Play present (4 on the ANC tab, 9 on the tile) in 12 claims: + +| Outcome | Claims (taps) | Evidence | +|---|---|---| +| attempt 1 refused by the stack ("already at opened state": Play on the channel), attempt 2 connected 0.87–1.11 s later, the `Set` answered | 8 (9) — #17, #19, #21 (2 taps), #23, #25, #28, #31, #33 | E1 "connect failed after 326 … 1071 ms (attempt 1/3)" ×8; L `already at opened state` 06:44:35.961, 06:44:49.138, 06:44:59.106, 06:45:45.626, 06:45:52.286, 06:46:03.767, 06:46:14.285, 06:46:20.634 | +| attempt 1 connected, the `Set` answered, the claim cut in its linger by Play's connect | 2 — #34, #35 | E1 06:46:26.250, 06:46:28.549 "… closed"; L `already at opened state` 06:46:26.112, 06:46:28.479 (Play's) | +| attempt 1 connected, cut before the `Get`'s answer: "answer cut off", a toast | 1 — #26 | E1 06:45:58.383; film 1 s632–634 | +| attempt 1 connected, cut **right after** the `Get`'s answer: no `Set`, no line, no toast | 1 — #29 | §5.3 | + +The stack path of every cut (4 of 4) is `CAP-075-FINDINGS.md` §5's: Play's `connect()` refused (`bt_port_api … RFCOMM_CreateConnectionWithSecurity: +already at opened state 2, … scn=2, is_server=false, … dlci=4`), `cleanup_rfc_slot … app_uid: 10205` (Play) and 70–135 ms later `… app_uid: 10354` (the +app), `NearbyDiscovery: RfcommEventStreamMedium … Failed to read from socket. Retry times remaining 5`, the app's `read return: -1` (L 06:45:58.170 … +06:45:58.254; the extract). **Nothing re-claimed by itself** (0 "re-claim"/"hold" lines in E1 and E2 — `grep -ciE 're-claim|hold'` → 0, exit 1; positive +control `CAP-075`'s export 2 → 31, exit 0). The tile's next tap after a failure was the user's (06:46:03.4, 06:46:13.9). + +### 5.3 The silent tile tap (🟢 for the wire and the logs, 🟡 for the code path) + +06:46:08.959 "ANC tile tapped (shown: OFF)" → claim #29 connected at attempt 1 (06:46:09.527) → `Get` 8016 (06:46:09.741) → the Buds' `Notify` 8029 `e8 e8 20` +06:46:09.878 → Play's connect refused at 06:46:09.879 → the stack's `DISC` of DLCI 0x04 (8030, phone → Buds) at **06:46:09.880** → the app's "closed" line +06:46:09.952. On the wire no `Set`; in E1 no failure line ("answer cut off", "Safe Mode", "rejected") and on film no toast (s640–646 pixel-identical, +although the tile's code shows a toast for any failure, `AncTileService.kt` lines 137–140) — the tile stayed "Off" and the user tapped again 4.9 s later. +🟡 **HYPOTHESIS (code reading, `BudsRepositoryImpl.kt`):** the `Notify` that answers the `Get` is also emitted to `_ancOutcomes` (line 708, after line 702's +`_ancNotifies`); if the `Set`'s wait (`sendAndAwaitOnClaim(_ancOutcomes, …)`, line 973) subscribes before line 708 runs, it takes that `Notify` as its +`AncOutcome.Notified` and returns success (line 993) without an ACK; the `Set` itself was written after the stack had dropped the port. Settling test (a +FEATURE session): a `FakeBudsTransport` test that emits the `Get`'s `Notify` with a delay between the two emissions and asserts that the tap then fails; and a +`Set` wait that ignores a `Notify` received before the `Set` was sent. + +## 6. The Buds' closes and the re-opens (ADR-044; M1 on hardware) + +### 6.1 Every session end (🟢) + +| Time | Session | Ended by | Re-open | Evidence | +|---|---|---|---|---| +| 06:36:57.99 | S0 (1.2.2) | the update (the phone's `DISC`) | the 1.3.0 start: LINK_BACK S1 06:37:00.656 | H1 1586 | +| 06:39:41.77 | S1 | the user's Disconnect | Connect S2 06:39:44.75 | H1 2691; E1 | +| 06:39:53.97 | S2 (ch 21) | **the Buds' `DISC`** (the Left out of the case) | AFTER_LOSS 1.504 s after the loss line → S3 ch 19 | H1 2985 | +| 06:41:31.28 | S3 (ch 19) | **the Buds' `DISC`** (a bud into an ear: AVRCP PLAY +0.31 s) | AFTER_LOSS 1.508 s → S4 ch 21 | H1 3994 | +| 06:42:23.19 | S4 | the ACL's end (`0x13`; both buds in the case) | the user's Connect 06:42:45.70 → S5 ch 21 | H1 4558 | +| 06:42:53.38 | S5 (ch 21) | **the Buds' `DISC`** | AFTER_LOSS 1.503 s → S6 ch 19 | H1 5357 | +| 06:42:57.11 | S6 (ch 19) | **the Buds' `DISC`** + `DM` (928 ms after the re-open) | **skipped** ("the session was lost 928 ms after an automatic re-open", `CHAIN_GUARD_MS`); the user's Connect 7.8 s later → S7 ch 21 | H1 5616/5621; E1 06:42:57.113 | +| 06:47:30.43 | S7 (ch 21) | **the Buds' `DISC`** (DLCI 0x02 and Play's 0x04) | AFTER_LOSS 1.504 s → S8 ch 19 | H1 8827/8828 | +| 06:47:44.72 | S8 (ch 19) | **the Buds' `DISC`** (0x02 and Play's 0x04) | AFTER_LOSS 1.504 s → S9 ch 21 | H1 9077/9080 | +| 07:02:39.43 | S10 | Bluetooth off | LINK_BACK S11 0.001 s after Android's link (07:02:46.159) | H2L 1276; §7 | +| 07:03:00.36 | S11 (ch 19) | **the Buds' `DISC`** (the Right out of the case) | AFTER_LOSS 1.503 s → S12 ch 21 | H2 641 | +| 07:04:12.27 | S12 | the user's Disconnect | Connect S13 07:04:15.35 | H2 1017 | +| 07:08:33.44 | S13 | the ACL's end (`0x13`), after the film | — (the app in the background) | H2 2666 | + +- 🟢 **M1 on hardware:** every re-open that got through its reads subscribed again (S3, S4, S8, S9, S12 AFTER_LOSS; S1, S10, S11 LINK_BACK; §2). The seven + `AFTER_LOSS` delays are 1.503–1.508 s (`LOSS_REOPEN_DELAY_MS` = 1 500 ms); the one skip is `CHAIN_GUARD_MS` working as ADR-044 says ("a loss within 10 s of + an automatic re-open schedules none"); afterwards nothing re-opened the session until the user's Connect (Android's link stayed connected, so no LINK_BACK + event came — as designed). The re-homes follow `PROTOCOL.md` §2.2a's L-1 (🟢 there): the Left alone out → 19 (S3), the Right alone out → 21 (S12), changes + with both out → the other channel (S4, S8, S9). +- 🟢 The Buds' closes that `CAP-075` saw only while the app was hidden happened here with the app on screen. None came within 5 ms of a change (the + 12-of-77 `DISC` case of `PROTOCOL.md` §4.1; the nearest: 3972 at 06:41:29.127, 2.15 s before the `DISC` 3994), so no push was lost to a `DISC` here; the + two reads that differed (S4, S7) followed a `DISC` without a push (S4) and the ACL's end with both buds in the case (S7) — §6.2. + +### 6.2 The first read after each re-open — and a cause that is overwritten (🟢 wire, 🟢 code) + +- The value read after every Connect/re-open (§2's table) equals the last pushed mode, except where nothing was pushed: S4 read 2 (Noise cancellation) after the + last push 3 (3972, Transparency) and the Buds' `DISC`; S7 read 2 after the last push 0/1 (4492/4502) and the ACL's end. **In both, the mode had changed while + the app had no session — the case ADR-065 item 4 / Q4 calls "Changed since the app last saw it".** +- **What the app did:** the snapshot claim's `Get` answered first (S4: `Notify` 4094 `e8 e8 08`, 06:41:33.418; S7: 5991, 06:43:07.250) → `causeOfNotify` → + `causeOfReadAnswer` → `CHANGED_SINCE_LAST_READ`; then the `ReadSetting 4:13` answer with the **same** mode (4135 at 06:41:34.165, +747 ms; 6003 at + 06:43:07.440, +190 ms) → `causeOfReadAnswer` → `READ` — it **replaced** the cause (`BudsRepositoryImpl.kt:385–386`: `if (… mode != _ancMode.value) + CHANGED_SINCE_LAST_READ else READ`). E1 logs only the second: 06:41:34.170 and 06:43:07.444 "Noise-control mode from the mode read (**read**)". Neither (i) + was opened before the next push changed the cause, so the film neither shows nor refutes the line; the two exports have **0** "changed_since" lines + (`grep -c changed_since`, exit 1; positive control `grep -c changed_by_buds` → 86 / 24, exit 0). +- 🟢 FACT (wire order + code): **the snapshot claim's `Notify` came before the `4:13` answer in 12 of 12 sessions** (by 107–1,459 ms: e.g. 1736 → 1750 + +107 ms, 3090 → 3124 +1,075 ms, 8929 → 8968 +1,459 ms; H2L 810 → 853, H2 485 → 546, 760 → 794, 1099 → 1137), **so a mode that changed while the app was not + connected is shown as "read", not as "changed since"** — the line stays only when the snapshot claim fails (Play refusing both attempts, or a cut). + ADR-065's (i) line 2 is therefore not reachable in the normal order. Severity: minor (an (i) line). Fix (§13 P1): a read answer equal to the shown mode keeps + the cause (`null`, as `causeOfStream`'s same-mode rule). + +### 6.3 "No mode settable" (M2 on hardware, 🟢) + +Six `4:{13:0}` (five read answers, one push: H1 4502) — each with both buds in the case; the app logged "… : no mode settable" and never showed 0 as a mode +(the ANC tab at 06:42:48 kept Off with "The Buds don't allow changing noise control right now (usually because no bud is in an ear) …"; film 1 s442); the next +1–4 (the snapshot's `Notify` or the read) allowed changes again (film 1 s462: the ANC tab without the line). + +## 7. Bluetooth off/on in part 2 (BL-20, 🟢) + +07:02:39.124 "Bluetooth adapter: ON -> TURNING_OFF" (film 2 s8) → H2L 1294 Disconnection Complete `0x16` 07:02:39.649 → E2 07:02:39.442 "Session loss cause: +Bluetooth was switched off on this phone" (final, not provisional — `ai-sessions/0064` F-3) → 07:02:44.681 ON → H2 151 ACL 07:02:45.890 → Android's link +CONNECTED 07:02:46.158 → LINK_BACK 07:02:46.159 → S11 with its subscription (540) and read (541 → 546 `4:{13:0}`). Part 1 had no Bluetooth off/on with 1.3.0 +(BL-10 not run; the one on film 1 was before the 1.2.2 session). + +## 8. Playback + +Part 1: AVRCP "Playing" 06:40:51.002 (H1 3553) after the Buds' PLAY 06:40:46.806; the Buds' PAUSE/PLAY at 06:40:53.995, 55.970, 06:41:10.617, 14.434, 19.327, +25.043, 29.130, 31.589, 41.315 (in-ear detection, on until 06:50:22); AVDTP START/SUSPEND 3569/3769, 3804/3881, 3926/4316 (06:40:51.4 … 06:41:44.8); after the +film AVRCP "Playing" 06:52:14.9. Part 2: AVRCP "Stopped" until 07:08:21.6 ("Playing", after the film); 37 AVDTP START/SUSPEND pairs of 3–8 s from 07:02:46 +(the phone's system sounds; no media session). Command: `tshark -r -Y "bthci_acl.chandle==0x000b && btavrcp" -T fields -e frame.number -e frame.time -e +_ws.col.Info` (exit 0; positive control `CAP-075-btsnoop_hci2.log` → 22 AVRCP frames incl. "PlayStatus: Paused"); AVDTP: `… && (btavdtp.signal_id==0x07 || +btavdtp.signal_id==0x09)` (exit 0). The volume panels on film (film 1 s370, s447–449, s701, s828; film 2 s172–173, s221–222) have no AVRCP volume frame +(🟡 phone keys, as `CAP-075` §6). + +## 9. `CAP-075` (1.2.1, the same two users) vs `CAP-076` (1.3.0) + +| Aspect | `CAP-075` set 1 (no Play) | `CAP-075` set 2 (Play) | `CAP-076` part 2 (no Play) | `CAP-076` part 1 (Play) | +|---|---|---|---|---| +| Holds on the noise-control tab | 41 | 31 (9 "hint", 19 "take it back", 3 stopped) | 12 | 29 | +| On screen within a second | **41 of 41** (the held claim) | **8 of 31** (2 late, 3 overdrawn, 2 after a user action, 16 never) | **12 of 12** (≤ 0.1 s) | **29 of 29** (≤ 0.1 s) | +| What carried them to the app | the app's held `Notify` claim | the app's claim when it had one; Play's otherwise | the settings stream (DLCI 0x02) | the settings stream — Play held DLCI 0x04 and got every `Notify` | +| The app's claims of DLCI 0x04 | 10 (holds of 14–36 s) | 19 (holds, re-claims, snapshots) | 11 (snapshots, Refresh, taps, Ring, Stop; ≤ 1.7 s each) | 22 (10 snapshots, 12 tap claims) — **0 holds, 0 re-claims** | +| Play's claims | 0 | 19 + 5 | 0 | 18 | +| Play's timers | — | recovery 5.33–5.82 s, retry 2.06–2.82 s | — | 5.13–5.97 s, 2.37–2.50 s (§1) | +| Taps | 0 `Set` | 0 `Set` | 4 `Set`, 4 ACKed at attempt 1 | 11 `Set`, 11 ACKed; 8 claims refused at attempt 1, 4 claims cut (§5.2) | +| "First hold only" pattern (`CAP-074`) | — | gone with the re-claim, ≈ 43 % coverage | gone | **gone** — the channel no longer matters for the holds | +| Session losses | 1 (the Buds, app hidden) | 1 (the Buds, app hidden) | 1 the Buds + 1 Bluetooth off + 1 ACL (after the film) | 6 the Buds' `DISC` + 1 ACL, all re-opened by ADR-044 or the user | + +**The answer per user (🟢 for this run):** without Play nothing changed for the holds (all shown, as `CAP-073`/`CAP-075` set 1) and the taps work at attempt +1; with Play the holds are now shown as well as without Play — the 1.2.x limit of `CAP-074`/`CAP-075` is gone — while each tap still meets Play's claim on +the shared channel (8 of 12 claims at attempt 2, 2 of 13 taps lost: one with a message, one silently). + +## 10. The counts for the verdict (the skeleton's analysis checklist) + +1. **The app's `SABM`s on DLCI 0x04 vs the user's actions (🟢):** part 1 22 = 10 snapshots (S0 … S9; S0 is 1.2.2's) + 12 claims for 13 taps (4 on the ANC tab, + 9 on the tile — E1's 9 "ANC tile tapped" lines; film 1 s553–576 and s618–662); part 2 11 = 4 snapshots (S10 … S13) + Refresh (07:04:32) + 4 taps + Ring + Stop. + **No claim the user did not cause.** +2. **Holds shown vs made (🟢):** ANC tab part 1 29 / 29, part 2 12 / 12; the Connection-tab stretch (part 1, 14 changes) not visible by design. +3. **Stream packets vs `Notify`s (🟢):** part 1: 109 field-13 `SERVER_STREAM` packets = 52 changes × 2 (both buds out) + 5 single packets (06:40:02, + 06:42:11 ×2, 06:47:07, 06:47:18) — with the 8 `4:13` answers the 117 of §3's filter; the 110 `Notify` frames: 42 on the app's claims (answers to its `Get`s and + `Set`s), 66 on Play's (every hold reached Play), 2 outside a claim's `SABM`–`DISC` window (`scripts/message_stream_claims.py`). Part 2: 34 packets = 16 × 2 + 2 single (07:03:02, + 07:03:07) — with H2's 3 `4:13` answers the 37 of §3; 16 `Notify`, all on the app's claims. **One push (or one pair) per hold, per own change and per + wear-driven switch; no hold without a push.** +4. **The first read after every Connect/re-open (🟢):** §2's table; the "changed since" line never shown — §6.2. + +## 11. The skeleton's "Refuted if", per step run + +| Step | Refuted if … | Outcome | +|---|---|---| +| BL-1 | a crash; a request missing, doubled or out of order; Safe Mode | **not refuted** (17 requests in order in every full session) | +| BL-2 | "-dirty"; another hash; the card or the line still there | **not refuted** | +| BL-3 | the subscription missing after the Connect | **not refuted** (S2 2838) | +| BL-4 | a hold without a stream packet; an app `SABM` on DLCI 0x04 during the holds; a mode shown that differs from the last packet's | **not refuted** for the wire (each change pushed; no app `SABM` 06:40:02–06:41:31 except the re-open's snapshot #9 at 06:41:33); the mode not shown on that tab | +| BL-5 | a hold not shown; an app `SABM`; a hint or "Take it back again" anywhere | **not refuted** (17 / 17; none) | +| BL-6 | the old hold sentence; the channel-taken line | **not refuted** | +| BL-7 | the stream packet of the own Set shown as "Changed by the Buds"; a `DISC` later than 2 s after the ACK | **not refuted** (the phone's `DISC` 1.507–1.516 s after the ACK in the 12 taps whose claim was not cut — e.g. 6726 06:44:37.547 → 6737 06:44:39.060 = 1.513 s; #34 and #35 were cut by Play 1.12 and 0.48 s after their ACK) | +| BL-8 | a second `SABM`; the app not following | **not refuted** for the 7 tile taps that got their `Set` through; the silent one (§5.3) did not follow — it changed nothing on the Buds either | +| BL-9 | a re-open without the subscription; a changed read without the line; the app sending anything in reaction to the packet | **refuted in part:** "a changed read without the line" — S4 and S7 (§6.2); the other two conditions not refuted | +| BL-11 | an app `SABM` not caused by a tap/tile/Refresh/pull/Connect | **not refuted** | +| BL-12 | a hold not shown | **not refuted** (12 / 12) | +| BL-14 | Safe Mode; "-dirty"; the card | **not refuted** | +| BL-16 | a hold not shown; an app `SABM` | **not refuted** | +| BL-17 | the own Set shown as the Buds' change | **not refuted** | +| BL-19 | as BL-9 | **not refuted** (S12's read equalled the pushed mode) | +| BL-20 | no subscription after the re-open | **not refuted** (S11 540) | +| BL-22 | the line missing after a differing read; the line after a read of the same mode | **not refuted** for the second (S13 equal, no line); the first not exercised (no hold while disconnected) | +| BL-23 | a value 0 shown as a mode | **not refuted** | +| BL-10, 13, 15, 18, 21, 24, 25 | — | not run | + +## 12. Verdict table (`RELEASING.md` C4 — the analysing session's proposal; the verdict is the maintainer's) + +| C4 category | Found? | Evidence | +|---|---|---| +| Crash | no | §0 (FATAL/ANR/StrictMode 0; 14 sessions) | +| Wrong write | no | §3 (the writes are the user's EQ/in-ear changes and 15 `Set`s, each answered; 0 `WriteSetting 4:13`; nothing on 0x08/0x0a) | +| Safe Mode on the verified firmware | no | `release_5.203` ×3, every write allowed | +| Lost session | **not by the app** | §6: 7 Buds-side closes and 2 ACL ends, every one re-opened by ADR-044 or the user's Connect; the skip by `CHAIN_GUARD_MS` left the session closed until the user tapped Connect (7.8 s) — as designed | +| ADR-064/065's main claim: every hold shown, any tab, both users, no claim | **verified** for the noise-control tab: 29/29 with Play, 12/12 without | §4 | +| The two requests once per session; the re-open subscribes again (M1); 0 never a mode (M2); the own Set as the app's (M3) | **verified** | §2, §6, §5.1 | +| Minor issues | (a) "Changed since the app last saw it" overwritten by the equal `4:13` answer (§6.2); (b) a tile tap cut after the `Get`'s answer sends nothing and says nothing (§5.3, 1 of 13); (c) a same-mode push logged with the kept cause (§4.4); (d) the per-tap collision with Play — 1.3.0's known issue, now measured (§5.2) | | +| Not verified by this run | a hold on the Connection, Sound or Controls tab **on screen** (BL-15/BL-21 not run; part 1's Connection-tab changes reached the app but that tab shows no mode); Home with holds (BL-24); Bluetooth off/on in the Play user (BL-10); another app's EQ/switch change (BL-13/BL-25); the ears (no camera) | | + +**The analysing session's view:** no heavy issue. The release goal of 1.3.0 is met in both users with the evidence of this run; (a)–(c) are minor and +fixable in a later version; (a) and (b) deserve a Known-issues line or a quick fix release (the maintainer's choice — Phase D). The decision is the +maintainer's (`RELEASING.md` C4). + +## 13. Open questions and proposals (proposals — no code in this session; `AGENTS.md` §6) + +| # | Proposal | Evidence | Design (sketch) | Cost | +|---|---|---|---|---| +| **P1** | **Keep "changed since" across the equal second read** | §6.2 (S4, S7) | `causeOfReadAnswer`: an answer equal to the shown mode returns `null` (keeps the cause), as `causeOfStream` does; a test with `CAP-076` H1 4094 → 4135 | small | +| **P2** | **A tile tap whose claim closes before its `Set` reports a failure** | §5.3 | the `Set`'s wait ignores a `Notify`/outcome received before the `Set` was written (a sequence number or a timestamp check), so a cut is `AnswerCutOff`/a send failure → the toast; a test emitting the `Get`'s `Notify` with a delay | small–medium | +| P3 | Log a same-mode push as "unchanged" | §4.4 | `applyAncState`: log the packet's own classification (`unchanged`) instead of the kept cause | trivial | +| P4 | Settle the two packets per change | §4.3 | the experiment of §4.3 (one Connect per fresh ACL; the official app on the same ACL) | a capture | +| P5 | Shorter attempt-2 latency (`CAP-075` §9 P3, still open) | §5.2: attempt 2 0.87–1.11 s after the refusal, 8 of 12 tap claims | unchanged from `TODO.md` §5 | small | +| P6 | Show the mode on the Connection tab (or the notification's line) | §4.2: 14 changes there reached the app but no screen | UI only | small | + +**Open questions:** ❓ the two same-mode pushes of 06:47:07/18 (single, both buds out of the case — a wear change of one bud with in-ear detection on?); ❓ the +seven CloseGuard lines (§0) — which sockets; ❓ `PROTOCOL.md` §6's "stored mode per wear state" — this run has three wear-driven switches coinciding with the +Buds' PAUSE/PLAY (§4.2) but no film of the ears; **it does not settle the item** (no promotion proposed); ❓ why the official app's captures show one packet per +change (§4.3). + +**`PROTOCOL.md` note (proposal, for the maintainer):** §4.1 — "OpenControl 1.3.0 subscribes once per MAESTRO session (`CAP-076`, 12 of 12 sessions, byte- +identical to `CAP-051` 1177/1178 and `CAP-056` 1451/1450); the Buds pushed every reported change of setting 13 to it, also while Google Play services held the +Message Stream (41 of 41 holds shown); with both buds out of the case each change came twice, with channel 21 and 19 (🟡 one per bud core)." + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS.md - https://tedsluis.github.io/opencontrolpixelbudspro2/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-41fc9acad7b3.txt b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-41fc9acad7b3.txt new file mode 100644 index 00000000..c24e0c2f --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-41fc9acad7b3.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66ade0a57d19c900ca59b1fba0056b550afc0576d2bf1418a6243b244ce51f75 +size 64047 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-a1c4370c8dcb.txt b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-a1c4370c8dcb.txt new file mode 100644 index 00000000..df610b55 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-OpenControl-for-Pixel-Buds-Pro-2-log-a1c4370c8dcb.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e7fcc667670eaa055c028aa0d60966e54b3ebb4cc66911180fb22268bd65adc +size 82247 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-checks-0092.log b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-checks-0092.log new file mode 100644 index 00000000..92d7d07f --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-checks-0092.log @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88ccbd29285bef7692bad3cdfd4106fc4119a6b08edd4f98de671c3613a2e380 +size 1838 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-shell.log b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-shell.log new file mode 100644 index 00000000..a03a8f41 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-adb-shell.log @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:307f948a714f24d7b136bb8e56809413f4d7f1e87254d06e94320447b049dbc9 +size 1473 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log new file mode 100644 index 00000000..70470268 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b171e3caf6e8c5256e401c4d2b8711d866bc2868e589660f163cf89e75e400f1 +size 607389 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log.last b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log.last new file mode 100644 index 00000000..153ca86b --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci1.log.last @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:489c44ce60ee69930dae0a00e52586426c787dac107e631cdb46fc894d395b65 +size 833776 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log new file mode 100644 index 00000000..87dfa067 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3674b9fb32d67cd0ad35a705ce6dc03136a3db8f95d20c5737657ebabbfbddf +size 133422 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log.last b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log.last new file mode 100644 index 00000000..d164d0b4 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-btsnoop_hci2.log.last @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c332bc7d9cba4c4a6f300d3d3457187d6c94d61fcd316e239a45599112db3f4 +size 71981 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-logcat-extract.txt b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-logcat-extract.txt new file mode 100644 index 00000000..b45eef64 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-logcat-extract.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15f2a497d17a0a0e7f42420383594ea5459fe623e141d7a02b1e3955963add91 +size 2295290 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-065105.txt b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-065105.txt new file mode 100644 index 00000000..7f9fef5f --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-065105.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e455b90251eb72f07db2c91c84c2f67a3f103b54e852d21bb73f66d3a0644d64 +size 99810 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-070741.txt b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-070741.txt new file mode 100644 index 00000000..ea3c1813 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-opencontrol-debug-20261015-070741.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b3ff719e7bdfb7e648ff7b1faacdaf4bab5dbf434b741f919a361d7505d6af5 +size 41811 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording1.mp4 b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording1.mp4 new file mode 100644 index 00000000..a3a7e650 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording1.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e008aa17aa289198b0f6dbddb07ce5935485889fec463c7659a026cba69d711b +size 247857029 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording2.mp4 b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording2.mp4 new file mode 100644 index 00000000..bb741854 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-recording2.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd4e51ec46a0c649a71a26870095e27246447869695b972492983e5fbb91999c +size 111446582 diff --git a/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-sha256sum.txt b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-sha256sum.txt new file mode 100644 index 00000000..40f80e80 --- /dev/null +++ b/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-sha256sum.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d48ac09d57856d6ae0fb06ce2f6153482fe606ab581334164c4786ab46e54fa +size 1307 diff --git a/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md b/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md deleted file mode 100644 index 16ba1fe4..00000000 --- a/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md +++ /dev/null @@ -1,167 +0,0 @@ -# Event Notes: OpenControl for Pixel Buds Pro 2 on Pixel 9a (GrapheneOS, the Owner user **with** Google Play services and the secondary user without) — Group BL, 1.3.0: the settings stream on the app's own channel (`CAP-076`) - -**Status:** 🔲 **Not yet captured — skeleton only** (written by `ai-sessions/0089` for 1.2.2 on 2026-10-13; **rewritten by `ai-sessions/0091` for 1.3.0 on -2026-10-14** — 1.2.2 was released without this run, `TODO.md` §1). The hardware run of **1.3.0** (versionCode 10300; `DECISIONS.md` ADR-064 and ADR-065): at every -MAESTRO session (Connect and every ADR-044 re-open) the app sends one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` after `SubscribeRuntimeInfo`, so -the Buds push every change of the noise-control mode — a press-and-hold on a bud, their own switch, the app's own `Set` — as a `4:{13:n}` packet on the app's -own channel (DLCI 0x02), **whatever tab is shown and without a claim of the shared channel DLCI 0x04**; every other setting the app reads follows the same -stream. The hold of 1.2.0 (ADR-061), the re-claim with the setting "Noise control with Google Play services" (ADR-062) and the Play-services line with the -`` entry (ADR-063) are gone. **Two new requests on the wire, nothing else new**: the official app's bytes (`CAP-051` 1177/1178, `CAP-056` 1451/1450). -**Two parts, both GrapheneOS users, the extended tier** (`RELEASING.md` §11a): **part 1** the Owner user (user 0) with Play services — where 1.2.x showed a hold -about half of the time at best; **part 2** the secondary user (user 10) without Play. The maintainer films; a later CAPTURE session analyses it and gives the -release verdict (`RELEASING.md` C3/C4). After the run: rename this folder to the screen recordings' first/last phone times and the recordings to -CAP-076-recording1.mp4 / CAP-076-recording2.mp4; the camera films to CAP-076-camera1.mp4 / CAP-076-camera2.mp4. - -**Expected (from the official app's captures — `CAP-051-FINDINGS.md` §2–§3, `CAP-056-FINDINGS.md` §4, `CAP-038-FINDINGS.md` §3; `ai-sessions/0091` RESULT -§A.1):** the stream packet for a hold follows the Buds' `Notify` by 1–144 ms (`CAP-051` 1707 → 1717, 1754 → 1755, 1801 → 1802), also while no client holds the -Message Stream (`CAP-038` 2170, 2205); so **every hold shows on screen within a second, on any tab, in both users**, with "Changed by the Buds at HH:MM:SS" in -the noise-control (i). The app's own tap: `Set` → ACK → `Notify` → the stream packet 269 ms after the Set (`CAP-051` 1527 → 1534) — shown as the app's change. -A wear change the Buds answer with a `DISC` of DLCI 0x02 (12 of 77 in the captures, `DESKRESEARCH_FINDINGS.md` 2026-10-14): the ADR-044 re-open ≈ 1.5 s later -subscribes again and reads 13 — the mode on screen may be a few seconds old in between. - -**Not in this run** (say so if asked): `ReadSetting 4:8/9/10` (the maintainer's choice in chat 2026-10-14, "Not part of this run"); `WriteSetting 4:{13:n}` -(`TODO.md` §4 E-01, needs its own approval); the case-sound experiment. - -## Log Metadata - -| Field | Value | -|---|---| -| Capture ID | `CAP-076` | -| Group(s) | BL (new) | -| Date | TBD | -| Phone | Pixel 9a, GrapheneOS (Android 17) — **part 1: the Owner user (user 0) with Google Play services** (`CAP-074`/`CAP-075` set 2's user); **part 2: the secondary user (user 10) without Google Play** (`CAP-073`/`CAP-075` set 1's user) | -| App under test | OpenControl for Pixel Buds Pro 2 **1.3.0** (versionCode 10300), the release APK of `scripts/release.sh 1.3.0` (B1), kept as `~/opencontrol-1.3.0-tested` (B3) — **Info tab on film in each user**: "App: 1.3.0, build ()", no "-dirty"; APK SHA-256 and certificate SHA-256 from the script's output; **the installed APK pulled** (P1) | -| Previous version in each user | 1.2.2 build `bfd7891` (installed 2026-10-14 with Obtainium in the Owner user; the secondary user's version on film) — **the stored "Noise control with Google Play services" choice of 1.2.1/1.2.2 is ignored by 1.3.0** (ADR-065 item 5: never read, never migrated); the card is gone | -| Google Play services (part 1) | present and active (`com.google.android.gms`, uid 10205 in `CAP-074`/`CAP-075`); **its *Nearby devices* permission state and its version on film** (P0 — `TODO.md` §3's standing item, not done in `CAP-075`) | -| Official Pixel Buds app | not used on this phone; **optionally on a second phone** for BL-13 / BL-25 (the EQ or a switch changed there — the stream packet reaches OpenControl) — say if no second phone was available | -| Buds | Pixel Buds Pro 2, firmware `release_5.203` expected (any other ⇒ Safe Mode: say so, stop) — both buds **worn** for every hold step (Settable `e8`; `00` means no bud worn, ADR-049) | -| Video files | TBD — the screen recording **with sound** per part (say each action and each hold aloud, as in `CAP-075`) **and a camera on the ears and the phone** for the hold steps (the hold on the bud and the screen in one frame; no address overlay — `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §3) | -| Log files | TBD — the HCI snoop logs, the app's debug export per part (Debug mode on), the app logcat, the system log with the GrapheneOS log viewer at the end of each part, the P0/P1 outputs | - -## Preparation - -| # | Check | Done | -|---|---|---| -| P0 | **In each user**: `adb shell am get-current-user`; `adb shell pm list packages --user \| grep -i -E "gms\|vending"; echo "exit=$?"` (part 1: Play listed, exit 0; part 2: nothing, exit 1); positive control `grep opencontrol` (exit 0). **Part 1 on film:** Settings → Apps → Google Play services → Permissions → *Nearby devices* (allowed / not allowed) and the app's version line. Save the outputs **with the exit statuses** | ☐ | -| P1 | In the installed 1.2.2 (`bfd7891`): Debug mode **on** (on film); gear → Settings: the card "Noise control with Google Play services" and its selected option on film (what 1.3.0 will ignore). `adb shell dumpsys package io.github.tedsluis.opencontrolpixelbuds \| grep -E "firstInstallTime\|lastUpdateTime\|versionCode"` → save. `adb install -r ~/opencontrol-1.3.0-tested/opencontrol-pixelbudspro2-1.3.0.apk` (one APK serves both users) — **no uninstall**; the same `dumpsys` → save. After the run: `adb shell pm path --user io.github.tedsluis.opencontrolpixelbuds` → `adb pull` → `sha256sum` against `~/opencontrol-1.3.0-tested` | ☐ | -| P2 | HCI snoop log on (set in the Owner; it stays on across the user switch) — **Bluetooth off and on on film** after enabling it | ☐ | -| P3 | The camera on a stand: the ears and the phone screen in one frame for BL-5 … BL-8 and BL-18 … BL-21; the phone's clock with seconds visible, or the status bar at a minute change at the start and the end | ☐ | -| P4 | Android's **screen recorder with sound** on for each part | ☐ | -| P5 | The status bar across a minute change at the start and the end of each recording | ☐ | -| P6 | **The system log at the end of each part** (the GrapheneOS log viewer, as `CAP-075`'s L) — crashes, ANRs, `StrictMode`; in part 1 Play's `RfcommEventStreamMedium` lines around the taps of BL-10/BL-11. `adb bugreportz` sends signal 3 to every Java process — not an app fault (`CAP-071-FINDINGS.md` §0) | ☐ | -| P7 | **Do Not Disturb on** | ☐ | -| P8 | Buds charged, **both worn** before BL-4; Android shows them connected before the app is opened | ☐ | -| P9 | **No app use before the film** — not opened between P1's install and BL-1 | ☐ | -| P10 | Optional: a second phone with the official Pixel Buds app paired to the same Buds (multipoint) for BL-13 / BL-25 — say on film if there is none | ☐ | - -**Rhythm:** one action, then 5–10 s; say what you do ("left hold", "right hold", "tap", "tile", "bud out", "Bluetooth off"). Export the debug log at the end -of each part. Serials: never unredacted in any note (first 4 + last 2 characters). - -## Steps - -DLCIs are session-local (MAESTRO 0x02 or 0x03, Message Stream 0x04 or 0x05). Every MAESTRO request starts `7e 00 3b 03 10 13 1d ea 71 de 7d 5e 25` on -**channel 19** or `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25` on **channel 21** (Info → "Control channel"). **Reference frames** — the official app's bytes, -re-derived by `ai-sessions/0091` with `tshark -r -Y 'frame.number==N' -T fields -E separator='|' -e frame.number -e frame.time -e frame.p2p_dir --e bthci_acl.chandle -e btrfcomm.dlci -e data.data` (exit 0) after scoping to the Buds' ACL handle, decoded with `python3 scripts/pwrpc_decode.py ` -(a payload holding several HDLC frames split on `7e`, each CRC-32 verified): - -| Request / event | Reference (whole pw_hdlc frame incl. CRC) | -|---|---| -| The Connect: `SABM` DLCI 0x02, then `ReadSetting 4:16`, 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29, `SubscribeRuntimeInfo`, **`SubscribeToSettingsChanges`, `ReadSetting 4:13`**, `GetHardwareInfo` — **fifteen MAESTRO requests** | `CAP-075` H2 1193 … 1302 (channel 19) / H1 762 … 841 (channel 21) for the thirteen of 1.2.x (`CAP-075-FINDINGS.md` §2), then the two new ones below, then `GetHardwareInfo` (`7e003b0310131dea71de7d5e25e3a5ec28ff1ebbb77e` on 19, `7e004b0310151dea71de7d5e25e3a5ec28f96761b57e` on 21) | -| **`SubscribeToSettingsChanges` REQUEST** (method `0x2821adf5`, empty payload) | channel 21: `CAP-051` 1177 — `7e004b0310151dea71de7d5e25f5ad2128019754b07e`; channel 19: `CAP-056` 1451 / `CAP-051` 2229 — `7e003b0310131dea71de7d5e25f5ad212807ee8eb27e`. Find them: `tshark -r CAP-076-btsnoop_hci1.log -Y 'btrfcomm.dlci==2 \|\| btrfcomm.dlci==3' -T fields -e frame.number -e frame.time -e frame.p2p_dir -e data.data \| grep -i "25f5ad2128019754b0\\\|25f5ad212807ee8eb2"` — **one per MAESTRO session, direction 0 (sent)** | -| **`ReadSetting 4:13` REQUEST** | channel 21: `CAP-051` 1178 / `CAP-036` 1517 — `7e004b0310151dea71de7d5e2551aed0ae2a02200d9e82abac7e`; channel 19: `CAP-056` 1450 / `CAP-021` 1069 — `7e003b0310131dea71de7d5e2551aed0ae2a02200d24c239477e`. Find: `… \| grep -i "2a02200d"` — one per MAESTRO session, right after the subscription | -| **Its RESPONSE `4:{13:n}`** (type 1; the first read's value after every Connect/re-open) | channel 19: `7e80a3032a04220268 0n 080110131dea71de7d5e2551aed0ae` + CRC — n = 0 `…c6b9d8857e` (`CAP-051` 2247, after the Buds' own `DISC`), 1 `…2e62233c7e` (`CAP-021` 1089), 2 `…57085e2d7e` (`CAP-056` 1468), 3 `…bfd3a5947e` (`CAP-027` 923); channel 21: n = 0 `7e00a5032a0422026800080110151dea71de7d5e2551aed0ae651b08147e` (`CAP-045` 2033), 4 `…067d5e749e7e` (`CAP-051` 1225). 1 = Off, 2 = Noise cancellation, 3 = Transparency, 4 = Adaptive, **0 = no mode settable** (no bud worn) | -| **The stream packet `4:{13:n}`** (type 7 `SERVER_STREAM`, direction 1, the Buds → the phone) — **one per hold, per own tap and per wear-driven switch** | channel 19: `7e80a3032a04220268 0n 080710131dea71de7d5e25f5ad2128` + CRC — n = 1 `…117bf5557e` (`CAP-021` 1842), 2 `…681188447e` (`CAP-038` 2205, `CAP-056` 2324/4028), 3 `…80ca73fd7e` (`CAP-038` 2170), 4 `…9ac572667e` (`CAP-056` 2259/4025); channel 21: 0 `7e00a5032a0422026800080710151dea71de7d5e25f5ad21285a02de7d5d7e` (`CAP-045` 2277), 2 `…cbb358d57e` (`CAP-051` 1717), 3 `…2368a36c7e` (1802/1534), 4 `…3967a2f77e` (1755). Find: `… \| grep -i "0807101[35]1dea71de7d5e25f5ad2128"`; count them against the holds made | -| A stream packet for another setting (BL-13 / BL-25, the official app on a second phone) | field 16 (the EQ): `CAP-015` 2116 `7e80a3032a1e221c8201190d…080710131dea71de7d5e25f5ad2128…`; field 2: `CAP-056` 2175 `7e80a3032a0422021001080710131dea71de7d5e25f5ad2128475780f97e`; field 12: `CAP-056` 1696; field 29: `CAP-069` 2502 — the value on screen with the packet's time ("read HH:MM:SS" in the card's (i)) | -| The app's own tap (unchanged, ADR-032) | `SABM` DLCI 0x04 (or 0x05), `08 11 00 00`, `Notify`, `08 12 00 14 01 e8 e8 …`, ACK `ff 01 00 06 08 12 01 e8 e8 `, then the Buds' `Notify` and **the stream packet of that mode on DLCI 0x02** (`CAP-051` 1527 → 1529 → 1530/1533 → 1534: 269 ms); the phone's `DISC` of DLCI 0x04 ≈ 1.5 s after the answer (`MESSAGE_STREAM_LINGER_MS`) | -| Play's Fast Pair claim (part 1) | `SABM` DLCI 0x04, first message `03 08 00 02 01 25` (`CAP-075-FINDINGS.md` §1) — its presence is expected and changes nothing for the stream | -| The Buds' own `DISC` on a wear change (BL-9 / BL-22) | `DISC` DLCI 0x02 from the Buds (`CAP-051` 1996, 5 ms after the Notify 1993) → the app's loss line → ADR-044's re-open ≈ 1.5 s later: `SABM` DLCI 0x02, the fifteen requests, the subscription and the read again (`CAP-051` 2229/2231 → 2247) | - -Anything else from the app on the wire (a request not in this table or in `CAP-075` §2/§3, a second `SubscribeToSettingsChanges` or `ReadSetting 4:13` within one -session, a `WriteSetting 4:{13:n}`, anything on DLCI 0x08/0x0a, a `SABM` on DLCI 0x04 that is not a tap, the tile, Refresh, a pull or the Connect's snapshot) is -a finding. - -### Part 1 — the Owner user (user 0) **with** Google Play services (screen recording with sound + the camera; `APP_TESTPLAN.md` Y1–Y13; `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`…`ANC-004`, `TOUCH-007`) - -| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | -|---|---|---|---|---|---|---| -| BL-1 | P0–P10 done, user 0, both buds worn | open the app | the session opens by itself (ADR-044); "Probably worn"; Left/Right %, the Case; the mode | DLCI 0x02 `SABM`; the **fifteen** requests in order — the subscription and `ReadSetting 4:13` after `SubscribeRuntimeInfo`, `GetHardwareInfo` last — answered; the snapshot claim (one `SABM` DLCI 0x04, `08 11` → `Notify`, `03 03` burst, released ≈ 1.5 s later) | `PAIR-003`, `BATT-004` | a crash; a request missing, doubled or out of order; Safe Mode | -| BL-2 | ready | gear → **Info**: 3 s on the build line, the firmware, the serials; **Settings**: 3 s on the whole tab | "App: 1.3.0, build ", no "-dirty"; `release_5.203` ×3; Settings shows **Dark mode, Case sounds, Use different Buds — no "Noise control with Google Play services" card, no "Google Play services: …" line** | nothing | `FW-003` | "-dirty"; another hash; the card or the line still there | -| BL-3 | back on Connection | **Disconnect**, wait 5 s, **Connect** | ready | the phone's `DISC` of DLCI 0x02; the fifteen requests again (the subscription and the read once more — one per session); the snapshot claim | `PAIR-003` | the subscription missing after the Connect | -| BL-4 | ready, stay on the **Connection** tab, both buds worn, the camera on the ears | **press and hold a bud 10 times, ≈ 6 s apart** (alternate left/right); say each; **touch nothing on the phone**; count aloud the mode changes you see on the Connection tab's noise-control line (if any) — then open the **ANC** tab once and read the mode | the mode in the app follows **every** hold within a second (the tab need not be open: the notification's mode line and, on opening ANC, the current mode) | per hold: **one stream packet `4:{13:n}` on DLCI 0x02** (direction 1), no `Notify` (no claim), **no `SABM` on DLCI 0x04 from the app**; Play's own claims may come and go | `TOUCH-007`, `ANC-001`…`004` | a hold without a stream packet; an app `SABM` on DLCI 0x04 during the holds; a mode shown that differs from the last packet's | -| **BL-5** | the **ANC** tab open, both buds worn, the camera on the ears | **press and hold a bud 10 times, ≈ 6 s apart**; say each; count aloud the holds you see change on screen | **10 of 10** holds change the mode on screen within a second; the (i) after one of them: **"Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves."**; the (i)'s last sentence "The Buds report every change of the mode on the app's own channel. …" | one stream packet per hold; **no app `SABM` on DLCI 0x04** (the ANC tab claims nothing by itself since 1.3.0) | `TOUCH-007`, `ANC-001`…`004` | a hold not shown; an app `SABM`; a hint or "Take it back again" anywhere | -| BL-6 | the ANC tab | open the ANC **(i)** right after a hold, read it, close | the mode with its time; the "Changed by the Buds at …" line; "Connection: Ready"; the last sentence with "that part is a hypothesis" | nothing | `TOUCH-007` | the old hold sentence; the channel-taken line | -| BL-7 | the ANC tab | **tap a mode** (one tap); wait 5 s; open the (i) | the tapped mode at once; the (i) without a "Changed by the Buds" line (the app's own change) | one `SABM` DLCI 0x04 + `08 11` + `08 12` + ACK; the Buds' `Notify`; **the stream packet of the tapped mode on DLCI 0x02 within ≈ 0.3 s of the `Set`**; the phone's `DISC` ≈ 1.5 s after the ACK; if Play is on the channel at that moment: attempt 1 refused, attempt 2 connects (`CAP-075` §5) — or the tap fails with its message: tap again, say so | `ANC-001`…`004` | the stream packet of the own Set shown as "Changed by the Buds"; a `DISC` later than 2 s after the ACK | -| BL-8 | the ANC tab | pull **Quick Settings** down; tap the **ANC tile** once; close; look at the app | the tile steps one mode; the app follows at once | as BL-7: one claim for the tile's `Set`; the stream packet; the `DISC` ≈ 1.5 s later | `ANC-001`…`004` | a second `SABM`; the app not following | -| **BL-9** | the ANC tab, **both buds worn**, the camera on the ears | **take one bud out** (say which); wait 10 s; **put it back**; wait 10 s; read the mode after each | if the Buds close the session (the loss line "… the Buds closed …", ready again ≈ 1.5 s later by itself): after the re-open the mode read (`ReadSetting 4:13`'s answer) — the (i) says **"Changed since the app last saw it (read at HH:MM:SS): …"** if it differs from the mode before, nothing if it is the same; if the Buds keep the session: the stream packet of their own switch, "Changed by the Buds" (the stored mode per wear state, 🟡 `PROTOCOL.md` §6) | either the Buds' `DISC` of DLCI 0x02 → the re-open's `SABM`, the fifteen requests, **the subscription and the read again** (the first read's value after the re-open), or a stream packet `4:{13:n}` without a `DISC` — say which happened | `PAIR-003`, `TOUCH-007` | a re-open without the subscription; a changed read without the line; the app sending anything on reaction to the packet | -| BL-10 | ready | **Bluetooth off** (Quick Settings); wait 5 s; **Bluetooth on**; wait 15 s | "Bluetooth was switched off" as the loss cause; ready again by itself once Android reconnects the Buds | the loss; the re-open's `SABM` DLCI 0x02, the fifteen requests — **the subscription again** (M1 on hardware) | `PAIR-003` | no subscription after the re-open | -| BL-11 | ready, the ANC tab | **tap a mode** while Play's claim is likely open (any moment — Play holds the channel most of the time in this user); if the tap fails ("… could not claim …"), wait 5 s and tap again | the mode after at most two taps; nothing else; **no hint, no "Take it back again"** | one claim per tap; a refused attempt 1 with Play's `DISC` is the `CAP-075` §5 collision — write down how many taps failed; **the app never re-claims by itself** | `ANC-001`…`004` | an app `SABM` not caused by a tap/tile/Refresh/pull/Connect | -| BL-12 | the ANC tab | hold a bud **5 more times** ≈ 6 s apart | 5 of 5 shown | 5 stream packets; no app `SABM` | `TOUCH-007` | a hold not shown | -| BL-13 (optional, P10) | the Sound tab | on the **second phone**, in the official Pixel Buds app: move one EQ slider, then switch **In-ear detection** off and on (Controls); watch OpenControl | the equalizer follows within a second ("read HH:MM:SS" in its (i), not "changed by this app"); the switch follows | the stream packets for field 16 and field 2 on OpenControl's DLCI 0x02 (the official app writes on its own session) | — | a value not following; a write from OpenControl in reaction | -| BL-end1 | — | Debug → **Export**; the system log; the status bar across a minute change; stop the recordings; **switch to user 10** | "Debug log saved (N lines)." | — | — | an empty file | - -### Part 2 — the secondary user (user 10) **without** Google Play (screen recording with sound + the camera; `APP_TESTPLAN.md` Y14–Y22) - -| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | -|---|---|---|---|---|---|---| -| BL-14 | user 10, P0 (no Play), both buds worn, the recordings on | open the app; **Info** 3 s; **Settings** 3 s | the session opens by itself; "App: 1.3.0, build "; no Play card, no Play line | the Connect sequence with the **fifteen** requests (the subscription and the read); the snapshot claim released ≈ 1.5 s after its answer | `PAIR-003`, `FW-003`, `BATT-004` | Safe Mode; "-dirty"; the card | -| BL-15 | ready, the **Connection** tab | **hold a bud 10 times** ≈ 6 s apart; say each; then open ANC | every hold followed (the mode line / the notification; ANC shows the last) | 10 stream packets; **no app `SABM`** on DLCI 0x04; 0 Play claims (positive control: part 1's count) | `TOUCH-007`, `ANC-001`…`004` | a hold without a packet; an app `SABM` | -| **BL-16** | the **ANC** tab, both worn | **hold a bud 10 times** ≈ 6 s apart | 10 of 10 shown within a second; "Changed by the Buds at …" in the (i) | 10 stream packets; no app `SABM`; **no `DISC` of DLCI 0x04 while the tab is shown — because there is no claim to cut** | `TOUCH-007`, `ANC-001`…`004` | a hold not shown; an app `SABM` | -| BL-17 | the ANC tab | **tap a mode**; wait 5 s; the (i) | the mode; no "Changed by the Buds" line | one claim: `08 11`, `08 12`, ACK, the `Notify`, the stream packet ≈ 0.3 s after the Set, the `DISC` ≈ 1.5 s after the ACK | `ANC-001`…`004` | the own Set shown as the Buds' change | -| BL-18 | the ANC tab | the **ANC tile** once; close; the app | the tile steps one mode; the app follows | as BL-17 | `ANC-001`…`004` | — | -| BL-19 | the ANC tab, both worn | **one bud out**; wait 10 s; **back in**; wait 10 s | as BL-9 (the re-open's read with "Changed since the app last saw it" if it differs, or the Buds' own switch as "Changed by the Buds") | as BL-9 | `PAIR-003`, `TOUCH-007` | as BL-9 | -| BL-20 | ready | **Bluetooth off**; 5 s; **on**; wait 15 s | the loss named; ready again by itself | the re-open's fifteen requests — the subscription again | `PAIR-003` | no subscription after the re-open | -| BL-21 | the Controls tab | hold a bud 5 times ≈ 6 s apart; then ANC | the last mode on ANC; (i) "Changed by the Buds" | 5 packets, no app `SABM` | `TOUCH-007` | — | -| BL-22 | ready | **Disconnect**; wait 5 s; hold a bud twice (no session); **Connect**; the ANC tab, the (i) | after the Connect the mode read; the (i) **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected."** | the phone's `DISC`; nothing while disconnected; the Connect's fifteen requests; the read's answer with the new mode | `PAIR-003`, `TOUCH-007` | the line missing after a differing read; the line after a read of the same mode | -| BL-23 | the ANC tab | both buds **into the case**; wait 15 s; **out into the ears**; wait 15 s | the session ends with its cause (or the Buds report "no mode settable": the mode kept, "The Buds don't allow changing noise control right now …"); ready again by itself; the mode read | the loss (or a stream packet `4:{13:0}` — `CAP-045` 2277 — and no loss); the re-open's requests; the read's answer (0 while in the case, 1–4 once worn) | `PAIR-003` | a value 0 shown as a mode | -| BL-24 (optional) | the ANC tab | **Home**; wait 10 s; hold a bud twice; return | on return the holds' result shown (the stream arrived while hidden — the session stays open in the foreground service) | 2 packets while hidden; no `SABM` | `TOUCH-007` | — | -| BL-25 (optional, P10) | the Sound tab | the second phone's official app: an EQ slider; a switch | OpenControl follows | the stream packets for 16 / the switch's field | — | — | -| BL-end2 | — | Debug → **Export**; the system log; the status bar across a minute change; stop the recordings; P1's APK pull | "Debug log saved (N lines)." | — | — | — | - -## Don'ts - -- Do not uninstall the installed 1.2.2 and do not clear the app's data (P1 — the run is an update; the ignored 1.2.x choice must still be in the file). -- Do not open the app before the film (P9). -- In BL-4, BL-5, BL-12, BL-15, BL-16 and BL-21 touch nothing on the phone while holding the buds; say each hold; keep the ears on camera. -- Do not tap two things within 5 s of each other. -- Do not write a serial number unredacted anywhere in this folder (first 4 + last 2 characters); no Wi-Fi name, phone number, account name or e-mail in any - committed text (the system log is committed as an extract, as `CAP-075`); the camera films carry no address overlay. - -## After the run - -Into this folder: the screen recordings and the camera films, the system logs (as extracts — the full logs and the originals to -`~/opencontrol-capture-originals/CAP-076/`), every debug export, the app logcats if saved, the HCI snoop logs, the P0/P1 outputs with their exit statuses, the -pulled APK's `sha256sum`, `dist/1.3.0/`'s `.sha256` and the script's certificate line; then `sha256sum *` into CAP-076-sha256sum.txt. Commit on the release -branch after the build commit (`RELEASING.md` C3) — never on `main`. - -## Analysis checklist - -- [ ] Every filter scoped to the Buds' ACL handle (the Connection Complete for the Buds' address with that handle shown); DLCIs by content; a negative with its - command, its exit status and a positive control (`AGENTS.md` §13 step 8). -- [ ] P0 (part 1: Play listed, the *Nearby devices* permission state and Play's version on film; part 2: no Play, exit 1 with the positive control), P1 (the - update: `firstInstallTime` unchanged, `lastUpdateTime` later, `versionCode=10300`); the Info frame's hash = B1's; the pulled APK's SHA-256 = B1's. -- [ ] **The two new requests, byte-identical** (the table above), **once per MAESTRO session**: count the `SubscribeToSettingsChanges` REQUESTs and the - `ReadSetting 4:13` REQUESTs against the `SABM`s of DLCI 0x02 (Connects + re-opens) in each log — equal; their position after `SubscribeRuntimeInfo` and - before `GetHardwareInfo`. -- [ ] **The counts for the verdict:** (1) OpenControl's `SABM`s on DLCI 0x04/0x05 (owner by the first phone message, `08 11` = the app, `03 08 00 02 01 25` = - Play — `CAP-075-FINDINGS.md` §1's method) **vs the taps, tile taps, pulls and Connects/re-opens made** — equal (no claim the user did not cause); - (2) **holds shown vs holds made** per step (BL-4, 5, 12, 15, 16, 21 — expected all); (3) **stream packets `4:{13:n}` vs `Notify`s** — one packet per - hold, per own tap and per wear-driven switch, with the `Notify`s only inside the app's or Play's claims; (4) **the first read's value after every - Connect/re-open** (the RESPONSE after each `ReadSetting 4:13`), its (i) line when it differs from the mode shown before. -- [ ] **BL-7 / BL-17:** the own tap's stream packet classed as the app's change (no "Changed by the Buds" line in the film); its delay after the `Set`. -- [ ] **BL-9 / BL-19 / BL-23:** which of the two happened (the Buds' `DISC` → re-open → read, or a stream packet without a `DISC`); the mode before and after; - the (i) line; a value 0 never shown as a mode (the "not allowed" line instead). -- [ ] **BL-10 / BL-20:** the subscription after the Bluetooth-off re-open (M1 on hardware). -- [ ] **BL-11:** the taps that collided with Play (`CAP-075` §5's refusal), how many, and that nothing re-claimed afterwards — the known issue of 1.3.0. -- [ ] **BL-13 / BL-25 if run:** the official app's writes on its own session, the stream packets on OpenControl's, the values on screen with the packet's time. -- [ ] The system logs: crashes, ANRs, `StrictMode`; the `bugreportz` SIGQUITs discounted. -- [ ] One row per step — done / not done / done differently; `APP_TESTPLAN.md` section Y and the Summary; traceability (`AGENTS.md` §13 step 7): `PAIR-003` - (BL-1, BL-3, BL-9, BL-10, BL-14, BL-19, BL-20, BL-22, BL-23), `BATT-004` (BL-1, BL-14), `FW-003` (BL-2, BL-14), `ANC-001`…`ANC-004` (BL-4, BL-5, BL-7, - BL-8, BL-11, BL-15 … BL-18), `TOUCH-007` (BL-4 … BL-6, BL-9, BL-12, BL-15, BL-16, BL-19, BL-21, BL-22, BL-24) — each referenced in the timeline or flagged - "expected but not observed". -- [ ] Update `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9, `id_registry.csv`, `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the Group column: BL), `APP_TESTPLAN.md`, `TODO.md` - §1/§2/§3; write the findings file (CAP-076-FINDINGS); an ADR-065 Update with the numbers; `PROTOCOL.md` §6's wear-change item if BL-9/BL-19/BL-23 settle - it (a promotion needs the maintainer's approval, `AGENTS.md` §6); the release verdict is the analysing session's, with the maintainer (`RELEASING.md` C4). - ---- -https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md - https://tedsluis.github.io/opencontrolpixelbudspro2/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES From 0f96949c153f3ad32a4788e0ae62241246362600 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Thu, 15 Oct 2026 08:59:00 +0200 Subject: [PATCH 09/11] chore(scripts): the four tools behind CAP-076's counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: PROJECT_RULES.md rule 4a — a script behind a count is committed. pwhdlc_crc_table.py (every pw_hdlc frame of DLCI 0x02/0x03 CRC-32 checked, as a table — pwrpc_decode.py does not check the CRC), message_stream_claims.py (the DLCI 0x04 claim inventory of both clients), stream_events.py (the settings stream's field-13 pushes merged into change events — the Buds push each change twice with both buds out) and film_mode_buttons.py (the selected mode button read at 10 fps from a screen recording). Each reproduces the analysis's results byte for byte. No app file changed. Co-Authored-By: Claude Opus 5.5 --- scripts/film_mode_buttons.py | 45 +++++++++++++++++ scripts/message_stream_claims.py | 75 ++++++++++++++++++++++++++++ scripts/pwhdlc_crc_table.py | 85 ++++++++++++++++++++++++++++++++ scripts/stream_events.py | 47 ++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100755 scripts/film_mode_buttons.py create mode 100755 scripts/message_stream_claims.py create mode 100755 scripts/pwhdlc_crc_table.py create mode 100755 scripts/stream_events.py diff --git a/scripts/film_mode_buttons.py b/scripts/film_mode_buttons.py new file mode 100755 index 00000000..8adae410 --- /dev/null +++ b/scripts/film_mode_buttons.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Which of OpenControl's four noise-control mode buttons is filled, frame by frame — the screen side of a hold-vs-push count. + +PROJECT_RULES.md rule 4a: the tool behind a count is committed. Written in ai-sessions/0092 (CAP-076-FINDINGS.md §4.1–§4.2). + +Input: a directory of PNG frames f0001.png … cut from a 1080-px-wide screen recording with the noise-control tab on screen (the card +without the "not allowed" line, the 1.3.0 layout), e.g. + ffmpeg -ss -t -i -vf "fps=10,crop=1080:400:0:540,pad=1080:1000:0:540" /f%04d.png +For each frame the mean grey value of a 40 × 40 patch inside each button (Noise cancellation, Off, Adaptive, Transparency) is measured; a +filled (selected) button reads < 170, an empty one ≈ 240. A frame with no or several dark patches (a dialog, a volume panel, the dimmed +card) prints "-" or "?". Only changes are printed, with the film time (t0 + frame/10) and the phone time (base + film time). + +Usage: + python3 scripts/film_mode_buttons.py + (CAP-076: film 1 base 23726.3 = 06:35:26.3, film 2 base 25351.5 = 07:02:31.5 — from the status bar's minute flips) +""" +import glob +import sys + +import numpy as np +from PIL import Image + +PATCHES = {"NC": (470, 640, 510, 680), "OFF": (940, 640, 980, 680), "ADA": (470, 860, 510, 900), "TRA": (940, 860, 980, 900)} +FILLED = 170 + + +def main(d, t0, base): + prev = None + for i, f in enumerate(sorted(glob.glob(d + "/f*.png"))): + a = np.asarray(Image.open(f).convert("L"), dtype=float) + means = {k: a[y0:y1, x0:x1].mean() for k, (x0, y0, x1, y1) in PATCHES.items()} + dark = [k for k, v in means.items() if v < FILLED] + sel = dark[0] if len(dark) == 1 else ("?" + "".join(dark) if dark else "-") + t = t0 + i / 10 + ph = base + t + if sel != prev: + print("%.1f %02d:%02d:%04.1f %s %s" % (t, ph // 3600, (ph % 3600) // 60, ph % 60, sel, + " ".join("%s=%.0f" % (k, v) for k, v in means.items()))) + prev = sel + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit(__doc__) + main(sys.argv[1], float(sys.argv[2]), float(sys.argv[3])) diff --git a/scripts/message_stream_claims.py b/scripts/message_stream_claims.py new file mode 100755 index 00000000..d58cd2fe --- /dev/null +++ b/scripts/message_stream_claims.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""The claim inventory of the Fast Pair Message Stream (RFCOMM DLCI 0x04/0x05) of one ACL handle: who claimed it, how long, what came on it. + +PROJECT_RULES.md rule 4a: the tool behind a count is committed. Written in ai-sessions/0092 (CAP-076), after the method of CAP-075-FINDINGS.md §1. + +A claim = a phone SABM on DLCI 0x04/0x05 up to the first DISC or DM on it (either side) — or the end of the log/ACL ("open"). Its owner is the +first phone message after the UA: `08 11 …` = OpenControl's Get (DECISIONS.md ADR-032), `03 08 00 02 01 25` = Google Play services' Fast Pair +(Platform type, CAP-075-FINDINGS.md §1); anything else is printed as "other:<4 bytes>" (e.g. `04 01 00 01` = OpenControl's Ring/Stop). Messages are +split as [Group][Code][Len:2 BE][Value] per direction (one RFCOMM frame may hold several). + +Output (TSV, one claim per line): #n, the SABM's frame and time, the DLCI, the owner, the end (frame, time, sender, DISC/DM), the duration, and +the frames of the Buds' Notify (`08 13`, with settable/enabled/mode bytes), the phone's Set (`08 12`), the Buds' ACK (`ff 01 00 06 08 …`) and NAK. + +Usage (needs tshark on PATH; run from the repository root): + python3 scripts/message_stream_claims.py 0x000b +""" +import datetime +import subprocess +import sys + +KIND = {0x2f: "SABM", 0x3f: "SABM", 0x63: "UA", 0x73: "UA", 0x43: "DISC", 0x53: "DISC", 0x0f: "DM", 0x1f: "DM"} +PLAY = bytes.fromhex("030800020125") + + +def ts(t): + return datetime.datetime.fromtimestamp(float(t)).strftime("%H:%M:%S.%f")[:-3] + + +def main(cap, handle): + flt = f"bthci_acl.chandle=={handle} && (btrfcomm.dlci==4 || btrfcomm.dlci==5)" + rows = subprocess.run(["tshark", "-r", cap, "-Y", flt, "-T", "fields", "-e", "frame.number", "-e", "frame.time_epoch", "-e", "frame.p2p_dir", + "-e", "btrfcomm.dlci", "-e", "btrfcomm.frame_type", "-e", "data.data"], + capture_output=True, text=True, check=True).stdout.splitlines() + claims, cur, bufs = [], None, {} + for r in rows: + fn, t, d, dl, ft, data = (r.split("\t") + [""] * 6)[:6] + kind = KIND.get(int(ft, 16) if ft else 0, "UIH") + who = "phone" if d == "0" else "buds" + if kind == "SABM" and who == "phone": + cur = {"sabm": (fn, t), "dlci": dl, "msgs": [], "owner": None, "end": None} + claims.append(cur) + bufs = {} + continue + if cur is None or cur["end"] is not None: + continue + if kind in ("DISC", "DM"): + cur["end"] = (fn, t, who, kind) + continue + if kind == "UIH" and data: + b = bufs.setdefault(who, bytearray()) + b.extend(bytes.fromhex(data)) + while len(b) >= 4: + n = int.from_bytes(b[2:4], "big") + if len(b) < 4 + n: + break + m = bytes(b[:4 + n]) + del b[:4 + n] + cur["msgs"].append((fn, t, who, m.hex())) + if who == "phone" and cur["owner"] is None: + cur["owner"] = "app" if m[:2] == b"\x08\x11" else ("play" if m[:6] == PLAY else "other:" + m[:4].hex()) + for i, c in enumerate(claims, 1): + e = c["end"] + dur = (float(e[1]) - float(c["sabm"][1])) if e else None + sel = lambda who, pre: [m for m in c["msgs"] if m[2] == who and m[3].startswith(pre)] # noqa: E731 + print(f"#{i}\t{c['sabm'][0]}\t{ts(c['sabm'][1])}\tdlci{c['dlci']}\t{c['owner']}\t" + f"end={e[0] if e else '-'} {ts(e[1]) if e else ''} {e[2] + ' ' + e[3] if e else 'open'}\tdur={'%.3f' % dur if dur else '-'}\t" + f"notify={','.join(m[0] + ':' + m[3][10:16] for m in sel('buds', '0813'))}\t" + f"set={','.join(m[0] + ':' + m[3][8:16] for m in sel('phone', '0812'))}\t" + f"ack={','.join(m[0] for m in sel('buds', 'ff01000608'))}\tnak={','.join(m[0] for m in sel('buds', 'ff02'))}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) diff --git a/scripts/pwhdlc_crc_table.py b/scripts/pwhdlc_crc_table.py new file mode 100755 index 00000000..bcd60af2 --- /dev/null +++ b/scripts/pwhdlc_crc_table.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Every pw_hdlc frame of the MAESTRO channel (RFCOMM DLCI 0x02/0x03) of one ACL handle, CRC-32 checked, as a table. + +PROJECT_RULES.md rule 4a: the tool behind a count is committed. Written in ai-sessions/0092 (CAP-076). + +The RFCOMM payloads are reassembled per (DLCI, direction), split on the 0x7E flags, unescaped (0x7D xx -> xx ^ 0x20); each frame's CRC-32 +(zlib polynomial, little-endian trailer) is checked over address + control + data (PROTOCOL.md §2.2a); the payload after the one-terminated +address and the control byte is parsed as a pw_rpc RpcPacket with scripts/pwrpc_decode.py's own functions. pwrpc_decode.py does not check +the CRC; this table does, and its parsed-frame count equals `pwrpc_decode.py --handle | wc -l`. + +Output (TSV, one frame per line, capture order): frame, time, p2p_dir (0 = phone -> Buds, 1 = Buds -> phone), dlci, crc (ok/BAD), type, +channel_id, service/method ("M/" = maestro_pw.Maestro), the decoded payload (+ status/call_id), the raw frame hex. A summary line +"# frames=N crc_bad=M" goes to stderr. + +Usage (needs tshark on PATH; run from the repository root): + python3 scripts/pwhdlc_crc_table.py 0x000b > table.tsv +""" +import collections +import datetime +import os +import subprocess +import sys +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import pwrpc_decode as P # noqa: E402 + + +def main(cap, handle): + flt = f"(btrfcomm.dlci==2 || btrfcomm.dlci==3) && btrfcomm.frame_type==0xef && bthci_acl.chandle=={handle}" + rows = subprocess.run(["tshark", "-r", cap, "-Y", flt, "-T", "fields", "-e", "frame.number", "-e", "frame.time_epoch", + "-e", "data.data", "-e", "btrfcomm.dlci", "-e", "frame.p2p_dir"], + capture_output=True, text=True, check=True).stdout.splitlines() + streams = collections.OrderedDict() + for r in rows: + p = r.split("\t") + if len(p) < 5 or not p[2]: + continue + buf, marks = streams.setdefault((p[3], p[4]), (bytearray(), [])) + marks.append((len(buf), int(p[0]), float(p[1]))) + buf.extend(bytes.fromhex(p[2])) + out, bad, total = [], 0, 0 + for (dlci, direction), (buf, marks) in streams.items(): + begin = None + for i, b in enumerate(buf): + if b != 0x7E: + continue + if begin is not None and i > begin + 1: + raw = bytes(buf[begin:i + 1]) + u = P.unescape(bytes(buf[begin + 1:i])) + fn, t = max(((f, tt) for off, f, tt in marks if off <= i), default=(None, None)) + if u is None or len(u) < 6: + out.append((fn, t, direction, dlci, "SHORT", "", "", "", "", raw.hex())) + begin = i + continue + total += 1 + crc_ok = zlib.crc32(u[:-4]) == int.from_bytes(u[-4:], "little") + bad += 0 if crc_ok else 1 + j = 0 + while j < len(u) and not u[j] & 1: + j += 1 + pk = P.parse(u[j + 2:-4]) + if pk: + d = {f: v for f, w, v in pk} + svc = int.from_bytes(d[3], "little") if 3 in d else None + met = int.from_bytes(d[4], "little") if 4 in d else None + name = (P.SERVICES.get(svc, hex(svc) if svc else "").replace("maestro_pw.Maestro", "M") + "/" + + P.METHODS.get(met, hex(met) if met else "")) + payload = (P.fmt(d[5]) if 5 in d else "") + (f" status={P.STATUS.get(d[6], d[6])}" if 6 in d else "") + \ + (f" call_id={d[7]}" if 7 in d else "") + out.append((fn, t, direction, dlci, "ok" if crc_ok else "BAD", P.TYPES.get(d.get(1, 0), d.get(1)), d.get(2), name, + payload, raw.hex())) + else: + out.append((fn, t, direction, dlci, "ok" if crc_ok else "BAD", "NOPARSE", "", "", "", raw.hex())) + begin = i + for o in sorted(out, key=lambda x: x[0] or 0): + t = datetime.datetime.fromtimestamp(o[1]).strftime("%H:%M:%S.%f")[:-3] if o[1] else "" + print("\t".join(str(x) for x in (o[0], t) + o[2:])) + print(f"# frames={total} crc_bad={bad}", file=sys.stderr) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) diff --git a/scripts/stream_events.py b/scripts/stream_events.py new file mode 100755 index 00000000..3998f7e6 --- /dev/null +++ b/scripts/stream_events.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""The settings stream's noise-control pushes (Maestro setting 13) as change events: the pair of packets of one change merged into one event. + +PROJECT_RULES.md rule 4a: the tool behind a count is committed. Written in ai-sessions/0092 (CAP-076-FINDINGS.md §4.3): with both buds out of the +case the Buds push every change of setting 13 twice, once with channel_id 21 and once with 19, 1–200 ms apart (🟡 one per bud core). + +Input: one or more tables of scripts/pwhdlc_crc_table.py. Every SERVER_STREAM packet of SubscribeToSettingsChanges whose payload is `4:{13:n}` is +an event; a packet with the same value as the previous event within MERGE_S seconds is added to that event. Output (one event per line): the first +packet's time and frame, the value (1 Off, 2 Noise cancellation, 3 Transparency, 4 Adaptive, 0 no mode settable — PROTOCOL.md §4.1) and every +packet of the event as ch@. + +Usage: + python3 scripts/pwhdlc_crc_table.py 0x000b > table.tsv + python3 scripts/stream_events.py table.tsv [table2.tsv …] +""" +import sys + +MERGE_S = 0.4 +MODE = {"1": "OFF", "2": "ACTIVE", "3": "TRANSPARENT", "4": "ADAPTIVE", "0": "none"} + + +def secs(t): + h, m, s = t.split(":") + return int(h) * 3600 + int(m) * 60 + float(s) + + +def main(paths): + events = [] + for path in paths: + for line in open(path): + p = line.rstrip("\n").split("\t") + if len(p) < 9 or p[5] != "SERVER_STREAM" or "SubscribeToSettingsChanges" not in p[7] or not p[8].startswith("4:{13:"): + continue + value = p[8][len("4:{13:"):].rstrip("}") + if events and events[-1][2] == value and secs(p[1]) - secs(events[-1][0]) < MERGE_S: + events[-1][3].append(f"ch{p[6]}@{p[0]}") + continue + events.append([p[1], p[0], value, [f"ch{p[6]}@{p[0]}"]]) + for t, fn, value, packets in events: + print(t, fn, MODE.get(value, value), " ".join(packets)) + print(f"# events={len(events)} packets={sum(len(e[3]) for e in events)}", file=sys.stderr) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit(__doc__) + main(sys.argv[1:]) From 191dbc3bb07ade8ffc3e0b7495885d6b8b82ef9d Mon Sep 17 00:00:00 2001 From: tedsluis Date: Thu, 15 Oct 2026 08:59:00 +0200 Subject: [PATCH 10/11] =?UTF-8?q?docs:=20CAP-076=20into=20the=20registers?= =?UTF-8?q?=20=E2=80=94=20ADR-065=20Update,=20PROTOCOL=20=C2=A74.1=20note,?= =?UTF-8?q?=20Known=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the maintainer's decisions on CAP-076 (chat 2026-10-15, ai-sessions/0092): the verdict "release as built + Known issues" (C4 minor issue) and the approved texts. DECISIONS.md ADR-065 Update (hardware-verified, with the numbers and the three minor defects); PROTOCOL.md §4.1 note and §8 row (no status change); ARCHITECTURE.md §5a/§6.0b; CHANGELOG.md [1.3.0] Known issues (the "changed since" line after a reconnect; the tap/collision line reworded); TODO.md (CAP-076 done, the Buds' state, P1–P6 recorded, no FEATURE prompt now); APP_TESTPLAN.md §Y results; TESTPLAN Group BL; the CAPTURE index row and Group BL status; id_registry.csv; PROJECT.md status; the sidebar's new path. Co-Authored-By: Claude Opus 5.5 --- APP_TESTPLAN.md | 50 +++++++++++++++++---------------- ARCHITECTURE.md | 4 +-- CAPTURE_BLUETOOTH_HCI_SNOOP.md | 9 +++--- CHANGELOG.md | 4 ++- DECISIONS.md | 10 +++++++ PROJECT.md | 2 +- PROTOCOL.md | 9 ++++++ TESTPLAN_BLUETOOTH_HCI_SNOOP.md | 28 +++++++++--------- TODO.md | 23 +++++++++------ _sidebar.md | 5 ++-- id_registry.csv | 2 +- 11 files changed, 88 insertions(+), 58 deletions(-) diff --git a/APP_TESTPLAN.md b/APP_TESTPLAN.md index 46a3af0b..a2af1b1b 100644 --- a/APP_TESTPLAN.md +++ b/APP_TESTPLAN.md @@ -418,7 +418,7 @@ measured 2026-10-12): recovery 5.2–5.7 s after it lost the channel, retry 2.2 ## Y. 1.3.0 (`ai-sessions/0091`: the settings stream on the app's own channel — ADR-064, ADR-065 — in both users) -In `CAP-076` as BL-1 … BL-25 (captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md, which has the reference frames per request). Two +In `CAP-076` as BL-1 … BL-25 (captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md, which has the reference frames per request). Two new requests on the wire at every Connect and re-open — `SubscribeToSettingsChanges` and `ReadSetting 4:13`, the official app's bytes (`CAP-051` 1177/1178, `CAP-056` 1451/1450); the Buds then push every change of the mode (and of every other setting the app reads) as a stream packet on the app's own channel, on any tab, with or without Google Play services. The hold (W), the "Noise-control channel" / "Noise control with Google Play services" setting (X, the 1.2.2 Y) are @@ -427,28 +427,28 @@ or a Connect's snapshot; the own tap's stream packet ≈ 0.3 s after the `Set`. | ID | Steps | Expected on screen | Expected on the wire | Result | Notes | |---|---|---|---|---|---| -| Y1 | **User 0 (Play).** Update over 1.2.2 (Debug mode on; the 1.2.2 card's choice on film first); open the app; Info; gear → Settings. | "App: 1.3.0, build ", no "-dirty"; Settings = Dark mode, Case sounds, Use different Buds — **no "Noise control with Google Play services" card, no "Google Play services: …" line** | the **fifteen** requests: `4:16`, the twelve settings, `SubscribeRuntimeInfo`, **`SubscribeToSettingsChanges`, `ReadSetting 4:13`**, `GetHardwareInfo`; the snapshot claim | | BL-1, BL-2 (`PAIR-003`, `BATT-004`, `FW-003`) | -| Y2 | Disconnect, Connect. | ready | the fifteen requests again — the subscription and the read once per session | | BL-3 (`PAIR-003`) | -| Y3 | **Connection tab**, both worn: 10 press-and-holds ≈ 6 s apart; touch nothing; then open ANC. | the mode follows every hold (the notification's line; ANC shows the last) | one `4:{13:n}` stream packet per hold on DLCI 0x02; **no app `SABM` on DLCI 0x04** | | BL-4 (`TOUCH-007`, `ANC-001`…`004`) | -| Y4 | **ANC tab**: 10 press-and-holds ≈ 6 s apart; the (i) after one. | **10 of 10** shown within a second; the (i) "Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves."; the last sentence "The Buds report every change of the mode on the app's own channel. … that part is a hypothesis." | one stream packet per hold; no app `SABM`; no hint, no "Take it back again" | | BL-5, BL-6 (`TOUCH-007`, `ANC-001`…`004`) | -| Y5 | Tap a mode; the (i). The ANC tile once. | the mode at once; no "Changed by the Buds" line for the own tap; the tile steps one mode and the app follows | one claim per tap/tile (`08 11`, `08 12`, ACK), the Buds' `Notify`, **the stream packet of the tapped mode ≈ 0.3 s after the `Set`**, the `DISC` ≈ 1.5 s after the ACK | | BL-7, BL-8 (`ANC-001`…`004`) | -| Y6 | One bud out (both worn), 10 s, back in, 10 s; read the mode after each. | either the loss and the re-open by itself with the mode read ("Changed since the app last saw it (read at …)" if it differs), or the Buds' own switch as "Changed by the Buds" (🟡) | the Buds' `DISC` → the re-open's fifteen requests (the subscription and the read again), or a stream packet without a `DISC` — say which | | BL-9 (`PAIR-003`, `TOUCH-007`) | -| Y7 | Bluetooth off, 5 s, on; wait. | "Bluetooth was switched off"; ready again by itself | the re-open's fifteen requests — the subscription again | | BL-10 (`PAIR-003`) | -| Y8 | Taps while Play holds the channel; tap again after a failed one. | the mode after at most two taps; **no hint, no re-claim, no "Take it back again"** | a refused attempt 1 with Play's `DISC` (the `CAP-075` §5 collision — the known issue); the app never re-claims by itself | | BL-11 (`ANC-001`…`004`) | -| Y9 | 5 more holds on the ANC tab. | 5 of 5 shown | 5 stream packets; no app `SABM` | | BL-12 (`TOUCH-007`) | -| Y10 | (optional) The official app on a second phone: an EQ slider; In-ear detection off/on. | the equalizer and the switch follow in OpenControl ("read HH:MM:SS", not "changed by this app") | the stream packets for field 16 and field 2 on OpenControl's channel | | BL-13 | -| Y11 | Export; the system log; switch to user 10. | "Debug log saved (N lines)." | — | | BL-end1 | -| Y12 | **User 10 (no Play).** Open the app; Info; Settings. | the build; no card, no Play line | the fifteen requests; the snapshot claim released ≈ 1.5 s after its answer | | BL-14 (`PAIR-003`, `FW-003`, `BATT-004`) | -| Y13 | Connection tab: 10 holds; then ANC: 10 holds; the (i). | every hold followed / shown; "Changed by the Buds at …" | 20 stream packets; no app `SABM`; 0 Play claims (positive control: part 1's count); **no `DISC` of DLCI 0x04 while the tab is shown — there is no claim** | | BL-15, BL-16 (`TOUCH-007`, `ANC-001`…`004`) | -| Y14 | A tap; the tile. | the mode; the app follows the tile; no "Changed by the Buds" for the own change | one claim each; the stream packet ≈ 0.3 s after the `Set`; the `DISC` ≈ 1.5 s after the ACK | | BL-17, BL-18 (`ANC-001`…`004`) | -| Y15 | One bud out / in (as Y6). | as Y6 | as Y6 | | BL-19 (`PAIR-003`, `TOUCH-007`) | -| Y16 | Bluetooth off / on. | ready again by itself | the re-open's requests — the subscription again | | BL-20 (`PAIR-003`) | -| Y17 | Controls tab: 5 holds; then ANC. | the last mode; "Changed by the Buds" | 5 packets; no app `SABM` | | BL-21 (`TOUCH-007`) | -| Y18 | Disconnect; two holds while disconnected; Connect; the ANC (i). | after the Connect the mode read; **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected."** | nothing while disconnected; the Connect's fifteen requests; the read's answer | | BL-22 (`PAIR-003`, `TOUCH-007`) | -| Y19 | Both buds into the case, 15 s, out, 15 s. | the session ends and re-opens by itself, or "The Buds don't allow changing noise control right now …" with the mode kept; the mode read once worn; **a value 0 never shown as a mode** | the loss and the re-open's requests, or a stream packet `4:{13:0}`; the read's answer 0 in the case, 1–4 worn | | BL-23 (`PAIR-003`) | -| Y20 | (optional) Home; two holds while hidden; return. | the holds' result on return | 2 packets while hidden; no `SABM` | | BL-24 (`TOUCH-007`) | -| Y21 | (optional) The official app on a second phone: an EQ slider; a switch. | OpenControl follows | the stream packets | | BL-25 | -| Y22 | Export; the system log; the APK pull. | "Debug log saved (N lines)." | — | | BL-end2 | +| Y1 | **User 0 (Play).** Update over 1.2.2 (Debug mode on; the 1.2.2 card's choice on film first); open the app; Info; gear → Settings. | "App: 1.3.0, build ", no "-dirty"; Settings = Dark mode, Case sounds, Use different Buds — **no "Noise control with Google Play services" card, no "Google Play services: …" line** | ✅ | | BL-1, BL-2 (`PAIR-003`, `BATT-004`, `FW-003`) — film 1: 1.2.2 card on film (s59), the update (s92–93), "App: 1.3.0, build ff27ee1" and Settings without the card or the Play line (s100–107); the LINK_BACK re-open with the seventeen requests (S1) | +| Y2 | Disconnect, Connect. | ready | ✅ | | BL-3 (`PAIR-003`) — 06:39:41/44 — S2: the subscription and the read again (H1 2838/2839); seventeen requests per full session, not fifteen (`CAP-076-FINDINGS.md` §2) | +| Y3 | **Connection tab**, both worn: 10 press-and-holds ≈ 6 s apart; touch nothing; then open ANC. | the mode follows every hold (the notification's line; ANC shows the last) | ⚠️ | | BL-4 (`TOUCH-007`, `ANC-001`…`004`) — done differently: 13 changes on the Connection tab 06:40:47–06:41:51 (holds and wear changes during podcast playback, three with the Buds' AVRCP PAUSE/PLAY) — each pushed (twice: ch 21 + 19), no app `SABM` except the re-open's snapshot; **that tab shows no mode** (the worn line's time follows); ANC opened later | +| Y4 | **ANC tab**: 10 press-and-holds ≈ 6 s apart; the (i) after one. | **10 of 10** shown within a second; the (i) "Changed by the Buds at HH:MM:SS: the Buds reported a new mode — a press-and-hold on a bud, or a change they made themselves."; the last sentence "The Buds report every change of the mode on the app's own channel. … that part is a hypothesis." | ✅ | | BL-5, BL-6 (`TOUCH-007`, `ANC-001`…`004`) — **17 of 17 within 0.1 s** (06:43:14–06:44:11, Play holding DLCI 0x04 throughout); the (i) "Changed by the Buds at 06:44:11 …" and the stream sentence (s536) | +| Y5 | Tap a mode; the (i). The ANC tile once. | the mode at once; no "Changed by the Buds" line for the own tap; the tile steps one mode and the app follows | ⚠️ | | BL-7, BL-8 (`ANC-001`…`004`) — 4 taps + 9 tile taps: 11 `Set`s ACKed, the stream 154–246 ms after, classed as the app's (no cause line), the `DISC` 1.507–1.516 s after the ACK; **one tile tap (06:46:08.96) cut by Play after the `Get`'s answer: no `Set`, no message** (`CAP-076-FINDINGS.md` §5.3) | +| Y6 | One bud out (both worn), 10 s, back in, 10 s; read the mode after each. | either the loss and the re-open by itself with the mode read ("Changed since the app last saw it (read at …)" if it differs), or the Buds' own switch as "Changed by the Buds" (🟡) | ⚠️ | | BL-9 (`PAIR-003`, `TOUCH-007`) — six Buds-side `DISC`s (06:39:53 … 06:47:44), every re-open 1.50–1.51 s later subscribed and read again; **the two differing reads (S4, S7) shown as "read" — the snapshot's "changed since" overwritten by the equal `4:13` answer** (§6.2) | +| Y7 | Bluetooth off, 5 s, on; wait. | "Bluetooth was switched off"; ready again by itself | not run | | BL-10 (`PAIR-003`) — BL-10 — no Bluetooth off/on with 1.3.0 in this user (the one on film was before the 1.2.2 session) | +| Y8 | Taps while Play holds the channel; tap again after a failed one. | the mode after at most two taps; **no hint, no re-claim, no "Take it back again"** | ⚠️ | | BL-11 (`ANC-001`…`004`) — 13 taps with Play present in 12 claims: 8 refused at attempt 1 and done at attempt 2 (0.87–1.11 s later), 2 cut in their linger, 1 cut before the `Get`'s answer (toast), 1 cut after it (silent); nothing re-claimed (§5.2) | +| Y9 | 5 more holds on the ANC tab. | 5 of 5 shown | ✅ | | BL-12 (`TOUCH-007`) — 12 holds (06:48:44–06:49:33), **12 of 12 within 0.1 s**; no app `SABM` | +| Y10 | (optional) The official app on a second phone: an EQ slider; In-ear detection off/on. | the equalizer and the switch follow in OpenControl ("read HH:MM:SS", not "changed by this app") | not run | | BL-13 — BL-13 — no second phone | +| Y11 | Export; the system log; switch to user 10. | "Debug log saved (N lines)." | ✅ | | BL-end1 — "Debug log saved (1062 lines)."; the app logcat and the System log saved (s946–976) | +| Y12 | **User 10 (no Play).** Open the app; Info; Settings. | the build; no card, no Play line | ✅ | | BL-14 (`PAIR-003`, `FW-003`, `BATT-004`) — BL-14 — S10–S12; Info "App: 1.3.0, build ff27ee1", Settings without the card (film 2 s73–86) | +| Y13 | Connection tab: 10 holds; then ANC: 10 holds; the (i). | every hold followed / shown; "Changed by the Buds at …" | ⚠️ | | BL-15, BL-16 (`TOUCH-007`, `ANC-001`…`004`) — BL-15 not run; BL-16: **12 of 12 within 0.1 s** (07:05:24–07:06:38), the (i) "Changed by the Buds at …" five times; 0 Play claims (positive control part 1: 18) | +| Y14 | A tap; the tile. | the mode; the app follows the tile; no "Changed by the Buds" for the own change | ⚠️ | | BL-17, BL-18 (`ANC-001`…`004`) — BL-17: 4 taps at attempt 1, ACK +25…82 ms, the stream +55…94 ms, no cause line; BL-18 (the tile) not run | +| Y15 | One bud out / in (as Y6). | as Y6 | ✅ | | BL-19 (`PAIR-003`, `TOUCH-007`) — BL-19 done differently: the Buds' `DISC` 07:03:00 (the Right out of the case) → the re-open on ch 21, a push before its subscription, the read equal | +| Y16 | Bluetooth off / on. | ready again by itself | ✅ | | BL-20 (`PAIR-003`) — BL-20 — 07:02:39/44; "Bluetooth was switched off on this phone"; the LINK_BACK re-open subscribed again (H2 540) | +| Y17 | Controls tab: 5 holds; then ANC. | the last mode; "Changed by the Buds" | not run | | BL-21 (`TOUCH-007`) — BL-21 | +| Y18 | Disconnect; two holds while disconnected; Connect; the ANC (i). | after the Connect the mode read; **"Changed since the app last saw it (read at HH:MM:SS): a press-and-hold on a bud or the Buds' own change, made while this app was not connected."** | ⚠️ | | BL-22 (`PAIR-003`, `TOUCH-007`) — BL-22 done differently: Disconnect/Connect 07:04:12/15 with no hold in between — the read equal, no line (correct); the differing-read line not exercised here (see Y6) | +| Y19 | Both buds into the case, 15 s, out, 15 s. | the session ends and re-opens by itself, or "The Buds don't allow changing noise control right now …" with the mode kept; the mode read once worn; **a value 0 never shown as a mode** | ✅ | | BL-23 (`PAIR-003`) — BL-23 in part 1: both buds into the case 06:42:11 (`4:{13:0}`), the ACL's end, the Connect's "no mode settable"; **0 never shown as a mode** (s442) | +| Y20 | (optional) Home; two holds while hidden; return. | the holds' result on return | not run | | BL-24 (`TOUCH-007`) — BL-24 | +| Y21 | (optional) The official app on a second phone: an EQ slider; a switch. | OpenControl follows | not run | | BL-25 — BL-25 — no second phone | +| Y22 | Export; the system log; the APK pull. | "Debug log saved (N lines)." | ✅ | | BL-end2 — "Debug log saved (462 lines)."; the app logcat; no system log in user 10; the APK pulled by `ai-sessions/0092` (SHA-256 = the tested build's) | ## After the run (within 1 minute of the last action) @@ -488,7 +488,9 @@ or a Connect's snapshot; the own tap's stream packet ≈ 0.3 s after the `Set`. | V 1.2.0 build | 19 | 13 | 1 | 4 | 1 | | W 1.2.0 rebuilt | 12 | 3 | 0 | 6 | 3 | | X 1.2.1 | 12 | 6 | 0 | 5 | 1 | -| Y 1.3.0 | 22 | | | | | +| Y 1.3.0 | 22 | 10 | 0 | 7 | 5 | + +**Run `CAP-076` (2026-10-15, 1.3.0 `ff27ee1`, part 1 the Owner with Play, part 2 the secondary user without; `ai-sessions/0092`, `CAP-076-FINDINGS.md`):** section Y — ✅ Y1, Y2, Y4, Y9, Y11, Y12, Y15, Y16, Y19, Y22; ⚠️ Y3 (the Connection tab shows no mode), Y5 (one tile tap lost silently), Y6 (the "changed since" line overwritten), Y8 (the per-tap collision with Play, measured), Y13/Y14 (BL-15/BL-18 not run), Y18 (no hold while disconnected); not run Y7, Y10, Y17, Y20, Y21 (the maintainer tested only the changed functionality). Every hold on the noise-control tab shown: 29 of 29 with Play, 12 of 12 without. Verdict: release as built with two Known issues (the maintainer, chat 2026-10-15). **Run `CAP-075` (2026-10-12 set 1, the user without Play; 2026-10-13 set 2, the Owner with Play; 1.2.1 `31ee183`; `ai-sessions/0088`, `CAP-075-FINDINGS.md`):** section X — ✅ X2, X5, X6, X7, X10, X11; ⚠️ X1 (set 2's card pre-set to "Take it back"), X3 (the hint ✓, no re-claim ✓, 0 of 9 holds shown — the claims lived 0.42 / 1.2 s), X4 (the (i) ✓; no mode tap), X9 (on the Connection tab), X12 (the switch not set back; the optional branches not run); not run X8. The X6 expectation "≈ 6 of 10" was 8 of 19 within a second (the 6/12-s steps lengthen the cycles). Verdict (the maintainer, chat 2026-10-13): no heavy issue — 1.2.1 released as built. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 21ebb97c..4c70495f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -507,7 +507,7 @@ summary), the current state is (every write below additionally passes the Safe-M | §4.5's other DLCI 0x02 settings (Touch & Hold, Head gestures, In-ear detection, Mono audio, Volume EQ, Volume Balance, Case sounds, Multipoint, Conversation Detection) | DLCI 0x02 | 🟢 FACT for several individual fields' number/semantic identity (ADR-019 and its Updates) | **ADR-036** — read-only `ReadSetting` for fields 2, 4, 7, 11, 15, 17, 19, 22, 27, 28 (no subscription); **ADR-045** (2026-09-26) — `WriteSetting` for 17, 19, 22, 4, 7; **ADR-046** (2026-09-28) — read + write of field 12; **ADR-047** (2026-09-28) — write of field 2 (both built `ai-sessions/0056`); **ADR-052** (2026-10-04) — read + write of field 29; **ADR-053** (2026-10-04) — write of 11; **ADR-054** (2026-10-06) — writes of 27, 28; **ADR-055** (2026-10-06) — write of 15 (all four built `ai-sessions/0074`) | **As built `ai-sessions/0052`:** reads of 2, 4, 7, 17, 19, 22 at Connect (`SettingsCodec`, fixtures `CAP-036` 1445…1540); **writes of 17, 19, 22, 4, 7 per ADR-045** (fixtures `CAP-019` 1720/1808, `CAP-020` 1741/1995, `CAP-021` 1895/3619/4315/4976, `CAP-022` 1621/1823/1922…2099, byte-identical); UI tabs Sound and Controls. **`ai-sessions/0056`:** field 12 read at Connect and written (ADR-046; fixtures `CAP-056` 1529/1531, 1689/1725/1786/1815/1843, OK 1697; channel 21 `CAP-041` 2176/2192/2198, `CAP-036` 1514/1516) and field 2 written (ADR-047; `CAP-056` 2173/4048, 2849/3627). **`ai-sessions/0074` (1.1.0):** fields 11, 15, 27, 28, 29 read at Connect and written — head gestures 1 = off / 2 = on (`SettingValue.HeadGestures`), the others 0/1; fixtures byte-identical: `CAP-069` 3161/3212 (11, ch 21), 2492/2564 (29, ch 21), `CAP-024` 1988/2023/2053/2084 and `CAP-058` 5623/5643/5680/5697 (28/27, both channels), `CAP-022` 1871/1895 (15, ch 19), `CAP-041` 2461 (15 off, ch 21); reads `CAP-036` 1471…1561 and their ch-19 counterparts in `CAP-024`/`CAP-069`. The 0052 settings were hardware-verified in `CAP-063`; 12 and 2 in `CAP-064` (ADR-046/047 Updates); **11, 15, 27, 28, 29 in `CAP-070`** (1.1.0, build `0323849`: 89 of 89 writes byte-identical and ACKed on both channels, read back on the next connection — `CAP-070-FINDINGS.md` §2–§3). *(Updated 2026-10-07, `ai-sessions/0075`, maintainer-approved in chat 2026-10-07, `AskUserQuestion` "Docs":)* the three forms not seen before — 11 and 29 on channel 19, `15:1` on channel 21 — are on the wire in `CAP-070` (A3668/A3679, A3614/A3685, A4800), byte-identical to the derived frames; since `ai-sessions/0076` (2026-10-07) these bytes are the unit-test fixtures (`Settings070`), the labelled structural tests and ADR-055's `// TODO(verify)` are gone (ADR-055 Update of 2026-10-07). | | Serial numbers (`GetHardwareInfo` field 7) | DLCI 0x02, pw_rpc `maestro_pw.Maestro` `GetHardwareInfo` | 🟢 FACT (method identity `PROTOCOL.md` §6 2026-09-24; the component order 7.1 Case / 7.2 Right / 7.3 Left, 2026-10-09) | **ADR-058** (2026-10-09) — one request per Connect, a read, not gated | **Implemented `ai-sessions/0082` (1.2.0)**: `Maestro.getHardwareInfoRequest`, `HardwareInfo`, `RoutedFrame.HardwareInfo`, `BudsRepositoryImpl.readHardwareInfo`; fixtures `CAP-036` 1415/1423 (ch 21) and `CAP-024` 801/832 (ch 19) — the answers with the serials redacted (`HardwareInfoFixtures`); the Info tab lines. **Hardware-verified in `CAP-072`** (30 requests byte-identical, one per session, 29 answered, the labels on the Info tab — ADR-058 Update of 2026-10-10) and again in `CAP-073` (11 of 11, 1.2.0 `5b4d5db`). | | Worn indicator ("probably worn") | — (no request; the Settable byte of DLCI 0x04, field 2 and the runtime-info charging flags) | 🟡 ADR-049 item 3 | **ADR-059** (2026-10-09) — a user-visible interpretation of a hypothesis, labelled as such | **Implemented `ai-sessions/0082` (1.2.0)**: `WornReading`/`wornReading()` (`:domain`), `BudsRepository.wornReading`, the battery card's line and (i); fixtures `CAP-065` 5465/6334, `CAP-064` 10394 (+ the field-2 write 9922), `CAP-062` 4845/2782. **Hardware-tested in `CAP-072`** (every reading followed the rule; `e8` on a table at 42 s, the documented limit — ADR-059 Update of 2026-10-10); the "in-ear detection is off" reading not yet seen on a phone (`TODO.md` §2). | -| "Changed by the Buds" (ANC-mode cause) | DLCI 0x04 `Notify` (existing) | 🟢 FACT for the `Notify` (`PROTOCOL.md` §4.1) | none needed (UI + state, no wire change; `CAP-069` §12 item 8); **ADR-061** (2026-10-10) — the claim held while the noise-control tab is on screen | **Hardware-verified `CAP-075` (`ai-sessions/0088`): the (i) line on film after a press-and-hold (17:27:31/37); a mode learned by a re-claim's own read shows no cause line — `CAP-075-FINDINGS.md` §9 P4.** **1.2.2 (`ai-sessions/0089`, ADR-062 Update 2): `AncModeCause.CHANGED_SINCE_LAST_READ` for a `Get` answer that differs from this connection's last report, its (i) line; the re-claim 3 s after every cut up to 20; Take it back again; fixtures `CAP-075` H2 2735/2980, 3006, 4893/5102, 5361, 3538/3964 (`Cap075Fixtures`); not hardware-verified — `CAP-076` (Group BL).** **Implemented `ai-sessions/0082` (1.2.0)**: `AncModeCause`, `BudsRepository.ancModeCause`, the (i) line; fixtures `CAP-045` 609/612/1583/1755/1818 (press-and-hold changes on a bud). `CAP-072`: shown inside a claim (A 13519, 17:51:10), never for a press-and-hold between claims (§5). **The hold, `ai-sessions/0084` (the 1.2.0 rebuild):** `setAncTabShown`, `releaseOrHold`; fixtures `CAP-072` A 13515/13519, B 825. **Hardware-verified for its main path in `CAP-073`** (`ai-sessions/0085`, 1.2.0 `5b4d5db`): the claim held 31 s on the tab, four unprovoked `Notify` shown without a tap, four taps on the held channel without a second `SABM`, the `DISC` 1.509 s after leaving the tab (`CAP-073-FINDINGS.md` §4); the tile, Home, a loss or re-open while the tab is shown and the (i) line not run. **In a user with Google Play services (`CAP-074`, 2026-10-12, `ai-sessions/0086`): the hold is cut 4.7–5.1 s after its `SABM` (12 of 12) by Play's Fast Pair recovery of the channel (5.2–5.7 s after it lost it), and the app does not re-claim — only the first press-and-hold after a Connect is shown there; verified as designed, the user-visible limit of 1.2.0 in that user (ADR-061 Update of 2026-10-12).** **1.2.1 (`ai-sessions/0087`, ADR-062):** the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default: the card's hint with the close's time, `ancChannelTaken`) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close of one visit, then the hint; the existing claim with its `Get`, nothing new on the wire); fixtures `CAP-074` B 15853/15860, the cut 5.133 s after `SABM` B 15822 (`Cap074Fixtures`); hardware-verified `CAP-075`. **1.3.0 (`ai-sessions/0091`, ADR-064/065): the cause comes from the Buds' settings stream on DLCI 0x02 (`4:{13:n}`, `SettingValue.AncState`), on any tab, without a claim — the hold, the re-claim, the setting and the Play line removed; `AncModeCause` unchanged, the rules in §3.1; fixtures `CAP-051` 1177/1178/1225/1717/1755/1802/1527…1534, `CAP-056` 1451/1450/1468/2259/4025/4028/1696/2175, `CAP-038` 2170/2205, `CAP-045` 2033/2277, `CAP-021` 1089/1842, `CAP-027` 923, `CAP-069` 2502, `CAP-015` 2116, `CAP-036` 1517/1519 (`SettingsStreamFixtures`); not hardware-verified — `CAP-076` (Group BL, rewritten).** | +| "Changed by the Buds" (ANC-mode cause) | DLCI 0x04 `Notify` (existing) | 🟢 FACT for the `Notify` (`PROTOCOL.md` §4.1) | none needed (UI + state, no wire change; `CAP-069` §12 item 8); **ADR-061** (2026-10-10) — the claim held while the noise-control tab is on screen | **Hardware-verified `CAP-075` (`ai-sessions/0088`): the (i) line on film after a press-and-hold (17:27:31/37); a mode learned by a re-claim's own read shows no cause line — `CAP-075-FINDINGS.md` §9 P4.** **1.2.2 (`ai-sessions/0089`, ADR-062 Update 2): `AncModeCause.CHANGED_SINCE_LAST_READ` for a `Get` answer that differs from this connection's last report, its (i) line; the re-claim 3 s after every cut up to 20; Take it back again; fixtures `CAP-075` H2 2735/2980, 3006, 4893/5102, 5361, 3538/3964 (`Cap075Fixtures`); not hardware-verified — `CAP-076` (Group BL).** **Implemented `ai-sessions/0082` (1.2.0)**: `AncModeCause`, `BudsRepository.ancModeCause`, the (i) line; fixtures `CAP-045` 609/612/1583/1755/1818 (press-and-hold changes on a bud). `CAP-072`: shown inside a claim (A 13519, 17:51:10), never for a press-and-hold between claims (§5). **The hold, `ai-sessions/0084` (the 1.2.0 rebuild):** `setAncTabShown`, `releaseOrHold`; fixtures `CAP-072` A 13515/13519, B 825. **Hardware-verified for its main path in `CAP-073`** (`ai-sessions/0085`, 1.2.0 `5b4d5db`): the claim held 31 s on the tab, four unprovoked `Notify` shown without a tap, four taps on the held channel without a second `SABM`, the `DISC` 1.509 s after leaving the tab (`CAP-073-FINDINGS.md` §4); the tile, Home, a loss or re-open while the tab is shown and the (i) line not run. **In a user with Google Play services (`CAP-074`, 2026-10-12, `ai-sessions/0086`): the hold is cut 4.7–5.1 s after its `SABM` (12 of 12) by Play's Fast Pair recovery of the channel (5.2–5.7 s after it lost it), and the app does not re-claim — only the first press-and-hold after a Connect is shown there; verified as designed, the user-visible limit of 1.2.0 in that user (ADR-061 Update of 2026-10-12).** **1.2.1 (`ai-sessions/0087`, ADR-062):** the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default: the card's hint with the close's time, `ancChannelTaken`) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close of one visit, then the hint; the existing claim with its `Get`, nothing new on the wire); fixtures `CAP-074` B 15853/15860, the cut 5.133 s after `SABM` B 15822 (`Cap074Fixtures`); hardware-verified `CAP-075`. **1.3.0 (`ai-sessions/0091`, ADR-064/065): the cause comes from the Buds' settings stream on DLCI 0x02 (`4:{13:n}`, `SettingValue.AncState`), on any tab, without a claim — the hold, the re-claim, the setting and the Play line removed; `AncModeCause` unchanged, the rules in §3.1; fixtures `CAP-051` 1177/1178/1225/1717/1755/1802/1527…1534, `CAP-056` 1451/1450/1468/2259/4025/4028/1696/2175, `CAP-038` 2170/2205, `CAP-045` 2033/2277, `CAP-021` 1089/1842, `CAP-027` 923, `CAP-069` 2502, `CAP-015` 2116, `CAP-036` 1517/1519 (`SettingsStreamFixtures`). **Hardware-verified in `CAP-076` (`ai-sessions/0092`, 1.3.0 `ff27ee1`, both users, ADR-065 Update of 2026-10-15): every hold on the noise-control tab shown within 0.1 s — 29 of 29 with Play holding DLCI 0x04, 12 of 12 without; the own `Set` as the app's change; the (i) "Changed by the Buds at …" on film; but "Changed since the app last saw it" is overwritten by the equal `4:13` answer after the snapshot's `Get` (`CAP-076-FINDINGS.md` §6.2) — a Known issue of 1.3.0.** | | Safe Mode / Startup Handshake | DLCI 0x02 announcement + DLCI 0x04 Model ID | 🟢 FACT (firmware string ADR-012/034; Model ID `PROTOCOL.md` §0.1) | **ADR-042** | **Implemented** (`SafeModeGate`, `:data`; Safe Mode card, `:ui`) | | Find My Buds Case / "ring both" | — | — | ADR-027 (premise narrowed 2026-09-24: spec `0x03` "ring both" untested) | **Out of scope**; sending `0x03` needs its own ADR | @@ -643,7 +643,7 @@ stack, not a new architectural choice (no `DECISIONS.md` entry; nothing here cha no setting. In the user with Google Play services a tap can still collide with Play's claim (`CAP-075-FINDINGS.md` §5: attempt 1 refused, attempt 2 ≈ 1 s later, or the tap's own error) — the known issue of 1.3.0; the holds themselves no longer depend on the channel. Always-on, payload-free lines: "Settings stream subscribed (channel N)", "Noise-control mode read (channel N)", "Noise-control mode from the settings stream ()" / "… from the mode read (…)", - "… : no mode settable", "Settings stream: field N not shown (not read by this app)", and the failures with their reason. Not hardware-verified: `CAP-076`. + "… : no mode settable", "Settings stream: field N not shown (not read by this app)", and the failures with their reason. Hardware-verified in `CAP-076` (`ai-sessions/0092`, 2026-10-15): the two requests in 12 of 12 sessions, every hold on the noise-control tab shown in both users, 13 taps with Play present — 8 claims refused at attempt 1 and done at attempt 2, 4 claims cut by Play's connect, one of them (a tile tap cut after the `Get`'s answer) without a message (`CAP-076-FINDINGS.md` §5). - **Observing Android's own state is read-only and visibility-bound (`ai-sessions/0041`).** The Connection screen mirrors what Android's Bluetooth settings show (paired / connected to this phone) via `OsConnectionObserver`: public APIs only — `BluetoothProfile.getConnectedDevices()` diff --git a/CAPTURE_BLUETOOTH_HCI_SNOOP.md b/CAPTURE_BLUETOOTH_HCI_SNOOP.md index b03a961c..ea7ca3e6 100644 --- a/CAPTURE_BLUETOOTH_HCI_SNOOP.md +++ b/CAPTURE_BLUETOOTH_HCI_SNOOP.md @@ -1869,9 +1869,9 @@ exists yet: this run makes the first.** Pixel 9a / GrapheneOS, **1.3.0** (versionCode 10300 — `ai-sessions/0091`; the 1.2.2 skeleton of `ai-sessions/0089` is replaced, 1.2.2 having been released without this run), installed over 1.2.2 `bfd7891` (one APK serves both users). **Two parts, the extended tier, screen recordings with sound and a camera on -the ears and the phone** for the hold steps. The skeleton captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md holds: **part 1, +the ears and the phone** for the hold steps. The skeleton captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md holds: **part 1, the Owner user (user 0) with Google Play services** — P0 with Play's *Nearby devices* permission state and version on film (`TODO.md` §3's standing item), the -update, Info and a Settings tab **without** the "Noise control with Google Play services" card and the Play line; the Connect's **fifteen** MAESTRO requests +update, Info and a Settings tab **without** the "Noise control with Google Play services" card and the Play line; the Connect's **seventeen** MAESTRO requests (the skeleton said fifteen — `CAP-076-FINDINGS.md` §2) (`SubscribeToSettingsChanges` and `ReadSetting 4:13` after `SubscribeRuntimeInfo`, byte-identical to `CAP-051` 1177/1178 on channel 21 or `CAP-056` 1451/1450 on 19); **ten press-and-holds with the noise-control tab closed and ten with it open** (≥ 10 each) — every hold one `4:{13:n}` stream packet on DLCI 0x02 and the mode on screen within a second, **no `SABM` on DLCI 0x04 from the app**; the (i) "Changed by the Buds at HH:MM:SS: …"; a tap on the tab and the tile (one claim @@ -1887,8 +1887,7 @@ taps/tile/pulls/Connects made; holds shown vs made; stream packets vs `Notify`s; the `4:{13:n}` answers and stream packets per channel and value, the other fields' packets, the own tap's order `CAP-051` 1527 → 1534) and the `tshark` lines to find them — in the skeleton's table. -**Status:** 🔲 planned (skeleton written 2026-10-13 by `ai-sessions/0089` for 1.2.2; rewritten 2026-10-14 by `ai-sessions/0091` for 1.3.0; the run follows -`RELEASING.md` B1–C2 for 1.3.0). +**Status:** ✅ captured 2026-10-15 (part 1 06:35–06:52, the Owner user with Google Play; part 2 07:01–07:08, the secondary user without) and analyzed 2026-10-15 by `ai-sessions/0092` (`CAP-076-FINDINGS.md`): the two requests once per session, every hold on the noise-control tab shown in both users (29/29, 12/12), the own taps as the app's change; three minor defects; verdict "release as built + Known issues" (the maintainer, chat 2026-10-15). The maintainer tested only the changed functionality; BL-10, BL-13, BL-15, BL-18, BL-21, BL-24, BL-25 not run (`CAP-076-EVENT-NOTES.md`). ### 4.3 Hardware Actions (either phone) @@ -2293,7 +2292,7 @@ is how the 2026-08-18 `CAP-005`/`CAP-007`/`CAP-010` ID-reuse incident (see | `CAP-073` | 2026-10-10 | Pixel 9a (secondary user 10 without Google Play) | 17 (`CP3A.261005.005`) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.2.0** rebuilt, build `5b4d5db`, release-signed (Info tab on film, no -dirty; installed APK SHA-256 = B1's), an update over the `ec6d163` 1.2.0 | BI (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004`, `TOUCH-007`, `CASE-004`, `CASE-005`, `INEAR-006`, `AUDIO-003`; `AUDIO-002`, `INEAR-004` not run | The re-test of the rebuilt 1.2.0 (`ai-sessions/0085`): 11 sessions with the thirteen requests byte-identical to `CAP-072`; the ADR-061 hold held DLCI 0x04 31 s on the noise-control tab, four unprovoked `Notify` shown without a tap, four taps on the held channel (no `SABM`), released 1.509 s after leaving the tab; balance one write per release, "Right 4" on the third; 6 writes and 4 `Set`s answered; no camera, no audio; verdict OK — ready to release | — (one `bugreportz` archive, not kept; the raw logs) | `CAP-073-btsnoop_hci.log` (08:22:51–08:35:07, the run), `CAP-073-btsnoop_hci.log.last` (2026-10-09 18:32 – 2026-10-10 08:22, before the run, the Owner user); one export, the app logcat, `CAP-073-logcat-extract.txt`, the shell log, `CAP-073-screen.mp4` (blurred) | analyzed | | `CAP-074` | 2026-10-12 | Pixel 9a (**the Owner user 0 with Google Play services**) | 17 (`CP3A.261005.005`) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.2.0**, build `5b4d5db`, release-signed (the published release; Info tab on film, no -dirty; the installed APK not pulled) | BJ (new) | `PAIR-003`, `ANC-001`–`ANC-004`, `TOUCH-007`, `BATT-004`, `FW-003`; `AUDIO-002` not run | The maintainer's ad-hoc run in the user with Play services (`ai-sessions/0086`): nine Disconnect/Connect pairs, after each the ANC tab and press-and-holds; 10 sessions byte-identical to `CAP-073`; **every ADR-061 hold cut 4.7–5.1 s after its `SABM` by Play services' Fast Pair recovery** (5.2–5.7 s after it lost the channel, 20 of 20) — 9 holds shown, 35 reached Play services: the cause of the first-hold-only pattern (`CAP-074-FINDINGS.md` §4, §8); fix: a Settings switch, the maintainer's choice | — (`bugreportz` by the maintainer, not kept; the raw logs) | `CAP-074-btsnoop_hci.log` (2026-10-11 19:50 – 2026-10-12 06:52: the run and a 06:30–06:31 window of an earlier process), `CAP-074-btsnoop_hci.log.last` (2026-10-11); one export (06:38–07:03), the app logcat, `CAP-074-logcat-extract.txt`, `CAP-074-recording.mp4` (with audio, as is), `CAP-074-sha256sum.txt` | analyzed | | `CAP-075` | 2026-10-12 (set 1, 17:22:31–17:28:43 on film) and 2026-10-13 (set 2, 06:29:04–06:34:32 on film) | Pixel 9a (**set 1: the secondary user without Google Play; set 2: the Owner user 0 with Google Play services**) | 17 (`CP3A.261005.005` in `CAP-074`; not re-read) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.2.1** (versionCode 10201), build `31ee183`, release-signed (Info tab on film in both users, no -dirty; the installed APK not pulled), updated over 1.2.0 `5b4d5db` on film | BK (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004` (no mode tap: shown from `Notify` only), `TOUCH-007`; `AUDIO-002`, `CASE-004`/`005` not observed | The hardware run of the "Noise-control channel" switch (`DECISIONS.md` ADR-062): without Play both positions identical, 41 of 41 holds shown, no foreign close; with Play "Show a hint" = the hint with the cut's time, 0 of 9 holds shown; "Take it back" = three chains × three re-claims (3/6/12 s scheduled, 4.0–4.8 s on the wire), every re-claim colliding with Play's retry, every hold cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 holds shown; the cut's stack path 15 of 15 in the system log (`CAP-075-FINDINGS.md` §4, §5, §8); verdict no heavy issue — 1.2.1 released as built; proposals P1/P2/P4 to the next FEATURE prompt | — (`bugreportz` 06:35:41 and the GrapheneOS log viewer's full log, both local only) | `CAP-075-btsnoop_hci1.log.last` + `CAP-075-btsnoop_hci1.log` (set 1, split at the Bluetooth off/on 17:23:04/08), `CAP-075-btsnoop_hci2.log.last` (2026-10-12 18:13 – 2026-10-13 06:29, incl. the Owner's unfilmed evening session) + `CAP-075-btsnoop_hci2.log` (06:29–06:36); two exports, two app logcats, `CAP-075-logcat-extract.txt` (set 2's system log, redacted), `CAP-075-recording1.mp4` / `CAP-075-recording2.mp4` (with audio, as is), `CAP-075-sha256sum.txt` | analyzed | -| `CAP-076` | TBD | Pixel 9a (**part 1: the Owner user 0 with Google Play services; part 2: the secondary user 10 without**) | 17 | `release_5.203` expected | OpenControl for Pixel Buds Pro 2 **1.3.0** (versionCode 10300), the release APK of `scripts/release.sh 1.3.0`, installed over 1.2.2 `bfd7891` — build hash on the Info tab on film, the installed APK pulled | BL (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004`, `TOUCH-007` | The hardware run of ADR-064/ADR-065 (`ai-sessions/0091`, 1.3.0): the two new requests once per MAESTRO session; press-and-holds with the noise-control tab closed and open shown in both users through the settings stream, no `SABM` on DLCI 0x04 from the app; the own tap's stream packet; the wear-change re-home and the re-open's read; Bluetooth off/on; taps while Play holds the channel; optionally the EQ/a switch from the official app on a second phone; the card and the Play line gone | — | TBD | planned | +| `CAP-076` | 2026-10-15 (part 1, 06:35:26–06:51:47 on film; part 2, 07:02:31–07:08:20 on film) | Pixel 9a (**part 1: the Owner user 0 with Google Play services; part 2: the secondary user 10 without**) | 17 (`CP3A.261005.005`, film 1 s595) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.3.0** (versionCode 10300), build `ff27ee1`, release-signed, installed over 1.2.2 `bfd7891` on film 1; the installed APK pulled: SHA-256 = the tested build's | BL (new) | `PAIR-003`, `BATT-004`, `FW-003`, `ANC-001`–`ANC-004`, `TOUCH-007`, `CASE-004`/`005`, `INEAR-001`–`003`, `FIND-001`; BL-10/15/18/21/24 not run | The hardware run of ADR-064/ADR-065: the two new requests once per MAESTRO session (12 of 12, byte-identical); holds on the noise-control tab shown within 0.1 s — 29 of 29 with Play holding DLCI 0x04, 12 of 12 without; 15 own `Set`s ACKed and classed as the app's; nothing held or re-claimed; every change pushed twice (channels 21 and 19) with both buds out; the re-open's "changed since" cause overwritten by the equal `4:13` answer; one tile tap lost silently to Play; verdict release as built with two Known issues (`CAP-076-FINDINGS.md`, `ai-sessions/0092`) | — (the GrapheneOS log viewer's system log and `adb logcat -b all` of part 1, both local only) | `CAP-076-btsnoop_hci1.log.last` + `CAP-076-btsnoop_hci1.log` (part 1, split at the Bluetooth off/on 06:35:34/06:36:10), `CAP-076-btsnoop_hci2.log.last` + `CAP-076-btsnoop_hci2.log` (part 2, split at 07:02:39/44); two exports, two app logcats, `CAP-076-logcat-extract.txt` (part 1, redacted), `CAP-076-adb-shell.log`, `CAP-076-adb-checks-0092.log`, `CAP-076-recording1.mp4` / `CAP-076-recording2.mp4` | analyzed | **Column notes:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 243005c8..68021a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,11 @@ Connect, both byte-identical to the official app's. ### Known issues - A tap on a noise-control mode claims the shared channel for about 1.5 seconds; on a phone with Google Play services, Play's Fast Pair may be on it at that - moment (about 5 seconds after a claim) — the tap then fails with a message; tap again. + moment (about 5 seconds after a claim) — the tap then usually fails with a message (in `CAP-076` once without one, from the tile: nothing changed); tap again. - When the Buds close the session themselves (one bud out while both are worn — 12 of 77 such changes in the captures), the app re-opens it and reads the mode again; in between, the mode shown can be a few seconds old. +- After a reconnect (or when the Buds re-open the session themselves), a mode that changed while the app was not connected shows as read: the (i) line + "Changed since the app last saw it" does not appear (`CAP-076`). A fix follows. ## [1.2.2] - 2026-10-14 diff --git a/DECISIONS.md b/DECISIONS.md index c0f6c885..a17a1eef 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2919,6 +2919,16 @@ motivated this). store, the probe and the `` entry; `ARCHITECTURE.md` §2.4, §3.1, §3.2, §5a, §6.0b updated; version 1.3.0 (`CHANGELOG.md`). - **Note (as built, `ai-sessions/0091`)**: item 1's "a send failure ends the sequence" is implemented as every other step of the Connect sequence — each request fails on its own and is never retried; a lost session fails them all (the test "a lost session during the subscription is not retried"). +- **Update (2026-10-15, `ai-sessions/0092`, `CAP-076`, maintainer-approved in chat 2026-10-15, `AskUserQuestion` "ADR-065 Upd", option *"Write it as drafted + (Recommended)"*): hardware-verified in both GrapheneOS users (1.3.0 `ff27ee1`).** One `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO + session, byte-identical to `CAP-051` 1177/1178 (channel 21) and `CAP-056` 1451/1450 (channel 19), after `SubscribeRuntimeInfo` and before `GetHardwareInfo` — + 12 of 12 full sessions (Connects, LINK_BACK and AFTER_LOSS re-opens, the re-open after Bluetooth off); press-and-holds on the noise-control tab shown within + 0.1 s, **29 of 29 with Google Play services holding the Message Stream and 12 of 12 without**; 33 app claims of DLCI 0x04, all user actions or the Connect's + snapshot (none held, none re-claimed); 15 own `Set`s ACKed and classed as the app's (the stream 55–246 ms after the `Set`); value 0 never shown as a mode. + Found: with both buds out of the case every change is pushed twice (channels 21 and 19, 🟡 one per bud core); item 4's "changed since the app last saw it" is + overwritten by the equal `ReadSetting 4:13` answer that follows the snapshot's `Get` (12 of 12 sessions in that order — `CAP-076-FINDINGS.md` §6.2); a tile + tap cut right after the `Get`'s answer sends nothing and says nothing (§5.3). Not verified: holds on other tabs on screen, Home, another phone's changes. The + decision is unchanged. --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/DECISIONS.md - https://tedsluis.github.io/opencontrolpixelbudspro2/DECISIONS diff --git a/PROJECT.md b/PROJECT.md index a385d81d..ea6e269c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -148,7 +148,7 @@ verdict: release build `86a6fb3` (the maintainer, chat 2026-10-08). **1.2.0** (prepared in `ai-sessions/0082`, 2026-10-09; published 2026-10-10, build `5b4d5db`): "Changed by the Buds" in the noise-control details, the component serial numbers on the Info tab (ADR-058), the "probably worn" indicator on the battery card (ADR-059), the Case sounds switches moved to gear → Settings and Conversation detection to Controls; volume-level notifications studied, not built (a draft ADR-060, a 1.3.0 candidate). Its hardware run `CAP-072` (`ai-sessions/0083`, 2026-10-09) found no heavy defect — every request answered and byte-identical, the serials and the worn line as specified — but showed that a press-and-hold is never shown (no claim open between actions, ADR-032); verdict **fix first** (the maintainer, chat 2026-10-10): ADR-061 holds the noise-control channel while that tab is on screen, then a short re-test. **Rebuilt** as the same version in `ai-sessions/0084` (2026-10-10): the hold built (`setAncTabShown`), the serial fixtures from OpenControl's own `CAP-072` exchange, a finer balance slider (the finger's value while dragging, steps of 1 near the centre); the re-test `CAP-073` (Group BI, `ai-sessions/0085`, 2026-10-10) found no heavy defect — the noise-control channel held on its tab, four press-and-holds shown without a tap, every request byte-identical to `CAP-072`; verdict **OK — ready to release** (the maintainer, chat 2026-10-10). -**`CAP-074`** (`ai-sessions/0086`, 2026-10-12, the released 1.2.0 in the GrapheneOS user **with** Google Play services) found the limit of that user: Play's Fast Pair takes the held noise-control channel back ≈ 5 s later and the app did not re-claim, so only the first press-and-hold after a Connect was shown. **1.2.1** (prepared in `ai-sessions/0087`, 2026-10-12; published 2026-10-13, build `31ee183`): the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close, then the hint) — ADR-062; its hardware run `CAP-075` (Group BK, `ai-sessions/0088`, 2026-10-12/13, both users) passed: without Play services both choices are identical and every press-and-hold on the tab is shown (41 of 41); with Play services the hint shows the cut's time and "Take it back" holds the channel about 43 % of a 44-s chain (Play's recovery 5.3–5.8 s, retry 2.1–2.8 s) — verdict "no heavy issue — release as built" (the maintainer, chat 2026-10-13); the next step is a constant re-claim step with a longer chain (`TODO.md` §5). **1.2.2** (prepared in `ai-sessions/0089`, 2026-10-13; not released): the setting renamed "Noise control with Google Play services" with options that say whether the phone has Google Play services and a line whether Android finds it (ADR-063), the re-claim 3 s after every cut up to 20 times, "Take it back again" on the hint, and a cause line for a mode the app's own read found changed (ADR-062 Update 2); released 2026-10-14 (build `bfd7891`) without a hardware run. **1.3.0** (prepared in `ai-sessions/0091`, 2026-10-14; not released): the app subscribes to the Buds' settings stream and reads the noise-control mode on its own channel (ADR-064), so a press-and-hold on a bud shows on any tab and every setting follows a change made elsewhere; the hold, the re-claim, the "Noise control with Google Play services" setting and the Play-services line are removed (ADR-065); its hardware run is `CAP-076` (Group BL, rewritten). +**`CAP-074`** (`ai-sessions/0086`, 2026-10-12, the released 1.2.0 in the GrapheneOS user **with** Google Play services) found the limit of that user: Play's Fast Pair takes the held noise-control channel back ≈ 5 s later and the app did not re-claim, so only the first press-and-hold after a Connect was shown. **1.2.1** (prepared in `ai-sessions/0087`, 2026-10-12; published 2026-10-13, build `31ee183`): the Settings tab's "Noise-control channel" choice — "Show a hint, do not take it back" (the default) or "Take it back while the noise-control tab is open" (a re-claim 3, 6 and 12 s after the first, second and third foreign close, then the hint) — ADR-062; its hardware run `CAP-075` (Group BK, `ai-sessions/0088`, 2026-10-12/13, both users) passed: without Play services both choices are identical and every press-and-hold on the tab is shown (41 of 41); with Play services the hint shows the cut's time and "Take it back" holds the channel about 43 % of a 44-s chain (Play's recovery 5.3–5.8 s, retry 2.1–2.8 s) — verdict "no heavy issue — release as built" (the maintainer, chat 2026-10-13); the next step is a constant re-claim step with a longer chain (`TODO.md` §5). **1.2.2** (prepared in `ai-sessions/0089`, 2026-10-13; not released): the setting renamed "Noise control with Google Play services" with options that say whether the phone has Google Play services and a line whether Android finds it (ADR-063), the re-claim 3 s after every cut up to 20 times, "Take it back again" on the hint, and a cause line for a mode the app's own read found changed (ADR-062 Update 2); released 2026-10-14 (build `bfd7891`) without a hardware run. **1.3.0** (prepared in `ai-sessions/0091`, 2026-10-14; not released): the app subscribes to the Buds' settings stream and reads the noise-control mode on its own channel (ADR-064), so a press-and-hold on a bud shows on any tab and every setting follows a change made elsewhere; the hold, the re-claim, the "Noise control with Google Play services" setting and the Play-services line are removed (ADR-065); its hardware run `CAP-076` (Group BL, `ai-sessions/0092`, 2026-10-15, both users) passed: every press-and-hold on the noise-control tab shown within 0.1 s — 29 of 29 with Google Play services holding the shared channel, 12 of 12 without — and three minor defects; verdict "release as built + Known issues" (the maintainer, chat 2026-10-15). --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/PROJECT.md - https://tedsluis.github.io/opencontrolpixelbudspro2/PROJECT diff --git a/PROTOCOL.md b/PROTOCOL.md index 776b0dd2..6bedad20 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -865,6 +865,14 @@ never decides which extracted finding is relevant (see `AGENTS.md` §4/§6, Supersedes the 🟡 items of 2026-09-28 (`CAP-056`) and 2026-10-04 (`CAP-069`, 10 of 10) in §6. Tables and commands: `DESKRESEARCH_FINDINGS.md` 2026-10-14; code side: the companion app's ANC state is fed only from setting 13 (`REVERSE_ENGINEERING.md`, `qhr` register, Update of 2026-10-14). Used by `DECISIONS.md` ADR-064. +- **Note (2026-10-15, `ai-sessions/0092`, maintainer-approved in chat 2026-10-15, `AskUserQuestion` "PROTOCOL", option *"Add the §4.1 note + §8 row + (Recommended)"*; no status change) — the stream in OpenControl's own logs (`CAP-076`).** OpenControl 1.3.0 subscribes once per MAESTRO session (12 of 12 + sessions, byte-identical to `CAP-051` 1177/1178 and `CAP-056` 1451/1450); the Buds pushed every reported change of setting 13 to it, also while Google Play + services held the Message Stream and received the `Notify` (41 of 41 holds shown, `CAP-076-FINDINGS.md` §4). With both buds out of the case each change came + twice, with channel 21 and 19 (68 of 68; e.g. `CAP-076-btsnoop_hci1.log` 6108 `7e00a5032a0422026801080710151dea71de7d5e25f5ad2128b2d925c47e` and 6117 + `7e80a3032a0422026801080710131dea71de7d5e25f5ad2128117bf5557e`), with one bud in the case once, on that bud's channel — 🟡 one packet per bud core (19 = + LEFT_BT_CORE, 21 = RIGHT_BT_CORE, §2.2a); the official app's captures show one packet per change (❓, `CAP-076-FINDINGS.md` §4.3). The §6 item "a stored + mode per wear state" is **not** settled by this run (three wear-driven switches within 2–7 ms of the Buds' AVRCP PAUSE/PLAY, no ears on film). ### 4.2 Equalizer (EQ) @@ -3757,6 +3765,7 @@ leaving them buried in prose elsewhere. | 2026-10-12 | **`ai-sessions/0086` — `CAP-074` (Group BJ, OpenControl 1.2.0 in the Owner user with Google Play services), maintainer-approved in chat 2026-10-12.** §4.1: a note that `Notify ANC state` reaches the one client holding DLCI 0x04 (35 press-and-hold `Notify` frames to Play services, 9 to the app). No status change. | Claude (AI), maintainer-approved | | 2026-10-13 | **`ai-sessions/0088` — `CAP-075` (Group BK, OpenControl 1.2.1 in both GrapheneOS users), maintainer-approved in chat 2026-10-13.** §4.1: a 🟢 FACT note on Google Play services' Fast Pair client — DLCI 0x04 in all its claims, recovery 5.3–5.8 s after a loss, retry 2.1–2.8 s after a collision, the stack's `already at opened state` refusal closing both sockets (15 of 15 cuts in the system log). No status change for the opcode. | Claude (AI), maintainer-approved | | 2026-10-14 | **`ai-sessions/0090` — the pbpctrl cross-check (E-01 … E-23), maintainer-approved in chat 2026-10-14.** **§4.1** 🟢 FACT: setting 13 = the noise-control mode on DLCI 0x02, pushed on the settings stream to a subscribed client for every reported change (73/73 Buds-side, 39/39 Set-driven), incl. while no client holds the Message Stream; `ReadSetting 4:13` = the latest mode (359/382) — used by ADR-064; §6's two 🟡 items on field 13 point there. **§6** 🟡 one-bud / two-bud mode (pbpctrl names 9/10). **§2.2** correction: the format was never pbpctrl's. **§4.5.7** note: pbpctrl's balance = −zigzag (same mapping). **§0.1** note: Class of Device `0x244404`. | Claude (AI), maintainer-approved | +| 2026-10-15 | **`ai-sessions/0092` — `CAP-076` (Group BL, OpenControl 1.3.0 in both GrapheneOS users), maintainer-approved in chat 2026-10-15.** §4.1: a note — the settings stream in OpenControl's own logs (12 of 12 sessions subscribed, 41 of 41 holds pushed while Play held the Message Stream), every change pushed twice with both buds out of the case (🟡 one per bud core). No status change; §6's wear item unchanged. | Claude (AI), maintainer-approved | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/PROTOCOL.md - https://tedsluis.github.io/opencontrolpixelbudspro2/PROTOCOL diff --git a/TESTPLAN_BLUETOOTH_HCI_SNOOP.md b/TESTPLAN_BLUETOOTH_HCI_SNOOP.md index 0213af2a..afe71dcf 100644 --- a/TESTPLAN_BLUETOOTH_HCI_SNOOP.md +++ b/TESTPLAN_BLUETOOTH_HCI_SNOOP.md @@ -115,10 +115,10 @@ _Make sure the buds are connected and active._ | ID | Description | Initiator | Capture scenario(s) | Existence source | Note | Evidence | |---|---|---|---|---|---|---| -| `ANC-001` | ANC → Off | User (App) | B, AU, AV, AX, AY, AZ, BA, BB, BG, BE, BI, BJ, BK | 🟢 | | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060` (`ai-sessions/0043`, app-validation runs); `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: OFF via the tile ACKed worn (frame 8755), NAK `0x02` when not worn (`CAP-062-FINDINGS.md` §2); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: tile ADAPTIVE→OFF worn, `Set 20` 3835 → ACK 3843; a tile `Set` with the buds on the table NAKed 3440 (reason 0x02); `CAP-065`: tile ADAPTIVE→OFF `Set 20` 2567 → ACK 2579 and 3337 → 3346 (worn); BA-5 Refresh with both loose → `Notify … 00 20` 5465; `CAP-066` (Group BB, `0062` build): BB-1 Refresh `08 11` → `Notify … 00 20` (A1652), BB-3 tile with `00` → no `Set` (A1951–1972), BB-4t tile `Notify 40` → `Set 20` A2370 → ACK A2372, BB-8 `Set 20` A5030 → ACK A5033, BB-5 six Refreshes → `00` (`CAP-066-FINDINGS.md` §2–§3) Planned: `CAP-068` (Group BD) — a `Set` by the release build. `CAP-068`: release build 1.0.1 without Play, tab and tile (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | -| `ANC-002` | ANC → Noise Cancellation | User (App) | B, AU, AV, AX, AY, AZ, BA, BG, BE, BI, BJ, BK | 🟢 | Sends a configuration command to the buds. | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: ACK worn (10348), NAK `0x02` not worn (6107, 8344 …); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-065`: in the app (F7) `Set 08` 10790 → ACK 10799, Home 1 s later; via the tile 2806 → 2814; one tile `Set 08` ACKed (2651) after the app's claim was closed — lost to the app (`CAP-065-FINDINGS.md` §3) `CAP-068`: release build 1.0.1, tab and tile (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | -| `ANC-003` | ANC → Adaptive | User (App) | B, AU, AV, AX, AY, AZ, BA, BB, BG, BE, BI, BJ, BK | 🟢🔵 | Pro 2-specific; added in firmware 4.467 (Sept. 2025). Automatically adjusts volume to the environment. | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: ACK worn (8705, 8929), NAK `0x02` not worn (6050, 10127); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: ADAPTIVE tapped with the buds on the table → ACKed 2698 (Settable `e8`, §3) and worn → ACK 2988; `CAP-065`: via the tile `Set 40` 3045 → ACK 3052 (worn); `CAP-066`: BB-2 Adaptive with `00` → `08 11` only, no `Set` (A1778–1800); BB-4 worn → `08 11` → `e8` → `Set 40` A2202 → ACK A2204 `CAP-068`: release build 1.0.1, tile only (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | -| `ANC-004` | ANC → Transparency | User (App) | B, AU, AV, AX, AZ, BA, BB, BG, BE, BI, BJ, BK | 🟢 | | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: ACK worn (8864), NAK `0x02` not worn (5998, 6155); `CAP-065`: via the tile `Set 80` 2869 → ACK 2879 (worn); not tapped in the app (→ `CAP-066` BB-8); `CAP-066`: BB-8 in the app → `08 11` → `e8` → `Set 80` A4879 → ACK A4881 `CAP-068`: release build 1.0.1, tab and tile (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | +| `ANC-001` | ANC → Off | User (App) | B, AU, AV, AX, AY, AZ, BA, BB, BG, BE, BI, BJ, BK, BL | 🟢 | | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060` (`ai-sessions/0043`, app-validation runs); `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: OFF via the tile ACKed worn (frame 8755), NAK `0x02` when not worn (`CAP-062-FINDINGS.md` §2); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: tile ADAPTIVE→OFF worn, `Set 20` 3835 → ACK 3843; a tile `Set` with the buds on the table NAKed 3440 (reason 0x02); `CAP-065`: tile ADAPTIVE→OFF `Set 20` 2567 → ACK 2579 and 3337 → 3346 (worn); BA-5 Refresh with both loose → `Notify … 00 20` 5465; `CAP-066` (Group BB, `0062` build): BB-1 Refresh `08 11` → `Notify … 00 20` (A1652), BB-3 tile with `00` → no `Set` (A1951–1972), BB-4t tile `Notify 40` → `Set 20` A2370 → ACK A2372, BB-8 `Set 20` A5030 → ACK A5033, BB-5 six Refreshes → `00` (`CAP-066-FINDINGS.md` §2–§3) Planned: `CAP-068` (Group BD) — a `Set` by the release build. `CAP-068`: release build 1.0.1 without Play, tab and tile (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | +| `ANC-002` | ANC → Noise Cancellation | User (App) | B, AU, AV, AX, AY, AZ, BA, BG, BE, BI, BJ, BK, BL | 🟢 | Sends a configuration command to the buds. | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: ACK worn (10348), NAK `0x02` not worn (6107, 8344 …); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-065`: in the app (F7) `Set 08` 10790 → ACK 10799, Home 1 s later; via the tile 2806 → 2814; one tile `Set 08` ACKed (2651) after the app's claim was closed — lost to the app (`CAP-065-FINDINGS.md` §3) `CAP-068`: release build 1.0.1, tab and tile (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | +| `ANC-003` | ANC → Adaptive | User (App) | B, AU, AV, AX, AY, AZ, BA, BB, BG, BE, BI, BJ, BK, BL | 🟢🔵 | Pro 2-specific; added in firmware 4.467 (Sept. 2025). Automatically adjusts volume to the environment. | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: ACK worn (8705, 8929), NAK `0x02` not worn (6050, 10127); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: ADAPTIVE tapped with the buds on the table → ACKed 2698 (Settable `e8`, §3) and worn → ACK 2988; `CAP-065`: via the tile `Set 40` 3045 → ACK 3052 (worn); `CAP-066`: BB-2 Adaptive with `00` → `08 11` only, no `Set` (A1778–1800); BB-4 worn → `08 11` → `e8` → `Set 40` A2202 → ACK A2204 `CAP-068`: release build 1.0.1, tile only (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | +| `ANC-004` | ANC → Transparency | User (App) | B, AU, AV, AX, AZ, BA, BB, BG, BE, BI, BJ, BK, BL | 🟢 | | `PROTOCOL.md` §4.1; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: ACK worn (8864), NAK `0x02` not worn (5998, 6155); `CAP-065`: via the tile `Set 80` 2869 → ACK 2879 (worn); not tapped in the app (→ `CAP-066` BB-8); `CAP-066`: BB-8 in the app → `08 11` → `e8` → `Set 80` A4879 → ACK A4881 `CAP-068`: release build 1.0.1, tab and tile (`CAP-068-FINDINGS.md` §2).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | | `ANC-006` | Tap the ANC mode that is already selected | User (App) | BD, BE | 🟡 | **Added 2026-10-03 (`ai-sessions/0069`, lead L68-7).** `CAP-001`'s two taps without a `Set` (08:51:32, 08:51:39) came when the Buds' `Notify` already read the tapped mode (1895 `… e8 e8 80`, 1990 `… e8 e8 20`) — 🟡 the official app sends nothing for a tap on the current mode. For OpenControl: does a `Set` of the current mode get an ACK or NAK reason `0x04` ("redundant device action")? (`ANC-005` is a declined ID.) | `CAP-001-FINDINGS.md` §5; `PROTOCOL.md` §4.1 `CAP-068` (`ai-sessions/0070`): OpenControl 1.0.1 sends the `Set`, the Buds ACK it (A2034 → A2036; `PROTOCOL.md` §4.1 Update 2026-10-04).; `CAP-069` §4: official app sends no `Set` (0 of 3) | | `CONV-001` | Toggle 'Conversation Detection' on/off | User (App) | C, AY, BG | 🟢 | Switches to Transparency and pauses media when you speak. | `PROTOCOL.md` §4.5.1; `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | | `MULTI-001` | Toggle 'Multipoint' on/off | User (App) | C, BE, BF, BG | 🟢 | Connects to 2 Bluetooth devices simultaneously; may trigger an SDP/connection update. | `PROTOCOL.md` §4.5.2 (both directions in `CAP-019`: ON 2293, OFF 2482); `CAP-069` §2 (4 filmed writes + SASS bit 2, ADR-053); `CAP-070` §3 (OpenControl 1.1.0: ch 21 and ch 19, ACKed); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | @@ -147,12 +147,12 @@ _Make sure the buds are connected and active._ | `AUDIO-003` | Shift the 'Volume balance' (Left/Right) | User (App) | H, AK, AY, AZ, BA, BB, BC, BF, BG, BI | 🟢 | **Note:** stored locally on the earbuds themselves (persistent write, works across devices) — explicitly stated in the app's own screenshot. **Scale/direction resolved 2026-09-12 (`CAP-046`, Group AK), promoted 🟢 FACT 2026-09-13 (`DECISIONS.md` ADR-026)**: range ±100, `+100`=Left, `-100`=Right (opposite of the prior working assumption). | `PROTOCOL.md` §4.5.7, `CAP-046-FINDINGS.md` §2; `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` I-3: 26 writes OK, Centre = `17:0` ×5, persisted (read 7044); `CAP-065`: field 17 read `17:0` (Centre) every time; no write — restore to Right 4 is `CAP-066` BB-10; `CAP-066` BB-10: 32 writes OK (A7723–7873), none `17:7` (Right 4) — the drag reached only Right 6 on the right side; ends at Centre `17:0`; `CAP-067` (1.0.0, no Play services): BC-3 Right 4 = `17:7` on the 17th drag (A876, = `CAP-064` 6671), 18 + 1 writes OK, BC-5 `17:0`, BC-R `17:7` (B834) read back; `CAP-070` §3 (ch-19 `17:7`, 53 writes OK); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | | `FW-001` | Tap the 'Firmware up to date' check | User (App) | I | 🟢 | Forces a manual update check. | `CAP-023-FINDINGS.md` §4 (cached, not live-queried) | | `FW-002` | View firmware version per component (L/R/Case) | User (App, view) | I, AS (withdrawn) | 🟢 | Possibly triggers a status query when 'More settings' is opened. | `PROTOCOL.md` §0.1, `CAP-023-FINDINGS.md` §3 | -| `FW-003` | View serial numbers per component | User (App, view) | I, AS (withdrawn), BH, BI, BJ, BK | 🟢 | Same as above. **Since 1.2.0 OpenControl reads the serials too** (`DECISIONS.md` ADR-058: one `GetHardwareInfo` per Connect, field 7 by position: Case, Right bud, Left bud). | `PROTOCOL.md` §6 (2026-10-09 Update); `CAP-072-FINDINGS.md` §2 (30 requests, 29 answers; the Info tab on the screen recording, Case/Right/Left) | +| `FW-003` | View serial numbers per component | User (App, view) | I, AS (withdrawn), BH, BI, BJ, BK, BL | 🟢 | Same as above. **Since 1.2.0 OpenControl reads the serials too** (`DECISIONS.md` ADR-058: one `GetHardwareInfo` per Connect, field 7 by position: Case, Right bud, Left bud). | `PROTOCOL.md` §6 (2026-10-09 Update); `CAP-072-FINDINGS.md` §2 (30 requests, 29 answers; the Info tab on the screen recording, Case/Right/Left) | | `FW-004` | View connection status ("Earbud status: Connected") | User (App, view) | I | 🟢 | | — (not exercised, `CAP-023-FINDINGS.md` §6 gap) | -| `INEAR-001` | Toggle 'In-ear detection' on/off | User (App) | J, AR, AZ, BA | 🟢 | Automatically plays/pauses audio when worn/not worn. | `PROTOCOL.md` §4.5.5; `CAP-056` 2173/2849/3627/4048/4344 — the "In-ear detection" switch = `qhr` field 2 on film, 🟢 (`PROTOCOL.md` §4.5.5 2026-09-28 Update, ADR-047); `CAP-064`: OpenControl's `4:{2:0}` 9922 / `4:{2:1}` 10817 → OK, SASS bit 4 follows (9927 `b0 00`, 10822 `b8 00`); with it off no pause on removal; `CAP-065`: field 2 read `2:1` at every Connect and on the Controls/Sound pulls (BA-12/13); no write | +| `INEAR-001` | Toggle 'In-ear detection' on/off | User (App) | J, AR, AZ, BA, BL | 🟢 | Automatically plays/pauses audio when worn/not worn. | `PROTOCOL.md` §4.5.5; `CAP-056` 2173/2849/3627/4048/4344 — the "In-ear detection" switch = `qhr` field 2 on film, 🟢 (`PROTOCOL.md` §4.5.5 2026-09-28 Update, ADR-047); `CAP-064`: OpenControl's `4:{2:0}` 9922 / `4:{2:1}` 10817 → OK, SASS bit 4 follows (9927 `b0 00`, 10822 `b8 00`); with it off no pause on removal; `CAP-065`: field 2 read `2:1` at every Connect and on the Controls/Sound pulls (BA-12/13); no write | | `CASE-001` | Toggle case sound 'Earbuds replaced' on/off | User (App) | J, BF, BG, AT | 🟢 | Setting stored on the chip inside the case. | `PROTOCOL.md` §4.5.8; `CAP-070` §3 (OpenControl 1.1.0, ACKed; the case-sound observation not recorded); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3 (no chime recorded)); `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | | `CASE-002` | Toggle case sound 'Other notifications' on/off | User (App) | J, BF, BG, AT | 🟢 | Covers: charging started, low battery, pairing successful, errors. | `PROTOCOL.md` §4.5.8; `CAP-070` §3 (OpenControl 1.1.0, ACKed); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3); `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | -| `FIND-001` | Play sound on Left earbud ('Find My Buds') | User (App) | K, AU, AV, AX, AY, BG | 🔵🟡 | Via Find Device/Find Hub integration in the app; individually addressable per component. | `PROTOCOL.md` §4.4; also `CAP-059`, `CAP-060` (`ai-sessions/0043`); `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: Ring Left `04 01 00 01 02` → ACK (9541/9552, 9772/9783), kept ringing after Disconnect until Stop (audio); `CAP-063`: Ring Left with both buds docked → ACK (8518/8520) `CAP-068`: Ring/Stop by direction (`CAP-068-FINDINGS.md` §6).; `CAP-069` §7: Find Hub only, "Can't play sound", no `04 01`; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | +| `FIND-001` | Play sound on Left earbud ('Find My Buds') | User (App) | K, AU, AV, AX, AY, BG, BL | 🔵🟡 | Via Find Device/Find Hub integration in the app; individually addressable per component. | `PROTOCOL.md` §4.4; also `CAP-059`, `CAP-060` (`ai-sessions/0043`); `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: Ring Left `04 01 00 01 02` → ACK (9541/9552, 9772/9783), kept ringing after Disconnect until Stop (audio); `CAP-063`: Ring Left with both buds docked → ACK (8518/8520) `CAP-068`: Ring/Stop by direction (`CAP-068-FINDINGS.md` §6).; `CAP-069` §7: Find Hub only, "Can't play sound", no `04 01`; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | | `FIND-002` | Play sound on Right earbud | User (App) | K, AU, AV, AX, BG | 🔵🟡 | | `PROTOCOL.md` §4.4; also `CAP-059`, `CAP-060`; `CAP-061`: attempted, nothing sent (Safe Mode, `CAP-061-FINDINGS.md` §1); `CAP-062`: Ring Right `04 01 00 01 01` → ACK (9660/9671), heard until Stop; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | | `FIND-003` | Play sound on Case | User (App) | K | 🔵🟡 | | `PROTOCOL.md` §4.4 (found to require Find Hub network path, not the local Ring command) | | `FIND-004` | Play sound on both earbuds simultaneously | User (App) | K | 🟡 | **Added 2026-09-24 (`ai-sessions/0045`, `DECISIONS.md` ADR-027 Update):** the official app uses Find Hub for this, but the Fast Pair Device Action spec defines a **local** Ring value `0x03` = "ring both left and right" — 🔴 untested on this firmware. Proposed experiment (needs its own ADR before anything is sent): send `04 01 00 01 03` on a claimed DLCI 0x04 and look for an ACK (`ff 01 … 04 01`) within 1 s and an audible ring on both buds. | `PROTOCOL.md` §4.4 (same as `FIND-003`) | @@ -167,10 +167,10 @@ _Physical interactions with the device._ | ID | Description | Initiator | Capture scenario(s) | Existence source | Note | Evidence | |---|---|---|---|---|---|---| | `CASE-003` | Open the charging case lid | User (Hardware) | M, AW, AY, AP | 🟢🔵 | Triggers a BLE advertisement / Fast Pair pop-up. | —; `CAP-061` (film 17:25:26); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`) | -| `CASE-004` | Remove Left earbud from the case | User (Hardware) | M, AW, AX, AY, AZ, BA, BB, BF, BG, AP, BI | 🔵 | Changes state to 'out of case'. | —; `CAP-061` (Left out 17:26:51–52, battery flag `64 64` frame 3233); `CAP-062` (Left out ≈ 06:46:33, stream 7118 `7:{2:0}`); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` (ears/slots on film, stream 6517, 9437); `CAP-065`: the Left (left slot) out at 11:08:22, 11:19:20, 11:21:32 with the Right docked (stream 1089, 8256, 10405: Right charging); lid closed with the Left inside (BA-7); `CAP-066`: the Left out of the case onto the table 16:17:26 (after the Right) and 16:22:19 (first; Right still charging, stream A5966) → Buds' ConnReq A5530, ch 19; `CAP-069` §8; `CAP-070` §4; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §4); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`) | -| `CASE-005` | Remove Right earbud from the case | User (Hardware) | M, AW, AX, AY, AZ, BA, BB, BF, BG, AP, BI | 🔵 | | —; `CAP-061` (Right out 17:26:36–37, battery flag `e4 64` frame 3146); `CAP-062` (Right out ≈ 06:46:21.5, `e4 64` frame 7058, stream 7033); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` (stream 6228, 9437); `CAP-065`: the Right (right slot) out at 11:12:52 and 11:20:54 with the Left docked (stream 4162, 9350: Left charging); `CAP-066`: the Right out of the case 16:17:24 (first → Buds' ConnReq A2804, ch 21) and ≈ 16:22:22 (stream A5966 → A5973); `CAP-069` §8; `CAP-070` §4; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §4); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`) | -| `INEAR-002` | Insert Left earbud into the ear | User (Hardware) | M, AR, AZ, BA, BB, BC, BF | 🔵 | Triggers the in-ear sensor (audio starts, if In-ear detection is on). | `CAP-056` §4 (re-insertions with in-ear detection on/off; which ear not identifiable on film); `CAP-064`: Left insertions on film (head right of frame), e.g. 10:06:58–10:07:06; `CAP-065`: Left insertions on film (head right), 11:08:28, 11:16:28, 11:21:48 → `Notify … e8 e8 40` (1352, 10620); `CAP-066`: Left insertions on film (head right) 16:14:21, 16:16:08, 16:20:56, 16:24:00 → `Notify … e8 …` (A1427, A2125, A4760, A6779); no session change; `CAP-067`: Left insertion 08:05:24–31 (left-hand slot, head out of view; 6.2.2 = 1 at A906); `CAP-069` §8 | -| `INEAR-003` | Insert Right earbud into the ear | User (Hardware) | M, AR, AZ, BA, BB, BC, BF | 🔵 | | —; `CAP-056` §4 (as `INEAR-002`); `CAP-064`: Right insertions on film (head left of frame), e.g. 10:06:44–52; `CAP-065`: Right insertions on film (head left), 11:09:10, 11:16:22, 11:21:58 → `Notify … e8 e8 40` (6224); `CAP-066`: Right insertions on film (head left) 16:14:12, 16:16:00, 16:20:48, 16:22:52, 16:24:50 → `Notify … e8 80` (A1396, A2090, A4741, A6274); 16:22:52 (Left on the table) → Buds `DISC` + ch 21 (A6278 → A6441); `CAP-067`: Right insertions on film (head left) 08:05:12 and 08:06:57; `CAP-069` §8 | +| `CASE-004` | Remove Left earbud from the case | User (Hardware) | M, AW, AX, AY, AZ, BA, BB, BF, BG, AP, BI, BL | 🔵 | Changes state to 'out of case'. | —; `CAP-061` (Left out 17:26:51–52, battery flag `64 64` frame 3233); `CAP-062` (Left out ≈ 06:46:33, stream 7118 `7:{2:0}`); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` (ears/slots on film, stream 6517, 9437); `CAP-065`: the Left (left slot) out at 11:08:22, 11:19:20, 11:21:32 with the Right docked (stream 1089, 8256, 10405: Right charging); lid closed with the Left inside (BA-7); `CAP-066`: the Left out of the case onto the table 16:17:26 (after the Right) and 16:22:19 (first; Right still charging, stream A5966) → Buds' ConnReq A5530, ch 19; `CAP-069` §8; `CAP-070` §4; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §4); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`) | +| `CASE-005` | Remove Right earbud from the case | User (Hardware) | M, AW, AX, AY, AZ, BA, BB, BF, BG, AP, BI, BL | 🔵 | | —; `CAP-061` (Right out 17:26:36–37, battery flag `e4 64` frame 3146); `CAP-062` (Right out ≈ 06:46:21.5, `e4 64` frame 7058, stream 7033); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` (stream 6228, 9437); `CAP-065`: the Right (right slot) out at 11:12:52 and 11:20:54 with the Left docked (stream 4162, 9350: Left charging); `CAP-066`: the Right out of the case 16:17:24 (first → Buds' ConnReq A2804, ch 21) and ≈ 16:22:22 (stream A5966 → A5973); `CAP-069` §8; `CAP-070` §4; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §4); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`) | +| `INEAR-002` | Insert Left earbud into the ear | User (Hardware) | M, AR, AZ, BA, BB, BC, BF, BL | 🔵 | Triggers the in-ear sensor (audio starts, if In-ear detection is on). | `CAP-056` §4 (re-insertions with in-ear detection on/off; which ear not identifiable on film); `CAP-064`: Left insertions on film (head right of frame), e.g. 10:06:58–10:07:06; `CAP-065`: Left insertions on film (head right), 11:08:28, 11:16:28, 11:21:48 → `Notify … e8 e8 40` (1352, 10620); `CAP-066`: Left insertions on film (head right) 16:14:21, 16:16:08, 16:20:56, 16:24:00 → `Notify … e8 …` (A1427, A2125, A4760, A6779); no session change; `CAP-067`: Left insertion 08:05:24–31 (left-hand slot, head out of view; 6.2.2 = 1 at A906); `CAP-069` §8 | +| `INEAR-003` | Insert Right earbud into the ear | User (Hardware) | M, AR, AZ, BA, BB, BC, BF, BL | 🔵 | | —; `CAP-056` §4 (as `INEAR-002`); `CAP-064`: Right insertions on film (head left of frame), e.g. 10:06:44–52; `CAP-065`: Right insertions on film (head left), 11:09:10, 11:16:22, 11:21:58 → `Notify … e8 e8 40` (6224); `CAP-066`: Right insertions on film (head left) 16:14:12, 16:16:00, 16:20:48, 16:22:52, 16:24:50 → `Notify … e8 80` (A1396, A2090, A4741, A6274); 16:22:52 (Left on the table) → Buds `DISC` + ch 21 (A6278 → A6441); `CAP-067`: Right insertions on film (head left) 08:05:12 and 08:06:57; `CAP-069` §8 | | `INEAR-005` | Take the Left bud out of the ear with both buds worn while the MAESTRO session is on channel 19 | User (Hardware) | BF, BH | 🟢 | **Added 2026-10-06 (`ai-sessions/0074`, lead L-1 of `ai-sessions/0061`, `TODO.md` §2).** 🟡 predicted: the Buds `DISC` MAESTRO with the ACL up and announce channel 21 — the mirror of the Right-out-on-21 result (`CAP-066` 3 of 3, `CAP-067`). Head in view. | `PROTOCOL.md` §2.2a (Updates of 2026-10-01 and 2026-10-03); `CAP-070` §4 (BF-18: the Left taken off on 19 ⇒ `DISC` + 21; wear not on film); `CAP-071`: not run (BG-20, no film 2); `CAP-072-FINDINGS.md` §4 (4 of 4: Buds `DISC` + 21, ear removal from the Buds' AVRCP PAUSE, head not in view; `PROTOCOL.md` §2.2a Update of 2026-10-10, 🟢) | | `INEAR-006` | The app's "probably worn" indicator read against the ears on film | User (App, view) | BH, BI | 🟡 | **Added 2026-10-09 (`ai-sessions/0082`, `DECISIONS.md` ADR-059).** The battery card's line — "Probably worn (checked HH:MM:SS)" / "Not worn (checked …)" / "Both buds in the case" / "Worn: unknown — in-ear detection is off" — against the ears on film: both out → Not worn; one in → Probably worn; both in the case → Both buds in the case; in-ear detection off → unknown; buds straight from the case to the table, watched 60 s (the ≈ 28 s `e8` of `CAP-064`). ADR-049 item 3 stays 🟡 whatever the result; a "Probably worn" with no bud in an ear beyond the first ≈ 30 s is a counter-example to record. | `DECISIONS.md` ADR-049/ADR-059 (Update of 2026-10-10); `CAP-072-FINDINGS.md` §4 (every reading per the rule; `e8` at 42 s on a table; the in-ear-off reading not seen) | | `TOUCH-002` | Tap once on a bud | User (Hardware) | N, AY | 🔵 | Play/pause **or** answer call **or** (Pixel Buds Pro only) leave Conversation Detection mode **or** confirm a choice with Gemini. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3; `CAP-063` (Group AY, `CAP-063-FINDINGS.md`) | @@ -178,7 +178,7 @@ _Physical interactions with the device._ | `TOUCH-004` | Triple-tap on a bud | User (Hardware) | N | 🔵 | Previous track. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3 | | `TOUCH-005` | Swipe forward on a bud | User (Hardware) | N | 🔵 | Raise volume. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3 | | `TOUCH-006` | Swipe backward on a bud | User (Hardware) | N | 🔵 | Lower volume. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3 | -| `TOUCH-007` | Press and hold on a bud | User (Hardware) | N, AM, BI, BJ, BK | 🔵 | Cycles ANC mode (incl. Adaptive) **or** activates Gemini/digital assistant, depending on per-earbud configuration (see `HOLD-*`). Requires Android 6.0+. **Captured 2026-08-30 (`CAP-027`)** — unlike `TOUCH-002`–`TOUCH-006`, confirmed on RFCOMM DLCI 0x04 (official Fast Pair Message Stream ANC Notify), not `libmaestro`. **Second confirming session, 2026-09-14/15 (`CAP-051`, Group AM):** three isolated physical gestures, video-confirmed, each producing a `Notify`(`0x13`)-only frame with no preceding `Set`/`Get` — reproduces `CAP-027`'s mechanism and directly confirms `CAP-038-FINDINGS.md` §5's own previously-unconfirmed "physical gesture" reading for its two Get-less/Set-less Notify frames. No accompanying DLCI 0x02 write found (clean negative, `CAP-051-FINDINGS.md` §3). | `CAP-027-FINDINGS.md` §4, `CAP-051-FINDINGS.md` §2/§3 | +| `TOUCH-007` | Press and hold on a bud | User (Hardware) | N, AM, BI, BJ, BK, BL | 🔵 | Cycles ANC mode (incl. Adaptive) **or** activates Gemini/digital assistant, depending on per-earbud configuration (see `HOLD-*`). Requires Android 6.0+. **Captured 2026-08-30 (`CAP-027`)** — unlike `TOUCH-002`–`TOUCH-006`, confirmed on RFCOMM DLCI 0x04 (official Fast Pair Message Stream ANC Notify), not `libmaestro`. **Second confirming session, 2026-09-14/15 (`CAP-051`, Group AM):** three isolated physical gestures, video-confirmed, each producing a `Notify`(`0x13`)-only frame with no preceding `Set`/`Get` — reproduces `CAP-027`'s mechanism and directly confirms `CAP-038-FINDINGS.md` §5's own previously-unconfirmed "physical gesture" reading for its two Get-less/Set-less Notify frames. No accompanying DLCI 0x02 write found (clean negative, `CAP-051-FINDINGS.md` §3). | `CAP-027-FINDINGS.md` §4, `CAP-051-FINDINGS.md` §2/§3 | | `HEAD-002` | Head gesture: Nod | User (Hardware) | O, AQ | 🔵 | Answers a call. Can also reply to a text via dictation if 'Spoken notifications' is on — English only. **Attempted 2026-09-12 (`CAP-028`) — inconclusive**: gestures not camera-visible (recording frames the phone, not the head); zero wire-visible traffic on the Buds' own connection, but no active call/notification existed for the gesture to act on, so this cannot distinguish "functionally inert, as expected" from "gesture not actually triggered." **Re-checked 2026-09-13 (`ai-sessions/0017`)**: the same clean-negative result re-confirmed across `CAP-028`'s *entire* 227.71s log (not just the originally-checked window), plus zero AT+/HFP, AVRCP, or SCO/eSCO traffic anywhere in the log — no new signal found. A correctly-scoped repeat (an actual call/notification active, camera also framing the gesture itself) is designed as `CAPTURE_BLUETOOTH_HCI_SNOOP.md` Group AQ (planned `CAP-055`). | `CAP-028-FINDINGS.md` §4 | | `HEAD-003` | Head gesture: Shake | User (Hardware) | O, AQ | 🔵 | Rejects a call. Can also dismiss a text reply under the same condition. **Attempted 2026-09-12 (`CAP-028`) — inconclusive, same caveat as `HEAD-002`.** **Re-checked 2026-09-13 (`ai-sessions/0017`)** — same full-log re-verification and Group AQ repeat design as `HEAD-002` above. | `CAP-028-FINDINGS.md` §4 | | `CONV-002` | User starts speaking (voice), triggering Conversation Detection | User (Hardware) | P, AY | 🟢 | Triggers Conversation Detection (if on) — pauses media, switches to Transparency. `CAP-029` (2026-09-12): media paused with no Buds command on RFCOMM or AVRCP — only the phone's own AVRCP `PlaybackStatusChanged` → Paused (frame 1593); `CAP-063`: the Buds sent AVRCP PAUSE/PLAY pass-through while the maintainer spoke (5389/5433, 🟡 — a tap sends the same). | `CAP-029-FINDINGS.md` §2; `CAP-063` (Group AY, `CAP-063-FINDINGS.md` §7) | @@ -244,7 +244,7 @@ _Sensors and firmware behavior, without a direct action from the app._ | `BATT-002` | Case broadcasts battery status via BLE advertisement | Buds/Case (Auto) | Q, AP | 🔵 | Official Fast Pair Battery Notification extension: 3 bytes (L/R/Case). (**Corrected 2026-09-24:** "advertised when the case opens and/or on value change; visible ≥8s, hidden after 20s" is not on the spec page — no trigger/timing is specified; the page advises against advertising battery data all the time, see the correction above.) | `PROTOCOL.md` §4.3 Option A (`CAP-011`: inconclusive — Fast Pair Service traffic present, but not structurally matching this layout; procedure deviation, see `CAP-011-FINDINGS.md`. **`CAP-043` (2026-09-13), a genuinely clean connection-free repeat: second confirmed non-match** — closes `CAP-011-FINDINGS.md` §4's open item by ruling out the active-connection confound; see `CAP-043-FINDINGS.md` §3/§4). **Re-verified 2026-09-13 (`ai-sessions/0017`)**: `CAP-043` frame 133's byte-level non-match re-run directly, same result. Only the idle/case-closed condition has been tested. **Corrected 2026-10-03 (`ai-sessions/0069`):** the spec page names one use case — show when the case has opened, hide when buds are removed or the case is closed — and no "optional single-bud" trigger; that case is `BATT-007` (Group AP, planned `CAP-054`, redesigned).; `CAP-054` (no clear battery field in 610 `0xFE2C` reports; `CAP-054-FINDINGS.md` §1) | | `BATT-003` | Buds update battery status while worn | Buds/Case (Auto) | Q, AP | 🔵 | No trigger and no step size (e.g. "every 1%") is officially specified for the advertisement (**corrected 2026-09-24** — the earlier "when RFCOMM connects, or when the value changes" is the Message Stream message's behaviour, not the advertisement's). | `PROTOCOL.md` §4.3 Option A (`CAP-011`: inconclusive, same as `BATT-002`); also §4.3 Option E (`CAP-011`, added 2026-08-23: a DLCI 0x08 message tracking a live 1% Left/Right drop, 🟢 FACT, ADR-014). **`CAP-043` (2026-09-13): not exercised** — case-closed/idle procedure produced no battery-value change to observe a trigger on, see `CAP-043-FINDINGS.md` §4. Group AP (planned `CAP-054`, `ai-sessions/0017`) may incidentally exercise this too if bud removal registers a battery-relevant state change.; `CAP-054`: not exercised (no bud worn) | | `BATT-007` | Battery Notification advertisement right after the case is opened, both buds inside, no phone connected | Buds/Case (Auto) | AP | 🔵 | **Added 2026-10-03 (`ai-sessions/0069`, lead L68-6).** The spec's own use case: *"One common use case for this is to use 0b0011 when the case has opened and 0b0100 when buds have been removed from the case or it has been closed again."* Never tested: `CAP-043` had the case closed, `CAP-011`/`CAP-059` a connection. A system-level HCI log only — the app scans nothing (ADR-006). | `PROTOCOL.md` §4.3 Option A; `CAP-054` (two lid openings, no Battery Notification field; `CAP-054-FINDINGS.md` §1) | -| `BATT-004` | Battery data via RFCOMM after connecting (instead of BLE advertisement) | Buds/Case (Auto) | A, AW, AX, AY, AZ, BA, BB, BC, BG, AP, BE, BF, BI, BJ, BK | 🔵 | Officially specified alternative channel — Fast Pair "Message Stream: Device Information". It is the generic Fast Pair "Battery updated" message, no Buds-specific schema (ADR-031; the `HardwareStatus` placeholder of `PROTOCOL.md` §3 never existed, ADR-041). See `PROTOCOL.md` §4.3 Option B. Piggybacks on the Group A connect capture rather than needing its own scenario (not Group Z — that scenario is a throwaway pipeline check, not a genuine evidence-gathering opportunity). | `PROTOCOL.md` §4.3 Option B — `Group 0x03 Code 0x03` 🟢 FACT (ADR-031, charging flag ADR-033; `CAP-009`, `CAP-059` — pointer added 2026-09-24); `CAP-061` (Left/Right every claim; Case via DLCI 0x08 never answered the app — ADR-043); `CAP-062`: Left/Right on every claim, Case from `SubscribeRuntimeInfo` 60 % (per-bud fields, `CAP-062-FINDINGS.md` §4); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: every app claim's `03 03` burst (23 claims); `CAP-065`: every app claim's `03 03` burst (25 claims), Case 89 from `SubscribeRuntimeInfo`; lid closed ⇒ that bud `ff` (6015); `CAP-066`: every app claim's `03 03` burst (23 claims), e.g. `03 03 00 03 4d cd ff` (Right 77 % charging, 16:22:22.394) then `4d 4d`; the (i) at 16:22:28 and 16:32:36 on film; `CAP-067` (no Play services): `03 03` on all 6 app claims (e.g. A294 `e4 e4 ff`, B470 `64 64 ff`), Case 95/93 % from runtime info (A324, B1710) `CAP-068` (`CAP-068-FINDINGS.md` §3).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §2, §7); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`); `CAP-070` (Group BF, `CAP-070-EVENT-NOTES.md`) | +| `BATT-004` | Battery data via RFCOMM after connecting (instead of BLE advertisement) | Buds/Case (Auto) | A, AW, AX, AY, AZ, BA, BB, BC, BG, AP, BE, BF, BI, BJ, BK, BL | 🔵 | Officially specified alternative channel — Fast Pair "Message Stream: Device Information". It is the generic Fast Pair "Battery updated" message, no Buds-specific schema (ADR-031; the `HardwareStatus` placeholder of `PROTOCOL.md` §3 never existed, ADR-041). See `PROTOCOL.md` §4.3 Option B. Piggybacks on the Group A connect capture rather than needing its own scenario (not Group Z — that scenario is a throwaway pipeline check, not a genuine evidence-gathering opportunity). | `PROTOCOL.md` §4.3 Option B — `Group 0x03 Code 0x03` 🟢 FACT (ADR-031, charging flag ADR-033; `CAP-009`, `CAP-059` — pointer added 2026-09-24); `CAP-061` (Left/Right every claim; Case via DLCI 0x08 never answered the app — ADR-043); `CAP-062`: Left/Right on every claim, Case from `SubscribeRuntimeInfo` 60 % (per-bud fields, `CAP-062-FINDINGS.md` §4); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: every app claim's `03 03` burst (23 claims); `CAP-065`: every app claim's `03 03` burst (25 claims), Case 89 from `SubscribeRuntimeInfo`; lid closed ⇒ that bud `ff` (6015); `CAP-066`: every app claim's `03 03` burst (23 claims), e.g. `03 03 00 03 4d cd ff` (Right 77 % charging, 16:22:22.394) then `4d 4d`; the (i) at 16:22:28 and 16:32:36 on film; `CAP-067` (no Play services): `03 03` on all 6 app claims (e.g. A294 `e4 e4 ff`, B470 `64 64 ff`), Case 95/93 % from runtime info (A324, B1710) `CAP-068` (`CAP-068-FINDINGS.md` §3).; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §2, §7); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`); `CAP-070` (Group BF, `CAP-070-EVENT-NOTES.md`) | | `INEAR-004` | In-ear sensor reports 'removed' (bud taken out of ear, not placed back in case) | Buds (Auto) | U, AR, AY, AZ, BB, BC, BF | 🟢 | Confirmed via screenshot (In-ear detection: "pauses audio when not worn"). **Gap closed 2026-08-14:** `CAPTURE_BLUETOOTH_HCI_SNOOP.md` Group U now brackets this specific transition, added to test whether DLCI 0x08 Group `0x04` Code `0x12`'s alternating value is event-driven (`CAP-004-FINDINGS.md` §5a Task 5). | `CAP-056` §4: removals with in-ear detection on → phone Paused 3/3; off → no pause 0/3, Buds `DISC` DLCI 0x02 still; `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: removals on film; the Buds `DISC` the session on some (3103, 4261, 10309), not on others (§6); `CAP-066`: a bud out of an ear onto the table 16:14:38/46, 16:16:46, 16:23:22, 16:24:10 → `Notify … e8 80` or `00 20`; the Right out with the Left worn → Buds `DISC` MAESTRO + ch 19 (A2502, A6803); `CAP-067`: Right out on 21 (head left) ⇒ Buds `DISC` A945 + ch 19; one bud out on 19 ⇒ `DISC` A1085 + ch 21 (bud not identifiable) Planned: `CAP-069` (Group BE) — one bud movement per step against DLCI 0x08 Code `0x05` (L68-5). `CAP-068` BD-6: Settable `00`, no `Set` (`CAP-068-FINDINGS.md` §2).; `CAP-069` §8; `CAP-071`: not identifiable (head out of view) | | `BATT-005` | 'Low battery' notification (case) | Buds/Case (Auto) | — | 🔵 | Confirmed specifically for Pro 2/2a: notification for both low case battery and fully charged case. Opportunistic only — no dedicated scenario, since it requires genuinely low battery; see §9. | — | | `BATT-006` | Battery-level change bracketing: `AT+CIND`/`battchg` vs. `AT+BIEV` cross-check over a natural battery decline | Buds/Case (Auto) | X | 🔴 | Added 2026-08-14. `CAP-001-FINDINGS.md` §3 found `battchg=3` (≈60%) and `AT+BIEV=2,100` (100%) disagreeing at the same moment — unresolved whether either indicator tracks a real level change. See `CAPTURE_BLUETOOTH_HCI_SNOOP.md` Group X. **Resolved 2026-08-23 by `CAP-009`, maintainer sign-off obtained (`ADR-015`)**: `battchg` is 🟢 FACT a single non-repeating snapshot at SLC setup, never a live reading; `AT+BIEV=2` is 🟢 FACT per-earbud (Right, this session, not a fixed aggregate) and pushes at an irregular (not fixed ~6–7s) cadence once idle. | `CAP-009-FINDINGS.md` §1–§5, `PROTOCOL.md` §4.3 Option C, `ADR-015` (also a bonus 4th-session, 75-occurrence confirmation of §4.3 Option E in §6, plus two further HYPOTHESES on DLCI 0x04/Option B and a BLE-scan/Option A candidate in §4/§7) | @@ -292,7 +292,7 @@ official-app capture can decide whether to exercise them._ |---|---|---|---|---|---|---| | `PAIR-001` | Pairing / bonding handshake (forget-and-re-pair baseline) | User (Hardware) | A, AU, AV, AW, AX, AY, BA, BC, AT | 🔵 | Lightweight, safely repeatable — see `CAPTURE_BLUETOOTH_HCI_SNOOP.md` Group A #1 for the exact procedure (do not confuse with `CASE-007`'s factory reset). Also exercised on the Pixel 9a session (§4.2 #1), and incidentally on Group R (forced bond removal there also clears the classic bond). | `CAP-059` (Group AU, 2026-09-20, OpenControl-app validation), `CAP-060` (Group AV, 2026-09-21); `CAP-061` (app CDM pairing, LE-first CTKD, `CAP-061-FINDINGS.md` §6); `CAP-062` (app CDM pairing, frames 1068–1179); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-065`: not exercised (B4/Z1 not done → `CAP-066` VII); `CAP-067`: (E) forget (B1145/B1151) + Z1 CDM pairing, SSP Just Works (IO caps B1294/B1299, key type `0x04` B1304), automatic re-open B1601; B4 not exercised; `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | | `PAIR-002` | Pairing / bonding handshake from a true factory-reset state | User (Hardware) | P (via `CASE-007`) | 🔵 | Optional, one-time, destructive comparison capture — see `CAPTURE_BLUETOOTH_HCI_SNOOP.md` Group P #16. | — | -| `PAIR-003` | Disconnect and reconnect to an already-bonded device | User (Hardware) | AV, AW, AX, AY, AZ, BA, BB, BC, BF, BG, AO, AP, BE, BI, BJ, BK | 🟡 | Distinct protocol state from `PAIR-001`/`PAIR-002` (no bonding handshake, per `PROTOCOL.md` §5) — captured on the Pixel 9a session (§4.2 #4). **Corrected 2026-08-28 — first Pixel 7a occurrence is actually `CAP-006` (2026-08-15), not `CAP-012`:** `CAP-006-EVENT-NOTES.md` (17:23:53–56) tags a Bluetooth-radio-toggle-triggered reconnect to the already-bonded Buds as `PAIR-003`-shaped — stored link key reused, no fresh pairing/link-key exchange — 11 days before `CAP-012` was captured; the earlier "first Pixel 7a occurrence... `CAP-012`" wording was wrong, not just imprecise. **`CAP-012` (2026-08-26, incidental — a manual disconnect/reconnect via system Bluetooth settings during a Group S session, `CAP-012-FINDINGS.md` §6) is a second, independent confirming instance**, also reusing the stored classic link key (no SSP), and additionally the first to show this kind of reconnect retriggering a full HFP AT-command SLC handshake — relevant to `PROTOCOL.md` §6's open question on when that handshake recurs. Also `CAP-060` (`ai-sessions/0043`, Group AV): four reconnect cycles in one session, each after a distinct drop mechanism (see `CAP-060-FINDINGS.md` §1). | `CAP-006-EVENT-NOTES.md` (17:23:53–56), `CAP-012-FINDINGS.md` §6, `CAP-060-FINDINGS.md` §1; `CAP-061` (5 app sessions); `CAP-062` (14 app sessions, `CAP-062-FINDINGS.md` §3); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: Disconnect/Connect 10:16:26/42 and 10:27:44/47; a Connect with both docked and the lid open paged the Buds (7201 → 7204); `CAP-065`: 10 session opens — 7 automatic (6 LINK_BACK, 1 AFTER_LOSS), Connect taps 11:19:52/11:20:14, one pull (BA-16); none after a Disconnect tap until Connect; `CAP-066`: 10 session opens, all automatic (ADR-044 LINK_BACK ×5 on link-up incl. after both Bluetooth-ons and the K3 return, AFTER_LOSS ×4, 1 at the restart after a force-stop); no Connect tap; `CAP-067`: re-open after Bluetooth on (B372, 2.8 s after the ACL; no app log), after the permission re-grant (B965) and after Z1 (B1601) `CAP-068`: 15 session ends recovered (`CAP-068-FINDINGS.md` §5).; `CAP-070` §2, §6; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §2, §6); `CAP-053` (Group AO, `CAP-053-EVENT-NOTES.md`); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | +| `PAIR-003` | Disconnect and reconnect to an already-bonded device | User (Hardware) | AV, AW, AX, AY, AZ, BA, BB, BC, BF, BG, AO, AP, BE, BI, BJ, BK, BL | 🟡 | Distinct protocol state from `PAIR-001`/`PAIR-002` (no bonding handshake, per `PROTOCOL.md` §5) — captured on the Pixel 9a session (§4.2 #4). **Corrected 2026-08-28 — first Pixel 7a occurrence is actually `CAP-006` (2026-08-15), not `CAP-012`:** `CAP-006-EVENT-NOTES.md` (17:23:53–56) tags a Bluetooth-radio-toggle-triggered reconnect to the already-bonded Buds as `PAIR-003`-shaped — stored link key reused, no fresh pairing/link-key exchange — 11 days before `CAP-012` was captured; the earlier "first Pixel 7a occurrence... `CAP-012`" wording was wrong, not just imprecise. **`CAP-012` (2026-08-26, incidental — a manual disconnect/reconnect via system Bluetooth settings during a Group S session, `CAP-012-FINDINGS.md` §6) is a second, independent confirming instance**, also reusing the stored classic link key (no SSP), and additionally the first to show this kind of reconnect retriggering a full HFP AT-command SLC handshake — relevant to `PROTOCOL.md` §6's open question on when that handshake recurs. Also `CAP-060` (`ai-sessions/0043`, Group AV): four reconnect cycles in one session, each after a distinct drop mechanism (see `CAP-060-FINDINGS.md` §1). | `CAP-006-EVENT-NOTES.md` (17:23:53–56), `CAP-012-FINDINGS.md` §6, `CAP-060-FINDINGS.md` §1; `CAP-061` (5 app sessions); `CAP-062` (14 app sessions, `CAP-062-FINDINGS.md` §3); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064`: Disconnect/Connect 10:16:26/42 and 10:27:44/47; a Connect with both docked and the lid open paged the Buds (7201 → 7204); `CAP-065`: 10 session opens — 7 automatic (6 LINK_BACK, 1 AFTER_LOSS), Connect taps 11:19:52/11:20:14, one pull (BA-16); none after a Disconnect tap until Connect; `CAP-066`: 10 session opens, all automatic (ADR-044 LINK_BACK ×5 on link-up incl. after both Bluetooth-ons and the K3 return, AFTER_LOSS ×4, 1 at the restart after a force-stop); no Connect tap; `CAP-067`: re-open after Bluetooth on (B372, 2.8 s after the ACL; no app log), after the permission re-grant (B965) and after Z1 (B1601) `CAP-068`: 15 session ends recovered (`CAP-068-FINDINGS.md` §5).; `CAP-070` §2, §6; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §2, §6); `CAP-053` (Group AO, `CAP-053-EVENT-NOTES.md`); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`); `CAP-069` (Group BE, `CAP-069-EVENT-NOTES.md`) | | `PAIR-004` | 'Forget' clears a pre-existing BLE association/link-key completely, even one not established via the Pixel Buds app | User (Hardware) | A (repeat), AT | 🔴 | Added 2026-08-14. `CAP-001-FINDINGS.md` §6 found a BLE link and a still-valid link key both existing *before* the on-screen "Forget" tap and before the case reopened — distinct claim from `PAIR-001`'s "fresh pairing procedure." See `CAPTURE_BLUETOOTH_HCI_SNOOP.md`'s Group-A repeat note. **Update 2026-08-26 (`CAP-013`) — approved as recorded (maintainer, chat 2026-09-30, `ai-sessions/0059`):** this question splits into two — the **primary** claim above (did a BLE link/key exist *before* the clearing action?) remains 🔴 fully OPEN, since `CAP-013`'s logging only started *after* the clearing action and the entire subsequent discovery/case-open/pair-button sequence (`CAP-013-FINDINGS.md` §0). The **secondary** claim — does the re-pairing *following* the clearing action show a fresh SSP handshake (clearing worked for whatever state existed right before logging began) or a reused key — is 🟢 answered: a complete fresh-SSP sequence was observed (`Delete Stored Link Key` → `Negative` Link Key Reply → IO Capability/User Confirmation/Simple Pairing Complete → new Link Key Notification, frames 117–270), no key-reuse path. A further repeat with logging verified to start *before* the clearing action is still needed for the primary claim. **Update 2026-08-27 (`CAP-031`) — approved as recorded (maintainer, chat 2026-09-30, `ai-sessions/0059`):** a third attempt, this time with a genuine narrow per-device "Forget" (screenshot-confirmed) and a live snoop-log file-size-polling check — **still did not capture the pre-clearing-action window**: the log's first frame lands 66s after the Forget tap (`CAP-031-FINDINGS.md` §0). **Primary claim remains 🔴 fully OPEN, untested a third time.** The secondary claim is reconfirmed 🟢 (sixth confirming fresh-SSP instance, frames 598–689, `CAP-031-FINDINGS.md` §2/§7). A fourth attempt is still needed, verifying the snoop log's own *content* freshness (not just file size) immediately before the Forget tap. **Update 2026-08-27 (`CAP-032`) — approved as recorded (maintainer, chat 2026-09-30, `ai-sessions/0059`): fourth attempt succeeded.** Extracted via the raw BTSnoop file path instead of the `btsnooz.py` fallback the three prior attempts used — the log is genuinely untruncated and its first frame (18:29:45.72) lands ~58s *before* the on-screen Forget tap (18:30:42), and ~30s before the video itself starts. **The primary claim is now answered for this session: 🟢 no BLE link and no valid classic link key existed for the Buds anywhere in the covered pre-Forget window** — zero classic connection events, exactly one LE connection (to an unrelated random-address device, not the Buds), and a `Delete Stored Link Key` issued at the Forget tap's own moment reporting `Num_Keys_Deleted = 0` (`CAP-032-FINDINGS.md` §0.3/§1). **This does not reproduce `CAP-001`'s original finding** — read as a clean counter-example, not a refutation; `CAP-001`'s own session-specific puzzle (why *that* session had residual state) remains independently 🔴 OPEN, tracked separately. The secondary claim is reconfirmed a seventh time (frames 1090–1153, `CAP-032-FINDINGS.md` §2/§7 Test B). The existence column stays 🔴 (legend §0.3: it records the *source* of existence, not a capture result); the result is the evidence pointer — `CAP-032` answers the primary question for that session (no BLE link or valid key before Forget), `CAP-001`'s own puzzle stays 🔴 (`PROTOCOL.md` §6). | `CAP-013-FINDINGS.md` §0/§2/§7, `CAP-031-FINDINGS.md` §0/§2/§7/§8, `CAP-032-FINDINGS.md` §0/§1/§2/§7; `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | | `APP-001` | Tap 'Set up' — hands off from the pairing UI (system settings or a generic BLE tool) to the official Pixel Buds app's first-run onboarding screen | User (App) | A | 🟢 | **Added retroactively 2026-08-20** — in active use since `CAP-002`/`CAP-003` (2026-08-09/10) but never had a catalog row — gap found by `scripts/lint_docs.py`. No RFCOMM traffic tied to this specific tap in either capture; it is a local UI-navigation event, not a protocol event. | — | | `APP-002` | Tap 'Continue' on the app's "Allow a connection to your Pixel Buds" nearby-device permission screen | User (App) | A | 🟢 | **Added retroactively 2026-08-20** — in active use since `CAP-002`/`CAP-003` (2026-08-09/10) but never had a catalog row — gap found by `scripts/lint_docs.py`. Immediately precedes the system `CompanionDeviceManager` "Allow" dialog (`DECISIONS.md` ADR-005) in both captures. | — | diff --git a/TODO.md b/TODO.md index 78c9e337..031db3eb 100644 --- a/TODO.md +++ b/TODO.md @@ -18,10 +18,12 @@ Legend: 🔴 open question · 🟡 hypothesis to test · **M** = needs the maint installed on the Pixel 9a with Obtainium (the maintainer, chat 2026-10-14: "Ik heb opencontrol for buds middels obtainium geupdate naar 1.2.2 op mijn pixel 9a telefoon"); the published APK's SHA-256 = the tested build's (`d750551a…8a6b`, re-checked with `gh release download`). E1–E4: PR #52. (1.2.1: D4 done 2026-10-13 with Obtainium; E1–E4 by PR #45.) -- [ ] `CAP-076` (Group BL) is **the 1.3.0 run** since `ai-sessions/0091` (2026-10-14): the skeleton of `ai-sessions/0089` (the 1.2.2 run, never made) is rewritten - for ADR-064/065 — both users, holds with the noise-control tab closed and open, the own tap, the wear-change re-home, Bluetooth off/on, taps while Play - holds the channel, optionally the EQ/a switch from the official app on a second phone; the counts for the verdict in its analysis checklist. Runs after - `RELEASING.md` B1–B3 for 1.3.0 (a CAPTURE session analyses it, C3/C4). +- [x] `CAP-076` (Group BL), **the 1.3.0 run**, captured 2026-10-15 in both users and analysed by `ai-sessions/0092` (`CAP-076-FINDINGS.md`): the two requests + once per session (12 of 12), every hold on the noise-control tab shown (29 of 29 with Play, 12 of 12 without), three minor defects (§5 below); **verdict + "release as built + Known issues"** (the maintainer, chat 2026-10-15) — next: `RELEASING.md` D1–D4 (the maintainer's), then E1–E4. Not run (the maintainer + tested only the changed functionality): BL-10, BL-13, BL-15, BL-18, BL-21, BL-24, BL-25. +- **The Buds' state after `CAP-076` (2026-10-15):** EQ `[−1, 0, 4, 2, 1.69]`, **in-ear detection off** (written 07:06:56, not switched on again), all four + press-and-hold modes, balance Centre — restore in-ear detection if wanted (**M**). - The Buds' state after `CAP-074`/`CAP-075`: a custom EQ `[−3.5, 0.5, 1.0, −1.0, 2.5]`, all four press-and-hold modes, balance Centre (`CAP-074-FINDINGS.md` §0; unchanged by `CAP-075`: 0 writes) — restore if wanted (**M**). The Owner user's stored "Noise-control channel" choice is "Take it back" and the secondary user's too (`CAP-075-FINDINGS.md` §4) — the default "Show a hint" is not restored. @@ -75,7 +77,6 @@ with the hold's untested branches below. **Next app run:** none planned — thes |---|---|---|---| | `CAP-055` | AQ | Nod/Shake head gestures with an active call or notification (needs a second phone) | Pixel 7a, official app | | `CAP-030` | Q #19–20 | Loud Noise Protection / Adaptive Audio (firmware naming never reconciled with `release_5.203`, `PROTOCOL.md` §0.1) | Pixel 7a, official app | -| `CAP-076` | BL | The 1.3.0 run (`ai-sessions/0091`, ADR-064/ADR-065; the 1.2.2 skeleton of `ai-sessions/0089` replaced): the two new requests once per session, holds shown on any tab through the settings stream, the own tap, the wear-change re-home, Bluetooth off/on, taps while Play holds the channel — both users | Pixel 9a, OpenControl 1.3.0 | From `ai-sessions/0072` (`CAP-053`/`054`/`058`, analysed 2026-10-06) — procedures for a next attempt, no skeleton written: @@ -95,7 +96,7 @@ From `ai-sessions/0072` (`CAP-053`/`054`/`058`, analysed 2026-10-06) — procedu - [ ] Optional, destructive, one-time: the factory-reset re-pair for comparison (`CAPTURE_BLUETOOTH_HCI_SNOOP.md` Group P #16) — it also resets the Find My Device link. - [ ] Record Play services' *Nearby devices* permission state and the phone's Android version (Settings → About, on film) in every capture - (`A68-CAP-23`: the Pixel 7a's version is unreconciled in `CAP-036`…`041`). `CAP-075`'s P0 asks for it on film (the first time), with Play services' version. **Not done in `CAP-075`** (the P0 film was not made; the user and Play's presence come from the logs, `CAP-075-FINDINGS.md` §0) — still open; `CAP-076`'s P0 asks again. + (`A68-CAP-23`: the Pixel 7a's version is unreconciled in `CAP-036`…`041`). `CAP-075`'s P0 asks for it on film (the first time), with Play services' version. **Not done in `CAP-075`** (the P0 film was not made; the user and Play's presence come from the logs, `CAP-075-FINDINGS.md` §0) — still open; `CAP-076`'s P0 asks again. **`CAP-076`: not on film either; read by `adb` at 07:53 instead (`ai-sessions/0092`, CAP-076-adb-checks-0092.log): Play 26.32.34, the *Nearby devices* group granted in user 0, not installed in user 10** — on film still open. - Standing practice (kept open on purpose): every capture session gets its Capture Index row with firmware, Android and app version (`PROJECT_RULES.md` rules 11 and 14); every hypothesis test is logged in the capture's FINDINGS before a promotion (rule 4). @@ -212,8 +213,14 @@ Open questions (each with where it is described): - [ ] **The noise-control channel in the Play user — after 1.3.0 (ADR-065: the re-claim is gone, the holds no longer depend on the channel):** what remains is the per-tap collision with Play's claim (`CAP-075-FINDINGS.md` §5; 1.3.0's known issue) — P3 a shorter attempt-2 latency for the on-demand channel (§9: attempt 2 0.7–1.0 s after attempt 1's refusal) is still open (**M**). Whether repeated collisions drive Play's "Retry times remaining" to 0 and what Play - does then stays ❓ (§4; `CAP-076` BL-11 counts the collided taps). - + does then stays ❓ (§4). **`CAP-076` (§5.2 of its findings):** 13 taps with Play present in 12 claims — 8 refused at attempt 1 and done at attempt 2 (0.87–1.11 s later), 2 cut in their linger, 1 cut before the `Get`'s answer (a toast), 1 after it (silent, P2 below); Play's counter went 5 → 4 once (06:46:28) and back. +- [ ] **After `CAP-076` (`ai-sessions/0092`, `CAP-076-FINDINGS.md` §13; the maintainer's choice in chat 2026-10-15: no FEATURE prompt now, recorded here):** + P1 keep "Changed since the app last saw it" across the reconnect's second, equal read (`causeOfReadAnswer` returns `null` for an equal answer; the + snapshot's `Notify` came first in 12 of 12 sessions — §6.2) (**S**); P2 a tile tap whose claim closes after the `Get`'s answer and before its `Set` + reports a failure (§5.3; a `Set` wait that ignores a `Notify` received before the `Set` was written; a test with the delay) (**S–M**); P3 log a same-mode + push as "unchanged" (§4.4) (**XS**); P4 the two packets per change with both buds out — one Connect on a fresh ACL, then the official app on the same + ACL (§4.3, a capture); P5 the shorter attempt-2 latency (above); P6 a mode line on the Connection tab (§4.2) (**S**). The seven CloseGuard lines of + `CAP-076` A1 (06:47:10/44, §0) — which sockets (❓). - [ ] **After the toolchain upgrade of `ai-sessions/0078`** (Gradle 9.7.1, AGP 9.3.3, Kotlin 2.4.20, BOM 2026.09.00, compileSdk 37): (a) hardware-tested in `CAP-071` (`ai-sessions/0080`): no behaviour change found — the same reads, request bytes, answers, re-opens and screens as 1.1.0; (b) **M** — raising `targetSdk` (lint `OldTargetApi` now reports 34) is its own session with a hardware run: Android 15 enforces edge-to-edge, diff --git a/_sidebar.md b/_sidebar.md index 9880147e..0b6b11cf 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -251,5 +251,6 @@ - **CAP-075 (Group BK)** - [CAP-075-EVENT-NOTES.md](/opencontrolpixelbudspro2/captures/CAP-075-2026-10-12_17-22-31_2026-10-13_06-34-32-Group_BK/CAP-075-EVENT-NOTES.md) - [CAP-075-FINDINGS.md](/opencontrolpixelbudspro2/captures/CAP-075-2026-10-12_17-22-31_2026-10-13_06-34-32-Group_BK/CAP-075-FINDINGS.md) - - **CAP-076 (Group BL)** _(planned)_ - - [CAP-076-EVENT-NOTES.md](/opencontrolpixelbudspro2/captures/CAP-076-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BL/CAP-076-EVENT-NOTES.md) + - **CAP-076 (Group BL)** + - [CAP-076-EVENT-NOTES.md](/opencontrolpixelbudspro2/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-EVENT-NOTES.md) + - [CAP-076-FINDINGS.md](/opencontrolpixelbudspro2/captures/CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL/CAP-076-FINDINGS.md) diff --git a/id_registry.csv b/id_registry.csv index fd0f1c0f..01d37f3f 100644 --- a/id_registry.csv +++ b/id_registry.csv @@ -241,6 +241,6 @@ CAP-072,capture,analyzed,"2026-10-09 17:27:26-18:23:39, Group BH (new) — the r CAP-073,capture,analyzed,"2026-10-10 08:23:35-08:33:24, Group BI (new) — the rebuilt 1.2.0 (5b4d5db, versionCode 10200; Info tab on film, no -dirty; the installed APK pulled = B1) installed over 1.2.0 ec6d163 in the GrapheneOS secondary user without Google Play: one screen recording without audio (Wi-Fi name blurred), two HCI logs, one export, the app logcat, a system-log extract, the shell log; only the changed functionality tested. 11 sessions with the thirteen requests byte-identical to CAP-072; the ADR-061 hold kept DLCI 0x04 31 s on the noise-control tab, four unprovoked Notify shown without a tap, four taps on the held channel, released 1.509 s after leaving; balance one write per release, Right 4 on the third; verdict OK — ready to release (ai-sessions/0085)",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 CAP-074,capture,analyzed,"2026-10-12 06:40:16-06:45:31, Group BJ (new) — the released 1.2.0 (5b4d5db, versionCode 10200; Info tab on film; APK not pulled) in the GrapheneOS Owner user with Google Play services: one screen recording with audio (as is), two HCI logs, one debug export, the app logcat, a system-log extract; the maintainer's ad-hoc run (nine Disconnect/Connect pairs, the ANC tab, press-and-holds). 10 sessions byte-identical to CAP-073; every ADR-061 hold cut 4.7-5.1 s after its SABM by Play services' Fast Pair recovery (5.2-5.7 s after it lost the channel, 20 of 20) — 9 holds shown, 35 reached Play services; the root cause of the first-hold-only pattern; fix: a Settings switch (hint only / re-claim with back-off), the maintainer's choice (ai-sessions/0086)",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 CAP-075,capture,analyzed,"2026-10-12 17:22:31 – 2026-10-13 06:34:32 (two sets), Group BK (new) — 1.2.1 (31ee183, versionCode 10201; Info tab on film in both users; APK not pulled), the Noise-control channel switch (ADR-062): set 1 the secondary user without Google Play (41 of 41 press-and-holds shown, both positions identical, no foreign close), set 2 the Owner with Google Play (hint only: the hint with the cut time, 0 of 9 holds shown; take it back: three chains of exactly three re-claims at 3/6/12 s, every re-claim colliding with Play's retry 2.1–2.8 s after the cut, every hold cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 holds shown; the stack path of the cut observed 15 of 15 in the system log); verdict: no heavy issue, 1.2.1 released as built; analysed by ai-sessions/0088",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 -CAP-076,capture,planned,"planned — Group BL (new): the hardware run of 1.3.0 (ADR-064/ADR-065, ai-sessions/0091; the 1.2.2 skeleton of ai-sessions/0089 replaced, 1.2.2 released without it) in both GrapheneOS users — the two new requests (SubscribeToSettingsChanges, ReadSetting 4:13) once per MAESTRO session; press-and-holds with the noise-control tab closed and open shown through the settings stream, no SABM on DLCI 0x04 from the app; the own tap's stream packet; the wear-change re-home and the re-open's read; Bluetooth off/on; taps while Play holds the channel; optionally the EQ/a switch from the official app on a second phone; the Play card and line gone; skeleton rewritten 2026-10-14",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 +CAP-076,capture,analyzed,"2026-10-15 06:35:26 – 07:08:20 (two parts), Group BL — 1.3.0 (ff27ee1, versionCode 10300; installed over 1.2.2 bfd7891 on film 1; the pulled APK = the tested build, SHA-256 03272c6f…a96c), the settings stream on the app's own channel (ADR-064/065): part 1 the Owner with Google Play, part 2 the secondary user without; one SubscribeToSettingsChanges and one ReadSetting 4:13 per MAESTRO session, byte-identical to CAP-051 1177/1178 and CAP-056 1451/1450 (12 of 12 full sessions); press-and-holds on the noise-control tab shown within 0.1 s, 29 of 29 with Play holding DLCI 0x04 and 12 of 12 without; 15 own Sets ACKed and classed as the app's; nothing held or re-claimed (33 app claims, all taps/tile/Refresh/Find/Connect snapshots); every change pushed twice (channels 21 and 19) with both buds out (🟡 one per bud core); the re-open's ""changed since"" cause overwritten by the equal 4:13 answer; a tile tap cut after the Get's answer silent; per-tap collisions with Play measured (8 of 12 claims at attempt 2, 4 cut); analyzed by ai-sessions/0092 — verdict release as built with two Known issues (the maintainer, chat 2026-10-15)",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 INEAR-006,test,catalogued,"The app's 'probably worn' indicator (ADR-059) read against the ears on film: both out, one in, both in the case, in-ear detection off, buds straight from the case to the table watched 60 s — added 2026-10-09, ai-sessions/0082",TESTPLAN_BLUETOOTH_HCI_SNOOP.md WELL-002,test,catalogued,"maestro_pw.Dosimeter SubscribeToLiveDb values correlated with a sound-level meter on film (the 🟡 that blocks ADR-060) — candidate, no capture scenario yet (added 2026-10-09, ai-sessions/0082)",TESTPLAN_BLUETOOTH_HCI_SNOOP.md From b06181e95e222a88d79f7bf285f84105da2eea00 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Thu, 15 Oct 2026 08:59:00 +0200 Subject: [PATCH 11/11] docs: ai-sessions/0092 RESULT and the INDEX rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: AI_SESSION_LOG_PROCEDURE.md — the session's record (the phases, the checkpoints verbatim, the files read, the deferred items); INDEX row 0092 and the 0091 back-fill "hardware run analysed by 0092". Co-Authored-By: Claude Opus 5.5 --- ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md | 176 ++++++++++++++++++ ai-sessions/INDEX.md | 4 +- 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md diff --git a/ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md b/ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md new file mode 100644 index 00000000..81c01dc4 --- /dev/null +++ b/ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md @@ -0,0 +1,176 @@ +# 0092_CAPTURE_RESULT_2026_10_15.md — Full analysis of CAP-076 (Group BL: the 1.3.0 build `ff27ee1`, the settings stream on the app's own channel, in the Owner user with Google Play services and the secondary user without) + +**Number:** 0092 +**Category:** CAPTURE +**Date:** 2026-10-15 +**Title:** Full analysis of `CAP-076` (Group BL): two screen recordings, four HCI snoop logs, two app debug exports, two app logcats, a system log, a full logcat and an adb shell log — part 1 (Owner, Play, 1.2.2 → 1.3.0) and part 2 (secondary user, no Play) +**Status:** awaiting maintainer sign-off (the analysis, the documents and the verdict done; the commit and push await the maintainer's yes) + +## Summary (plain language first) + +**What happened on the phone.** In the Owner user (with Google Play services) the maintainer started on 1.2.2, installed the tested 1.3.0 on film, and then +used the app for about 15 minutes: Disconnect/Connect, buds out of the case and into the ears during a podcast, both buds into the case and out again, 17 and +12 press-and-holds on the noise-control tab, four taps on a mode, the Quick Settings tile added and tapped nine times, the equalizer and in-ear detection +changed. In the secondary user (no Play) the film starts with a Bluetooth off/on; then Info, a Disconnect/Connect, a battery refresh, four taps, 12 +press-and-holds on the noise-control tab, the equalizer, in-ear detection off, Ring Left and Stop. The installed APK is the tested build (SHA-256 checked by +`adb pull` after the run). + +**Did every hold show?** On the noise-control tab, yes — **29 of 29 with Play services holding the shared channel, 12 of 12 without**, each on screen within a +tenth of a second of the Buds' push (measured frame by frame at 10 fps). Play received the Message Stream `Notify` of every hold in the Owner user; the app got +the same change on its own channel, as ADR-064/065 intended. Thirteen changes made on the Connection tab (holds and bud-in/out during the podcast) reached the app +too, but that tab shows no mode, so they cannot be "seen"; the maintainer's "the ANC mode in the app changed too" holds for every hold the screen can show. + +**What the two new requests did on the wire.** One `SubscribeToSettingsChanges` and one `ReadSetting 4:13` in every MAESTRO session that got through its reads +(12 of 12; a 13th was closed by the Buds after six reads) — byte-identical to the official app's, in the planned place; a full session now sends seventeen +requests. With both buds out of the case the Buds push every change **twice** (channel 21 and 19) — harmless for the app, an open question for the protocol. + +**What did not work as designed (minor).** (a) After a reconnect the "Changed since the app last saw it" line is overwritten 0.1–1.5 s later by the second, +equal read (12 of 12 sessions in that order) — it never shows; (b) one of 13 taps made while Play was on the channel (a tile tap) was cut right after the Buds' +answer and changed nothing without a message; (c) a log line shows the old cause for a same-mode push. Taps with Play present are still slower (8 of 12 claims +needed a second attempt) — 1.3.0's known issue, now measured. No crash, no wrong write, no Safe Mode. + +**What the maintainer decided (chat 2026-10-15, verbatim in "Checkpoints"):** release 1.3.0 as built with the Known-issues lines (C4 minor issue); the ADR-065 +Update and a `PROTOCOL.md` §4.1 note written; no FEATURE prompt now (P1–P6 in `TODO.md` §5); four analysis scripts committed; exports and films as is; the +folder renamed. **Next for the maintainer:** the commit/push answer, then `RELEASING.md` D1–D4 (merge, tag, publish) — never by this session. + +## Progress + +- **Started** 2026-10-15. Branch `feature/settings-stream` at `f41f970` (the 0092 prompt commit, after the build commit `ff27ee1`), level with + `origin/feature/settings-stream`; `origin/main` at `c1d898b`; PR #56 open. `git status --short`: the fourteen untracked capture files and + `android/domain/bin/` only. +- **Phase 0 step 2 done:** the folder listed fresh — all 14 files and their first eight SHA-256 hex digits equal the prompt's §2 tables; modes as stated + (seven files 750, the rest 644). **Difference:** the empty file `test` in the repository root that §2 names is **not present** (`ls -la test`: "No such + file or directory") — nothing to ask at Checkpoint 0 for it. +- **Scratch directory:** /tmp/claude-1000/cap076.0VWc (scripts, contact sheets, decoded tables). +- **Reading done** (Phase 0): AGENTS.md, PROJECT_RULES.md, PROJECT.md, DECISIONS.md (ADR-001 … ADR-065, every Update) and AI_SESSION_LOG_PROCEDURE.md in + full; ARCHITECTURE.md §3.1, §3.2, §5a (the cause row), §6.0b in full; PROTOCOL.md §2.2a (the L-1 Updates), §4.1 (head, the 2026-10-04 … 2026-10-14 + Updates/Notes), §6 (the 2026-09-28/10-04/10-14 items), §8 (last rows); TODO.md §1, §5 and the CAP-076/Nearby lines; the 0091 RESULT in full; the CAP-076 + skeleton in full; CAP-075-FINDINGS.md in full. Still to read when a finding touches them: RELEASING.md C3/C4, the Kotlin sources (log strings), CAP-075 + EVENT-NOTES method, CAP-071 layouts, CAP-074 §8, CAP-051/056/038 sections. +- **Checkpoint 0 answered** (chat 2026-10-15, verbatim in "Checkpoints" below). +- **Phase 0 done.** Originals: the 14 files copied with `cp -a` to ~/opencontrol-capture-originals/CAP-076/ (sorted `sha256sum` lists of source and copy + `diff` → identical). `chmod 644` on the seven 750 files (all 14 now 644). **Phone checks (07:52–07:53, read-only; output verbatim in the scratch file + CAP-076-adb-checks-0092.log, to be committed into the folder):** `pm path --user 0` and `--user 10` give the same `base.apk` (exit 0 each); `versionCode=10300`, + `versionName=1.3.0`, `lastUpdateTime=2026-10-15 06:36:57` (unchanged since the run — no install after it); the pulled APK's SHA-256 **= `03272c6f…a96c` = the + tested build's** (`~/opencontrol-1.3.0-tested/….apk.sha256`). Play services `versionName=26.32.34 (260400-968093310)`; `User 0: installed=true`, `User 10: + installed=false`; in user 0 `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (the *Nearby devices* group) `granted=true` — **the state at 07:53, an + hour after part 1** (no grant change in between is shown; ⚪ that it was the same during the run). +- **Phase A, film 1 done** (scratch: f1/ 981 frames at 1 fps, diff1.txt, chg1.txt, sheets/f1_*.jpg, notes_film1.md): 307 seconds with a change below + the status bar, all viewed on 39 labelled sheets; the other 674 pixel-identical (|Δ| ≤ 25 everywhere below the top 30 px). **Clock:** the status bar's minute + flips at film t = 33.8, 93.7, 153.7, 453.7, 933.7 s (10-fps crops, flip.sh) ⇒ phone time = 06:35:26.3 + t (± 0.1 s), no drift; the film covers + 06:35:26.3–06:51:47.3; `creation_time` 06:52:11 is 24 s after the film's end (neither start nor end). Cross-check: the 1.3.0 relaunch on film s94 = 06:37:00.3, + the export's "Permissions (start)" 06:37:00.476. **Film 2 has no audio stream** (`ffprobe -show_entries stream=codec_type` → video only; film 1: video + + audio) — the prompt's "an audio track" for recording 2 is not so. +- **Phase A, film 2 done** (f2/, diff2.txt, sheets/f2_*.jpg, notes_film2.md): 168 changed seconds viewed on 21 sheets, 181 pixel-identical. **Clock:** flips + at t = 28.5, 88.5, 148.5, 208.5 s ⇒ phone time = 07:02:31.5 + t (± 0.1 s); the film covers 07:02:31.5–07:08:20.4 — `creation_time` 07:08:20 is its end. The + film starts 0.5 s before the Bluetooth panel's off (s8, 07:02:39.5 — the export's 07:02:39 OFF) — it does **not** cover the part-2 process start (07:01:03), + the first session (07:01:07–07:01:08) or the second (07:01:13–07:02:39); the screen recorder was "Scherm opnemen" (screen only, no audio). +- **Phase A, audio done:** film 1 (a1.wav, sil1.txt, audio1_segments.txt, rms1.txt): 31 non-silent segments at −40 dB / 0.3 s (95 at −50 dB, 20.9 s in + all); median RMS −69 dB per 100 ms. Ten voiced 0.5-s segments 06:40:48–06:41:53 (f0 200–420 Hz with harmonics, spectral flatness 0.15–0.27 — speech-like, + words not intelligible to the analyst), four 06:42:11–06:42:18 (110–220 Hz), the rest short clicks (flatness ≥ 0.5). Film 2 has no audio stream. +- **Phase B, HCI logs (in progress):** scratch bin/hdlc.py (CRC-32 per pw_hdlc frame; positive control: the CAP-051 1177 reference passes, one flipped bit + fails) → rpc_hci1.log.tsv 466 frames / rpc_hci2.log.last.tsv 39 / rpc_hci2.log.tsv 153, **0 CRC failures**, counts equal to scripts/pwrpc_decode.py + --handle 0x000b (466/39/153). ctl_*.txt (RFCOMM control frames), bin/claims.py → claims_*.txt (DLCI 0x04: hci1.log 40 claims = 22 app + 18 Play; hci2.log.last + 1 app; hci2.log 10 app incl. 2 Ring/Stop). hci1.log.last: 0 frames on handle 0x000b, 0 RFCOMM (positive control: hci1.log 3,670 / 2,556). + **Lead found:** with both buds out of the case every field-13 change arrives as two SERVER_STREAM packets, ch 21 and ch 19 (s13_*.txt), single while one + bud is in the case — the export's paired lines; 🟡 to be stated with the runtime-info charging flags. +- **Phase B, exports and app logcats read:** E1 1,061 lines (444 hex, 289 pw_rpc, 328 other — every "other" line read; the hex lines are the received + bytes, matched to the wire by script), E2 461 (201/120/140, all "other" read). App logcat 1: PID 10952 (1.2.2) 26 lines 06:35:43–06:36:57, PID 11800 + (1.3.0) 578; app logcat 2: PID 4104 (1.2.2, user 10, 06:08:17–06:13:05) 20 lines, PID 18179 (1.3.0, user 10, from 07:01:02.56) 424. FATAL 0, ANR 0, + StrictMode 0 in both (positive control: the tag OpenControlBuds 278 / 94); "A resource failed to call close." ×7 in logcat 1 (finalizer thread, 06:47:10, + 06:47:44), 0 in logcat 2. **The exports carry raw hex lines** (Debug mode on) — the prompt's "no raw hex lines" is not so; they hold the serials + (GetHardwareInfo answers). +- **Phase B done.** Full logcat (part 1 window 06:35–06:53, win1.txt 94,164 lines): the update (`PackageManager` installation completed 06:36:58.015; + `am_kill … killDueToPackageUpdate` of 4104 (user 10, 1.2.2) and 10952 (user 0) at 06:36:57.93/.94; `am_proc_start` 11800 06:36:59.954); 15 `already at + opened state` (11 the app's refused attempt 1, 4 Play's connect while the app held the channel — the four on-demand closes); Play's timers recovery + 5.13–5.97 s (17), retry 2.37–2.50 s (3), 4.49 s once after "Retry times remaining 4". The extract CAP-076-logcat-extract.txt (scratch, 14,121 lines, 9 + GetHardwareInfo hex lines redacted; 0 e-mail, Wi-Fi name, real MAC or serial) is ready. Wire: all 12 subscriptions and 12 `ReadSetting 4:13` byte-identical + to the references, after `SubscribeRuntimeInfo`, before `GetHardwareInfo`; every `ReadSetting` field one byte form per channel; 17 MAESTRO requests per + full session (the skeleton's "fifteen" is the 1.2.x count + 2 miscounted); writes P1 3 / P2 2 (EQ, in-ear off/on; P2 ends with in-ear detection **off**), + all OK; 0 `WriteSetting 4:{13:n}`. Holds vs film at 10 fps (btn.py over the button crops): P1 ANC tab 29/29 within ≤ 0.1 s, P2 12/12; P1 13 changes on the + Connection tab (no mode shown there; the worn line's checked time moves); 3 wear-driven switches coincide with the Buds' AVRCP PAUSE/PLAY (2–7 ms). Own + Sets 15/15 ACKed, the stream 55–246 ms after. **Pairs:** every change twice (ch 21 + ch 19) with both buds out (🟡 H1 per-core push; conflicts with the + official app's single packets — open). **Code findings:** (a) `causeOfReadAnswer` returns READ for an equal read and so overwrites the snapshot Get's + CHANGED_SINCE_LAST_READ 0.4–0.7 s later (S4 06:41:33, S7 06:43:05 — wire 4094→4135, 5991→6003); (b) the log prints the kept cause for a same-mode push + (06:47:07/18 "set_by_app"); (c) a tile tap whose claim is cut right after the Get's answer sent no Set and showed nothing (06:46:08.96; 🟡 cause). + Kotlin read in full: BudsRepositoryImpl.kt; in part: RfcommBudsTransport.kt (send), AncTileService.kt (onClick). +- **Phase C done:** CAP-076-EVENT-NOTES.md (the timeline of both parts, the steps as run, the Test-IDs; the skeleton as the appendix) and CAP-076-FINDINGS.md + (§0–§13) written; numbers re-checked (the pairs 68 of 68, the read order 12 of 12, the release 1.507–1.516 s). External validation: no new specification + claim needed a fetch — the user type, `0x13`/`0x16`, the one-connection-per-channel behaviour and the pw_rpc packet types are cited from the fetches recorded in + `CAP-075-FINDINGS.md` §0, §5, §7 and `scripts/pwrpc_decode.py`. +- **Phase D checkpoint answered** (verbatim below). +- **Phase D documents done:** the folder renamed (`git mv`; the two full system logs removed from it after a hash check against the originals; the listing + diffed before/after); CAP-076-logcat-extract.txt, CAP-076-adb-checks-0092.log, CAP-076-sha256sum.txt (13 data files) added; the four scripts written and + checked to reproduce the scratch results byte for byte (tables, claims, 57 + 18 events, the button reads); `id_registry.csv` (CAP-076 analyzed), + `CAPTURE_BLUETOOTH_HCI_SNOOP.md` (the §9 row, the Group BL status, "seventeen"), `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (BL on 14 Test-IDs), `APP_TESTPLAN.md` §Y + (results, the Summary row 22 / 10 / 0 / 7 / 5, the run note), `TODO.md` §1/§3/§5, `ARCHITECTURE.md` §5a/§6.0b, `DECISIONS.md` ADR-065 Update, + `PROTOCOL.md` §4.1 note + §8 row, `CHANGELOG.md` `[1.3.0]` Known issues, `PROJECT.md` status, `_sidebar.md`, `ai-sessions/INDEX.md` (0091 back-fill, 0092). + The kept ~/opencontrol-1.3.0-tested/release-notes.md got the Known-issues paragraph (the APK's SHA-256 re-checked: OK). + +## Checkpoints + +**Checkpoint 0 (chat 2026-10-15, `AskUserQuestion`):** + +| Question | Answer (verbatim) | +|---|---| +| Users | "Yes, as read (Recommended)" — part 1 the Owner (user 0) with Play, part 2 the secondary user (user 10) without Play | +| Folder name | "Rename after Phase A (Recommended)" — `git mv` to CAP-076-2026-10-15_HH-mm-ss_HH-mm-ss-Group_BL with the measured film times; the recordings keep their names | +| Modes+sha | "Both (Recommended)" — chmod 644 on the seven 750 files; CAP-076-sha256sum.txt | +| Films | "As is, audio kept (Recommended)" | +| System logs | "Extract committed, full local (Recommended)" — CAP-076-logcat-extract.txt (part 1's window, the app/Bluetooth/Play lines, redacted); the two full files moved out of the repository | +| Originals | "~/opencontrol-capture-originals/CAP-076/ (Recommended)" | +| Phone | "Yes, attach it now" — read-only adb checks: the installed APK's SHA-256 and Play's *Nearby devices* permission and version | + +**Phase D checkpoint (chat 2026-10-15, `AskUserQuestion`):** + +| Question | Answer (verbatim) | +|---|---| +| Verdict C4 | "Release as built + Known issues (Recommended)" — C4 minor issue; Known-issues lines for (a) the "changed since" line overwritten after a reconnect and (b) the reworded tap/collision line, in `CHANGELOG.md` `[1.3.0]` and the kept release notes | +| ADR-065 Upd | "Write it as drafted (Recommended)" | +| PROTOCOL | "Add the §4.1 note + §8 row (Recommended)" — no status change; §6's wear item not settled | +| Next step | "Not now" — P1–P6 recorded in `TODO.md` only | +| Scripts | "Commit 4 scripts (Recommended)" — scripts/pwhdlc_crc_table.py, scripts/message_stream_claims.py, scripts/stream_events.py, scripts/film_mode_buttons.py | +| Exports | "As is (Recommended)" | +| Notes file | "Yes, edit both (Recommended)" — CHANGELOG and ~/opencontrol-1.3.0-tested/release-notes.md | +| Rename | "Rename + update refs (Recommended)" — CAP-076-2026-10-15_06-35-26_07-08-20-Group_BL | + +**Commit checkpoint (chat 2026-10-15, `AskUserQuestion`):** + +| Question | Answer (verbatim) | +|---|---| +| Commit+push | "Yes, commit and push (Recommended)" — four commits on `feature/settings-stream`, no force, no merge | +| Kept notes | "Yes, apply it (Recommended)" — the "**Known issues in 1.3.0:** …" paragraph added to ~/opencontrol-1.3.0-tested/release-notes.md (the APK and its .sha256 untouched, re-checked) | +| PR #56 body | "Yes, after the push (Recommended)" — B3, C1, C2, C3 ticked, the C4 verdict line | + +## Files read + +- **In full:** `AGENTS.md`, `PROJECT_RULES.md`, `PROJECT.md`, `DECISIONS.md` (ADR-001 … ADR-065 with every Update, the draft ADR-060), `AI_SESSION_LOG_PROCEDURE.md`, + this prompt, `ai-sessions/0091_FEATURE_RESULT_2026_10_14.md`, the `CAP-076` skeleton, `CAP-075-FINDINGS.md`, `BudsRepositoryImpl.kt` (1–1795), both exports + (every non-hex line; the hex lines matched to the wire by script), the adb shell log, the kept release notes. +- **In part:** `ARCHITECTURE.md` (§3.1, §3.2, the §5a row, §6.0b), `PROTOCOL.md` (§2.2a L-1 Updates, §4.1 head and its 2026-10-04 … 2026-10-14 Updates/Notes, + §6's items of 2026-09-28/10-04/10-14, §8's last rows), `TODO.md` (§1, §3, §5, the CAP-076 lines), `RELEASING.md` (C1–C4, the release log rows), + `CHANGELOG.md` (`[1.3.0]`), `APP_TESTPLAN.md` (§X, §Y, Summary), `CAPTURE_BLUETOOTH_HCI_SNOOP.md` (Group BL, the §9 rows), `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` (the + exercised rows), `CAP-075-EVENT-NOTES.md` (its structure), `id_registry.csv` (the CAP-074 … CAP-076 rows), `RfcommBudsTransport.kt` (`send`), + `AncTileService.kt` (the tap handler), `scripts/pwrpc_decode.py`, `scripts/anc_setting13_crosscheck.py` (its header). The two app logcats: every line of the old + processes and every Bluetooth/System/crash line; the UI-event lines by count. The full logcat and the system log: by the stated filters over part 1's window. +- **Not read** (the prompt named them): `PROTOCOL.md` §4.5, `CAP-071-*` (the layout followed via `CAP-075`'s, which follows it), `CAP-074-FINDINGS.md` §8, + `CAP-051`/`CAP-056`/`CAP-038` findings (only their stream packets re-checked with `pwrpc_decode.py`), `DESKRESEARCH_FINDINGS.md` 2026-10-14, + `0088_CAPTURE_RESULT` (only its head), the Kotlin files `CodecRouter.kt`, `SettingFrame.kt`, `Maestro.kt`, `SessionReopener.kt`, `SessionDiagnostics.kt`, + `AncScreen.kt`, `SettingsMenu.kt`, `MainActivity.kt`, `ValueCurrency.kt`, `AncModeCause.kt` — each finding of this run that touches code was traced in the files + read in full or part; the log strings matched were all in `BudsRepositoryImpl.kt` and the transport/reopener/diagnostics lines quoted from the exports. + +## Deferred documentation + +- `TODO.md` §5: "After `CAP-076` … P1 keep "Changed since the app last saw it" across the reconnect's second, equal read …; P2 a tile tap whose claim closes + after the `Get`'s answer and before its `Set` reports a failure …; P3 log a same-mode push as "unchanged" …; P4 the two packets per change …; P5 …; P6 a mode + line on the Connection tab …" and the CloseGuard lines. +- `TODO.md` §1: "The Buds' state after `CAP-076` … in-ear detection off … restore in-ear detection if wanted (**M**)." +- `TODO.md` §3: the *Nearby devices* state "on film still open". +- `README.md` (status, Known issues) — E1 of `RELEASING.md` after the release, as `TODO.md` §1's "next: D1–D4, then E1–E4". + +## Commits + +Not committed — awaiting the maintainer's yes in chat (the RESULT's next session back-fills the hashes, `AI_SESSION_LOG_PROCEDURE.md` §4b). + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/0092_CAPTURE_RESULT_2026_10_15.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/0092_CAPTURE_RESULT_2026_10_15 diff --git a/ai-sessions/INDEX.md b/ai-sessions/INDEX.md index fa23c4b9..625a6da9 100644 --- a/ai-sessions/INDEX.md +++ b/ai-sessions/INDEX.md @@ -97,8 +97,8 @@ a new session** — the same "check before assigning" discipline `id_registry.cs | 0088 | CAPTURE | 2026-10-12 | Full analysis of `CAP-075` (Group BK: 1.2.1 build `31ee183`, the "Noise-control channel" switch — **two sets**: the secondary user without Google Play (2026-10-12: one screen recording with audio, two HCI logs, one export, the app logcat; 1.2.0 → 1.2.1 updated on film) and the Owner with Google Play (2026-10-13: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`): the real events per set, the holds per switch position, the Play user's cut mechanism frame by frame, the four-way comparison `CAP-073`/`CAP-074`/`CAP-075`, the answer per user to "which works best", improvement proposals for the Play user, the release verdict proposal; no app change | complete (2026-10-13) — `CAP-075-EVENT-NOTES.md` (the two timelines, the steps as run, Test-IDs) and `CAP-075-FINDINGS.md` §0–§12: without Play both positions identical (41 of 41 holds shown, no foreign close); with Play the hint 2 of 2 and 0 of 9 holds shown, "Take it back" three chains × three re-claims at 3/6/12 s (4.0–4.8 s on the wire, every one colliding with Play's retry 2.1–2.8 s after the cut), holds cut 5.3–5.8 s after Play's loss, 43 % coverage then nothing, 8 of 19 shown; the cut's stack path 15 of 15 in the system log; Play's timers = `CAP-074`'s. Checkpoint 0 + Phase D approved in chat: the folder/recording renames, modes, the logcat extract, the films as is, the ADR-062 Update, a 🟢 `PROTOCOL.md` §4.1 note, the CHANGELOG Known-issues text, the verdict "no heavy issue — 1.2.1 released as built", P1 + P2 + P4 → the next FEATURE prompt; the leftover worktree of 0087's prompt removed; no app change. **Continuation in chat 2026-10-13:** PR #44 merged, 1.2.1 published (tag `v1.2.1` on `31ee183`, the notes' Known-issues text measured), E1–E4 by the pull request of `release/1.2.1-after` (#45); D4 done — installed with Obtainium | | 0089 | FEATURE | 2026-10-13 | The noise-control channel in the Play user after `CAP-075` (`ai-sessions/0088`, the maintainer's choice "P1 + P2 + P4"): a constant 3-s re-claim step with a longer chain (capped), a "Take it back again" action on the hint card, a cause label for a mode learned by a re-claim's own read; the switch renamed (the maintainer's example "Channel control") with options that say whether the phone has Google Play services and plainer texts; real `CAP-075` bytes as fixtures, mutation checks, the `CAP-076` (Group BL) skeleton, the documents, the release preparation (no tag, no publication) | awaiting maintainer sign-off — built on `feature/channel-control` (gate green: `:data` 1672, `:domain` 42, `:hardware` 64, `:ui` 102; M1–M6 killed): the re-claim 3 s after every cut up to 20 (`HOLD_RECLAIM_STEP_MS`, `HOLD_RECLAIM_CAP`), "Take it back again" (`reclaimAncChannel`, no `Set`), `AncModeCause.CHANGED_SINCE_LAST_READ` and its (i) line, the card renamed "Noise control with Google Play services" with options that say whether the phone has Play services and ADR-063's "found / not found" line (a `` entry); ADR-062 Update 2, ADR-063 and the `AGENTS.md` §1 note approved in chat 2026-10-13; 1.2.2 (10202) prepared; `CAP-076` (Group BL) skeleton for both users, `APP_TESTPLAN.md` §Y; commits `e60fb7c` … `bfd7891`, PR #49 merged (`5b204ea`); **1.2.2 published 2026-10-14** (tag `v1.2.2` on `bfd7891`) after the maintainer's own test, without `CAP-076`; installed with Obtainium (D4, 2026-10-14) | | 0090 | CROSSCHECK | 2026-10-14 | Point-by-point check of the pbpctrl-derived findings list (E-01 … E-23: `qzed/pbpctrl`, MagicPodsCore's Pixel Buds branch, omarchy-pixelbuds at pinned commits) against this project's captures, APK notes and documents — led by the question whether the app can learn a press-and-hold's noise-control mode on DLCI 0x02 (Maestro setting 13, `SubscribeToSettingsChanges`) instead of the contested Message Stream; proposals only where the rules require the maintainer; no app code, no wire bytes | complete — the leading question answered yes: setting 13 = the mode on DLCI 0x02, pushed on `SubscribeToSettingsChanges` for 73/73 Buds-side and 39/39 app-driven changes (also with no Message Stream open), `ReadSetting 4:13` 359/382; OpenControl never subscribed (0/33 logs); the APK feeds its ANC state only from 13 (`fxb.smali:1237`). Approved in chat 2026-10-14 and applied: `PROTOCOL.md` §4.1 🟢 Update (+ §6, §2.2, §4.5.7, §0.1 notes), **ADR-064** (subscribe + read 13, read only; build next) with pointers on ADR-025/034/036, ADR-060 item (vii), the `qhr` register Update, a desk entry, `CAP-051-FINDINGS.md` §3, `AGENTS.md` §5 note, `ARCHITECTURE.md` §5a rule, `TODO.md`, `scripts/anc_setting13_crosscheck.py`; no app code; commits `7bfb078` … `0b1681a` on `research/pbpctrl-crosscheck`, pushed, PR #55. **Continuation in chat 2026-10-14:** the existing captures checked for the new findings — one-bud/two-bud stored mode 28 of 28 in the official app's captures (🟡 strong, `PROTOCOL.md` §6, `scripts/wear_mode_crosscheck.py`); settings 8/9/10 and `SubscribeToOobeActions` never on any wire; runtime-info 7.x = the charging flags in 96.9 % | -| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; pushed 2026-10-14, commits `bb7a843` … `391b0c5`, PR #56 open); RESULT `0091_FEATURE_RESULT_2026_10_14.md` | -| 0092 | CAPTURE | 2026-10-15 | Full analysis of `CAP-076` (Group BL: the 1.3.0 build `ff27ee1`, the settings stream on the app's own channel — ADR-064/065 — **two parts**: the Owner user with Google Play (06:35–06:52, the update 1.2.2 → 1.3.0 on film: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`, the adb shell log) and the secondary user without Google Play (07:01–07:08: one screen recording with audio, two HCI logs, one export, the app logcat)): the real events per part, the two new requests once per MAESTRO session, every press-and-hold against the screen, the own taps' stream packets, the re-homes and re-opens, the comparison with `CAP-075`, the counts for the verdict, the release verdict proposal (`RELEASING.md` C3/C4); no app change | planned — prompt written 2026-10-15 (chat, after the maintainer's partial run of `CAP-076`); not started | +| 0091 | FEATURE | 2026-10-14 | Build ADR-064: one `SubscribeToSettingsChanges` and one `ReadSetting 4:13` per MAESTRO session (Connect and every ADR-044 re-open), the stream feeding every setting the app reads; ADR-061/062/063 (the hold, the re-claim, the "Noise control with Google Play services" card, the Play-services line) replaced by one new ADR and removed from the app; the cause line rewritten (wear-driven changes stay 🟡); version 1.3.0; real fixtures (`CAP-051`, `CAP-056`, `CAP-038`, `CAP-045`, `CAP-021`, `CAP-027`), mutation checks, `CAP-076` rewritten as the 1.3.0 run in both users; `WriteSetting 4:{13:n}` out of scope | done 2026-10-14 — built on branch `feature/settings-stream` (ADR-065 accepted in chat; the gate green, M1–M5 caught; `CAP-076` skeleton rewritten; pushed 2026-10-14, commits `bb7a843` … `391b0c5`, PR #56 open); RESULT `0091_FEATURE_RESULT_2026_10_14.md`; hardware run analysed by 0092 (`CAP-076`, 2026-10-15: verdict release as built + Known issues) | +| 0092 | CAPTURE | 2026-10-15 | Full analysis of `CAP-076` (Group BL: the 1.3.0 build `ff27ee1`, the settings stream on the app's own channel — ADR-064/065 — **two parts**: the Owner user with Google Play (06:35–06:52, the update 1.2.2 → 1.3.0 on film: one screen recording with audio, two HCI logs, one export, the app logcat, the system log, a full `logcat -b all`, the adb shell log) and the secondary user without Google Play (07:01–07:08: one screen recording with audio, two HCI logs, one export, the app logcat)): the real events per part, the two new requests once per MAESTRO session, every press-and-hold against the screen, the own taps' stream packets, the re-homes and re-opens, the comparison with `CAP-075`, the counts for the verdict, the release verdict proposal (`RELEASING.md` C3/C4); no app change | complete 2026-10-15 — `CAP-076-EVENT-NOTES.md` (the timeline) and `CAP-076-FINDINGS.md` written; every hold on the noise-control tab shown (29/29 with Play, 12/12 without); three minor defects; the maintainer's verdict "release as built + Known issues"; ADR-065 Update, `PROTOCOL.md` §4.1 note, registers, four scripts; not committed — awaiting the maintainer's yes | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/INDEX.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/INDEX