From 96369eedbcda4874ca8778d014d5275f470d8dd0 Mon Sep 17 00:00:00 2001 From: GorangN Date: Sat, 18 Apr 2026 19:42:14 +0200 Subject: [PATCH 01/13] =?UTF-8?q?Audio=C3=BCberwachung=20mit=20NAudio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EasyBluetoothAudio/Services/AudioService.cs | 87 +++++++++++++++++++ .../Services/Interfaces/IAudioService.cs | 9 ++ .../ViewModels/MainViewModel.cs | 63 ++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index 444381b..c3cf958 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using Windows.Devices.Enumeration; using Windows.Media.Audio; @@ -267,4 +268,90 @@ protected virtual void Dispose(bool disposing) Disconnect(); } } + + /// + public bool IsAudioCurrentlyPlaying() + { + object? enumeratorObj = null; + object? deviceObj = null; + object? meterObj = null; + try + { + enumeratorObj = new MMDeviceEnumeratorCoClass(); + var enumerator = (IMMDeviceEnumerator)enumeratorObj; + + const int eRender = 0; + const int eMultimedia = 1; + enumerator.GetDefaultAudioEndpoint(eRender, eMultimedia, out var device); + deviceObj = device; + + var meterGuid = typeof(IAudioMeterInformation).GUID; + const int clsCtxAll = 23; + device.Activate(ref meterGuid, clsCtxAll, IntPtr.Zero, out meterObj); + var meter = (IAudioMeterInformation)meterObj; + + meter.GetPeakValue(out float peak); + return peak > 0.0001f; + } + catch (Exception ex) + { + Debug.WriteLine($"[AudioMeter] Error reading peak meter: {ex.Message}"); + // On failure, report no audio — the keepalive timer will eventually fire, + // which is the safe default (a harmless sub-second recycle). + return false; + } + finally + { + if (meterObj != null) { Marshal.ReleaseComObject(meterObj); } + if (deviceObj != null) { Marshal.ReleaseComObject(deviceObj); } + if (enumeratorObj != null) { Marshal.ReleaseComObject(enumeratorObj); } + } + } + + #region Core Audio COM Interop (peak meter) + + /// COM co-class for . + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + private class MMDeviceEnumeratorCoClass + { + } + + /// Minimal declaration of the Core Audio IMMDeviceEnumerator interface. + [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDeviceEnumerator + { + /// Placeholder for vtable slot — not called. + void EnumAudioEndpoints( + int dataFlow, + uint stateMask, + [MarshalAs(UnmanagedType.IUnknown)] out object devices); + + /// Returns the default audio endpoint for the specified data-flow direction and role. + void GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device); + } + + /// Minimal declaration of the Core Audio IMMDevice interface. + [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMMDevice + { + /// Activates a COM interface on this device endpoint. + void Activate( + ref Guid iid, + int clsCtx, + IntPtr activationParams, + [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); + } + + /// Minimal declaration of the Core Audio IAudioMeterInformation interface. + [ComImport, Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IAudioMeterInformation + { + /// Returns the current peak sample value for all channels. + void GetPeakValue(out float pfPeak); + } + + #endregion } diff --git a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs index 2ecc1e5..727523a 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -47,6 +47,15 @@ public interface IAudioService /// Task IsBluetoothPhysicallyConnectedAsync(string deviceId); + /// + /// Checks whether audio is currently flowing through the default system render endpoint + /// using the Core Audio peak meter. May return false negatives for Bluetooth audio + /// (Windows does not always reflect A2DP audio in the peak meter), but a positive result + /// reliably indicates an active audio stream. Intended as a keepalive suppression signal. + /// + /// true if audio activity was detected; otherwise false. + bool IsAudioCurrentlyPlaying(); + /// /// Disconnects the active Bluetooth audio connection and releases resources. /// diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index b2eadb5..3de845a 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -50,12 +50,23 @@ public partial class MainViewModel( /// internal const int ReconnectPhysicallyConnectedDelayMs = 0; + /// + /// Interval in milliseconds after which the monitor proactively recycles the + /// if no audio activity has been + /// detected by the Core Audio peak meter. This prevents the connection from silently entering + /// a zombie state (reported as Opened but no longer routing audio) after prolonged idle. + /// The peak meter may produce false negatives for Bluetooth audio, so this acts as a fallback: + /// if audio is detected the timer resets and no recycle occurs. + /// + internal const int KeepaliveIntervalMs = 20 * 60 * 1_000; // 20 minutes + private CancellationTokenSource? _monitorCts; private string? _lastDeviceId; private string? _monitoredDeviceId; private bool _isRefreshing; private volatile bool _isReconnecting; private DateTime _lastDisconnectTime = DateTime.UtcNow; + private DateTime _lastAudioDetectedTime; /// /// until the first successful call in this @@ -470,7 +481,9 @@ private void StartConnectionMonitor(string deviceId, string deviceName) { StopConnectionMonitor(); _monitoredDeviceId = deviceId; + _lastAudioDetectedTime = DateTime.UtcNow; audioService.ConnectionLost += OnConnectionLostFromService; + Microsoft.Win32.SystemEvents.SessionSwitch += OnSessionSwitch; _monitorCts = new CancellationTokenSource(); var token = _monitorCts.Token; @@ -495,7 +508,37 @@ private void StartConnectionMonitor(string deviceId, string deviceName) IsConnected = true; StatusText = "STREAMING ACTIVE"; }); + _lastAudioDetectedTime = DateTime.UtcNow; + } + + // Peak meter as positive signal: audio detected → reset keepalive timer. + // False negatives (silence reported while audio plays) are harmless — + // only 20 minutes of sustained silence triggers a recycle. + if (audioService.IsAudioCurrentlyPlaying()) + { + _lastAudioDetectedTime = DateTime.UtcNow; + } + + // Keepalive: recycle AudioPlaybackConnection after prolonged silence + // to prevent zombie state where State reports Opened but no audio routes. + if (!_isReconnecting + && (DateTime.UtcNow - _lastAudioDetectedTime).TotalMilliseconds >= KeepaliveIntervalMs) + { + _lastAudioDetectedTime = DateTime.UtcNow; + Debug.WriteLine("[Monitor] Keepalive: recycling AudioPlaybackConnection after prolonged silence."); + var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); + if (!ok) + { + dispatcherService.Invoke(() => + { + _isReconnecting = true; + DisconnectCommand.NotifyCanExecuteChanged(); + StatusText = "RECONNECTING..."; + IsConnected = false; + }); + } } + continue; } @@ -531,6 +574,7 @@ private void StartConnectionMonitor(string deviceId, string deviceName) StatusText = "STREAMING ACTIVE"; }); messenger.Send(new ConnectionEstablishedMessage(deviceName)); + _lastAudioDetectedTime = DateTime.UtcNow; break; } @@ -551,6 +595,7 @@ private void StartConnectionMonitor(string deviceId, string deviceName) /// private void StopConnectionMonitor() { + Microsoft.Win32.SystemEvents.SessionSwitch -= OnSessionSwitch; audioService.ConnectionLost -= OnConnectionLostFromService; _isReconnecting = false; _monitorCts?.Cancel(); @@ -558,6 +603,24 @@ private void StopConnectionMonitor() _monitorCts = null; } + /// + /// Handles Windows session switch events (screen unlock, console connect) by resetting + /// the keepalive timer so the next monitor poll triggers an immediate connection recycle. + /// After a lock/sleep period the + /// is most likely to be in a zombie state. + /// + /// The event source. + /// Session switch event arguments. + private void OnSessionSwitch(object sender, Microsoft.Win32.SessionSwitchEventArgs e) + { + if (e.Reason == Microsoft.Win32.SessionSwitchReason.SessionUnlock + || e.Reason == Microsoft.Win32.SessionSwitchReason.ConsoleConnect) + { + _lastAudioDetectedTime = DateTime.MinValue; + Debug.WriteLine("[Monitor] Session resumed — keepalive timer reset for immediate recycle."); + } + } + /// /// Handles the event by cross-checking the actual /// device state before updating the UI, guarding against transient audio-endpoint state changes From 4e8220e18fe4da530cc70e07c6849328c11f4efd Mon Sep 17 00:00:00 2001 From: GorangN Date: Sat, 18 Apr 2026 19:50:20 +0200 Subject: [PATCH 02/13] Connection Delay before OpenAsynv without blocking the BT Connection --- .../MainViewModelTests.cs | 76 +------------------ EasyBluetoothAudio/Services/AudioService.cs | 34 +++++++++ .../ViewModels/MainViewModel.cs | 49 ------------ 3 files changed, 37 insertions(+), 122 deletions(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 36132af..2e9743d 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -39,6 +39,7 @@ public MainViewModelTests() _devicePickerServiceMock.Setup(s => s.ShowAsync()).Returns(Task.CompletedTask); _settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings()); _dispatcherServiceMock.Setup(s => s.Invoke(It.IsAny())).Callback(a => a()); + _audioServiceMock.Setup(s => s.IsAudioCurrentlyPlaying()).Returns(false); } private MainViewModel CreateViewModel() @@ -447,8 +448,8 @@ public async Task Monitor_ReconnectSucceeds_ResumesStreaming() await vm.RefreshDevicesAsync(); await vm.ConnectAsync(); - // Initial settle ~5s + monitor poll 10s + reconnect settle 5s + margin = ~23s - await Task.Delay(23000); + // Monitor poll 10s + reconnect attempt + margin = ~13s + await Task.Delay(13000); Assert.True(vm.IsConnected); Assert.Equal("STREAMING ACTIVE", vm.StatusText); @@ -520,77 +521,6 @@ public async Task ConnectionLost_Event_DoesNotUpdateUi_WhenCrossCheckShowsStillC vm.Disconnect(); } - /// - /// Verifies that ConnectAsync waits for the settling delay when called - /// immediately after Disconnect, to allow Windows to complete Bluetooth teardown. - /// - [Fact] - public async Task ConnectAsync_WaitsForSettleDelay_WhenCalledRightAfterDisconnect() - { - var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); - - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); - vm.Disconnect(); - - var sw = System.Diagnostics.Stopwatch.StartNew(); - await vm.ConnectAsync(); - sw.Stop(); - - Assert.True(vm.IsConnected); - // Allow 200ms tolerance for scheduling overhead - Assert.True(sw.ElapsedMilliseconds >= MainViewModel.ReconnectSettleDelayMs - 200, - $"Expected settle delay of {MainViewModel.ReconnectSettleDelayMs}ms but ConnectAsync returned in {sw.ElapsedMilliseconds}ms"); - - vm.Disconnect(); - } - - /// - /// Verifies that the reconnect loop applies a settling delay before the first reconnect - /// attempt, meaning ConnectBluetoothAudioAsync is not called immediately after disconnect detection. - /// - [Fact] - public async Task Monitor_ReconnectLoop_WaitsForSettleDelayBeforeFirstAttempt() - { - var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - - DateTime? firstConnectTime = null; - DateTime? secondConnectTime = null; - - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")) - .ReturnsAsync(() => - { - if (firstConnectTime == null) - { - firstConnectTime = DateTime.UtcNow; - return true; - } - secondConnectTime = DateTime.UtcNow; - return true; - }); - - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); - - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); - - // Initial settle ~5s + monitor poll 10s + reconnect settle 5s + margin = ~23s - await Task.Delay(23_000); - - Assert.NotNull(secondConnectTime); - var gap = (secondConnectTime!.Value - firstConnectTime!.Value).TotalMilliseconds; - Assert.True(gap >= MainViewModel.ReconnectSettleDelayMs, - $"Expected at least {MainViewModel.ReconnectSettleDelayMs}ms between initial connect and first reconnect, got {gap:F0}ms"); - - vm.Disconnect(); - } - /// /// Verifies that an available update is discovered and surfaced. /// diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index c3cf958..b8c8880 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -16,10 +16,21 @@ namespace EasyBluetoothAudio.Services; /// public class AudioService : IAudioService, IDisposable { + /// + /// Milliseconds to wait between Start() and OpenAsync() to allow Windows + /// to complete teardown of the previous before the + /// new audio endpoint negotiates A2DP with the remote device. + /// Applied on the first connect (stale endpoint from prior session) and after a disconnect + /// where the physical Bluetooth link was torn down. + /// + internal const int SettleDelayMs = 5_000; + private readonly IDispatcherService _dispatcherService; private AudioPlaybackConnection? _audioConnection; private volatile bool _isAudioConnectionActive; private string? _activeDeviceId; + private bool _hasConnectedBefore; + private DateTime _lastDisconnectTime = DateTime.UtcNow; /// public event EventHandler? ConnectionLost; @@ -89,6 +100,13 @@ public async Task ConnectBluetoothAudioAsync(string deviceId) Debug.WriteLine($"[ConnectBT] Connecting to audio endpoint {deviceId}..."); + // Determine whether a settle delay is needed between Start() and OpenAsync(). + // On the first connect after app start, a stale AudioPlaybackConnection from the + // prior session may still be registered — always settle. + // On subsequent connects, settle only if the physical BT link was torn down. + var needsSettle = !_hasConnectedBefore + || !await IsBluetoothPhysicallyConnectedAsync(deviceId); + // All WinRT audio API calls must run on the UI (STA) thread. // When called from a background thread (e.g. the reconnect monitor), Start() and // OpenAsync() fail silently on the MTA thread pool — dispatching everything here @@ -104,6 +122,20 @@ await _dispatcherService.InvokeAsync(async () => _audioConnection.StateChanged += OnAudioConnectionStateChanged; _audioConnection.Start(); + + // Allow Windows to complete teardown of the previous audio endpoint before + // the new connection negotiates A2DP with the remote device. + if (needsSettle) + { + var timeSinceDisconnect = (DateTime.UtcNow - _lastDisconnectTime).TotalMilliseconds; + var settleRemaining = SettleDelayMs - (int)timeSinceDisconnect; + if (settleRemaining > 0) + { + Debug.WriteLine($"[ConnectBT] Settling {settleRemaining}ms between Start() and OpenAsync()..."); + await Task.Delay(settleRemaining); + } + } + openResult = await _audioConnection.OpenAsync(); }); @@ -118,6 +150,7 @@ await _dispatcherService.InvokeAsync(async () => Debug.WriteLine("[ConnectBT] AudioPlaybackConnection Success!"); _activeDeviceId = deviceId; _isAudioConnectionActive = true; + _hasConnectedBefore = true; return true; } @@ -245,6 +278,7 @@ public void Disconnect() _audioConnection = null; _activeDeviceId = null; _isAudioConnectionActive = false; + _lastDisconnectTime = DateTime.UtcNow; } } diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index 3de845a..071b592 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -35,21 +35,6 @@ public partial class MainViewModel( IDispatcherService dispatcherService, IMessenger messenger) : ObservableObject { - /// - /// Milliseconds to wait after a disconnect before attempting to reconnect, - /// allowing Windows to complete Bluetooth teardown before a new connection is opened. - /// Also applied on the first connect after app start to release any leftover audio endpoint - /// from a previous session. - /// - internal const int ReconnectSettleDelayMs = 5_000; - - /// - /// Milliseconds to wait when the Bluetooth device is already physically connected in Windows - /// (i.e. paired and in range) but the audio connection needs to be re-established. - /// No settle is required because the physical Bluetooth link was never torn down. - /// - internal const int ReconnectPhysicallyConnectedDelayMs = 0; - /// /// Interval in milliseconds after which the monitor proactively recycles the /// if no audio activity has been @@ -65,16 +50,8 @@ public partial class MainViewModel( private string? _monitoredDeviceId; private bool _isRefreshing; private volatile bool _isReconnecting; - private DateTime _lastDisconnectTime = DateTime.UtcNow; private DateTime _lastAudioDetectedTime; - /// - /// until the first successful call in this - /// session. The first connect always uses the full settle delay to clear any stale - /// left over from a prior session. - /// - private bool _isFirstConnect = true; - /// /// Gets or sets the currently selected Bluetooth device. /// Persists the device ID to settings when changed by the user. @@ -255,23 +232,6 @@ internal async Task ConnectAsync() IsBusy = true; StatusText = $"CONNECTING TO {SelectedBluetoothDevice.Name}..."; - // On the very first connect after app start we always settle in full, because a stale - // AudioPlaybackConnection from the prior session may still be registered with Windows. - // For all subsequent connects we can skip the settle when the BT link is already up. - var skipSettle = !_isFirstConnect - && await audioService.IsBluetoothPhysicallyConnectedAsync(SelectedBluetoothDevice.Id); - _isFirstConnect = false; - - if (!skipSettle) - { - var timeSinceDisconnect = (DateTime.UtcNow - _lastDisconnectTime).TotalMilliseconds; - var settleRemaining = ReconnectSettleDelayMs - (int)timeSinceDisconnect; - if (settleRemaining > 0) - { - await Task.Delay(settleRemaining); - } - } - var ok = await audioService.ConnectBluetoothAudioAsync(SelectedBluetoothDevice.Id); if (!ok) { @@ -316,7 +276,6 @@ internal void Disconnect() IsConnected = false; StatusText = "DISCONNECTED"; - _lastDisconnectTime = DateTime.UtcNow; } /// @@ -554,14 +513,6 @@ private void StartConnectionMonitor(string deviceId, string deviceName) try { audioService.Disconnect(); } catch { /* already stopped */ } - // Give Windows time to complete Bluetooth teardown before attempting reconnect. - // Skip the delay entirely if the device is already physically paired and in range. - var physicallyConnectedForSettle = await audioService.IsBluetoothPhysicallyConnectedAsync(deviceId); - if (!physicallyConnectedForSettle) - { - await Task.Delay(ReconnectSettleDelayMs, token); - } - while (!token.IsCancellationRequested) { var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); From bdecb9ef6a5b35422ad0db75bed7ca762833211c Mon Sep 17 00:00:00 2001 From: GorangN Date: Sat, 18 Apr 2026 20:05:25 +0200 Subject: [PATCH 03/13] Windows Audio Mixer Bug entfernt --- .../MainViewModelTests.cs | 1 - EasyBluetoothAudio/Services/AudioService.cs | 86 ------------------- .../Services/Interfaces/IAudioService.cs | 9 -- .../ViewModels/MainViewModel.cs | 38 ++++---- 4 files changed, 15 insertions(+), 119 deletions(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 2e9743d..12c716e 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -39,7 +39,6 @@ public MainViewModelTests() _devicePickerServiceMock.Setup(s => s.ShowAsync()).Returns(Task.CompletedTask); _settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings()); _dispatcherServiceMock.Setup(s => s.Invoke(It.IsAny())).Callback(a => a()); - _audioServiceMock.Setup(s => s.IsAudioCurrentlyPlaying()).Returns(false); } private MainViewModel CreateViewModel() diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index b8c8880..87f2bb6 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using Windows.Devices.Enumeration; using Windows.Media.Audio; @@ -303,89 +302,4 @@ protected virtual void Dispose(bool disposing) } } - /// - public bool IsAudioCurrentlyPlaying() - { - object? enumeratorObj = null; - object? deviceObj = null; - object? meterObj = null; - try - { - enumeratorObj = new MMDeviceEnumeratorCoClass(); - var enumerator = (IMMDeviceEnumerator)enumeratorObj; - - const int eRender = 0; - const int eMultimedia = 1; - enumerator.GetDefaultAudioEndpoint(eRender, eMultimedia, out var device); - deviceObj = device; - - var meterGuid = typeof(IAudioMeterInformation).GUID; - const int clsCtxAll = 23; - device.Activate(ref meterGuid, clsCtxAll, IntPtr.Zero, out meterObj); - var meter = (IAudioMeterInformation)meterObj; - - meter.GetPeakValue(out float peak); - return peak > 0.0001f; - } - catch (Exception ex) - { - Debug.WriteLine($"[AudioMeter] Error reading peak meter: {ex.Message}"); - // On failure, report no audio — the keepalive timer will eventually fire, - // which is the safe default (a harmless sub-second recycle). - return false; - } - finally - { - if (meterObj != null) { Marshal.ReleaseComObject(meterObj); } - if (deviceObj != null) { Marshal.ReleaseComObject(deviceObj); } - if (enumeratorObj != null) { Marshal.ReleaseComObject(enumeratorObj); } - } - } - - #region Core Audio COM Interop (peak meter) - - /// COM co-class for . - [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] - private class MMDeviceEnumeratorCoClass - { - } - - /// Minimal declaration of the Core Audio IMMDeviceEnumerator interface. - [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - private interface IMMDeviceEnumerator - { - /// Placeholder for vtable slot — not called. - void EnumAudioEndpoints( - int dataFlow, - uint stateMask, - [MarshalAs(UnmanagedType.IUnknown)] out object devices); - - /// Returns the default audio endpoint for the specified data-flow direction and role. - void GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device); - } - - /// Minimal declaration of the Core Audio IMMDevice interface. - [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - private interface IMMDevice - { - /// Activates a COM interface on this device endpoint. - void Activate( - ref Guid iid, - int clsCtx, - IntPtr activationParams, - [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); - } - - /// Minimal declaration of the Core Audio IAudioMeterInformation interface. - [ComImport, Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - private interface IAudioMeterInformation - { - /// Returns the current peak sample value for all channels. - void GetPeakValue(out float pfPeak); - } - - #endregion } diff --git a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs index 727523a..2ecc1e5 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -47,15 +47,6 @@ public interface IAudioService /// Task IsBluetoothPhysicallyConnectedAsync(string deviceId); - /// - /// Checks whether audio is currently flowing through the default system render endpoint - /// using the Core Audio peak meter. May return false negatives for Bluetooth audio - /// (Windows does not always reflect A2DP audio in the peak meter), but a positive result - /// reliably indicates an active audio stream. Intended as a keepalive suppression signal. - /// - /// true if audio activity was detected; otherwise false. - bool IsAudioCurrentlyPlaying(); - /// /// Disconnects the active Bluetooth audio connection and releases resources. /// diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index 071b592..c0cbd35 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -37,11 +37,10 @@ public partial class MainViewModel( { /// /// Interval in milliseconds after which the monitor proactively recycles the - /// if no audio activity has been - /// detected by the Core Audio peak meter. This prevents the connection from silently entering - /// a zombie state (reported as Opened but no longer routing audio) after prolonged idle. - /// The peak meter may produce false negatives for Bluetooth audio, so this acts as a fallback: - /// if audio is detected the timer resets and no recycle occurs. + /// to prevent it from silently + /// entering a zombie state (reported as Opened but no longer routing audio) after + /// prolonged idle. The recycle is sub-second when the device is physically connected. + /// Also reset immediately on Windows session unlock via . /// internal const int KeepaliveIntervalMs = 20 * 60 * 1_000; // 20 minutes @@ -50,7 +49,7 @@ public partial class MainViewModel( private string? _monitoredDeviceId; private bool _isRefreshing; private volatile bool _isReconnecting; - private DateTime _lastAudioDetectedTime; + private DateTime _lastKeepaliveTime; /// /// Gets or sets the currently selected Bluetooth device. @@ -440,7 +439,7 @@ private void StartConnectionMonitor(string deviceId, string deviceName) { StopConnectionMonitor(); _monitoredDeviceId = deviceId; - _lastAudioDetectedTime = DateTime.UtcNow; + _lastKeepaliveTime = DateTime.UtcNow; audioService.ConnectionLost += OnConnectionLostFromService; Microsoft.Win32.SystemEvents.SessionSwitch += OnSessionSwitch; _monitorCts = new CancellationTokenSource(); @@ -467,24 +466,17 @@ private void StartConnectionMonitor(string deviceId, string deviceName) IsConnected = true; StatusText = "STREAMING ACTIVE"; }); - _lastAudioDetectedTime = DateTime.UtcNow; + _lastKeepaliveTime = DateTime.UtcNow; } - // Peak meter as positive signal: audio detected → reset keepalive timer. - // False negatives (silence reported while audio plays) are harmless — - // only 20 minutes of sustained silence triggers a recycle. - if (audioService.IsAudioCurrentlyPlaying()) - { - _lastAudioDetectedTime = DateTime.UtcNow; - } - - // Keepalive: recycle AudioPlaybackConnection after prolonged silence - // to prevent zombie state where State reports Opened but no audio routes. + // Keepalive: periodically recycle the AudioPlaybackConnection to prevent + // zombie state where State reports Opened but Windows no longer routes audio. + // The recycle is sub-second when the device is physically connected. if (!_isReconnecting - && (DateTime.UtcNow - _lastAudioDetectedTime).TotalMilliseconds >= KeepaliveIntervalMs) + && (DateTime.UtcNow - _lastKeepaliveTime).TotalMilliseconds >= KeepaliveIntervalMs) { - _lastAudioDetectedTime = DateTime.UtcNow; - Debug.WriteLine("[Monitor] Keepalive: recycling AudioPlaybackConnection after prolonged silence."); + _lastKeepaliveTime = DateTime.UtcNow; + Debug.WriteLine("[Monitor] Keepalive: recycling AudioPlaybackConnection."); var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); if (!ok) { @@ -525,7 +517,7 @@ private void StartConnectionMonitor(string deviceId, string deviceName) StatusText = "STREAMING ACTIVE"; }); messenger.Send(new ConnectionEstablishedMessage(deviceName)); - _lastAudioDetectedTime = DateTime.UtcNow; + _lastKeepaliveTime = DateTime.UtcNow; break; } @@ -567,7 +559,7 @@ private void OnSessionSwitch(object sender, Microsoft.Win32.SessionSwitchEventAr if (e.Reason == Microsoft.Win32.SessionSwitchReason.SessionUnlock || e.Reason == Microsoft.Win32.SessionSwitchReason.ConsoleConnect) { - _lastAudioDetectedTime = DateTime.MinValue; + _lastKeepaliveTime = DateTime.MinValue; Debug.WriteLine("[Monitor] Session resumed — keepalive timer reset for immediate recycle."); } } From b97b4c36b60e2455260a48c6bc9fad8708baa0bc Mon Sep 17 00:00:00 2001 From: GorangN Date: Sat, 18 Apr 2026 20:33:53 +0200 Subject: [PATCH 04/13] On Exit Dispose --- EasyBluetoothAudio/App.xaml.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EasyBluetoothAudio/App.xaml.cs b/EasyBluetoothAudio/App.xaml.cs index f66e213..0b5055f 100644 --- a/EasyBluetoothAudio/App.xaml.cs +++ b/EasyBluetoothAudio/App.xaml.cs @@ -180,6 +180,12 @@ public void ShutdownForUpdate() /// protected override void OnExit(ExitEventArgs e) { + // Dispose the audio service to release the AudioPlaybackConnection and its + // associated mixer endpoints (render + capture) before the process exits. + // Without this, the virtual audio endpoints linger in the Windows Volume Mixer + // and require a reboot or driver reset to disappear. + (ServiceProvider?.GetService() as IDisposable)?.Dispose(); + if (_ownsMutex) { _mutex?.ReleaseMutex(); From 8ccf894ad59ee3ade47817ecaac40bb0a5ee51e7 Mon Sep 17 00:00:00 2001 From: GorangN Date: Sat, 18 Apr 2026 21:25:33 +0200 Subject: [PATCH 05/13] =?UTF-8?q?Reconnect-H=C3=A4rtung=20+=20TrayIcon-Bin?= =?UTF-8?q?dings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settle-Delay greift jetzt auf Zeitabstand seit letztem Teardown statt auf den physischen BT-Link — der Zombie-Retry-Loop rauschte sonst ohne Delay in UnknownFailure. Diagnose-Logging für StateChanged, reason-Tag am Disconnect und Grund-Logging in IsDeviceConnected. TaskbarIcon und ContextMenu haben expliziten DataContext auf das Window, behebt die OpenCommand/ExitCommand BindingExpression-Fehler. --- .../MainViewModelTests.cs | 2 +- EasyBluetoothAudio/Services/AudioService.cs | 79 +++++++++++-------- .../Services/Interfaces/IAudioService.cs | 6 +- .../ViewModels/MainViewModel.cs | 4 +- EasyBluetoothAudio/Views/MainWindow.xaml | 4 +- 5 files changed, 55 insertions(+), 40 deletions(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 12c716e..33288b4 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -253,7 +253,7 @@ public async Task Disconnect_DisconnectsAndSetsStatus() Assert.False(vm.IsConnected); Assert.Equal("DISCONNECTED", vm.StatusText); - _audioServiceMock.Verify(s => s.Disconnect(), Times.Once); + _audioServiceMock.Verify(s => s.Disconnect(It.IsAny()), Times.Once); } /// diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index 87f2bb6..56c4621 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -92,19 +92,17 @@ public async Task ConnectBluetoothAudioAsync(string deviceId) { try { - _audioConnection?.Dispose(); - _audioConnection = null; - _isAudioConnectionActive = false; - _activeDeviceId = null; + TearDownAudioConnection("pre-connect"); Debug.WriteLine($"[ConnectBT] Connecting to audio endpoint {deviceId}..."); - // Determine whether a settle delay is needed between Start() and OpenAsync(). - // On the first connect after app start, a stale AudioPlaybackConnection from the - // prior session may still be registered — always settle. - // On subsequent connects, settle only if the physical BT link was torn down. - var needsSettle = !_hasConnectedBefore - || !await IsBluetoothPhysicallyConnectedAsync(deviceId); + // A settle delay between Start() and OpenAsync() is required whenever the previous + // audio endpoint was torn down less than SettleDelayMs ago — regardless of whether + // the physical BT link is still up. In the common zombie-state scenario the ACL link + // stays connected while Windows rebuilds the A2DP endpoint, and skipping the settle + // there is what produces the UnknownFailure burst in the retry loop. + var timeSinceDisconnect = (DateTime.UtcNow - _lastDisconnectTime).TotalMilliseconds; + var needsSettle = !_hasConnectedBefore || timeSinceDisconnect < SettleDelayMs; // All WinRT audio API calls must run on the UI (STA) thread. // When called from a background thread (e.g. the reconnect monitor), Start() and @@ -126,7 +124,6 @@ await _dispatcherService.InvokeAsync(async () => // the new connection negotiates A2DP with the remote device. if (needsSettle) { - var timeSinceDisconnect = (DateTime.UtcNow - _lastDisconnectTime).TotalMilliseconds; var settleRemaining = SettleDelayMs - (int)timeSinceDisconnect; if (settleRemaining > 0) { @@ -154,33 +151,46 @@ await _dispatcherService.InvokeAsync(async () => } Debug.WriteLine($"[ConnectBT] Failed status: {openResult?.Status}"); - if (_audioConnection != null) - { - _audioConnection.StateChanged -= OnAudioConnectionStateChanged; - _audioConnection.Dispose(); - _audioConnection = null; - } + TearDownAudioConnection($"open-failed-{openResult?.Status}"); return false; } catch (Exception ex) { Debug.WriteLine($"[ConnectBT] Error: {ex.Message}"); - if (_audioConnection != null) - { - _audioConnection.StateChanged -= OnAudioConnectionStateChanged; - _audioConnection.Dispose(); - _audioConnection = null; - } + TearDownAudioConnection("connect-exception"); return false; } } + /// + /// Unhooks the handler, disposes the current + /// connection and resets tracking fields. Always updates so + /// subsequent connect attempts apply the Settle delay against a correct timestamp. + /// + /// Short tag describing why the teardown is happening (logged when a connection was actually open). + private void TearDownAudioConnection(string reason) + { + if (_audioConnection != null) + { + Debug.WriteLine($"[AudioService] Tearing down connection (reason={reason})."); + _audioConnection.StateChanged -= OnAudioConnectionStateChanged; + _audioConnection.Dispose(); + _audioConnection = null; + } + + _isAudioConnectionActive = false; + _activeDeviceId = null; + _lastDisconnectTime = DateTime.UtcNow; + } + private void OnAudioConnectionStateChanged(AudioPlaybackConnection sender, object args) { try { - _isAudioConnectionActive = sender.State == AudioPlaybackConnectionState.Opened; - if (sender.State != AudioPlaybackConnectionState.Opened) + var state = sender.State; + Debug.WriteLine($"[StateChanged] State={state}"); + _isAudioConnectionActive = state == AudioPlaybackConnectionState.Opened; + if (state != AudioPlaybackConnectionState.Opened) { ConnectionLost?.Invoke(this, EventArgs.Empty); } @@ -199,13 +209,16 @@ public async Task IsBluetoothDeviceConnectedAsync(string deviceId) { if (_activeDeviceId != deviceId || _audioConnection == null) { + Debug.WriteLine($"[IsDeviceConnected] returning false: reason=no-active-connection, activeId={_activeDeviceId ?? "null"}, queriedId={deviceId}"); return false; } // Directly read the connection state instead of relying solely on the event-driven flag, // because StateChanged does not fire reliably when the device goes out of range. - if (_audioConnection.State != AudioPlaybackConnectionState.Opened) + var state = _audioConnection.State; + if (state != AudioPlaybackConnectionState.Opened) { + Debug.WriteLine($"[IsDeviceConnected] returning false: reason=state-not-opened, connectionState={state}"); _isAudioConnectionActive = false; return false; } @@ -221,6 +234,7 @@ public async Task IsBluetoothDeviceConnectedAsync(string deviceId) if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var val) && val is bool btConnected && !btConnected) { + Debug.WriteLine($"[IsDeviceConnected] returning false: reason=aep-disconnected, connectionState={state}"); _isAudioConnectionActive = false; return false; } @@ -267,17 +281,12 @@ public async Task IsBluetoothPhysicallyConnectedAsync(string deviceId) } /// - public void Disconnect() + public void Disconnect(string reason = "unspecified") { if (_audioConnection != null) { - Debug.WriteLine("[Disconnect] Closing connection..."); - _audioConnection.StateChanged -= OnAudioConnectionStateChanged; - _audioConnection.Dispose(); - _audioConnection = null; - _activeDeviceId = null; - _isAudioConnectionActive = false; - _lastDisconnectTime = DateTime.UtcNow; + Debug.WriteLine($"[Disconnect] Closing connection (reason={reason})..."); + TearDownAudioConnection(reason); } } @@ -298,7 +307,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - Disconnect(); + Disconnect("dispose"); } } diff --git a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs index 2ecc1e5..a4dc1ea 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -50,5 +50,9 @@ public interface IAudioService /// /// Disconnects the active Bluetooth audio connection and releases resources. /// - void Disconnect(); + /// + /// Short tag identifying the caller ("user", "monitor-detected-loss", "dispose", "reconnect-request"). + /// Logged alongside the teardown to make the debug trace diagnosable when the reconnect loop fires. + /// + void Disconnect(string reason = "unspecified"); } diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index c0cbd35..a4717b6 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -266,7 +266,7 @@ internal void Disconnect() try { - audioService.Disconnect(); + audioService.Disconnect("user"); } catch (Exception ex) { @@ -502,7 +502,7 @@ private void StartConnectionMonitor(string deviceId, string deviceName) IsConnected = false; }); - try { audioService.Disconnect(); } + try { audioService.Disconnect("monitor-detected-loss"); } catch { /* already stopped */ } while (!token.IsCancellationRequested) diff --git a/EasyBluetoothAudio/Views/MainWindow.xaml b/EasyBluetoothAudio/Views/MainWindow.xaml index 1762345..009e20c 100644 --- a/EasyBluetoothAudio/Views/MainWindow.xaml +++ b/EasyBluetoothAudio/Views/MainWindow.xaml @@ -1,4 +1,5 @@  - + From 300c59e83e2ec272c4e8c1aa1ce57d848fd13271 Mon Sep 17 00:00:00 2001 From: GorangN Date: Mon, 20 Apr 2026 12:19:38 +0200 Subject: [PATCH 06/13] Implement session-based zombie detection and harden tray exit --- AGENTS.md | 123 ++++++++++++++ .../MainViewModelTests.cs | 3 + EasyBluetoothAudio/App.xaml.cs | 30 ++++ EasyBluetoothAudio/EasyBluetoothAudio.csproj | 1 + .../Messages/ShowBalloonRequestedMessage.cs | 20 +++ EasyBluetoothAudio/Services/AudioService.cs | 155 +++++++++++++++++- .../Services/Interfaces/IAudioService.cs | 13 ++ .../ViewModels/MainViewModel.cs | 95 +++++++++++ EasyBluetoothAudio/Views/MainWindow.xaml | 6 +- tasks/lessons.md | 7 + tasks/todo.md | 72 ++++++++ 11 files changed, 518 insertions(+), 7 deletions(-) create mode 100644 AGENTS.md create mode 100644 EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs create mode 100644 tasks/lessons.md create mode 100644 tasks/todo.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f832e9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# Codex Rules for EasyBluetoothAudio + +## Workflow Orchestration + +### Planning +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- If something goes sideways, STOP and re-plan immediately — don't keep pushing +- Use plan mode for verification steps, not just building +- Write detailed specs upfront to reduce ambiguity + +### Subagent Strategy +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One task per subagent for focused execution + +### Self-Improvement Loop +- After ANY correction from the user: update `tasks/lessons.md` with the pattern +- Write rules that prevent the same mistake from recurring +- Ruthlessly iterate on these lessons until mistake rate drops +- Review lessons at session start for relevant context + +### Verification Before Done +- Never mark a task complete without proving it works +- Diff behavior between main and your changes when relevant +- Ask: "Would a staff engineer approve this?" +- Run tests, check logs, demonstrate correctness + +### Elegance (Balanced) +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: implement the elegant solution instead +- Skip for simple, obvious fixes — don't over-engineer + +### Autonomous Bug Fixing +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests — then resolve them +- Zero context switching required from the user + +--- + +## Task Management + +1. **Plan First**: Write plan to `tasks/todo.md` with checkable items +2. **Verify Plan**: Check in before starting implementation +3. **Track Progress**: Mark items complete as you go +4. **Explain Changes**: High-level summary at each step +5. **Document Results**: Add review section to `tasks/todo.md` +6. **Capture Lessons**: Update `tasks/lessons.md` after corrections + +--- + +## Core Principles + +- **Simplicity First**: Make every change as simple as possible. Impact minimal code. +- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards. +- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs. + +--- + +## WPF MVVM & Clean Code Design Guide + +### 1. General Principles + +- **Microsoft Guidelines**: Follow official C# and .NET design guidelines (naming, coding style). +- **Zero Warnings**: Code must compile with zero errors and zero warnings. + - Do not suppress warnings unless absolutely necessary (justify via pragma comment). + - Fix warnings about missing comments, unused variables, or unreachable code through refactoring. +- **Commit Rules**: + - Never commit broken or non-compiling code. + - Commit messages must be meaningful and linked to the respective DevOps item or ticket. + +### 2. Refactoring & Clean Code + +- **Boy Scout Rule**: Always leave the code cleaner than you found it. Refactor whenever you touch code. +- **SOLID Principles**: Classes must have a single responsibility; program against interfaces, not concrete implementations. +- **Coordination**: Changes affecting cross-domain interfaces, database structures, or global configs must be coordinated with the team before implementation. + +### 3. Formatting & Comments + +- **Curly Brackets — Mandatory**: Every loop or conditional (if/else) must use curly brackets, even for single lines. + - Correct: `if (a == b) { DoSomething(); }` + - Incorrect: `if (a == b) DoSomething();` +- **XML Documentation — Strict Rule**: + - Every public class, method, variable, property, and enum must have a detailed `/// `. + - For methods: document all ``, ``, and `` tags. +- **Inline Comments**: + - Forbidden for obvious code. Code must be self-explanatory through excellent naming. + - Only allowed to explain highly complex, business-specific workarounds or algorithms (explain the "Why", not the "What"). +- **File Structure**: + - Every class, interface, and enum must reside in its own separate file. + - Files exceeding ~300 lines usually indicate an SRP violation and should be split. + +### 4. Naming Conventions + +- **PascalCase**: Namespaces, Classes, Interfaces (prefix `I`), Methods, Properties, Public Members. +- **camelCase**: Parameters and local variables. +- **Private Backing Fields**: Underscore + camelCase (e.g., `_myVariable`). No Hungarian Notation (use `_isActive`, not `_bIsActive`). +- **Events**: Use `Closing`/`Closed` instead of `BeforeClose`/`AfterClose`. Event handlers follow the `On[EventName]` pattern. + +### 5. Interfaces & Dependency Injection + +- Interfaces are **mandatory** for: hardware integrations, external API calls, database access, all external services. +- Access to modules implementing an interface must occur **exclusively** through the interface (IoC). +- **Forbidden**: Casting an interface to its concrete class to bypass the abstraction (e.g., `(module as MyConcreteService).DoSomethingSecret()`). +- **DI**: Use `Microsoft.Extensions.DependencyInjection`; inject services via Constructor Injection. + +### 6. Architecture & Layer Separation + +Strict Clean Architecture layers. Circular dependencies are strictly forbidden. + +| Layer | Contents | Dependencies | +|-------|----------|--------------| +| **Core / Domain** | Abstract domain models, enums, global interfaces | None | +| **Application / Services** | Business logic, abstractions, ViewModels | Core only | +| **Infrastructure / Data** | DB access, external APIs, hardware implementations | Core + Application | +| **UI / Presentation** | WPF Views and XAML | Application (via ViewModels) | + +### 7. WPF & MVVM + +- **Separation of Concerns**: Strict separation between GUI and business logic. +- **DataBindings**: UI communicates with application logic exclusively via DataBindings. GUI controls handle visual states only. +- **Commands**: User actions (Buttons, Menus, Toolbars) must trigger `ICommand` / `RelayCommand` in the ViewModel. +- **Code-Behind**: `.xaml.cs` files must remain completely empty except for `InitializeComponent()`. **No business logic in code-behind.** diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 33288b4..62d6a3b 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -39,6 +39,9 @@ public MainViewModelTests() _devicePickerServiceMock.Setup(s => s.ShowAsync()).Returns(Task.CompletedTask); _settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings()); _dispatcherServiceMock.Setup(s => s.Invoke(It.IsAny())).Callback(a => a()); + // Default the peak meter to null so the monitor's zombie branch stays inert in tests + // that do not exercise it. Tests that need zombie behavior can override this setup. + _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns((float?)null); } private MainViewModel CreateViewModel() diff --git a/EasyBluetoothAudio/App.xaml.cs b/EasyBluetoothAudio/App.xaml.cs index 0b5055f..a01a5f8 100644 --- a/EasyBluetoothAudio/App.xaml.cs +++ b/EasyBluetoothAudio/App.xaml.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Diagnostics; using System.IO.Pipes; using System.Linq; using System.Threading; @@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Win32; using System.Net.Http; +using EasyBluetoothAudio.Messages; using EasyBluetoothAudio.Services; using EasyBluetoothAudio.Services.Interfaces; using EasyBluetoothAudio.ViewModels; @@ -91,9 +93,27 @@ protected override void OnStartup(StartupEventArgs e) var mainViewModel = ServiceProvider.GetRequiredService(); mainWindow.DataContext = mainViewModel; + mainWindow.OpenTrayMenuItem.Click += (_, _) => + { + if (mainViewModel.OpenCommand.CanExecute(null)) + { + mainViewModel.OpenCommand.Execute(null); + } + }; + mainWindow.ExitTrayMenuItem.Click += (_, _) => + { + if (mainViewModel.ExitCommand.CanExecute(null)) + { + mainViewModel.ExitCommand.Execute(null); + } + }; mainWindow.TrayIcon.ShowBalloonTip("Easy Bluetooth Audio", "App started in system tray.", Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info); + WeakReferenceMessenger.Default.Register(this, (_, msg) => + Current.Dispatcher.InvokeAsync(() => + mainWindow.TrayIcon?.ShowBalloonTip(msg.Value.Title, msg.Value.Body, msg.Value.Icon))); + mainViewModel.RequestShow += () => { var mousePt = System.Windows.Forms.Cursor.Position; @@ -110,7 +130,15 @@ protected override void OnStartup(StartupEventArgs e) mainViewModel.RequestExit += () => { + Debug.WriteLine("[App] RequestExit received from tray."); _isExiting = true; + _refreshTimer?.Stop(); + if (!mainWindow.TrayIcon.IsDisposed) + { + mainWindow.TrayIcon.Dispose(); + } + + mainWindow.Close(); Current.Shutdown(); }; @@ -180,6 +208,8 @@ public void ShutdownForUpdate() /// protected override void OnExit(ExitEventArgs e) { + Debug.WriteLine($"[App] OnExit code={e.ApplicationExitCode}"); + // Dispose the audio service to release the AudioPlaybackConnection and its // associated mixer endpoints (render + capture) before the process exits. // Without this, the virtual audio endpoints linger in the Windows Volume Mixer diff --git a/EasyBluetoothAudio/EasyBluetoothAudio.csproj b/EasyBluetoothAudio/EasyBluetoothAudio.csproj index 9a58c09..2519bc0 100644 --- a/EasyBluetoothAudio/EasyBluetoothAudio.csproj +++ b/EasyBluetoothAudio/EasyBluetoothAudio.csproj @@ -31,6 +31,7 @@ + diff --git a/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs b/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs new file mode 100644 index 0000000..5ebf205 --- /dev/null +++ b/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs @@ -0,0 +1,20 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; +using Hardcodet.Wpf.TaskbarNotification; + +namespace EasyBluetoothAudio.Messages; + +/// +/// Payload describing a tray balloon tip that the application should surface to the user. +/// +/// The balloon header text. +/// The balloon body text shown underneath the header. +/// The system icon rendered next to the message (info, warning, error). +public sealed record BalloonContent(string Title, string Body, BalloonIcon Icon); + +/// +/// Messenger message requesting that the tray icon display a balloon tip. +/// Used by non-UI layers (such as the ViewModel's zombie-recovery branch) to surface +/// user-facing notifications without holding a reference to the TrayIcon instance. +/// +/// The balloon content to show. +public sealed class ShowBalloonRequestedMessage(BalloonContent Value) : ValueChangedMessage(Value); diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index 56c4621..bde3ed6 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using NAudio.CoreAudioApi; using Windows.Devices.Enumeration; using Windows.Media.Audio; using EasyBluetoothAudio.Models; @@ -28,7 +29,9 @@ public class AudioService : IAudioService, IDisposable private AudioPlaybackConnection? _audioConnection; private volatile bool _isAudioConnectionActive; private string? _activeDeviceId; + private string? _activeDeviceName; private bool _hasConnectedBefore; + private bool _hasDumpedSessions; private DateTime _lastDisconnectTime = DateTime.UtcNow; /// @@ -145,6 +148,7 @@ await _dispatcherService.InvokeAsync(async () => { Debug.WriteLine("[ConnectBT] AudioPlaybackConnection Success!"); _activeDeviceId = deviceId; + _activeDeviceName = await TryGetDeviceFriendlyNameAsync(deviceId); _isAudioConnectionActive = true; _hasConnectedBefore = true; return true; @@ -180,9 +184,33 @@ private void TearDownAudioConnection(string reason) _isAudioConnectionActive = false; _activeDeviceId = null; + _activeDeviceName = null; + _hasDumpedSessions = false; _lastDisconnectTime = DateTime.UtcNow; } + /// + /// Fetches the human-readable name for a given WinRT device ID, used to match the + /// Bluetooth capture endpoint exposed by CoreAudio (whose FriendlyName embeds + /// the same device name). Returns when the lookup fails so + /// the peak-meter heuristic falls back to "no judgment" rather than a false zombie verdict. + /// + /// The WinRT device identifier of the connected Bluetooth source. + /// The friendly name, or on failure. + private static async Task TryGetDeviceFriendlyNameAsync(string deviceId) + { + try + { + var info = await DeviceInformation.CreateFromIdAsync(deviceId); + return string.IsNullOrWhiteSpace(info?.Name) ? null : info.Name; + } + catch (Exception ex) + { + Debug.WriteLine($"[ConnectBT] Failed to resolve friendly name for {deviceId}: {ex.Message}"); + return null; + } + } + private void OnAudioConnectionStateChanged(AudioPlaybackConnection sender, object args) { try @@ -232,11 +260,19 @@ public async Task IsBluetoothDeviceConnectedAsync(string deviceId) new[] { "System.Devices.Aep.IsConnected" }); if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var val) - && val is bool btConnected && !btConnected) + && val is bool btConnected + && !btConnected) { - Debug.WriteLine($"[IsDeviceConnected] returning false: reason=aep-disconnected, connectionState={state}"); - _isAudioConnectionActive = false; - return false; + if (deviceId.EndsWith("\\SNK", StringComparison.OrdinalIgnoreCase)) + { + Debug.WriteLine($"[IsDeviceConnected] AEP reports disconnected for active SNK endpoint; trusting AudioPlaybackConnection.State={state}."); + } + else + { + Debug.WriteLine($"[IsDeviceConnected] returning false: reason=aep-disconnected, connectionState={state}"); + _isAudioConnectionActive = false; + return false; + } } } catch (Exception ex) @@ -290,6 +326,117 @@ public void Disconnect(string reason = "unspecified") } } + /// + public float? GetActiveDevicePeakLevel() + { + var activeDeviceName = _activeDeviceName; + if (string.IsNullOrEmpty(activeDeviceName)) + { + return null; + } + + try + { + using var enumerator = new MMDeviceEnumerator(); + + // Preferred match: a Capture endpoint whose FriendlyName embeds the BT device name. + // Some BT drivers expose the A2DP source that way; the WinRT AudioPlaybackConnection + // path used by this app does not. When no match is found we fall through to the + // per-session Render fallback below. + var captureEndpoints = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active); + try + { + foreach (var endpoint in captureEndpoints) + { + if (endpoint.FriendlyName.Contains(activeDeviceName, StringComparison.OrdinalIgnoreCase)) + { + var peak = endpoint.AudioMeterInformation.MasterPeakValue; + Debug.WriteLine($"[PeakMeter] capture={endpoint.FriendlyName} peak={peak:F4}"); + return peak; + } + } + } + finally + { + foreach (var endpoint in captureEndpoints) + { + endpoint.Dispose(); + } + } + + // Fallback: inspect sessions on the Default Render endpoint and only trust a session + // that can be matched back to the active Bluetooth device. If no session matches, we + // return null instead of trusting the aggregated endpoint peak. + using var defaultRender = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + var sessionManager = defaultRender.AudioSessionManager; + var sessions = sessionManager.Sessions; + var shouldDumpSessions = !_hasDumpedSessions; + float? matchedPeak = null; + string? matchedSession = null; + + if (shouldDumpSessions && sessions.Count == 0) + { + Debug.WriteLine($"[PeakMeter][Sessions] No render sessions found on '{defaultRender.FriendlyName}'."); + } + + for (var i = 0; i < sessions.Count; i++) + { + try + { + using var session = sessions[i]; + var displayName = session.DisplayName ?? string.Empty; + var iconPath = session.IconPath ?? string.Empty; + var processId = session.GetProcessID; + var state = session.State; + var peak = session.AudioMeterInformation.MasterPeakValue; + + if (shouldDumpSessions) + { + var loggedDisplayName = string.IsNullOrWhiteSpace(displayName) ? "" : displayName; + var loggedIconPath = string.IsNullOrWhiteSpace(iconPath) ? "" : iconPath; + Debug.WriteLine($"[PeakMeter][Sessions] idx={i} displayName='{loggedDisplayName}' iconPath='{loggedIconPath}' pid={processId} state={state} peak={peak:F4}"); + } + + if (matchedPeak == null && + (displayName.Contains(activeDeviceName, StringComparison.OrdinalIgnoreCase) || + iconPath.Contains(activeDeviceName, StringComparison.OrdinalIgnoreCase))) + { + matchedPeak = peak; + matchedSession = string.IsNullOrWhiteSpace(displayName) ? iconPath : displayName; + + if (!shouldDumpSessions) + { + break; + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[PeakMeter][Sessions] idx={i} error={ex.Message}"); + } + } + + if (shouldDumpSessions) + { + _hasDumpedSessions = true; + } + + if (matchedPeak.HasValue) + { + Debug.WriteLine($"[PeakMeter] session match='{matchedSession}' peak={matchedPeak.Value:F4}"); + return matchedPeak.Value; + } + + Debug.WriteLine($"[PeakMeter] No session matched '{activeDeviceName}' - zombie detection disabled until matcher is refined."); + return null; + } + catch (Exception ex) + { + Debug.WriteLine($"[PeakMeter] Error reading peak: {ex.Message}"); + return null; + } + } + /// /// Releases the audio connection and suppresses finalization. /// diff --git a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs index a4dc1ea..1ed58df 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -55,4 +55,17 @@ public interface IAudioService /// Logged alongside the teardown to make the debug trace diagnosable when the reconnect loop fires. /// void Disconnect(string reason = "unspecified"); + + /// + /// Returns the current peak audio level on the capture endpoint associated with the active + /// Bluetooth connection. Used to detect the Windows A2DP zombie state where the connection + /// reports Opened but no samples actually flow through Windows to the render stack. + /// + /// + /// The master peak value in the range [0.0, 1.0] when a matching capture endpoint is + /// found, or when no active device is known, the endpoint cannot be + /// matched by name, or the CoreAudio enumeration fails. Callers must treat + /// as "no judgment possible" and therefore not derive a zombie verdict from it. + /// + float? GetActiveDevicePeakLevel(); } diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index a4717b6..b60046f 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; +using Hardcodet.Wpf.TaskbarNotification; using EasyBluetoothAudio.Messages; using EasyBluetoothAudio.Models; using EasyBluetoothAudio.Services; @@ -44,12 +45,30 @@ public partial class MainViewModel( /// internal const int KeepaliveIntervalMs = 20 * 60 * 1_000; // 20 minutes + /// + /// Time (in milliseconds) that the peak meter on the active Bluetooth capture endpoint + /// must remain at zero before the monitor treats the connection as a Windows-A2DP zombie + /// (state reports Opened but no samples are actually routed). Chosen well above the + /// typical gap between tracks so legitimate silence does not trigger a recycle. + /// + internal const int ZombieSilenceThresholdMs = 15_000; + + /// + /// Number of consecutive zombie-triggered recycle attempts that may fail to restore audio + /// before the user is notified via a balloon tip. After the notification the monitor keeps + /// silently recycling, but the user is made aware that manual intervention (toggling the + /// audio route on the phone) may be required. + /// + internal const int ZombieRecycleAttemptsBeforeNotify = 2; + private CancellationTokenSource? _monitorCts; private string? _lastDeviceId; private string? _monitoredDeviceId; private bool _isRefreshing; private volatile bool _isReconnecting; private DateTime _lastKeepaliveTime; + private DateTime _firstSilenceObservation; + private int _consecutiveFailedRecycles; /// /// Gets or sets the currently selected Bluetooth device. @@ -490,6 +509,17 @@ private void StartConnectionMonitor(string deviceId, string deviceName) } } + // Zombie detection: Windows may keep AudioPlaybackConnection.State == Opened + // indefinitely while silently not routing any samples. The only reliable + // evidence of actual audio flow is a non-zero peak on the BT capture endpoint. + // When iOS is streaming (the user's confirmed scenario) the peak must be > 0; + // sustained zero over the silence threshold is treated as a zombie and triggers + // a targeted recycle. Users are notified once after two failed recycles. + if (!_isReconnecting) + { + await CheckForZombieAndMaybeRecycleAsync(deviceId); + } + continue; } @@ -541,11 +571,76 @@ private void StopConnectionMonitor() Microsoft.Win32.SystemEvents.SessionSwitch -= OnSessionSwitch; audioService.ConnectionLost -= OnConnectionLostFromService; _isReconnecting = false; + _firstSilenceObservation = DateTime.MinValue; + _consecutiveFailedRecycles = 0; _monitorCts?.Cancel(); _monitorCts?.Dispose(); _monitorCts = null; } + /// + /// Reads the peak level on the active capture endpoint and, if audio has been silent for + /// milliseconds despite the connection reporting + /// Opened, recycles the to + /// force Windows to rebuild the routing. After + /// consecutive failed recycles a balloon tip is surfaced once to prompt manual intervention; + /// subsequent recycles continue silently until the peak becomes non-zero again. + /// + /// The device identifier to reconnect to when the zombie is confirmed. + /// A task representing the asynchronous check. + private async Task CheckForZombieAndMaybeRecycleAsync(string deviceId) + { + var peak = audioService.GetActiveDevicePeakLevel(); + if (peak is null) + { + return; + } + + if (peak.Value > 0.0001f) + { + _firstSilenceObservation = DateTime.MinValue; + _consecutiveFailedRecycles = 0; + return; + } + + if (_firstSilenceObservation == DateTime.MinValue) + { + _firstSilenceObservation = DateTime.UtcNow; + return; + } + + var silenceMs = (DateTime.UtcNow - _firstSilenceObservation).TotalMilliseconds; + if (silenceMs < ZombieSilenceThresholdMs) + { + return; + } + + Debug.WriteLine($"[Zombie] Peak=0 over {(int)silenceMs}ms — recycling."); + _lastKeepaliveTime = DateTime.UtcNow; + _firstSilenceObservation = DateTime.MinValue; + _consecutiveFailedRecycles++; + + var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); + if (!ok) + { + dispatcherService.Invoke(() => + { + _isReconnecting = true; + DisconnectCommand.NotifyCanExecuteChanged(); + StatusText = "RECONNECTING..."; + IsConnected = false; + }); + } + + if (_consecutiveFailedRecycles == ZombieRecycleAttemptsBeforeNotify) + { + messenger.Send(new ShowBalloonRequestedMessage(new BalloonContent( + "Bluetooth-Audio", + "Kein Sound erkannt. Bitte am iPhone kurz die Audio-Ausgabe togglen.", + BalloonIcon.Warning))); + } + } + /// /// Handles Windows session switch events (screen unlock, console connect) by resetting /// the keepalive timer so the next monitor poll triggers an immediate connection recycle. diff --git a/EasyBluetoothAudio/Views/MainWindow.xaml b/EasyBluetoothAudio/Views/MainWindow.xaml index 009e20c..4743adc 100644 --- a/EasyBluetoothAudio/Views/MainWindow.xaml +++ b/EasyBluetoothAudio/Views/MainWindow.xaml @@ -33,9 +33,9 @@ LeftClickCommand="{Binding OpenCommand}" DoubleClickCommand="{Binding OpenCommand}"> - - - + + + diff --git a/tasks/lessons.md b/tasks/lessons.md new file mode 100644 index 0000000..cea7722 --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,7 @@ +# Lessons + +- When a health check is supposed to reason about one remote source, never fall back to an aggregated endpoint-level meter that can be kept alive by unrelated local audio. Prefer source- or session-specific telemetry, and return `null` when isolation is not yet trustworthy. +- For WinRT Bluetooth `\SNK` audio endpoints, do not assume `System.Devices.Aep.IsConnected` tracks an already-open `AudioPlaybackConnection`. If the audio connection state is authoritative and the WinRT property disagrees, log the mismatch and avoid letting the property short-circuit the audio-path diagnostics. +- Do not use `x:Reference` from a tray-hosted `ContextMenu` back to the `TaskbarIcon` while that icon is still being constructed. In WPF this can create a cyclic XAML dependency and crash startup; assign `ContextMenu.DataContext` after the window has been created instead. +- For tray-hosted WPF `ContextMenu` commands, prefer binding each `MenuItem.Command` via `PlacementTarget.DataContext.` on the ancestor `ContextMenu`. It avoids both startup-time path errors against the `TaskbarIcon` object and markup-time recursion from trying to push a shared `DataContext` into the menu. +- For Hardcodet tray menus, direct event wiring can be more reliable than WPF command bindings. If tray `MenuItem` commands regress across `ContextMenu`/`TaskbarIcon` binding changes, wire `Click` handlers after window creation and invoke the ViewModel commands explicitly. diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..ce2ea56 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,72 @@ +# Zombie-Peak-Refinement + +- [x] Review the existing `AudioService` peak-meter flow and confirm the current default-render aggregation fallback. +- [x] Define the refinement scope: keep the capture-endpoint best-case path, replace only the default-render fallback with render-session matching, and add one-shot session diagnostics plus teardown reset. +- [x] Add session-dump state tracking and swap the render fallback in `EasyBluetoothAudio/Services/AudioService.cs`. +- [x] Verify the change with a clean build and test run. + +## Review + +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green. +- Verification used a temporary output path outside the repo because the normal app output is currently in use by a running `EasyBluetoothAudio.exe`. + +## Follow-Up + +- [x] Analyze the user log and confirm why no `[PeakMeter]` lines appeared in the captured window. +- [x] Refine `AudioService.IsBluetoothDeviceConnectedAsync()` so an already opened `AudioPlaybackConnection` for the `\SNK` endpoint is not rejected solely because `System.Devices.Aep.IsConnected` reports `false`. +- [x] Re-run build and tests after the connectivity-gate refinement. + +- Follow-up review: +- The user log showed the first connect succeeded at `11:54:52.981`, but manual disconnect happened at `11:55:01.489`, which is before the monitor's first 10 s poll at `11:55:02.981`; therefore no `[PeakMeter]` lines could appear in that excerpt yet. +- The same log also showed `DeviceDiscover ... Connected: False` even after `AudioPlaybackConnection Success!`, confirming that the WinRT `System.Devices.Aep.IsConnected` property is unreliable for this active `\SNK` endpoint and would have short-circuited the zombie/session path on the first poll. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors after the refinement. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green after the refinement. + +## Tray Exit Fix + +- [x] Inspect the tray icon context menu binding and application exit path. +- [x] Route the tray context menu directly to the tray icon's `DataContext` and harden the explicit shutdown path. +- [x] Re-run build and tests for the tray-exit fix. + +- Tray-exit review: +- `EasyBluetoothAudio/Views/MainWindow.xaml` now binds the tray context menu directly to `TrayIcon.DataContext` via `x:Reference`, avoiding the brittle `PlacementTarget` lookup for the Hardcodet tray-hosted context menu. +- `EasyBluetoothAudio/App.xaml.cs` now stops the refresh timer, disposes the tray icon if needed, closes the main window, and only then calls `Current.Shutdown()` when `RequestExit` fires. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green. + +## Tray Exit Correction + +- [x] Analyze the startup crash caused by the tray-exit binding change. +- [x] Replace the cyclic XAML tray-menu binding with post-construction `ContextMenu.DataContext` wiring in `App.xaml.cs`. +- [x] Re-run build and tests after the startup-fix correction. + +- Tray-exit correction review: +- `EasyBluetoothAudio/Views/MainWindow.xaml` no longer uses `x:Reference TrayIcon` inside the tray-hosted `ContextMenu`, removing the XAML cycle that crashed startup. +- `EasyBluetoothAudio/App.xaml.cs` now assigns `mainWindow.TrayIcon.ContextMenu.DataContext = mainViewModel;` immediately after `mainWindow.DataContext = mainViewModel;`, so `OpenCommand` and `ExitCommand` still resolve correctly without markup-time recursion. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green. + +## Tray Binding Cleanup + +- [x] Analyze the remaining startup binding errors for `OpenCommand` and `ExitCommand`. +- [x] Replace post-construction tray-menu `DataContext` mutation with direct `PlacementTarget.DataContext.` bindings on the `MenuItem`s. +- [x] Re-run build and tests after the binding cleanup. + +- Tray-binding cleanup review: +- `EasyBluetoothAudio/Views/MainWindow.xaml` now binds tray menu commands directly through `PlacementTarget.DataContext.OpenCommand` and `PlacementTarget.DataContext.ExitCommand` on the ancestor `ContextMenu`, so the menu no longer tries to resolve commands on the `TaskbarIcon` object itself during startup. +- `EasyBluetoothAudio/App.xaml.cs` no longer mutates `TrayIcon.ContextMenu.DataContext` after construction, removing the source of the remaining startup binding noise while preserving the explicit shutdown path. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green. + +## Tray Exit Reliability + +- [x] Analyze the repeated tray-exit regression after the binding cleanup. +- [x] Replace tray-menu command bindings with direct click wiring to `MainViewModel.OpenCommand` / `ExitCommand` after window creation. +- [x] Re-run build and tests after the reliability fix. + +- Tray-exit reliability review: +- `EasyBluetoothAudio/Views/MainWindow.xaml` now exposes named tray menu items instead of relying on tray-hosted WPF command bindings for `Open` and `Exit`. +- `EasyBluetoothAudio/App.xaml.cs` wires `OpenTrayMenuItem.Click` and `ExitTrayMenuItem.Click` directly to `MainViewModel.OpenCommand` and `ExitCommand`, and logs `[App] RequestExit received from tray.` / `[App] OnExit code=...` so the next run will tell us unambiguously whether the exit click path fired. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green. From cbba2e6ae7e4d80413b1b0c7b14dc5b6366b9ee6 Mon Sep 17 00:00:00 2001 From: GorangN Date: Mon, 20 Apr 2026 12:39:29 +0200 Subject: [PATCH 07/13] Badge Compute settle delay before resetting disconnect timestamp --- EasyBluetoothAudio/Services/AudioService.cs | 15 ++++++++++----- tasks/lessons.md | 1 + tasks/todo.md | 13 +++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index bde3ed6..3e2606d 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -95,7 +95,7 @@ public async Task ConnectBluetoothAudioAsync(string deviceId) { try { - TearDownAudioConnection("pre-connect"); + TearDownAudioConnection("pre-connect", updateDisconnectTimestamp: false); Debug.WriteLine($"[ConnectBT] Connecting to audio endpoint {deviceId}..."); @@ -168,11 +168,13 @@ await _dispatcherService.InvokeAsync(async () => /// /// Unhooks the handler, disposes the current - /// connection and resets tracking fields. Always updates so - /// subsequent connect attempts apply the Settle delay against a correct timestamp. + /// connection and resets tracking fields. Real disconnect/failure teardowns update + /// so subsequent connect attempts apply the Settle delay + /// against the last actual disconnect instead of an internal pre-connect reset. /// /// Short tag describing why the teardown is happening (logged when a connection was actually open). - private void TearDownAudioConnection(string reason) + /// when this teardown represents a real disconnect or failed connect attempt; otherwise . + private void TearDownAudioConnection(string reason, bool updateDisconnectTimestamp = true) { if (_audioConnection != null) { @@ -186,7 +188,10 @@ private void TearDownAudioConnection(string reason) _activeDeviceId = null; _activeDeviceName = null; _hasDumpedSessions = false; - _lastDisconnectTime = DateTime.UtcNow; + if (updateDisconnectTimestamp) + { + _lastDisconnectTime = DateTime.UtcNow; + } } /// diff --git a/tasks/lessons.md b/tasks/lessons.md index cea7722..1aea255 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -5,3 +5,4 @@ - Do not use `x:Reference` from a tray-hosted `ContextMenu` back to the `TaskbarIcon` while that icon is still being constructed. In WPF this can create a cyclic XAML dependency and crash startup; assign `ContextMenu.DataContext` after the window has been created instead. - For tray-hosted WPF `ContextMenu` commands, prefer binding each `MenuItem.Command` via `PlacementTarget.DataContext.` on the ancestor `ContextMenu`. It avoids both startup-time path errors against the `TaskbarIcon` object and markup-time recursion from trying to push a shared `DataContext` into the menu. - For Hardcodet tray menus, direct event wiring can be more reliable than WPF command bindings. If tray `MenuItem` commands regress across `ContextMenu`/`TaskbarIcon` binding changes, wire `Click` handlers after window creation and invoke the ViewModel commands explicitly. +- When extracting connection cleanup into a shared teardown helper, preserve the semantics of any timing fields that distinguish an internal reset from a real disconnect. A pre-connect cleanup may need the same disposal/reset logic without rewriting the timestamp that drives reconnect backoff or settle-delay decisions. diff --git a/tasks/todo.md b/tasks/todo.md index ce2ea56..623173c 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -70,3 +70,16 @@ - `EasyBluetoothAudio/App.xaml.cs` wires `OpenTrayMenuItem.Click` and `ExitTrayMenuItem.Click` directly to `MainViewModel.OpenCommand` and `ExitCommand`, and logs `[App] RequestExit received from tray.` / `[App] OnExit code=...` so the next run will tell us unambiguously whether the exit click path fired. - `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors. - `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green. + +## Pre-Connect Settle Timing Review + +- [x] Validate whether `TearDownAudioConnection("pre-connect")` incorrectly resets `_lastDisconnectTime` before the settle-delay decision. +- [x] Preserve the teardown cleanup for `pre-connect` while keeping `_lastDisconnectTime` tied only to real disconnect/failure paths. +- [x] Re-run build and tests after the timestamp fix. + +- Pre-connect timing review: +- The review finding is valid: `ConnectBluetoothAudioAsync()` called `TearDownAudioConnection("pre-connect")` before evaluating `timeSinceDisconnect`, and the shared teardown helper unconditionally set `_lastDisconnectTime = DateTime.UtcNow`. +- That meant every connect/reconnect attempt observed an almost-zero disconnect age and therefore re-applied the full settle window, even when the previous physical disconnect had happened much earlier. +- `EasyBluetoothAudio/Services/AudioService.cs` now calls `TearDownAudioConnection("pre-connect", updateDisconnectTimestamp: false)`, so the pre-connect cleanup still disposes stale state without rewriting the settle reference timestamp used by the subsequent `needsSettle` check. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors after the timestamp fix. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green after the timestamp fix. From d6c53c7b62f8accb8e7f0ac0a5c1d5c839c77c30 Mon Sep 17 00:00:00 2001 From: GorangN Date: Mon, 20 Apr 2026 13:32:38 +0200 Subject: [PATCH 08/13] ZombieRecycleBackoffMs von 2 Minuten --- .../MainViewModelTests.cs | 68 +++++++++++++++++++ .../ViewModels/MainViewModel.cs | 21 +++++- tasks/lessons.md | 2 + tasks/todo.md | 26 +++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 62d6a3b..2bb415c 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -1,4 +1,5 @@ using System.Threading; +using System.Reflection; using CommunityToolkit.Mvvm.Messaging; using Moq; using EasyBluetoothAudio.ViewModels; @@ -58,6 +59,13 @@ private MainViewModel CreateViewModel() _messenger); } + private static void SetPrivateField(MainViewModel vm, string fieldName, T value) + { + var field = typeof(MainViewModel).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + field!.SetValue(vm, value); + } + /// /// Verifies default property values after construction. /// @@ -523,6 +531,66 @@ public async Task ConnectionLost_Event_DoesNotUpdateUi_WhenCrossCheckShowsStillC vm.Disconnect(); } + /// + /// Verifies that the zombie detector does not immediately recycle again while its + /// silence-recovery backoff window is still active. + /// + [Fact] + public async Task ZombieCheck_DoesNotRecycleAgain_WithinBackoffWindow() + { + _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns(0.0f); + _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + + var vm = CreateViewModel(); + SetPrivateField(vm, "_firstSilenceObservation", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieSilenceThresholdMs + 1000)); + SetPrivateField(vm, "_lastZombieRecycleTime", DateTime.UtcNow); + SetPrivateField(vm, "_consecutiveFailedRecycles", MainViewModel.ZombieRecycleAttemptsBeforeNotify); + + await vm.CheckForZombieAndMaybeRecycleAsync("1"); + + _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync("1"), Times.Never); + } + + /// + /// Verifies that the zombie detector is allowed to recycle again after the silence-recovery + /// backoff window has elapsed. + /// + [Fact] + public async Task ZombieCheck_RecyclesAgain_AfterBackoffElapsed() + { + _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns(0.0f); + _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + + var vm = CreateViewModel(); + SetPrivateField(vm, "_firstSilenceObservation", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieSilenceThresholdMs + 1000)); + SetPrivateField(vm, "_lastZombieRecycleTime", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieRecycleBackoffMs + 1000)); + SetPrivateField(vm, "_consecutiveFailedRecycles", MainViewModel.ZombieRecycleAttemptsBeforeNotify); + + await vm.CheckForZombieAndMaybeRecycleAsync("1"); + + _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync("1"), Times.Once); + } + + /// + /// Verifies that one failed zombie-recovery attempt does not immediately lock the monitor + /// into the long idle backoff window. + /// + [Fact] + public async Task ZombieCheck_RecyclesAgain_AfterFirstFailedRecovery() + { + _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns(0.0f); + _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + + var vm = CreateViewModel(); + SetPrivateField(vm, "_firstSilenceObservation", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieSilenceThresholdMs + 1000)); + SetPrivateField(vm, "_lastZombieRecycleTime", DateTime.UtcNow); + SetPrivateField(vm, "_consecutiveFailedRecycles", MainViewModel.ZombieRecycleAttemptsBeforeNotify - 1); + + await vm.CheckForZombieAndMaybeRecycleAsync("1"); + + _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync("1"), Times.Once); + } + /// /// Verifies that an available update is discovered and surfaced. /// diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index b60046f..f044fe9 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -53,6 +53,13 @@ public partial class MainViewModel( /// internal const int ZombieSilenceThresholdMs = 15_000; + /// + /// Minimum time in milliseconds that must pass after a silence-triggered recycle before the + /// monitor may derive another zombie verdict from continued silence. This reduces reconnect + /// storms during legitimate idle periods while still allowing periodic recovery attempts. + /// + internal const int ZombieRecycleBackoffMs = 2 * 60 * 1_000; + /// /// Number of consecutive zombie-triggered recycle attempts that may fail to restore audio /// before the user is notified via a balloon tip. After the notification the monitor keeps @@ -68,6 +75,7 @@ public partial class MainViewModel( private volatile bool _isReconnecting; private DateTime _lastKeepaliveTime; private DateTime _firstSilenceObservation; + private DateTime _lastZombieRecycleTime; private int _consecutiveFailedRecycles; /// @@ -572,6 +580,7 @@ private void StopConnectionMonitor() audioService.ConnectionLost -= OnConnectionLostFromService; _isReconnecting = false; _firstSilenceObservation = DateTime.MinValue; + _lastZombieRecycleTime = DateTime.MinValue; _consecutiveFailedRecycles = 0; _monitorCts?.Cancel(); _monitorCts?.Dispose(); @@ -588,7 +597,7 @@ private void StopConnectionMonitor() /// /// The device identifier to reconnect to when the zombie is confirmed. /// A task representing the asynchronous check. - private async Task CheckForZombieAndMaybeRecycleAsync(string deviceId) + internal async Task CheckForZombieAndMaybeRecycleAsync(string deviceId) { var peak = audioService.GetActiveDevicePeakLevel(); if (peak is null) @@ -599,10 +608,19 @@ private async Task CheckForZombieAndMaybeRecycleAsync(string deviceId) if (peak.Value > 0.0001f) { _firstSilenceObservation = DateTime.MinValue; + _lastZombieRecycleTime = DateTime.MinValue; _consecutiveFailedRecycles = 0; return; } + if (_consecutiveFailedRecycles >= ZombieRecycleAttemptsBeforeNotify + && _lastZombieRecycleTime != DateTime.MinValue + && (DateTime.UtcNow - _lastZombieRecycleTime).TotalMilliseconds < ZombieRecycleBackoffMs) + { + _firstSilenceObservation = DateTime.MinValue; + return; + } + if (_firstSilenceObservation == DateTime.MinValue) { _firstSilenceObservation = DateTime.UtcNow; @@ -618,6 +636,7 @@ private async Task CheckForZombieAndMaybeRecycleAsync(string deviceId) Debug.WriteLine($"[Zombie] Peak=0 over {(int)silenceMs}ms — recycling."); _lastKeepaliveTime = DateTime.UtcNow; _firstSilenceObservation = DateTime.MinValue; + _lastZombieRecycleTime = DateTime.UtcNow; _consecutiveFailedRecycles++; var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); diff --git a/tasks/lessons.md b/tasks/lessons.md index 1aea255..9732183 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -6,3 +6,5 @@ - For tray-hosted WPF `ContextMenu` commands, prefer binding each `MenuItem.Command` via `PlacementTarget.DataContext.` on the ancestor `ContextMenu`. It avoids both startup-time path errors against the `TaskbarIcon` object and markup-time recursion from trying to push a shared `DataContext` into the menu. - For Hardcodet tray menus, direct event wiring can be more reliable than WPF command bindings. If tray `MenuItem` commands regress across `ContextMenu`/`TaskbarIcon` binding changes, wire `Click` handlers after window creation and invoke the ViewModel commands explicitly. - When extracting connection cleanup into a shared teardown helper, preserve the semantics of any timing fields that distinguish an internal reset from a real disconnect. A pre-connect cleanup may need the same disposal/reset logic without rewriting the timestamp that drives reconnect backoff or settle-delay decisions. +- A zero peak alone cannot distinguish a real A2DP zombie from a user simply being idle. If silence is the only signal available, never let the monitor recycle on every threshold crossing; add a cooldown or refractory period so normal idle does not create reconnect storms. +- A cooldown on zombie recovery attempts must not suppress the first follow-up retry after a failed recycle. If the first reconnect does not restore audio, blocking all further retries for minutes strands the user in silence; only enter the long cooldown after enough failed attempts to conclude we are in a repeated-failure state rather than a one-off missed recovery. diff --git a/tasks/todo.md b/tasks/todo.md index 623173c..90cf755 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -83,3 +83,29 @@ - `EasyBluetoothAudio/Services/AudioService.cs` now calls `TearDownAudioConnection("pre-connect", updateDisconnectTimestamp: false)`, so the pre-connect cleanup still disposes stale state without rewriting the settle reference timestamp used by the subsequent `needsSettle` check. - `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors after the timestamp fix. - `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 91/91 tests green after the timestamp fix. + +## Idle Zombie Backoff + +- [x] Analyze the idle log and confirm whether repeated silence-triggered zombie recycles are delaying resume after inactivity. +- [x] Add a cooldown so continued silence after one zombie recycle does not immediately trigger the next recycle again. +- [x] Re-run build and tests after the idle-backoff refinement. + +- Idle-zombie backoff review: +- The new user log shows the matched iPhone session staying at `peak=0,0000` for minutes while the phone is simply idle, and the monitor therefore recycles every roughly 30 seconds (`12:46:56`, `12:47:28`, `12:48:00`, `12:48:32`, `12:49:03`). +- That repeated recycle pattern is enough to explain why resuming audio can feel delayed: the app keeps tearing down and reopening the route even though the silence is not proof of a zombie. +- `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now adds a `ZombieRecycleBackoffMs` window so one silence-triggered recycle is allowed, but continued silence must remain stable through a longer cooldown before another recycle is even considered. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors after the idle-backoff refinement. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 93/93 tests green after the idle-backoff refinement. + +## Zombie Backoff Regression + +- [x] Analyze the new log and confirm whether the idle-backoff change can leave the route silent after a failed first zombie recycle. +- [x] Refine the backoff so the first failed zombie recovery may still retry promptly, while long repeated recycle storms remain suppressed afterwards. +- [x] Re-run build and tests after the regression fix. + +- Zombie-backoff regression review: +- The new log shows a healthy stream with non-zero peaks up to `13:23:17`, then a real zero-peak transition, then one zombie recycle at `13:23:47`, and afterwards sustained `peak=0,0000` with no further recovery attempt. +- That behavior matches the current backoff exactly: after the first recycle, `_lastZombieRecycleTime` blocks all further zombie retries for two minutes, so a failed first recovery leaves the user stuck in silence. +- `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now applies the long `ZombieRecycleBackoffMs` cooldown only after the monitor has already accumulated the configured number of failed zombie recycles, instead of after the very first attempt. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors after the regression fix. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 94/94 tests green after the regression fix. From 487166dec48bafeef0be8d418dd908d21db63dd3 Mon Sep 17 00:00:00 2001 From: GorangN Date: Mon, 20 Apr 2026 17:46:29 +0200 Subject: [PATCH 09/13] Recovery-UX: Manuelles Reconnect & bounded Auto-Reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Automatische Zombie-Erkennung und Peak-Metering entfernt (NAudio-Abhängigkeit entfällt) - Recovery-Logik auf manuelle UX umgestellt: „Reconnect“-Button im Hauptfenster und Tray-Menü - Automatischer Reconnect nur noch bei echtem Verbindungsverlust oder Initial-Fehlschlag, max. 2 Versuche à 3s - Nach erschöpften Versuchen klare Nutzerführung: „AUDIO LOST - CLICK CONNECT“ - ViewModel und Service-Interfaces entsprechend vereinfacht und restrukturiert - UI: Split-Button-Animation für „CONNECT“ → „RECONNECT“/„DISCONNECT“ - Tests umfassend angepasst und erweitert, keine versteckten Idle-Recoverys mehr - Lessons- und Todo-Dokumentation aktualisiert - Build und Tests fehlerfrei --- .../MainViewModelTests.cs | 560 +++++++++--------- EasyBluetoothAudio/App.xaml.cs | 7 + EasyBluetoothAudio/EasyBluetoothAudio.csproj | 1 - .../Messages/ShowBalloonRequestedMessage.cs | 2 +- EasyBluetoothAudio/Services/AudioService.cs | 201 +------ .../Services/Interfaces/IAudioService.cs | 25 +- .../ViewModels/MainViewModel.cs | 535 ++++++++++------- .../Views/BluetoothConfigView.xaml | 254 ++++++-- EasyBluetoothAudio/Views/MainWindow.xaml | 1 + tasks/lessons.md | 4 +- tasks/todo.md | 54 ++ 11 files changed, 903 insertions(+), 741 deletions(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 2bb415c..d3a4e5e 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -1,11 +1,11 @@ using System.Threading; -using System.Reflection; using CommunityToolkit.Mvvm.Messaging; -using Moq; -using EasyBluetoothAudio.ViewModels; +using EasyBluetoothAudio.Messages; +using EasyBluetoothAudio.Models; using EasyBluetoothAudio.Services; using EasyBluetoothAudio.Services.Interfaces; -using EasyBluetoothAudio.Models; +using EasyBluetoothAudio.ViewModels; +using Moq; namespace EasyBluetoothAudio.Tests; @@ -37,47 +37,42 @@ public MainViewModelTests() _qualityServiceMock = new Mock(); _messenger = new WeakReferenceMessenger(); - _devicePickerServiceMock.Setup(s => s.ShowAsync()).Returns(Task.CompletedTask); - _settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings()); - _dispatcherServiceMock.Setup(s => s.Invoke(It.IsAny())).Callback(a => a()); - // Default the peak meter to null so the monitor's zombie branch stays inert in tests - // that do not exercise it. Tests that need zombie behavior can override this setup. - _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns((float?)null); + _devicePickerServiceMock.Setup(service => service.ShowAsync()).Returns(Task.CompletedTask); + _settingsServiceMock.Setup(service => service.Load()).Returns(new AppSettings()); + _dispatcherServiceMock.Setup(service => service.Invoke(It.IsAny())).Callback(action => action()); } private MainViewModel CreateViewModel() { - var settingsVm = new SettingsViewModel(_settingsServiceMock.Object, _startupServiceMock.Object, _qualityServiceMock.Object, _messenger); - var updateVm = new UpdateViewModel(_updateServiceMock.Object); + var settingsViewModel = new SettingsViewModel( + _settingsServiceMock.Object, + _startupServiceMock.Object, + _qualityServiceMock.Object, + _messenger); + var updateViewModel = new UpdateViewModel(_updateServiceMock.Object); + return new MainViewModel( _audioServiceMock.Object, _devicePickerServiceMock.Object, - updateVm, - settingsVm, + updateViewModel, + settingsViewModel, _settingsServiceMock.Object, _dispatcherServiceMock.Object, _messenger); } - private static void SetPrivateField(MainViewModel vm, string fieldName, T value) - { - var field = typeof(MainViewModel).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(field); - field!.SetValue(vm, value); - } - /// /// Verifies default property values after construction. /// [Fact] public void Constructor_SetsDefaultState() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); - Assert.False(vm.IsConnected); - Assert.False(vm.IsBusy); - Assert.Equal("IDLE", vm.StatusText); - Assert.Empty(vm.BluetoothDevices); + Assert.False(viewModel.IsConnected); + Assert.False(viewModel.IsBusy); + Assert.Equal("IDLE", viewModel.StatusText); + Assert.Empty(viewModel.BluetoothDevices); } /// @@ -91,14 +86,14 @@ public async Task RefreshDevices_PopulatesCollection() new() { Name = "iPhone", Id = "1", IsPhoneOrComputer = true }, new() { Name = "Laptop", Id = "2", IsPhoneOrComputer = true } }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(devices); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(devices); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); - Assert.Equal(2, vm.BluetoothDevices.Count); - Assert.Contains(vm.BluetoothDevices, d => d.Name == "iPhone"); - Assert.Contains(vm.BluetoothDevices, d => d.Name == "Laptop"); + Assert.Equal(2, viewModel.BluetoothDevices.Count); + Assert.Contains(viewModel.BluetoothDevices, device => device.Name == "iPhone"); + Assert.Contains(viewModel.BluetoothDevices, device => device.Name == "Laptop"); } /// @@ -112,16 +107,16 @@ public async Task RefreshDevices_PreservesSelection() new() { Name = "iPhone", Id = "1" }, new() { Name = "Laptop", Id = "2" } }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(devices); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(devices); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - vm.SelectedBluetoothDevice = vm.BluetoothDevices.First(d => d.Id == "2"); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + viewModel.SelectedBluetoothDevice = viewModel.BluetoothDevices.First(device => device.Id == "2"); - await vm.RefreshDevicesAsync(); + await viewModel.RefreshDevicesAsync(); - Assert.NotNull(vm.SelectedBluetoothDevice); - Assert.Equal("2", vm.SelectedBluetoothDevice!.Id); + Assert.NotNull(viewModel.SelectedBluetoothDevice); + Assert.Equal("2", viewModel.SelectedBluetoothDevice!.Id); } /// @@ -135,13 +130,13 @@ public async Task RefreshDevices_SelectsFirst_WhenNoPreviousSelection() new() { Name = "iPhone", Id = "1" }, new() { Name = "Laptop", Id = "2" } }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(devices); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(devices); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); - Assert.NotNull(vm.SelectedBluetoothDevice); - Assert.Equal("1", vm.SelectedBluetoothDevice!.Id); + Assert.NotNull(viewModel.SelectedBluetoothDevice); + Assert.Equal("1", viewModel.SelectedBluetoothDevice!.Id); } /// @@ -150,12 +145,12 @@ public async Task RefreshDevices_SelectsFirst_WhenNoPreviousSelection() [Fact] public async Task RefreshDevices_SetsErrorStatus_OnException() { - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ThrowsAsync(new Exception("fail")); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ThrowsAsync(new Exception("fail")); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); - Assert.Equal("SCAN ERROR", vm.StatusText); + Assert.Equal("SCAN ERROR", viewModel.StatusText); } /// @@ -169,12 +164,12 @@ public async Task RefreshDevices_DoesNotTriggerSettingSave() new() { Name = "iPhone", Id = "1" }, new() { Name = "Laptop", Id = "2" } }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(devices); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(devices); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); - _settingsServiceMock.Verify(s => s.Save(It.IsAny()), Times.Never); + _settingsServiceMock.Verify(service => service.Save(It.IsAny()), Times.Never); } /// @@ -184,34 +179,63 @@ public async Task RefreshDevices_DoesNotTriggerSettingSave() public async Task ConnectAsync_SetsStatusAndIsConnected_OnSuccess() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - Assert.True(vm.IsConnected); - Assert.Equal("STREAMING ACTIVE", vm.StatusText); - Assert.False(vm.IsBusy); + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + Assert.False(viewModel.IsBusy); } /// - /// Verifies behavior when the audio service reports connection failure. + /// Verifies that a failed initial connect uses the bounded retry budget and then falls back to manual reconnect. /// [Fact] - public async Task ConnectAsync_SetsErrorStatus_OnConnectionFailure() + public async Task ConnectAsync_UsesBoundedRetries_AndFallsBackToManualReconnect() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(false); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(false); + + var balloonCount = 0; + _messenger.Register(this, (_, _) => balloonCount++); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + Assert.False(viewModel.IsConnected); + Assert.Equal("AUDIO LOST - CLICK CONNECT", viewModel.StatusText); + Assert.Equal(1, balloonCount); + _audioServiceMock.Verify( + service => service.ConnectBluetoothAudioAsync("1"), + Times.Exactly(MainViewModel.AutoReconnectAttemptLimit + 1)); + } + + /// + /// Verifies that a failed initial connect can recover on a bounded automatic retry. + /// + [Fact] + public async Task ConnectAsync_RetriesAfterInitialFailure_AndRecovers() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + + var connectResults = new Queue(new[] { false, true }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(() => connectResults.Dequeue()); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - Assert.False(vm.IsConnected); - Assert.Equal("WAITING FOR SOURCE...", vm.StatusText); + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(2)); } /// @@ -220,12 +244,12 @@ public async Task ConnectAsync_SetsErrorStatus_OnConnectionFailure() [Fact] public async Task ConnectAsync_DoesNothing_WhenNoDeviceSelected() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); - await vm.ConnectAsync(); + await viewModel.ConnectAsync(); - Assert.False(vm.IsConnected); - _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync(It.IsAny()), Times.Never); + Assert.False(viewModel.IsConnected); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync(It.IsAny()), Times.Never); } /// @@ -235,15 +259,15 @@ public async Task ConnectAsync_DoesNothing_WhenNoDeviceSelected() public async Task ConnectAsync_SetsErrorStatus_OnException() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ThrowsAsync(new Exception("timeout")); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ThrowsAsync(new Exception("timeout")); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - Assert.False(vm.IsConnected); - Assert.StartsWith("ERROR:", vm.StatusText); + Assert.False(viewModel.IsConnected); + Assert.StartsWith("ERROR:", viewModel.StatusText); } /// @@ -253,18 +277,58 @@ public async Task ConnectAsync_SetsErrorStatus_OnException() public async Task Disconnect_DisconnectsAndSetsStatus() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - vm.Disconnect(); + viewModel.Disconnect(); - Assert.False(vm.IsConnected); - Assert.Equal("DISCONNECTED", vm.StatusText); - _audioServiceMock.Verify(s => s.Disconnect(It.IsAny()), Times.Once); + Assert.False(viewModel.IsConnected); + Assert.Equal("DISCONNECTED", viewModel.StatusText); + _audioServiceMock.Verify(service => service.Disconnect(It.IsAny()), Times.Once); + } + + /// + /// Verifies that manual reconnect can connect a selected device even while disconnected. + /// + [Fact] + public async Task ReconnectAsync_ReconnectsSelectedDevice_WhenDisconnected() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ReconnectAsync(); + + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.Disconnect("manual-recover"), Times.Never); + } + + /// + /// Verifies that manual reconnect explicitly tears down the active route before reconnecting. + /// + [Fact] + public async Task ReconnectAsync_CyclesConnection_WhenAlreadyConnected() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + await viewModel.ReconnectAsync(); + + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.Disconnect("manual-recover", true), Times.Once); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(2)); } /// @@ -273,9 +337,9 @@ public async Task Disconnect_DisconnectsAndSetsStatus() [Fact] public void CanConnect_FalseWhenNoDeviceSelected() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); - Assert.False(vm.ConnectCommand.CanExecute(null)); + Assert.False(viewModel.ConnectCommand.CanExecute(null)); } /// @@ -285,14 +349,14 @@ public void CanConnect_FalseWhenNoDeviceSelected() public async Task CanConnect_FalseWhenAlreadyConnected() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - Assert.False(vm.ConnectCommand.CanExecute(null)); + Assert.False(viewModel.ConnectCommand.CanExecute(null)); } /// @@ -301,9 +365,24 @@ public async Task CanConnect_FalseWhenAlreadyConnected() [Fact] public void CanDisconnect_FalseWhenNotConnected() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); + + Assert.False(viewModel.DisconnectCommand.CanExecute(null)); + } + + /// + /// Verifies that manual reconnect is available when a device is selected and no operation is running. + /// + [Fact] + public async Task CanReconnect_TrueWhenDeviceSelected() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - Assert.False(vm.DisconnectCommand.CanExecute(null)); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + + Assert.True(viewModel.ReconnectCommand.CanExecute(null)); } /// @@ -312,11 +391,11 @@ public void CanDisconnect_FalseWhenNotConnected() [Fact] public async Task OpenBluetoothSettingsCommand_CallsDevicePickerService() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); - await vm.OpenBluetoothSettingsCommand.ExecuteAsync(null); + await viewModel.OpenBluetoothSettingsCommand.ExecuteAsync(null); - _devicePickerServiceMock.Verify(p => p.ShowAsync(), Times.Once); + _devicePickerServiceMock.Verify(service => service.ShowAsync(), Times.Once); } /// @@ -325,14 +404,14 @@ public async Task OpenBluetoothSettingsCommand_CallsDevicePickerService() [Fact] public void StatusText_RaisesPropertyChanged() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); string? raisedProperty = null; - vm.PropertyChanged += (s, e) => raisedProperty = e.PropertyName; + viewModel.PropertyChanged += (_, args) => raisedProperty = args.PropertyName; - vm.StatusText = "TESTING"; + viewModel.StatusText = "TESTING"; Assert.Equal("StatusText", raisedProperty); - Assert.Equal("TESTING", vm.StatusText); + Assert.Equal("TESTING", viewModel.StatusText); } /// @@ -341,11 +420,11 @@ public void StatusText_RaisesPropertyChanged() [Fact] public void SelectedBluetoothDevice_RaisesPropertyChanged() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); string? raisedProperty = null; - vm.PropertyChanged += (s, e) => raisedProperty = e.PropertyName; + viewModel.PropertyChanged += (_, args) => raisedProperty = args.PropertyName; - vm.SelectedBluetoothDevice = new BluetoothDevice { Name = "Test", Id = "1" }; + viewModel.SelectedBluetoothDevice = new BluetoothDevice { Name = "Test", Id = "1" }; Assert.Equal("SelectedBluetoothDevice", raisedProperty); } @@ -356,11 +435,11 @@ public void SelectedBluetoothDevice_RaisesPropertyChanged() [Fact] public void RequestShow_RaisedByOpenCommand() { - var vm = CreateViewModel(); - bool raised = false; - vm.RequestShow += () => raised = true; + var viewModel = CreateViewModel(); + var raised = false; + viewModel.RequestShow += () => raised = true; - vm.OpenCommand.Execute(null); + viewModel.OpenCommand.Execute(null); Assert.True(raised); } @@ -371,224 +450,136 @@ public void RequestShow_RaisedByOpenCommand() [Fact] public void RequestExit_RaisedByExitCommand() { - var vm = CreateViewModel(); - bool raised = false; - vm.RequestExit += () => raised = true; + var viewModel = CreateViewModel(); + var raised = false; + viewModel.RequestExit += () => raised = true; - vm.ExitCommand.Execute(null); + viewModel.ExitCommand.Execute(null); Assert.True(raised); } /// - /// Verifies that the connection monitor detects a disconnection and sets reconnecting status. + /// Verifies that the monitor successfully reconnects after a real connection-lost event. /// [Fact] - public async Task Monitor_DetectsDisconnectionAndSetsReconnecting() + public async Task ConnectionLost_Event_RunsBoundedReconnect_AndRecovers() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - - var connectCallCount = 0; - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")) - .ReturnsAsync(() => - { - connectCallCount++; - // First call succeeds (initial connect), subsequent calls fail (reconnect attempts) - return connectCallCount <= 1; - }); - - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")) - .ReturnsAsync(false); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var connectResults = new Queue(new[] { true, true }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(() => connectResults.Dequeue()); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); - await Task.Delay(11000); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - Assert.Equal("RECONNECTING...", vm.StatusText); - Assert.False(vm.IsConnected); + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); + await Task.Delay(MainViewModel.AutoReconnectDelayMs + 500); - vm.Disconnect(); + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.Disconnect("service-connection-lost"), Times.Once); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(2)); } /// - /// Verifies that the connection monitor stops polling after disconnect. + /// Verifies that the monitor stops retrying after the bounded reconnect budget is exhausted. /// [Fact] - public async Task Monitor_StopsOnDisconnect() + public async Task ConnectionLost_Event_StopsAfterRetryBudget_AndFallsBackToManualReconnect() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.SetupSequence(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(true) + .ReturnsAsync(false) + .ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var balloonCount = 0; + _messenger.Register(this, (_, _) => balloonCount++); - vm.Disconnect(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - await Task.Delay(6000); + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); + await Task.Delay((MainViewModel.AutoReconnectDelayMs * MainViewModel.AutoReconnectAttemptLimit) + 1000); + await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); - _audioServiceMock.Verify(s => s.IsBluetoothDeviceConnectedAsync(It.IsAny()), Times.Never); + Assert.False(viewModel.IsConnected); + Assert.Equal("AUDIO LOST - CLICK CONNECT", viewModel.StatusText); + Assert.Equal(1, balloonCount); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(3)); } /// - /// Verifies that the connection monitor successfully reconnects. + /// Verifies that the monitor does not perform hidden idle-time recycling while the device stays connected. /// [Fact] - public async Task Monitor_ReconnectSucceeds_ResumesStreaming() + public async Task Monitor_DoesNothingDuringIdle_WhenConnectionRemainsHealthy() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - - var callCount = 0; - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")) - .ReturnsAsync(() => - { - callCount++; - return callCount > 1; - }); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); - var vm = CreateViewModel(); - _settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings()); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); - // Monitor poll 10s + reconnect attempt + margin = ~13s - await Task.Delay(13000); - - Assert.True(vm.IsConnected); - Assert.Equal("STREAMING ACTIVE", vm.StatusText); - - vm.Disconnect(); + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Once); } /// - /// Verifies that raising ConnectionLost immediately sets IsConnected to false - /// and updates StatusText to "RECONNECTING..." without waiting for the next poll, - /// provided the cross-check confirms the device is actually disconnected. + /// Verifies that the connection monitor stops polling after disconnect. /// [Fact] - public async Task ConnectionLost_Event_UpdatesUiImmediately() + public async Task Monitor_StopsOnDisconnect() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - - // After initial connect the device is considered connected; once the event fires, - // the cross-check must also confirm the disconnect. - var connected = true; - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")) - .ReturnsAsync(() => connected); - - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); - Assert.True(vm.IsConnected); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - // Simulate OS-level disconnect: cross-check will now return false - connected = false; - _audioServiceMock.Raise(s => s.ConnectionLost += null, EventArgs.Empty); - await Task.Delay(200); - - Assert.False(vm.IsConnected); - Assert.Equal("RECONNECTING...", vm.StatusText); + viewModel.Disconnect(); + await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); - vm.Disconnect(); + _audioServiceMock.Verify(service => service.IsBluetoothDeviceConnectedAsync(It.IsAny()), Times.Never); } /// - /// Verifies that raising ConnectionLost does NOT update the UI when the cross-check - /// confirms the device is still connected (transient audio-endpoint state change, not a real disconnect). + /// Verifies that a transient ConnectionLost event does not update the UI when the cross-check + /// confirms the device is still connected. /// [Fact] public async Task ConnectionLost_Event_DoesNotUpdateUi_WhenCrossCheckShowsStillConnected() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; - _audioServiceMock.Setup(s => s.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - // Cross-check always confirms the device is still connected - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); - - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); - Assert.True(vm.IsConnected); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - _audioServiceMock.Raise(s => s.ConnectionLost += null, EventArgs.Empty); + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); await Task.Delay(200); - // UI must remain stable — no false "RECONNECTING..." from a transient event - Assert.True(vm.IsConnected); - Assert.Equal("STREAMING ACTIVE", vm.StatusText); - - vm.Disconnect(); - } - - /// - /// Verifies that the zombie detector does not immediately recycle again while its - /// silence-recovery backoff window is still active. - /// - [Fact] - public async Task ZombieCheck_DoesNotRecycleAgain_WithinBackoffWindow() - { - _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns(0.0f); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - - var vm = CreateViewModel(); - SetPrivateField(vm, "_firstSilenceObservation", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieSilenceThresholdMs + 1000)); - SetPrivateField(vm, "_lastZombieRecycleTime", DateTime.UtcNow); - SetPrivateField(vm, "_consecutiveFailedRecycles", MainViewModel.ZombieRecycleAttemptsBeforeNotify); - - await vm.CheckForZombieAndMaybeRecycleAsync("1"); - - _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync("1"), Times.Never); - } - - /// - /// Verifies that the zombie detector is allowed to recycle again after the silence-recovery - /// backoff window has elapsed. - /// - [Fact] - public async Task ZombieCheck_RecyclesAgain_AfterBackoffElapsed() - { - _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns(0.0f); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - - var vm = CreateViewModel(); - SetPrivateField(vm, "_firstSilenceObservation", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieSilenceThresholdMs + 1000)); - SetPrivateField(vm, "_lastZombieRecycleTime", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieRecycleBackoffMs + 1000)); - SetPrivateField(vm, "_consecutiveFailedRecycles", MainViewModel.ZombieRecycleAttemptsBeforeNotify); - - await vm.CheckForZombieAndMaybeRecycleAsync("1"); - - _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync("1"), Times.Once); - } - - /// - /// Verifies that one failed zombie-recovery attempt does not immediately lock the monitor - /// into the long idle backoff window. - /// - [Fact] - public async Task ZombieCheck_RecyclesAgain_AfterFirstFailedRecovery() - { - _audioServiceMock.Setup(s => s.GetActiveDevicePeakLevel()).Returns(0.0f); - _audioServiceMock.Setup(s => s.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - - var vm = CreateViewModel(); - SetPrivateField(vm, "_firstSilenceObservation", DateTime.UtcNow - TimeSpan.FromMilliseconds(MainViewModel.ZombieSilenceThresholdMs + 1000)); - SetPrivateField(vm, "_lastZombieRecycleTime", DateTime.UtcNow); - SetPrivateField(vm, "_consecutiveFailedRecycles", MainViewModel.ZombieRecycleAttemptsBeforeNotify - 1); - - await vm.CheckForZombieAndMaybeRecycleAsync("1"); - - _audioServiceMock.Verify(s => s.ConnectBluetoothAudioAsync("1"), Times.Once); + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); } /// @@ -598,13 +589,13 @@ public async Task ZombieCheck_RecyclesAgain_AfterFirstFailedRecovery() public async Task CheckForUpdate_FindsNewVersion() { var update = new UpdateInfo("v2.0.0", "2.0.0", "http://url", "Notes"); - _updateServiceMock.Setup(s => s.CheckForUpdateAsync(It.IsAny())) + _updateServiceMock.Setup(service => service.CheckForUpdateAsync(It.IsAny())) .ReturnsAsync(update); - var vm = CreateViewModel(); - await vm.Updater.CheckForUpdateAsync(); + var viewModel = CreateViewModel(); + await viewModel.Updater.CheckForUpdateAsync(); - Assert.True(vm.Updater.UpdateAvailable); + Assert.True(viewModel.Updater.UpdateAvailable); } /// @@ -614,14 +605,13 @@ public async Task CheckForUpdate_FindsNewVersion() public async Task InstallUpdate_CallsService() { var update = new UpdateInfo("v2.0.0", "2.0.0", "http://url", "Notes"); - _updateServiceMock.Setup(s => s.CheckForUpdateAsync(It.IsAny())) + _updateServiceMock.Setup(service => service.CheckForUpdateAsync(It.IsAny())) .ReturnsAsync(update); - var vm = CreateViewModel(); - await vm.Updater.CheckForUpdateAsync(); - - await vm.Updater.InstallUpdateAsync(); + var viewModel = CreateViewModel(); + await viewModel.Updater.CheckForUpdateAsync(); + await viewModel.Updater.InstallUpdateAsync(); - _updateServiceMock.Verify(s => s.DownloadAndInstallAsync(update, It.IsAny()), Times.Once); + _updateServiceMock.Verify(service => service.DownloadAndInstallAsync(update, It.IsAny()), Times.Once); } } diff --git a/EasyBluetoothAudio/App.xaml.cs b/EasyBluetoothAudio/App.xaml.cs index a01a5f8..25a9cce 100644 --- a/EasyBluetoothAudio/App.xaml.cs +++ b/EasyBluetoothAudio/App.xaml.cs @@ -100,6 +100,13 @@ protected override void OnStartup(StartupEventArgs e) mainViewModel.OpenCommand.Execute(null); } }; + mainWindow.ReconnectTrayMenuItem.Click += async (_, _) => + { + if (mainViewModel.ReconnectCommand.CanExecute(null)) + { + await mainViewModel.ReconnectCommand.ExecuteAsync(null); + } + }; mainWindow.ExitTrayMenuItem.Click += (_, _) => { if (mainViewModel.ExitCommand.CanExecute(null)) diff --git a/EasyBluetoothAudio/EasyBluetoothAudio.csproj b/EasyBluetoothAudio/EasyBluetoothAudio.csproj index 2519bc0..9a58c09 100644 --- a/EasyBluetoothAudio/EasyBluetoothAudio.csproj +++ b/EasyBluetoothAudio/EasyBluetoothAudio.csproj @@ -31,7 +31,6 @@ - diff --git a/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs b/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs index 5ebf205..17fd2ae 100644 --- a/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs +++ b/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs @@ -13,7 +13,7 @@ public sealed record BalloonContent(string Title, string Body, BalloonIcon Icon) /// /// Messenger message requesting that the tray icon display a balloon tip. -/// Used by non-UI layers (such as the ViewModel's zombie-recovery branch) to surface +/// Used by non-UI layers (such as the ViewModel's recovery flow) to surface /// user-facing notifications without holding a reference to the TrayIcon instance. /// /// The balloon content to show. diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index 3e2606d..d8961fd 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -2,11 +2,10 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; -using NAudio.CoreAudioApi; -using Windows.Devices.Enumeration; -using Windows.Media.Audio; using EasyBluetoothAudio.Models; using EasyBluetoothAudio.Services.Interfaces; +using Windows.Devices.Enumeration; +using Windows.Media.Audio; namespace EasyBluetoothAudio.Services; @@ -20,8 +19,10 @@ public class AudioService : IAudioService, IDisposable /// Milliseconds to wait between Start() and OpenAsync() to allow Windows /// to complete teardown of the previous before the /// new audio endpoint negotiates A2DP with the remote device. - /// Applied on the first connect (stale endpoint from prior session) and after a disconnect - /// where the physical Bluetooth link was torn down. + /// Applied on the first connect, or when the last real disconnect happened within + /// this window. Internal recycles (pre-connect reset, user-triggered reconnect while + /// the phone stays up) do not update , so they bypass the + /// settle. /// internal const int SettleDelayMs = 5_000; @@ -29,9 +30,7 @@ public class AudioService : IAudioService, IDisposable private AudioPlaybackConnection? _audioConnection; private volatile bool _isAudioConnectionActive; private string? _activeDeviceId; - private string? _activeDeviceName; private bool _hasConnectedBefore; - private bool _hasDumpedSessions; private DateTime _lastDisconnectTime = DateTime.UtcNow; /// @@ -56,30 +55,31 @@ public async Task> GetBluetoothDevicesAsync() string[] requestedProperties = { "System.Devices.Aep.IsConnected" }; var devices = await DeviceInformation.FindAllAsync(selector, requestedProperties); - foreach (var d in devices) + foreach (var device in devices) { - bool connected = false; + var connected = false; try { - if (d.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) && value is bool isConnected) + if (device.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) + && value is bool isConnected) { connected = isConnected; } } catch (Exception ex) { - Debug.WriteLine($"[DeviceDiscover] Error retrieving properties for {d.Name}: {ex.Message}"); + Debug.WriteLine($"[DeviceDiscover] Error retrieving properties for {device.Name}: {ex.Message}"); } result.Add(new BluetoothDevice { - Name = d.Name, - Id = d.Id, + Name = device.Name, + Id = device.Id, IsConnected = connected, IsPhoneOrComputer = true }); - Debug.WriteLine($"[DeviceDiscover] Found Source: {d.Name} (ID: {d.Id}, Connected: {connected})"); + Debug.WriteLine($"[DeviceDiscover] Found Source: {device.Name} (ID: {device.Id}, Connected: {connected})"); } } catch (Exception ex) @@ -99,18 +99,15 @@ public async Task ConnectBluetoothAudioAsync(string deviceId) Debug.WriteLine($"[ConnectBT] Connecting to audio endpoint {deviceId}..."); - // A settle delay between Start() and OpenAsync() is required whenever the previous - // audio endpoint was torn down less than SettleDelayMs ago — regardless of whether - // the physical BT link is still up. In the common zombie-state scenario the ACL link - // stays connected while Windows rebuilds the A2DP endpoint, and skipping the settle - // there is what produces the UnknownFailure burst in the retry loop. + // Settle only when the last real disconnect was within SettleDelayMs, or on the + // first connect. Internal recycles (pre-connect teardown, manual-recover while the + // phone stays up) leave _lastDisconnectTime untouched, so timeSinceDisconnect + // reflects the last actual BT-layer loss rather than our own audio-endpoint reset. + // AEP.IsConnected cannot be used here: it reports False for \SNK endpoints even + // when the phone is still BT-connected (see lessons.md). var timeSinceDisconnect = (DateTime.UtcNow - _lastDisconnectTime).TotalMilliseconds; var needsSettle = !_hasConnectedBefore || timeSinceDisconnect < SettleDelayMs; - // All WinRT audio API calls must run on the UI (STA) thread. - // When called from a background thread (e.g. the reconnect monitor), Start() and - // OpenAsync() fail silently on the MTA thread pool — dispatching everything here - // ensures the same threading behaviour as a user-initiated connect. AudioPlaybackConnectionOpenResult? openResult = null; await _dispatcherService.InvokeAsync(async () => { @@ -123,8 +120,6 @@ await _dispatcherService.InvokeAsync(async () => _audioConnection.StateChanged += OnAudioConnectionStateChanged; _audioConnection.Start(); - // Allow Windows to complete teardown of the previous audio endpoint before - // the new connection negotiates A2DP with the remote device. if (needsSettle) { var settleRemaining = SettleDelayMs - (int)timeSinceDisconnect; @@ -148,9 +143,9 @@ await _dispatcherService.InvokeAsync(async () => { Debug.WriteLine("[ConnectBT] AudioPlaybackConnection Success!"); _activeDeviceId = deviceId; - _activeDeviceName = await TryGetDeviceFriendlyNameAsync(deviceId); _isAudioConnectionActive = true; _hasConnectedBefore = true; + _lastDisconnectTime = DateTime.MinValue; return true; } @@ -186,36 +181,12 @@ private void TearDownAudioConnection(string reason, bool updateDisconnectTimesta _isAudioConnectionActive = false; _activeDeviceId = null; - _activeDeviceName = null; - _hasDumpedSessions = false; if (updateDisconnectTimestamp) { _lastDisconnectTime = DateTime.UtcNow; } } - /// - /// Fetches the human-readable name for a given WinRT device ID, used to match the - /// Bluetooth capture endpoint exposed by CoreAudio (whose FriendlyName embeds - /// the same device name). Returns when the lookup fails so - /// the peak-meter heuristic falls back to "no judgment" rather than a false zombie verdict. - /// - /// The WinRT device identifier of the connected Bluetooth source. - /// The friendly name, or on failure. - private static async Task TryGetDeviceFriendlyNameAsync(string deviceId) - { - try - { - var info = await DeviceInformation.CreateFromIdAsync(deviceId); - return string.IsNullOrWhiteSpace(info?.Name) ? null : info.Name; - } - catch (Exception ex) - { - Debug.WriteLine($"[ConnectBT] Failed to resolve friendly name for {deviceId}: {ex.Message}"); - return null; - } - } - private void OnAudioConnectionStateChanged(AudioPlaybackConnection sender, object args) { try @@ -246,8 +217,6 @@ public async Task IsBluetoothDeviceConnectedAsync(string deviceId) return false; } - // Directly read the connection state instead of relying solely on the event-driven flag, - // because StateChanged does not fire reliably when the device goes out of range. var state = _audioConnection.State; if (state != AudioPlaybackConnectionState.Opened) { @@ -256,16 +225,14 @@ public async Task IsBluetoothDeviceConnectedAsync(string deviceId) return false; } - // Cross-check with Windows device manager to detect out-of-range scenarios - // where AudioPlaybackConnection.State may still appear Opened. try { var deviceInfo = await DeviceInformation.CreateFromIdAsync( deviceId, new[] { "System.Devices.Aep.IsConnected" }); - if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var val) - && val is bool btConnected + if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) + && value is bool btConnected && !btConnected) { if (deviceId.EndsWith("\\SNK", StringComparison.OrdinalIgnoreCase)) @@ -282,8 +249,6 @@ public async Task IsBluetoothDeviceConnectedAsync(string deviceId) } catch (Exception ex) { - // DeviceInfo query failed transiently (e.g. WinRT error during Bluetooth teardown). - // AudioPlaybackConnection.State was already confirmed Opened above, so trust that. Debug.WriteLine($"[IsDeviceConnected] DeviceInfo query failed (assuming connected): {ex.Message}"); return true; } @@ -306,8 +271,8 @@ public async Task IsBluetoothPhysicallyConnectedAsync(string deviceId) deviceId, new[] { "System.Devices.Aep.IsConnected" }); - if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var val) - && val is bool btConnected) + if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) + && value is bool btConnected) { return btConnected; } @@ -322,123 +287,12 @@ public async Task IsBluetoothPhysicallyConnectedAsync(string deviceId) } /// - public void Disconnect(string reason = "unspecified") + public void Disconnect(string reason = "unspecified", bool preserveDisconnectTimestamp = false) { if (_audioConnection != null) { Debug.WriteLine($"[Disconnect] Closing connection (reason={reason})..."); - TearDownAudioConnection(reason); - } - } - - /// - public float? GetActiveDevicePeakLevel() - { - var activeDeviceName = _activeDeviceName; - if (string.IsNullOrEmpty(activeDeviceName)) - { - return null; - } - - try - { - using var enumerator = new MMDeviceEnumerator(); - - // Preferred match: a Capture endpoint whose FriendlyName embeds the BT device name. - // Some BT drivers expose the A2DP source that way; the WinRT AudioPlaybackConnection - // path used by this app does not. When no match is found we fall through to the - // per-session Render fallback below. - var captureEndpoints = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active); - try - { - foreach (var endpoint in captureEndpoints) - { - if (endpoint.FriendlyName.Contains(activeDeviceName, StringComparison.OrdinalIgnoreCase)) - { - var peak = endpoint.AudioMeterInformation.MasterPeakValue; - Debug.WriteLine($"[PeakMeter] capture={endpoint.FriendlyName} peak={peak:F4}"); - return peak; - } - } - } - finally - { - foreach (var endpoint in captureEndpoints) - { - endpoint.Dispose(); - } - } - - // Fallback: inspect sessions on the Default Render endpoint and only trust a session - // that can be matched back to the active Bluetooth device. If no session matches, we - // return null instead of trusting the aggregated endpoint peak. - using var defaultRender = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); - var sessionManager = defaultRender.AudioSessionManager; - var sessions = sessionManager.Sessions; - var shouldDumpSessions = !_hasDumpedSessions; - float? matchedPeak = null; - string? matchedSession = null; - - if (shouldDumpSessions && sessions.Count == 0) - { - Debug.WriteLine($"[PeakMeter][Sessions] No render sessions found on '{defaultRender.FriendlyName}'."); - } - - for (var i = 0; i < sessions.Count; i++) - { - try - { - using var session = sessions[i]; - var displayName = session.DisplayName ?? string.Empty; - var iconPath = session.IconPath ?? string.Empty; - var processId = session.GetProcessID; - var state = session.State; - var peak = session.AudioMeterInformation.MasterPeakValue; - - if (shouldDumpSessions) - { - var loggedDisplayName = string.IsNullOrWhiteSpace(displayName) ? "" : displayName; - var loggedIconPath = string.IsNullOrWhiteSpace(iconPath) ? "" : iconPath; - Debug.WriteLine($"[PeakMeter][Sessions] idx={i} displayName='{loggedDisplayName}' iconPath='{loggedIconPath}' pid={processId} state={state} peak={peak:F4}"); - } - - if (matchedPeak == null && - (displayName.Contains(activeDeviceName, StringComparison.OrdinalIgnoreCase) || - iconPath.Contains(activeDeviceName, StringComparison.OrdinalIgnoreCase))) - { - matchedPeak = peak; - matchedSession = string.IsNullOrWhiteSpace(displayName) ? iconPath : displayName; - - if (!shouldDumpSessions) - { - break; - } - } - } - catch (Exception ex) - { - Debug.WriteLine($"[PeakMeter][Sessions] idx={i} error={ex.Message}"); - } - } - - if (shouldDumpSessions) - { - _hasDumpedSessions = true; - } - - if (matchedPeak.HasValue) - { - Debug.WriteLine($"[PeakMeter] session match='{matchedSession}' peak={matchedPeak.Value:F4}"); - return matchedPeak.Value; - } - - Debug.WriteLine($"[PeakMeter] No session matched '{activeDeviceName}' - zombie detection disabled until matcher is refined."); - return null; - } - catch (Exception ex) - { - Debug.WriteLine($"[PeakMeter] Error reading peak: {ex.Message}"); - return null; + TearDownAudioConnection(reason, updateDisconnectTimestamp: !preserveDisconnectTimestamp); } } @@ -462,5 +316,4 @@ protected virtual void Dispose(bool disposing) Disconnect("dispose"); } } - } diff --git a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs index 1ed58df..65875f5 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -51,21 +51,14 @@ public interface IAudioService /// Disconnects the active Bluetooth audio connection and releases resources. /// /// - /// Short tag identifying the caller ("user", "monitor-detected-loss", "dispose", "reconnect-request"). - /// Logged alongside the teardown to make the debug trace diagnosable when the reconnect loop fires. + /// Short tag identifying the caller ("user", "monitor-detected-loss", "dispose", "manual-recover"). + /// Logged alongside the teardown to make the debug trace diagnosable when the reconnect flow runs. /// - void Disconnect(string reason = "unspecified"); - - /// - /// Returns the current peak audio level on the capture endpoint associated with the active - /// Bluetooth connection. Used to detect the Windows A2DP zombie state where the connection - /// reports Opened but no samples actually flow through Windows to the render stack. - /// - /// - /// The master peak value in the range [0.0, 1.0] when a matching capture endpoint is - /// found, or when no active device is known, the endpoint cannot be - /// matched by name, or the CoreAudio enumeration fails. Callers must treat - /// as "no judgment possible" and therefore not derive a zombie verdict from it. - /// - float? GetActiveDevicePeakLevel(); + /// + /// when the caller is tearing down the audio endpoint for an internal + /// recycle (e.g. manual reconnect while the phone stays Bluetooth-connected) and the + /// last-disconnect bookkeeping that drives the settle delay should not be reset to "now"; + /// otherwise (the default), which treats the teardown as a real disconnect. + /// + void Disconnect(string reason = "unspecified", bool preserveDisconnectTimestamp = false); } diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index f044fe9..fac1f3b 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -4,15 +4,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Windows.Input; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using Hardcodet.Wpf.TaskbarNotification; using EasyBluetoothAudio.Messages; using EasyBluetoothAudio.Models; using EasyBluetoothAudio.Services; using EasyBluetoothAudio.Services.Interfaces; +using Hardcodet.Wpf.TaskbarNotification; namespace EasyBluetoothAudio.ViewModels; @@ -37,46 +36,38 @@ public partial class MainViewModel( IMessenger messenger) : ObservableObject { /// - /// Interval in milliseconds after which the monitor proactively recycles the - /// to prevent it from silently - /// entering a zombie state (reported as Opened but no longer routing audio) after - /// prolonged idle. The recycle is sub-second when the device is physically connected. - /// Also reset immediately on Windows session unlock via . + /// Number of automatic reconnect attempts that may run after a real connection loss + /// or failed initial connect before the UI falls back to explicit manual reconnect. /// - internal const int KeepaliveIntervalMs = 20 * 60 * 1_000; // 20 minutes + internal const int AutoReconnectAttemptLimit = 2; /// - /// Time (in milliseconds) that the peak meter on the active Bluetooth capture endpoint - /// must remain at zero before the monitor treats the connection as a Windows-A2DP zombie - /// (state reports Opened but no samples are actually routed). Chosen well above the - /// typical gap between tracks so legitimate silence does not trigger a recycle. + /// Delay in milliseconds between automatic reconnect attempts. /// - internal const int ZombieSilenceThresholdMs = 15_000; + internal const int AutoReconnectDelayMs = 3_000; /// - /// Minimum time in milliseconds that must pass after a silence-triggered recycle before the - /// monitor may derive another zombie verdict from continued silence. This reduces reconnect - /// storms during legitimate idle periods while still allowing periodic recovery attempts. + /// Poll interval in milliseconds for verifying that the currently monitored device is still connected. /// - internal const int ZombieRecycleBackoffMs = 2 * 60 * 1_000; + internal const int MonitorPollDelayMs = 10_000; /// - /// Number of consecutive zombie-triggered recycle attempts that may fail to restore audio - /// before the user is notified via a balloon tip. After the notification the monitor keeps - /// silently recycling, but the user is made aware that manual intervention (toggling the - /// audio route on the phone) may be required. + /// Maximum time the startup auto-update check is allowed to block initialization. + /// If the GitHub call has not completed within this window, the check is abandoned and + /// startup continues so users on slow or restrictive networks are not stalled at launch. + /// The HTTP request continues in the background and will be observed by HttpClient itself. /// - internal const int ZombieRecycleAttemptsBeforeNotify = 2; + private const int AutoUpdateCheckTimeoutMs = 10_000; private CancellationTokenSource? _monitorCts; private string? _lastDeviceId; private string? _monitoredDeviceId; + private string? _monitoredDeviceName; private bool _isRefreshing; private volatile bool _isReconnecting; - private DateTime _lastKeepaliveTime; - private DateTime _firstSilenceObservation; - private DateTime _lastZombieRecycleTime; - private int _consecutiveFailedRecycles; + private int _autoReconnectInFlight; + private int _monitorGeneration; + private bool _hasShownReconnectBalloon; /// /// Gets or sets the currently selected Bluetooth device. @@ -84,6 +75,7 @@ public partial class MainViewModel( /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectCommand))] private BluetoothDevice? _selectedBluetoothDevice; /// @@ -99,6 +91,7 @@ public partial class MainViewModel( /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectCommand))] private bool _isBusy; /// @@ -169,8 +162,6 @@ public async Task InitializeAsync() if (initialSettings.AutoUpdateOnStartup) { - // Await the update check before touching Bluetooth so we do not connect - // an audio stream that would be immediately torn down by the installer shutdown. await CheckAndAutoInstallUpdateAsync(); } else @@ -190,7 +181,7 @@ public async Task InitializeAsync() /// Refreshes the Bluetooth device list from the audio service while preserving the current selection. /// /// A task representing the asynchronous refresh operation. - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] public async Task RefreshDevicesAsync() { try @@ -213,8 +204,8 @@ public async Task RefreshDevicesAsync() } } - var deviceIds = new System.Collections.Generic.HashSet(devices.Select(n => n.Id)); - var toRemove = BluetoothDevices.Where(d => !deviceIds.Contains(d.Id)).ToList(); + var deviceIds = new System.Collections.Generic.HashSet(devices.Select(device => device.Id)); + var toRemove = BluetoothDevices.Where(device => !deviceIds.Contains(device.Id)).ToList(); foreach (var item in toRemove) { BluetoothDevices.Remove(item); @@ -222,7 +213,7 @@ public async Task RefreshDevicesAsync() if (SelectedBluetoothDevice == null && currentSelectedId != null) { - SelectedBluetoothDevice = BluetoothDevices.FirstOrDefault(d => d.Id == currentSelectedId); + SelectedBluetoothDevice = BluetoothDevices.FirstOrDefault(device => device.Id == currentSelectedId); } if (SelectedBluetoothDevice == null) @@ -243,9 +234,10 @@ public async Task RefreshDevicesAsync() /// /// Establishes an audio connection to the currently selected Bluetooth device. + /// Performs a bounded automatic retry if the initial connect fails. /// /// A task representing the asynchronous connect operation. - [CommunityToolkit.Mvvm.Input.RelayCommand(CanExecute = nameof(CanConnect))] + [RelayCommand(CanExecute = nameof(CanConnect))] internal async Task ConnectAsync() { if (SelectedBluetoothDevice == null) @@ -253,24 +245,30 @@ internal async Task ConnectAsync() return; } + var deviceId = SelectedBluetoothDevice.Id; + var deviceName = SelectedBluetoothDevice.Name; + try { IsBusy = true; - StatusText = $"CONNECTING TO {SelectedBluetoothDevice.Name}..."; + ResetReconnectGuidance(); + StopConnectionMonitor(); + StatusText = $"CONNECTING TO {deviceName}..."; + + var connected = await ConnectWithAutoReconnectAsync( + deviceId, + deviceName, + CancellationToken.None, + static () => true); - var ok = await audioService.ConnectBluetoothAudioAsync(SelectedBluetoothDevice.Id); - if (!ok) + if (!connected) { - StatusText = "WAITING FOR SOURCE..."; - StartConnectionMonitor(SelectedBluetoothDevice.Id, SelectedBluetoothDevice.Name); + ApplyReconnectRequiredState(); + ShowReconnectGuidanceBalloon(); return; } - IsConnected = true; - StatusText = "STREAMING ACTIVE"; - messenger.Send(new ConnectionEstablishedMessage(SelectedBluetoothDevice.Name)); - - StartConnectionMonitor(SelectedBluetoothDevice.Id, SelectedBluetoothDevice.Name); + StartConnectionMonitor(deviceId, deviceName); } catch (Exception ex) { @@ -286,7 +284,7 @@ internal async Task ConnectAsync() /// /// Disconnects the current Bluetooth audio device. /// - [CommunityToolkit.Mvvm.Input.RelayCommand(CanExecute = nameof(CanDisconnect))] + [RelayCommand(CanExecute = nameof(CanDisconnect))] internal void Disconnect() { StopConnectionMonitor(); @@ -300,14 +298,73 @@ internal void Disconnect() Debug.WriteLine($"[Disconnect] Error: {ex.Message}"); } + ResetReconnectGuidance(); + _isReconnecting = false; IsConnected = false; StatusText = "DISCONNECTED"; + DisconnectCommand.NotifyCanExecuteChanged(); + } + + /// + /// Performs an explicit disconnect/connect cycle for the currently selected device. + /// This is the primary manual reconnect action exposed in the UI and tray. + /// + /// A task representing the asynchronous reconnect operation. + [RelayCommand(CanExecute = nameof(CanReconnect))] + internal async Task ReconnectAsync() + { + if (SelectedBluetoothDevice == null) + { + return; + } + + var deviceId = SelectedBluetoothDevice.Id; + var deviceName = SelectedBluetoothDevice.Name; + + try + { + IsBusy = true; + ResetReconnectGuidance(); + StopConnectionMonitor(); + StatusText = "RECONNECTING..."; + + if (IsConnected || _isReconnecting) + { + try + { + audioService.Disconnect("manual-recover", preserveDisconnectTimestamp: true); + } + catch (Exception ex) + { + Debug.WriteLine($"[Reconnect] Disconnect error: {ex.Message}"); + } + } + + var connected = await audioService.ConnectBluetoothAudioAsync(deviceId); + if (!connected) + { + ApplyReconnectRequiredState(); + return; + } + + ApplyConnectedState(deviceName); + StartConnectionMonitor(deviceId, deviceName); + } + catch (Exception ex) + { + StatusText = "ERROR: " + ex.Message; + IsConnected = false; + } + finally + { + IsBusy = false; + } } /// /// Opens the system Settings panel. /// - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] private void OpenSettings() { IsSettingsOpen = true; @@ -318,7 +375,7 @@ private void OpenSettings() /// the device list when the picker is dismissed. /// /// A task representing the asynchronous operation. - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] private async Task OpenBluetoothSettingsAsync() { try @@ -338,7 +395,7 @@ private async Task OpenBluetoothSettingsAsync() /// /// Requests the main window to show itself. /// - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] private void Open() { RequestShow?.Invoke(); @@ -347,7 +404,7 @@ private void Open() /// /// Requests the application to exit. /// - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] private void Exit() { RequestExit?.Invoke(); @@ -381,14 +438,6 @@ private void OnSettingsSaved(AppSettings settings) AutoConnect = settings.AutoConnect; } - /// - /// Maximum time the startup auto-update check is allowed to block initialization. - /// If the GitHub call has not completed within this window, the check is abandoned and - /// startup continues so users on slow or restrictive networks are not stalled at launch. - /// The HTTP request continues in the background and will be observed by HttpClient itself. - /// - private const int AutoUpdateCheckTimeoutMs = 10_000; - /// /// Checks for an available update and, if one is found, downloads and installs it /// silently. Used when the user has enabled the AutoUpdateOnStartup setting so the @@ -404,7 +453,7 @@ private async Task CheckAndAutoInstallUpdateAsync() var completed = await Task.WhenAny(checkTask, Task.Delay(AutoUpdateCheckTimeoutMs)); if (completed != checkTask) { - Debug.WriteLine("[AutoUpdate] Check timed out — continuing startup."); + Debug.WriteLine("[AutoUpdate] Check timed out - continuing startup."); return; } @@ -434,8 +483,7 @@ private void OnReconnectRequested() return; } - Disconnect(); - _ = ConnectAsync(); + _ = ReconnectAsync(); }); } @@ -451,259 +499,308 @@ private bool CanConnect() /// /// Determines whether the disconnect command can execute. /// - /// if currently connected. + /// if currently connected or automatically reconnecting. private bool CanDisconnect() { return IsConnected || _isReconnecting; } /// - /// Starts a background task that monitors the Bluetooth connection and automatically reconnects. + /// Determines whether the explicit manual reconnect command can execute. + /// + /// if a device is selected and no blocking operation is running. + private bool CanReconnect() + { + return SelectedBluetoothDevice != null && !IsBusy; + } + + /// + /// Starts a background task that monitors the currently connected device and only reacts + /// to confirmed connection loss. Idle silence does not trigger any background reconnect. /// /// The device identifier to monitor. /// The friendly device name for status messages. private void StartConnectionMonitor(string deviceId, string deviceName) { StopConnectionMonitor(); + _monitoredDeviceId = deviceId; - _lastKeepaliveTime = DateTime.UtcNow; + _monitoredDeviceName = deviceName; audioService.ConnectionLost += OnConnectionLostFromService; - Microsoft.Win32.SystemEvents.SessionSwitch += OnSessionSwitch; _monitorCts = new CancellationTokenSource(); - var token = _monitorCts.Token; - - const int pollDelayMs = 10_000; + var cancellationToken = _monitorCts.Token; + var monitorGeneration = _monitorGeneration; _ = Task.Run(async () => { try { - while (!token.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { - await Task.Delay(pollDelayMs, token); + await Task.Delay(MonitorPollDelayMs, cancellationToken); var connected = await audioService.IsBluetoothDeviceConnectedAsync(deviceId); if (connected) { - if (_isReconnecting) - { - dispatcherService.Invoke(() => - { - _isReconnecting = false; - IsConnected = true; - StatusText = "STREAMING ACTIVE"; - }); - _lastKeepaliveTime = DateTime.UtcNow; - } - - // Keepalive: periodically recycle the AudioPlaybackConnection to prevent - // zombie state where State reports Opened but Windows no longer routes audio. - // The recycle is sub-second when the device is physically connected. - if (!_isReconnecting - && (DateTime.UtcNow - _lastKeepaliveTime).TotalMilliseconds >= KeepaliveIntervalMs) - { - _lastKeepaliveTime = DateTime.UtcNow; - Debug.WriteLine("[Monitor] Keepalive: recycling AudioPlaybackConnection."); - var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); - if (!ok) - { - dispatcherService.Invoke(() => - { - _isReconnecting = true; - DisconnectCommand.NotifyCanExecuteChanged(); - StatusText = "RECONNECTING..."; - IsConnected = false; - }); - } - } - - // Zombie detection: Windows may keep AudioPlaybackConnection.State == Opened - // indefinitely while silently not routing any samples. The only reliable - // evidence of actual audio flow is a non-zero peak on the BT capture endpoint. - // When iOS is streaming (the user's confirmed scenario) the peak must be > 0; - // sustained zero over the silence threshold is treated as a zombie and triggers - // a targeted recycle. Users are notified once after two failed recycles. - if (!_isReconnecting) - { - await CheckForZombieAndMaybeRecycleAsync(deviceId); - } - continue; } - // Connection lost – enter reconnect loop - dispatcherService.Invoke(() => - { - _isReconnecting = true; - DisconnectCommand.NotifyCanExecuteChanged(); - StatusText = "RECONNECTING..."; - IsConnected = false; - }); - - try { audioService.Disconnect("monitor-detected-loss"); } - catch { /* already stopped */ } + await HandleConfirmedConnectionLossAsync( + deviceId, + deviceName, + "monitor-detected-loss", + cancellationToken, + monitorGeneration); - while (!token.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) { - var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); - if (ok) - { - dispatcherService.Invoke(() => - { - _isReconnecting = false; - IsConnected = true; - StatusText = "STREAMING ACTIVE"; - }); - messenger.Send(new ConnectionEstablishedMessage(deviceName)); - _lastKeepaliveTime = DateTime.UtcNow; - break; - } - - await Task.Delay(pollDelayMs, token); + break; } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + } catch (Exception ex) { Debug.WriteLine($"[Monitor] Unexpected error: {ex.Message}"); } - }, token); + }, cancellationToken); } /// - /// Stops the connection monitor background task. + /// Stops the connection monitor background task and clears the current monitored device metadata. /// private void StopConnectionMonitor() { - Microsoft.Win32.SystemEvents.SessionSwitch -= OnSessionSwitch; + Interlocked.Increment(ref _monitorGeneration); audioService.ConnectionLost -= OnConnectionLostFromService; + _monitoredDeviceId = null; + _monitoredDeviceName = null; _isReconnecting = false; - _firstSilenceObservation = DateTime.MinValue; - _lastZombieRecycleTime = DateTime.MinValue; - _consecutiveFailedRecycles = 0; _monitorCts?.Cancel(); _monitorCts?.Dispose(); _monitorCts = null; + DisconnectCommand.NotifyCanExecuteChanged(); } /// - /// Reads the peak level on the active capture endpoint and, if audio has been silent for - /// milliseconds despite the connection reporting - /// Opened, recycles the to - /// force Windows to rebuild the routing. After - /// consecutive failed recycles a balloon tip is surfaced once to prompt manual intervention; - /// subsequent recycles continue silently until the peak becomes non-zero again. + /// Attempts an initial connection and, if that fails, performs a bounded automatic reconnect sequence. /// - /// The device identifier to reconnect to when the zombie is confirmed. - /// A task representing the asynchronous check. - internal async Task CheckForZombieAndMaybeRecycleAsync(string deviceId) + /// The device identifier to connect. + /// The friendly device name for status updates and notifications. + /// A cancellation token that can abort the retry loop. + /// A guard used to discard stale reconnect results after the monitor has been stopped. + /// if the device is connected; otherwise . + private async Task ConnectWithAutoReconnectAsync( + string deviceId, + string deviceName, + CancellationToken cancellationToken, + Func shouldApplyResult) { - var peak = audioService.GetActiveDevicePeakLevel(); - if (peak is null) + var connected = await audioService.ConnectBluetoothAudioAsync(deviceId); + if (connected) { - return; + ApplyConnectedState(deviceName); + return true; } - if (peak.Value > 0.0001f) - { - _firstSilenceObservation = DateTime.MinValue; - _lastZombieRecycleTime = DateTime.MinValue; - _consecutiveFailedRecycles = 0; - return; - } + return await TryAutoReconnectAsync(deviceId, deviceName, cancellationToken, shouldApplyResult); + } - if (_consecutiveFailedRecycles >= ZombieRecycleAttemptsBeforeNotify - && _lastZombieRecycleTime != DateTime.MinValue - && (DateTime.UtcNow - _lastZombieRecycleTime).TotalMilliseconds < ZombieRecycleBackoffMs) + /// + /// Performs the bounded automatic reconnect sequence used for real connection loss + /// and failed initial connects. + /// + /// The device identifier to reconnect. + /// The friendly device name for status updates. + /// A cancellation token that can abort the retry loop. + /// A guard used to discard stale reconnect results after the monitor has been stopped. + /// if a retry restored the connection; otherwise . + private async Task TryAutoReconnectAsync( + string deviceId, + string deviceName, + CancellationToken cancellationToken, + Func shouldApplyResult) + { + dispatcherService.Invoke(ApplyReconnectingState); + + for (var attempt = 0; attempt < AutoReconnectAttemptLimit; attempt++) { - _firstSilenceObservation = DateTime.MinValue; - return; + if (cancellationToken.IsCancellationRequested) + { + return false; + } + + await Task.Delay(AutoReconnectDelayMs, cancellationToken); + + var connected = await audioService.ConnectBluetoothAudioAsync(deviceId); + if (!connected) + { + continue; + } + + if (!shouldApplyResult()) + { + audioService.Disconnect("stale-auto-reconnect"); + return false; + } + + dispatcherService.Invoke(() => ApplyConnectedState(deviceName)); + return true; } - if (_firstSilenceObservation == DateTime.MinValue) + return false; + } + + /// + /// Handles a confirmed connection loss by running the bounded automatic reconnect sequence. + /// When the retry budget is exhausted, the monitor is stopped and the UI falls back to manual reconnect. + /// + /// The device identifier to reconnect. + /// The friendly device name for status updates. + /// The disconnect reason passed through to the audio service. + /// A cancellation token for the current monitor run. + /// The generation stamp of the current monitor run. + /// A task representing the asynchronous reconnect operation. + private async Task HandleConfirmedConnectionLossAsync( + string deviceId, + string deviceName, + string disconnectReason, + CancellationToken cancellationToken, + int monitorGeneration) + { + if (Interlocked.CompareExchange(ref _autoReconnectInFlight, 1, 0) != 0) { - _firstSilenceObservation = DateTime.UtcNow; return; } - var silenceMs = (DateTime.UtcNow - _firstSilenceObservation).TotalMilliseconds; - if (silenceMs < ZombieSilenceThresholdMs) + try { - return; - } + dispatcherService.Invoke(ApplyReconnectingState); - Debug.WriteLine($"[Zombie] Peak=0 over {(int)silenceMs}ms — recycling."); - _lastKeepaliveTime = DateTime.UtcNow; - _firstSilenceObservation = DateTime.MinValue; - _lastZombieRecycleTime = DateTime.UtcNow; - _consecutiveFailedRecycles++; + try + { + audioService.Disconnect(disconnectReason); + } + catch (Exception ex) + { + Debug.WriteLine($"[Reconnect] Disconnect error: {ex.Message}"); + } - var ok = await audioService.ConnectBluetoothAudioAsync(deviceId); - if (!ok) - { - dispatcherService.Invoke(() => + var connected = await TryAutoReconnectAsync( + deviceId, + deviceName, + cancellationToken, + () => !cancellationToken.IsCancellationRequested && monitorGeneration == _monitorGeneration); + + if (connected || cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) { - _isReconnecting = true; - DisconnectCommand.NotifyCanExecuteChanged(); - StatusText = "RECONNECTING..."; - IsConnected = false; - }); - } + return; + } - if (_consecutiveFailedRecycles == ZombieRecycleAttemptsBeforeNotify) + dispatcherService.Invoke(ApplyReconnectRequiredState); + ShowReconnectGuidanceBalloon(); + StopConnectionMonitor(); + } + finally { - messenger.Send(new ShowBalloonRequestedMessage(new BalloonContent( - "Bluetooth-Audio", - "Kein Sound erkannt. Bitte am iPhone kurz die Audio-Ausgabe togglen.", - BalloonIcon.Warning))); + Interlocked.Exchange(ref _autoReconnectInFlight, 0); } } /// - /// Handles Windows session switch events (screen unlock, console connect) by resetting - /// the keepalive timer so the next monitor poll triggers an immediate connection recycle. - /// After a lock/sleep period the - /// is most likely to be in a zombie state. + /// Applies the connected UI state and emits the connection-established message. + /// + /// The friendly device name of the connected source. + private void ApplyConnectedState(string deviceName) + { + ResetReconnectGuidance(); + _isReconnecting = false; + IsConnected = true; + StatusText = "STREAMING ACTIVE"; + DisconnectCommand.NotifyCanExecuteChanged(); + messenger.Send(new ConnectionEstablishedMessage(deviceName)); + } + + /// + /// Applies the transient reconnecting UI state used during bounded automatic reconnect. /// - /// The event source. - /// Session switch event arguments. - private void OnSessionSwitch(object sender, Microsoft.Win32.SessionSwitchEventArgs e) + private void ApplyReconnectingState() { - if (e.Reason == Microsoft.Win32.SessionSwitchReason.SessionUnlock - || e.Reason == Microsoft.Win32.SessionSwitchReason.ConsoleConnect) + _isReconnecting = true; + IsConnected = false; + StatusText = "RECONNECTING..."; + DisconnectCommand.NotifyCanExecuteChanged(); + } + + /// + /// Applies the stable failure state used after the automatic reconnect budget is exhausted. + /// + private void ApplyReconnectRequiredState() + { + _isReconnecting = false; + IsConnected = false; + StatusText = "AUDIO LOST - CLICK CONNECT"; + DisconnectCommand.NotifyCanExecuteChanged(); + } + + /// + /// Clears the one-shot reconnect guidance notification gate for a fresh connection attempt. + /// + private void ResetReconnectGuidance() + { + _hasShownReconnectBalloon = false; + } + + /// + /// Shows the one-shot tray balloon that explains the manual reconnect action after the + /// bounded automatic reconnect budget has been exhausted. + /// + private void ShowReconnectGuidanceBalloon() + { + if (_hasShownReconnectBalloon) { - _lastKeepaliveTime = DateTime.MinValue; - Debug.WriteLine("[Monitor] Session resumed — keepalive timer reset for immediate recycle."); + return; } + + _hasShownReconnectBalloon = true; + messenger.Send(new ShowBalloonRequestedMessage(new BalloonContent( + "Bluetooth Audio", + "Use Connect or toggle the route on the iPhone.", + BalloonIcon.Warning))); } /// - /// Handles the event by cross-checking the actual - /// device state before updating the UI, guarding against transient audio-endpoint state changes - /// that do not represent a real Bluetooth disconnect (e.g. Windows audio device switches). + /// Reacts to the audio service's immediate connection-lost signal and starts the bounded + /// reconnect flow without waiting for the next poll cycle, but only after a cross-check + /// confirms the device is actually disconnected. /// /// The audio service that raised the event. /// Event arguments. private async void OnConnectionLostFromService(object? sender, EventArgs e) { - if (!IsConnected || _monitoredDeviceId == null) + if (_monitorCts == null || _monitoredDeviceId == null || _monitoredDeviceName == null) { return; } - var stillConnected = await audioService.IsBluetoothDeviceConnectedAsync(_monitoredDeviceId); - if (stillConnected) + var cancellationToken = _monitorCts.Token; + var deviceId = _monitoredDeviceId; + var deviceName = _monitoredDeviceName; + var monitorGeneration = _monitorGeneration; + + var stillConnected = await audioService.IsBluetoothDeviceConnectedAsync(deviceId); + if (stillConnected || cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) { return; } - dispatcherService.Invoke(() => - { - IsConnected = false; - _isReconnecting = true; - DisconnectCommand.NotifyCanExecuteChanged(); - StatusText = "RECONNECTING..."; - }); + await HandleConfirmedConnectionLossAsync( + deviceId, + deviceName, + "service-connection-lost", + cancellationToken, + monitorGeneration); } } diff --git a/EasyBluetoothAudio/Views/BluetoothConfigView.xaml b/EasyBluetoothAudio/Views/BluetoothConfigView.xaml index 54dc528..ad96816 100644 --- a/EasyBluetoothAudio/Views/BluetoothConfigView.xaml +++ b/EasyBluetoothAudio/Views/BluetoothConfigView.xaml @@ -4,7 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d"> - + @@ -62,74 +62,240 @@ - - - - - - - - + + + + + + - - - - - - - - - + + - - - - - + + + - + + + + + + + + + + + + + + - - + + - + diff --git a/tasks/lessons.md b/tasks/lessons.md index 9732183..db04b3f 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,10 +1,12 @@ # Lessons - When a health check is supposed to reason about one remote source, never fall back to an aggregated endpoint-level meter that can be kept alive by unrelated local audio. Prefer source- or session-specific telemetry, and return `null` when isolation is not yet trustworthy. -- For WinRT Bluetooth `\SNK` audio endpoints, do not assume `System.Devices.Aep.IsConnected` tracks an already-open `AudioPlaybackConnection`. If the audio connection state is authoritative and the WinRT property disagrees, log the mismatch and avoid letting the property short-circuit the audio-path diagnostics. +- For WinRT Bluetooth `\SNK` audio endpoints, do not use `System.Devices.Aep.IsConnected` as a reconnect/settle gate in either direction. It can report `True` when no audio connection is open, and `False` when the phone is still BT-connected and the next `OpenAsync` succeeds immediately. Track the "is the phone still up?" distinction via our own teardown bookkeeping (e.g. a `preserveDisconnectTimestamp` flag on internal recycles) instead of the AEP property. - Do not use `x:Reference` from a tray-hosted `ContextMenu` back to the `TaskbarIcon` while that icon is still being constructed. In WPF this can create a cyclic XAML dependency and crash startup; assign `ContextMenu.DataContext` after the window has been created instead. - For tray-hosted WPF `ContextMenu` commands, prefer binding each `MenuItem.Command` via `PlacementTarget.DataContext.` on the ancestor `ContextMenu`. It avoids both startup-time path errors against the `TaskbarIcon` object and markup-time recursion from trying to push a shared `DataContext` into the menu. - For Hardcodet tray menus, direct event wiring can be more reliable than WPF command bindings. If tray `MenuItem` commands regress across `ContextMenu`/`TaskbarIcon` binding changes, wire `Click` handlers after window creation and invoke the ViewModel commands explicitly. - When extracting connection cleanup into a shared teardown helper, preserve the semantics of any timing fields that distinguish an internal reset from a real disconnect. A pre-connect cleanup may need the same disposal/reset logic without rewriting the timestamp that drives reconnect backoff or settle-delay decisions. - A zero peak alone cannot distinguish a real A2DP zombie from a user simply being idle. If silence is the only signal available, never let the monitor recycle on every threshold crossing; add a cooldown or refractory period so normal idle does not create reconnect storms. - A cooldown on zombie recovery attempts must not suppress the first follow-up retry after a failed recycle. If the first reconnect does not restore audio, blocking all further retries for minutes strands the user in silence; only enter the long cooldown after enough failed attempts to conclude we are in a repeated-failure state rather than a one-off missed recovery. +- If recovery depends on weak heuristics instead of authoritative connection state, do not hide it behind background automation. Prefer a manual-first UX with a clear `Reconnect` action and a small, bounded auto-reconnect budget for real loss only. +- For this UI's connect-to-connected transition, do not animate only one secondary button into place while the other snaps to its final slot. The full-width `CONNECT` control must visually collapse from the midpoint while `RECONNECT` and `DISCONNECT` grow outward from that same center line, so the motion reads as one button splitting into two. diff --git a/tasks/todo.md b/tasks/todo.md index 90cf755..a5d05e0 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -109,3 +109,57 @@ - `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now applies the long `ZombieRecycleBackoffMs` cooldown only after the monitor has already accumulated the configured number of failed zombie recycles, instead of after the very first attempt. - `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 0 warnings and 0 errors after the regression fix. - `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out\\bin\\"` passed with 94/94 tests green after the regression fix. + +## Manual-First Recovery UX + +- [x] Remove the peak-/keepalive-based zombie recovery path from `MainViewModel` and `AudioService`. +- [x] Add bounded auto-reconnect only for real connection loss and failed initial connects. +- [x] Add `Reconnect` to the main UI and tray menu, and surface explicit manual reconnect status text. +- [x] Rewrite the affected tests for bounded reconnects and manual recovery, then re-run build and tests. + +## Review + +- `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now uses a manual-first recovery flow: idle silence no longer triggers hidden recycling, real loss gets at most two auto-reconnect attempts spaced by 3 seconds, and exhausted recovery lands in `AUDIO LOST - CLICK RECOVER`. +- `EasyBluetoothAudio/Services/Interfaces/IAudioService.cs`, `EasyBluetoothAudio/Services/AudioService.cs`, and `EasyBluetoothAudio/EasyBluetoothAudio.csproj` no longer expose or depend on the peak-meter/NAudio path; recovery decisions are now based only on real connection state and explicit user action. +- `EasyBluetoothAudio/Views/BluetoothConfigView.xaml`, `EasyBluetoothAudio/Views/MainWindow.xaml`, and `EasyBluetoothAudio/App.xaml.cs` now expose `Reconnect` in both the main window and tray menu, wired directly to `MainViewModel.ReconnectCommand`. The main window swaps the left-slot button between `CONNECT` and `RECONNECT` based on `IsConnected` so the primary action slot always reflects the next useful step. +- `EasyBluetoothAudio.Tests/MainViewModelTests.cs` now validates bounded reconnect behavior, manual recovery, exhausted-retry fallback, and the absence of idle-time hidden recycling. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out-final\\bin\\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="%TEMP%\\EasyBluetoothAudio-codex-out-final\\bin\\"` passed with 95/95 tests green. + +## Split-Button Animation + +- [x] Replace the always-visible DISCONNECT button with a single full-width CONNECT button while disconnected. +- [x] On IsConnected transition, animate DISCONNECT fading and sliding in from the left while CONNECT collapses to RECONNECT on the left half. +- [x] Re-run build and tests after the UI change. + +- Split-button animation review: +- `EasyBluetoothAudio/Views/BluetoothConfigView.xaml` replaces the previous `UniformGrid` that always showed DISCONNECT with a 2-column `Grid` in which a single full-width CONNECT button is shown while disconnected; when `IsConnected` flips to `True`, CONNECT collapses, RECONNECT occupies column 0 at half width, and DISCONNECT in column 1 animates `Opacity` from 0 to 1 and `TranslateTransform.X` from `-40` to `0` over 0.35 s with a cubic `EaseOut` easing, producing the "button splits into two" effect. A symmetric 0.25 s exit animation reverses the fade and slide when the app disconnects. +- No ViewModel or command changes were required; `ConnectCommand`, `ReconnectCommand`, and `DisconnectCommand` and their `CanExecute` gating remain intact, so the new layout preserves all prior behavior (including bounded auto-reconnect and manual recovery). +- `dotnet build C:/dev/EasyBluetoothAudio/EasyBluetoothAudio.slnx -p:BaseOutputPath="$TEMP/EasyBluetoothAudio-splitbtn-out/bin/"` passed with 0 warnings and 0 errors. +- `dotnet test C:/dev/EasyBluetoothAudio/EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="$TEMP/EasyBluetoothAudio-splitbtn-out/bin/"` passed with 95/95 tests green. + +## Git Commit Grouping Review + +- [x] Inspect the current workspace diff file by file. +- [x] Separate the changes into coherent commit-sized groups by purpose and dependency. +- [x] Record the proposed commit plan and rationale in the review section. + +## Review + +- Recommended primary commit 1: `feat: switch recovery to manual reconnect` for the `MainViewModel` / `AudioService` recovery redesign, NAudio removal, tray reconnect wiring, tests, and the matching lesson/task notes. +- Recommended primary commit 2: `feat: animate split reconnect controls` for the `BluetoothConfigView.xaml` transition from a static connect/disconnect pair to the full-width connect state plus animated reconnect/disconnect split layout. +- `EasyBluetoothAudio/Views/BluetoothConfigView.xaml` and `tasks/todo.md` need partial staging if the implementation and UI-animation changes are split cleanly into those two commits. + +## Center-Split Button Animation + +- [x] Review the current `BluetoothConfigView.xaml` button transition and isolate why the existing motion does not read as a center split. +- [x] Rework the action-button animation so the full-width `CONNECT` button collapses from the center while `RECONNECT` and `DISCONNECT` expand outward from that center line. +- [x] Re-run build and tests after the XAML update. + +## Review + +- The previous transition did not read as a single button splitting in two because `RECONNECT` snapped into its final left slot immediately while only `DISCONNECT` animated in. That produced a layout swap plus a slide, not a center-origin split. +- `EasyBluetoothAudio/Views/BluetoothConfigView.xaml` now overlays the full-width `CONNECT` button on top of the two connected-state buttons. On `IsConnected = true`, `CONNECT` collapses on `ScaleX` around its center while `RECONNECT` and `DISCONNECT` each animate from `ScaleX = 0` at the center line outward to their final half-width positions. +- No ViewModel or command logic changed; the update is isolated to the action-button XAML animation. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-center-split-out\bin\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-center-split-out\bin\"` passed with 95/95 tests green. From 76b11143af64b01f2d3e4e6e72505c649af21e4c Mon Sep 17 00:00:00 2001 From: GorangN Date: Tue, 21 Apr 2026 16:52:57 +0200 Subject: [PATCH 10/13] feat: separate connect/reconnect with manual-first recovery UX - Add explicit IsRecoverableConnectionLoss state to distinguish full Connect from one-shot Reconnect - Implement bounded auto-reconnect (2 attempts, 3s spacing) for real connection loss - Add physical-aware fallback: AUDIO LOST guides to Reconnect if BT link remains, else Connect - Add ShowReconnectActions/ShowConnectAction computed properties for UI visibility - Update BluetoothConfigView.xaml triggers to use ShowReconnectActions instead of IsConnected - Rewrite MainViewModelTests to validate recoverable state, physical fallback, and no idle-time hidden recycling --- .../MainViewModelTests.cs | 171 ++++++++++++++-- .../ViewModels/MainViewModel.cs | 185 ++++++++++++++---- .../Views/BluetoothConfigView.xaml | 6 +- 3 files changed, 308 insertions(+), 54 deletions(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index d3a4e5e..0354358 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -40,6 +40,7 @@ public MainViewModelTests() _devicePickerServiceMock.Setup(service => service.ShowAsync()).Returns(Task.CompletedTask); _settingsServiceMock.Setup(service => service.Load()).Returns(new AppSettings()); _dispatcherServiceMock.Setup(service => service.Invoke(It.IsAny())).Callback(action => action()); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync(It.IsAny())).ReturnsAsync(false); } private MainViewModel CreateViewModel() @@ -200,6 +201,7 @@ public async Task ConnectAsync_UsesBoundedRetries_AndFallsBackToManualReconnect( var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("1")).ReturnsAsync(false); var balloonCount = 0; _messenger.Register(this, (_, _) => balloonCount++); @@ -210,12 +212,40 @@ public async Task ConnectAsync_UsesBoundedRetries_AndFallsBackToManualReconnect( Assert.False(viewModel.IsConnected); Assert.Equal("AUDIO LOST - CLICK CONNECT", viewModel.StatusText); + Assert.True(viewModel.ConnectCommand.CanExecute(null)); + Assert.False(viewModel.ReconnectCommand.CanExecute(null)); Assert.Equal(1, balloonCount); _audioServiceMock.Verify( service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(MainViewModel.AutoReconnectAttemptLimit + 1)); } + /// + /// Verifies that a failed initial connect falls back to manual reconnect when the physical + /// Bluetooth link is still present. + /// + [Fact] + public async Task ConnectAsync_FallsBackToReconnect_WhenPhysicalLinkRemains() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("1")).ReturnsAsync(true); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + Assert.False(viewModel.IsConnected); + Assert.Equal("AUDIO LOST - CLICK RECONNECT", viewModel.StatusText); + Assert.False(viewModel.ConnectCommand.CanExecute(null)); + Assert.True(viewModel.ReconnectCommand.CanExecute(null)); + Assert.True(viewModel.DisconnectCommand.CanExecute(null)); + _audioServiceMock.Verify( + service => service.ConnectBluetoothAudioAsync("1"), + Times.Exactly(MainViewModel.AutoReconnectAttemptLimit + 1)); + } + /// /// Verifies that a failed initial connect can recover on a bounded automatic retry. /// @@ -292,10 +322,34 @@ public async Task Disconnect_DisconnectsAndSetsStatus() } /// - /// Verifies that manual reconnect can connect a selected device even while disconnected. + /// Verifies that disconnect clears a recoverable audio-loss fallback and returns the UI to + /// a clean disconnected state. + /// + [Fact] + public async Task Disconnect_ClearsRecoverableAudioLossState() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("1")).ReturnsAsync(true); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + viewModel.Disconnect(); + + Assert.False(viewModel.IsConnected); + Assert.Equal("DISCONNECTED", viewModel.StatusText); + Assert.True(viewModel.ConnectCommand.CanExecute(null)); + Assert.False(viewModel.ReconnectCommand.CanExecute(null)); + } + + /// + /// Verifies that manual reconnect is no longer a disconnected-state alias for full connect. /// [Fact] - public async Task ReconnectAsync_ReconnectsSelectedDevice_WhenDisconnected() + public async Task ReconnectAsync_DoesNothing_WhenNotConnectedOrRecoverable() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); @@ -305,9 +359,39 @@ public async Task ReconnectAsync_ReconnectsSelectedDevice_WhenDisconnected() await viewModel.RefreshDevicesAsync(); await viewModel.ReconnectAsync(); - Assert.True(viewModel.IsConnected); - Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); - _audioServiceMock.Verify(service => service.Disconnect("manual-recover"), Times.Never); + Assert.False(viewModel.IsConnected); + Assert.Equal("IDLE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Never); + _audioServiceMock.Verify(service => service.Disconnect("manual-recover", true), Times.Never); + } + + /// + /// Verifies that manual reconnect performs only one reconnect attempt after a recoverable + /// audio-loss fallback, rather than starting another automatic retry loop. + /// + [Fact] + public async Task ReconnectAsync_AttemptsOneShotReconnect_WhenLossIsRecoverable() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.SetupSequence(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(false) + .ReturnsAsync(false) + .ReturnsAsync(false) + .ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("1")).ReturnsAsync(true); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + await viewModel.ReconnectAsync(); + + Assert.False(viewModel.IsConnected); + Assert.Equal("AUDIO LOST - CLICK RECONNECT", viewModel.StatusText); + Assert.False(viewModel.ConnectCommand.CanExecute(null)); + Assert.True(viewModel.ReconnectCommand.CanExecute(null)); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(4)); + _audioServiceMock.Verify(service => service.Disconnect("manual-recover", true), Times.Never); } /// @@ -371,10 +455,11 @@ public void CanDisconnect_FalseWhenNotConnected() } /// - /// Verifies that manual reconnect is available when a device is selected and no operation is running. + /// Verifies that manual reconnect stays unavailable when only a device is selected and no + /// active or recoverable audio route exists yet. /// [Fact] - public async Task CanReconnect_TrueWhenDeviceSelected() + public async Task CanReconnect_FalseWhenOnlyDeviceSelected() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); @@ -382,7 +467,7 @@ public async Task CanReconnect_TrueWhenDeviceSelected() var viewModel = CreateViewModel(); await viewModel.RefreshDevicesAsync(); - Assert.True(viewModel.ReconnectCommand.CanExecute(null)); + Assert.False(viewModel.ReconnectCommand.CanExecute(null)); } /// @@ -487,10 +572,11 @@ public async Task ConnectionLost_Event_RunsBoundedReconnect_AndRecovers() } /// - /// Verifies that the monitor stops retrying after the bounded reconnect budget is exhausted. + /// Verifies that the monitor falls back to manual reconnect after the bounded retry budget is + /// exhausted while the physical Bluetooth link remains present. /// [Fact] - public async Task ConnectionLost_Event_StopsAfterRetryBudget_AndFallsBackToManualReconnect() + public async Task ConnectionLost_Event_FallsBackToReconnect_WhenPhysicalLinkRemains() { var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); @@ -499,6 +585,7 @@ public async Task ConnectionLost_Event_StopsAfterRetryBudget_AndFallsBackToManua .ReturnsAsync(false) .ReturnsAsync(false); _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("1")).ReturnsAsync(true); var balloonCount = 0; _messenger.Register(this, (_, _) => balloonCount++); @@ -512,11 +599,73 @@ public async Task ConnectionLost_Event_StopsAfterRetryBudget_AndFallsBackToManua await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); Assert.False(viewModel.IsConnected); - Assert.Equal("AUDIO LOST - CLICK CONNECT", viewModel.StatusText); + Assert.Equal("AUDIO LOST - CLICK RECONNECT", viewModel.StatusText); + Assert.False(viewModel.ConnectCommand.CanExecute(null)); + Assert.True(viewModel.ReconnectCommand.CanExecute(null)); Assert.Equal(1, balloonCount); _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(3)); } + /// + /// Verifies that the monitor falls back to full connect after the bounded retry budget is + /// exhausted and the physical Bluetooth link is no longer present. + /// + [Fact] + public async Task ConnectionLost_Event_FallsBackToConnect_WhenPhysicalLinkIsGone() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.SetupSequence(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(true) + .ReturnsAsync(false) + .ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("1")).ReturnsAsync(false); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); + await Task.Delay((MainViewModel.AutoReconnectDelayMs * MainViewModel.AutoReconnectAttemptLimit) + 1000); + await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); + + Assert.False(viewModel.IsConnected); + Assert.Equal("AUDIO LOST - CLICK CONNECT", viewModel.StatusText); + Assert.True(viewModel.ConnectCommand.CanExecute(null)); + Assert.False(viewModel.ReconnectCommand.CanExecute(null)); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(3)); + } + + /// + /// Verifies that a user disconnect during the service-loss reconnect delay cancels the pending + /// reconnect without surfacing an unhandled cancellation from the async event handler. + /// + [Fact] + public async Task ConnectionLost_Event_DoesNotThrow_WhenUserDisconnectsDuringPendingReconnectDelay() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); + viewModel.Disconnect(); + await Task.Delay(200); + + Assert.False(viewModel.IsConnected); + Assert.Equal("DISCONNECTED", viewModel.StatusText); + Assert.True(viewModel.ConnectCommand.CanExecute(null)); + Assert.False(viewModel.ReconnectCommand.CanExecute(null)); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Once); + _audioServiceMock.Verify(service => service.Disconnect("service-connection-lost"), Times.Once); + _audioServiceMock.Verify(service => service.Disconnect("user"), Times.Once); + } + /// /// Verifies that the monitor does not perform hidden idle-time recycling while the device stays connected. /// diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index fac1f3b..ea471c5 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -83,9 +83,20 @@ public partial class MainViewModel( /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectCommand))] [NotifyCanExecuteChangedFor(nameof(DisconnectCommand))] private bool _isConnected; + /// + /// Gets a value indicating whether the audio stream is lost but the physical Bluetooth + /// link is still present, so the UI should guide the user to manual reconnect. + /// + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectCommand))] + [NotifyCanExecuteChangedFor(nameof(DisconnectCommand))] + private bool _isRecoverableConnectionLoss; + /// /// Gets a value indicating whether a connection operation is in progress. /// @@ -113,6 +124,28 @@ public partial class MainViewModel( [ObservableProperty] private string _statusText = "IDLE"; + /// + /// Gets a value indicating whether the reconnect/disconnect action pair should be shown. + /// + public bool ShowReconnectActions + { + get + { + return IsConnected || IsRecoverableConnectionLoss; + } + } + + /// + /// Gets a value indicating whether the full connect action should be shown. + /// + public bool ShowConnectAction + { + get + { + return !ShowReconnectActions; + } + } + /// /// Gets the update view model injected into this instance. /// @@ -253,6 +286,7 @@ internal async Task ConnectAsync() IsBusy = true; ResetReconnectGuidance(); StopConnectionMonitor(); + IsRecoverableConnectionLoss = false; StatusText = $"CONNECTING TO {deviceName}..."; var connected = await ConnectWithAutoReconnectAsync( @@ -263,8 +297,7 @@ internal async Task ConnectAsync() if (!connected) { - ApplyReconnectRequiredState(); - ShowReconnectGuidanceBalloon(); + await ApplyManualFallbackStateAsync(deviceId, showGuidanceBalloon: true); return; } @@ -282,7 +315,7 @@ internal async Task ConnectAsync() } /// - /// Disconnects the current Bluetooth audio device. + /// Disconnects the current Bluetooth audio device and clears any recoverable fallback state. /// [RelayCommand(CanExecute = nameof(CanDisconnect))] internal void Disconnect() @@ -298,22 +331,19 @@ internal void Disconnect() Debug.WriteLine($"[Disconnect] Error: {ex.Message}"); } - ResetReconnectGuidance(); - _isReconnecting = false; - IsConnected = false; - StatusText = "DISCONNECTED"; - DisconnectCommand.NotifyCanExecuteChanged(); + ApplyDisconnectedState("DISCONNECTED"); } /// - /// Performs an explicit disconnect/connect cycle for the currently selected device. - /// This is the primary manual reconnect action exposed in the UI and tray. + /// Performs a one-shot audio-route recycle for the currently selected device. + /// This is the primary manual reconnect action exposed in the UI and tray when the + /// audio route is active or the physical Bluetooth link is still recoverable. /// /// A task representing the asynchronous reconnect operation. [RelayCommand(CanExecute = nameof(CanReconnect))] internal async Task ReconnectAsync() { - if (SelectedBluetoothDevice == null) + if (SelectedBluetoothDevice == null || !CanReconnect()) { return; } @@ -326,6 +356,7 @@ internal async Task ReconnectAsync() IsBusy = true; ResetReconnectGuidance(); StopConnectionMonitor(); + IsRecoverableConnectionLoss = false; StatusText = "RECONNECTING..."; if (IsConnected || _isReconnecting) @@ -343,7 +374,7 @@ internal async Task ReconnectAsync() var connected = await audioService.ConnectBluetoothAudioAsync(deviceId); if (!connected) { - ApplyReconnectRequiredState(); + await ApplyManualFallbackStateAsync(deviceId, showGuidanceBalloon: false); return; } @@ -429,6 +460,26 @@ partial void OnSelectedBluetoothDeviceChanged(BluetoothDevice? value) } } + /// + /// Raises derived action-visibility notifications when the connected state changes. + /// + /// The updated connected state. + partial void OnIsConnectedChanged(bool value) + { + OnPropertyChanged(nameof(ShowReconnectActions)); + OnPropertyChanged(nameof(ShowConnectAction)); + } + + /// + /// Raises derived action-visibility notifications when the recoverable-loss state changes. + /// + /// The updated recoverable-loss state. + partial void OnIsRecoverableConnectionLossChanged(bool value) + { + OnPropertyChanged(nameof(ShowReconnectActions)); + OnPropertyChanged(nameof(ShowConnectAction)); + } + /// /// Handles the by updating the AutoConnect flag. /// @@ -471,14 +522,14 @@ private async Task CheckAndAutoInstallUpdateAsync() /// /// Handles the by cycling the audio connection - /// so that a quality registry change takes effect without requiring manual user action. - /// No-op if no device is selected or if the app is neither connected nor reconnecting. + /// so that a quality registry change takes effect without requiring a full reconnect. + /// No-op if no device is selected or if no active/recoverable audio route exists. /// private void OnReconnectRequested() { dispatcherService.Invoke(() => { - if (SelectedBluetoothDevice == null || (!IsConnected && !_isReconnecting)) + if (SelectedBluetoothDevice == null || (!IsConnected && !IsRecoverableConnectionLoss)) { return; } @@ -490,28 +541,28 @@ private void OnReconnectRequested() /// /// Determines whether the connect command can execute. /// - /// if a device is selected, not connected, and not busy. + /// if a device is selected, no recoverable route exists, and no blocking operation is running. private bool CanConnect() { - return SelectedBluetoothDevice != null && !IsConnected && !IsBusy; + return SelectedBluetoothDevice != null && !IsConnected && !IsRecoverableConnectionLoss && !IsBusy; } /// /// Determines whether the disconnect command can execute. /// - /// if currently connected or automatically reconnecting. + /// if the app has an active, recoverable, or in-flight reconnect route to clear. private bool CanDisconnect() { - return IsConnected || _isReconnecting; + return IsConnected || IsRecoverableConnectionLoss || _isReconnecting; } /// /// Determines whether the explicit manual reconnect command can execute. /// - /// if a device is selected and no blocking operation is running. + /// if a device is selected, no blocking operation is running, and a recoverable route exists. private bool CanReconnect() { - return SelectedBluetoothDevice != null && !IsBusy; + return SelectedBluetoothDevice != null && !IsBusy && (IsConnected || IsRecoverableConnectionLoss); } /// @@ -655,7 +706,8 @@ private async Task TryAutoReconnectAsync( /// /// Handles a confirmed connection loss by running the bounded automatic reconnect sequence. - /// When the retry budget is exhausted, the monitor is stopped and the UI falls back to manual reconnect. + /// When the retry budget is exhausted, the monitor is stopped and the UI falls back to the + /// correct manual action for the remaining Bluetooth state. /// /// The device identifier to reconnect. /// The friendly device name for status updates. @@ -677,8 +729,6 @@ private async Task HandleConfirmedConnectionLossAsync( try { - dispatcherService.Invoke(ApplyReconnectingState); - try { audioService.Disconnect(disconnectReason); @@ -699,8 +749,17 @@ private async Task HandleConfirmedConnectionLossAsync( return; } - dispatcherService.Invoke(ApplyReconnectRequiredState); - ShowReconnectGuidanceBalloon(); + var isPhysicallyConnected = await audioService.IsBluetoothPhysicallyConnectedAsync(deviceId); + if (cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + { + return; + } + + dispatcherService.Invoke(() => + { + ApplyReconnectRequiredState(isPhysicallyConnected); + ShowReconnectGuidanceBalloon(isPhysicallyConnected); + }); StopConnectionMonitor(); } finally @@ -718,6 +777,7 @@ private void ApplyConnectedState(string deviceName) ResetReconnectGuidance(); _isReconnecting = false; IsConnected = true; + IsRecoverableConnectionLoss = false; StatusText = "STREAMING ACTIVE"; DisconnectCommand.NotifyCanExecuteChanged(); messenger.Send(new ConnectionEstablishedMessage(deviceName)); @@ -730,6 +790,7 @@ private void ApplyReconnectingState() { _isReconnecting = true; IsConnected = false; + IsRecoverableConnectionLoss = false; StatusText = "RECONNECTING..."; DisconnectCommand.NotifyCanExecuteChanged(); } @@ -737,14 +798,50 @@ private void ApplyReconnectingState() /// /// Applies the stable failure state used after the automatic reconnect budget is exhausted. /// - private void ApplyReconnectRequiredState() + /// when the Bluetooth link still exists and the UI should guide to manual reconnect. + private void ApplyReconnectRequiredState(bool isPhysicallyConnected) + { + _isReconnecting = false; + IsConnected = false; + IsRecoverableConnectionLoss = isPhysicallyConnected; + StatusText = isPhysicallyConnected + ? "AUDIO LOST - CLICK RECONNECT" + : "AUDIO LOST - CLICK CONNECT"; + DisconnectCommand.NotifyCanExecuteChanged(); + } + + /// + /// Applies the fully disconnected state after the user intentionally disconnects or clears + /// a recoverable audio-loss prompt. + /// + /// The status text to show in the UI. + private void ApplyDisconnectedState(string statusText) { + ResetReconnectGuidance(); _isReconnecting = false; IsConnected = false; - StatusText = "AUDIO LOST - CLICK CONNECT"; + IsRecoverableConnectionLoss = false; + StatusText = statusText; DisconnectCommand.NotifyCanExecuteChanged(); } + /// + /// Applies the correct manual fallback state after a connect or reconnect attempt fails. + /// + /// The device identifier whose physical Bluetooth state should be checked. + /// to show the one-shot tray guidance balloon. + /// A task representing the asynchronous state update. + private async Task ApplyManualFallbackStateAsync(string deviceId, bool showGuidanceBalloon) + { + var isPhysicallyConnected = await audioService.IsBluetoothPhysicallyConnectedAsync(deviceId); + ApplyReconnectRequiredState(isPhysicallyConnected); + + if (showGuidanceBalloon) + { + ShowReconnectGuidanceBalloon(isPhysicallyConnected); + } + } + /// /// Clears the one-shot reconnect guidance notification gate for a fresh connection attempt. /// @@ -757,7 +854,8 @@ private void ResetReconnectGuidance() /// Shows the one-shot tray balloon that explains the manual reconnect action after the /// bounded automatic reconnect budget has been exhausted. /// - private void ShowReconnectGuidanceBalloon() + /// to guide the user to the manual reconnect action; otherwise guide them to a full connect. + private void ShowReconnectGuidanceBalloon(bool guideToReconnect) { if (_hasShownReconnectBalloon) { @@ -765,9 +863,10 @@ private void ShowReconnectGuidanceBalloon() } _hasShownReconnectBalloon = true; + var actionLabel = guideToReconnect ? "Reconnect" : "Connect"; messenger.Send(new ShowBalloonRequestedMessage(new BalloonContent( "Bluetooth Audio", - "Use Connect or toggle the route on the iPhone.", + $"Use {actionLabel} or toggle the route on the iPhone.", BalloonIcon.Warning))); } @@ -790,17 +889,23 @@ private async void OnConnectionLostFromService(object? sender, EventArgs e) var deviceName = _monitoredDeviceName; var monitorGeneration = _monitorGeneration; - var stillConnected = await audioService.IsBluetoothDeviceConnectedAsync(deviceId); - if (stillConnected || cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + try { - return; - } + var stillConnected = await audioService.IsBluetoothDeviceConnectedAsync(deviceId); + if (stillConnected || cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + { + return; + } - await HandleConfirmedConnectionLossAsync( - deviceId, - deviceName, - "service-connection-lost", - cancellationToken, - monitorGeneration); + await HandleConfirmedConnectionLossAsync( + deviceId, + deviceName, + "service-connection-lost", + cancellationToken, + monitorGeneration); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + { + } } } diff --git a/EasyBluetoothAudio/Views/BluetoothConfigView.xaml b/EasyBluetoothAudio/Views/BluetoothConfigView.xaml index ad96816..6355ad5 100644 --- a/EasyBluetoothAudio/Views/BluetoothConfigView.xaml +++ b/EasyBluetoothAudio/Views/BluetoothConfigView.xaml @@ -110,7 +110,7 @@ - + @@ -170,7 +170,7 @@ - + @@ -227,7 +227,7 @@ - + From 1ea6e402215497b22c968e0a6f5ad571fc539076 Mon Sep 17 00:00:00 2001 From: GorangN Date: Tue, 21 Apr 2026 16:53:11 +0200 Subject: [PATCH 11/13] chore: update task tracking and lessons for manual recovery UX --- tasks/lessons.md | 2 ++ tasks/todo.md | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/tasks/lessons.md b/tasks/lessons.md index db04b3f..27d6775 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -10,3 +10,5 @@ - A cooldown on zombie recovery attempts must not suppress the first follow-up retry after a failed recycle. If the first reconnect does not restore audio, blocking all further retries for minutes strands the user in silence; only enter the long cooldown after enough failed attempts to conclude we are in a repeated-failure state rather than a one-off missed recovery. - If recovery depends on weak heuristics instead of authoritative connection state, do not hide it behind background automation. Prefer a manual-first UX with a clear `Reconnect` action and a small, bounded auto-reconnect budget for real loss only. - For this UI's connect-to-connected transition, do not animate only one secondary button into place while the other snaps to its final slot. The full-width `CONNECT` control must visually collapse from the midpoint while `RECONNECT` and `DISCONNECT` grow outward from that same center line, so the motion reads as one button splitting into two. +- When a UI exposes both `Connect` and `Reconnect`, keep their semantics distinct. `Connect` is the full route setup path, while `Reconnect` should only recycle the audio stream when the physical Bluetooth link still exists; exhausted fallback states must guide to the correct action explicitly. +- Any `async void` event handler that awaits work using a cancellable monitor token must catch `OperationCanceledException` for the current token/generation. Otherwise a normal stop like `StopConnectionMonitor()` can turn expected cancellation into an unhandled WPF exception. diff --git a/tasks/todo.md b/tasks/todo.md index a5d05e0..d2fd3a7 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -163,3 +163,30 @@ - No ViewModel or command logic changed; the update is isolated to the action-button XAML animation. - `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-center-split-out\bin\"` passed with 0 warnings and 0 errors. - `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-center-split-out\bin\"` passed with 95/95 tests green. + +## Connect/Reconnect Separation + +- [x] Refactor `MainViewModel` so `Connect` and `Reconnect` have distinct semantics with an explicit recoverable audio-loss state. +- [x] Apply the physical-aware fallback after exhausted auto-reconnect attempts so the UI guides to `Reconnect` only when Bluetooth is still physically up. +- [x] Update `BluetoothConfigView.xaml` so `CONNECT`, `RECONNECT`, and `DISCONNECT` visibility follows the new recoverable state instead of `IsConnected` alone. +- [x] Extend `MainViewModelTests` for recoverable fallback, physical disconnect fallback, and one-shot manual reconnect behavior. +- [x] Re-run the targeted `MainViewModelTests` suite and record the verification results. + +## Review + +- `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now separates full `Connect` from one-shot manual `Reconnect` via an explicit recoverable-loss state. Exhausted fallback checks `IsBluetoothPhysicallyConnectedAsync(...)` and lands in either `AUDIO LOST - CLICK RECONNECT` or `AUDIO LOST - CLICK CONNECT` accordingly. +- `EasyBluetoothAudio/Views/BluetoothConfigView.xaml` now drives the split-button visibility from `ShowReconnectActions` instead of `IsConnected` alone, so the connected-state controls stay available when only the stream is lost but Bluetooth remains physically linked. +- `EasyBluetoothAudio.Tests/MainViewModelTests.cs` now covers recoverable vs. non-recoverable fallback, the new disconnected-state `Reconnect` no-op, one-shot manual reconnect without hidden retries, and clearing the recoverable state via `Disconnect`. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.Tests\EasyBluetoothAudio.Tests.csproj -c Release --no-restore --filter MainViewModelTests` passed with 34/34 `MainViewModelTests` green. + +## Service-Loss Cancellation Handling + +- [x] Inspect the `ConnectionLost` event path and confirm where monitor cancellation can escape the `async void` handler. +- [x] Treat monitor cancellation as a normal stop condition during service-triggered reconnect and keep the rest of the reconnect flow unchanged. +- [x] Add a regression test for disconnecting while the service-loss reconnect delay is pending, then re-run the targeted `MainViewModelTests` suite. + +## Review + +- `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now wraps the service-triggered `async void` `OnConnectionLostFromService(...)` path in an `OperationCanceledException` filter keyed to the current monitor token/generation, so `StopConnectionMonitor()` cancellation is treated as a normal shutdown instead of escaping as an unhandled exception. +- `EasyBluetoothAudio.Tests/MainViewModelTests.cs` now includes `ConnectionLost_Event_DoesNotThrow_WhenUserDisconnectsDuringPendingReconnectDelay`, which raises the immediate `ConnectionLost` event, disconnects while the reconnect `Task.Delay(...)` is still pending, and asserts the UI stays in the clean `DISCONNECTED` state without issuing another connect attempt. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.Tests\EasyBluetoothAudio.Tests.csproj -c Release --no-restore --filter MainViewModelTests -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-service-loss-cancel-out\bin\"` passed with 35/35 `MainViewModelTests` green. From 553f18c72d72e540f817c8635a7736873753d6d5 Mon Sep 17 00:00:00 2001 From: GorangN Date: Tue, 21 Apr 2026 17:15:59 +0200 Subject: [PATCH 12/13] fix: cancel stale connect retry flows --- .../MainViewModelTests.cs | 32 ++++++ .../ViewModels/MainViewModel.cs | 104 +++++++++++++++++- tasks/lessons.md | 1 + tasks/todo.md | 12 ++ 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/EasyBluetoothAudio.Tests/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 0354358..c1e3157 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using CommunityToolkit.Mvvm.Messaging; using EasyBluetoothAudio.Messages; @@ -268,6 +269,37 @@ public async Task ConnectAsync_RetriesAfterInitialFailure_AndRecovers() _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Exactly(2)); } + /// + /// Verifies that a user disconnect cancels the pending initial auto-reconnect delay so the + /// original connect flow cannot run a stale retry after disconnect semantics were requested. + /// + [Fact] + public async Task ConnectAsync_DoesNotRetry_WhenUserDisconnectsDuringPendingRetryDelay() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.SetupSequence(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(false) + .ReturnsAsync(true); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + + var connectTask = viewModel.ConnectAsync(); + await Task.Delay(200); + + Assert.True(viewModel.DisconnectCommand.CanExecute(null)); + + viewModel.Disconnect(); + await connectTask.WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.False(viewModel.IsConnected); + Assert.False(viewModel.IsBusy); + Assert.Equal("DISCONNECTED", viewModel.StatusText); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Once); + _audioServiceMock.Verify(service => service.IsBluetoothPhysicallyConnectedAsync(It.IsAny()), Times.Never); + } + /// /// Verifies that connect does nothing when no device is selected. /// diff --git a/EasyBluetoothAudio/ViewModels/MainViewModel.cs b/EasyBluetoothAudio/ViewModels/MainViewModel.cs index ea471c5..144a403 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -59,6 +59,7 @@ public partial class MainViewModel( /// private const int AutoUpdateCheckTimeoutMs = 10_000; + private CancellationTokenSource? _connectAttemptCts; private CancellationTokenSource? _monitorCts; private string? _lastDeviceId; private string? _monitoredDeviceId; @@ -66,6 +67,7 @@ public partial class MainViewModel( private bool _isRefreshing; private volatile bool _isReconnecting; private int _autoReconnectInFlight; + private int _connectAttemptGeneration; private int _monitorGeneration; private bool _hasShownReconnectBalloon; @@ -280,6 +282,7 @@ internal async Task ConnectAsync() var deviceId = SelectedBluetoothDevice.Id; var deviceName = SelectedBluetoothDevice.Name; + var (connectAttemptCts, cancellationToken, connectAttemptGeneration) = BeginConnectAttempt(); try { @@ -292,12 +295,20 @@ internal async Task ConnectAsync() var connected = await ConnectWithAutoReconnectAsync( deviceId, deviceName, - CancellationToken.None, - static () => true); + cancellationToken, + () => IsCurrentConnectAttempt(cancellationToken, connectAttemptGeneration)); + + if (!IsCurrentConnectAttempt(cancellationToken, connectAttemptGeneration)) + { + return; + } if (!connected) { - await ApplyManualFallbackStateAsync(deviceId, showGuidanceBalloon: true); + await ApplyManualFallbackStateAsync( + deviceId, + showGuidanceBalloon: true, + () => IsCurrentConnectAttempt(cancellationToken, connectAttemptGeneration)); return; } @@ -310,6 +321,7 @@ internal async Task ConnectAsync() } finally { + CompleteConnectAttempt(connectAttemptCts); IsBusy = false; } } @@ -320,6 +332,7 @@ internal async Task ConnectAsync() [RelayCommand(CanExecute = nameof(CanDisconnect))] internal void Disconnect() { + CancelPendingConnectAttempt(); StopConnectionMonitor(); try @@ -565,6 +578,58 @@ private bool CanReconnect() return SelectedBluetoothDevice != null && !IsBusy && (IsConnected || IsRecoverableConnectionLoss); } + /// + /// Starts a new user-triggered connect attempt scope and cancels any previous pending + /// initial-connect retry sequence that should no longer be allowed to apply UI state. + /// + /// The cancellation source, token, and generation stamp for the new connect attempt. + private (CancellationTokenSource ConnectAttemptCts, CancellationToken CancellationToken, int ConnectAttemptGeneration) BeginConnectAttempt() + { + CancelPendingConnectAttempt(); + var connectAttemptCts = new CancellationTokenSource(); + _connectAttemptCts = connectAttemptCts; + return (connectAttemptCts, connectAttemptCts.Token, _connectAttemptGeneration); + } + + /// + /// Cancels any pending user-triggered initial connect attempt so stale retries or fallback + /// state updates cannot outlive a later disconnect or replacement connect operation. + /// + private void CancelPendingConnectAttempt() + { + Interlocked.Increment(ref _connectAttemptGeneration); + _connectAttemptCts?.Cancel(); + _connectAttemptCts?.Dispose(); + _connectAttemptCts = null; + } + + /// + /// Clears the active connect-attempt scope if it still belongs to the completing operation. + /// + /// The cancellation source captured by the completing connect attempt. + private void CompleteConnectAttempt(CancellationTokenSource connectAttemptCts) + { + if (!ReferenceEquals(_connectAttemptCts, connectAttemptCts)) + { + return; + } + + connectAttemptCts.Dispose(); + _connectAttemptCts = null; + } + + /// + /// Determines whether the supplied connect-attempt token and generation still belong to the + /// latest user-triggered initial connect operation. + /// + /// The token captured for the connect attempt. + /// The generation stamp captured for the connect attempt. + /// if the connect attempt is still current; otherwise . + private bool IsCurrentConnectAttempt(CancellationToken cancellationToken, int connectAttemptGeneration) + { + return !cancellationToken.IsCancellationRequested && connectAttemptGeneration == _connectAttemptGeneration; + } + /// /// Starts a background task that monitors the currently connected device and only reacts /// to confirmed connection loss. Idle silence does not trigger any background reconnect. @@ -652,6 +717,12 @@ private async Task ConnectWithAutoReconnectAsync( var connected = await audioService.ConnectBluetoothAudioAsync(deviceId); if (connected) { + if (!shouldApplyResult()) + { + audioService.Disconnect("stale-auto-reconnect"); + return false; + } + ApplyConnectedState(deviceName); return true; } @@ -678,12 +749,24 @@ private async Task TryAutoReconnectAsync( for (var attempt = 0; attempt < AutoReconnectAttemptLimit; attempt++) { - if (cancellationToken.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested || !shouldApplyResult()) { return false; } - await Task.Delay(AutoReconnectDelayMs, cancellationToken); + try + { + await Task.Delay(AutoReconnectDelayMs, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return false; + } + + if (!shouldApplyResult()) + { + return false; + } var connected = await audioService.ConnectBluetoothAudioAsync(deviceId); if (!connected) @@ -831,9 +914,18 @@ private void ApplyDisconnectedState(string statusText) /// The device identifier whose physical Bluetooth state should be checked. /// to show the one-shot tray guidance balloon. /// A task representing the asynchronous state update. - private async Task ApplyManualFallbackStateAsync(string deviceId, bool showGuidanceBalloon) + private async Task ApplyManualFallbackStateAsync( + string deviceId, + bool showGuidanceBalloon, + Func? shouldApplyResult = null) { + var canApplyResult = shouldApplyResult ?? (() => true); var isPhysicallyConnected = await audioService.IsBluetoothPhysicallyConnectedAsync(deviceId); + if (!canApplyResult()) + { + return; + } + ApplyReconnectRequiredState(isPhysicallyConnected); if (showGuidanceBalloon) diff --git a/tasks/lessons.md b/tasks/lessons.md index 27d6775..9e7fdcf 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -12,3 +12,4 @@ - For this UI's connect-to-connected transition, do not animate only one secondary button into place while the other snaps to its final slot. The full-width `CONNECT` control must visually collapse from the midpoint while `RECONNECT` and `DISCONNECT` grow outward from that same center line, so the motion reads as one button splitting into two. - When a UI exposes both `Connect` and `Reconnect`, keep their semantics distinct. `Connect` is the full route setup path, while `Reconnect` should only recycle the audio stream when the physical Bluetooth link still exists; exhausted fallback states must guide to the correct action explicitly. - Any `async void` event handler that awaits work using a cancellable monitor token must catch `OperationCanceledException` for the current token/generation. Otherwise a normal stop like `StopConnectionMonitor()` can turn expected cancellation into an unhandled WPF exception. +- Any user-triggered retry loop needs its own cancellation/generation scope, not `CancellationToken.None`. If `Disconnect()` or a replacement connect request can happen while retries or fallback checks are pending, stale post-await results must be discarded before they can reapply connected or recovery UI state. diff --git a/tasks/todo.md b/tasks/todo.md index d2fd3a7..548517f 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -190,3 +190,15 @@ - `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now wraps the service-triggered `async void` `OnConnectionLostFromService(...)` path in an `OperationCanceledException` filter keyed to the current monitor token/generation, so `StopConnectionMonitor()` cancellation is treated as a normal shutdown instead of escaping as an unhandled exception. - `EasyBluetoothAudio.Tests/MainViewModelTests.cs` now includes `ConnectionLost_Event_DoesNotThrow_WhenUserDisconnectsDuringPendingReconnectDelay`, which raises the immediate `ConnectionLost` event, disconnects while the reconnect `Task.Delay(...)` is still pending, and asserts the UI stays in the clean `DISCONNECTED` state without issuing another connect attempt. - `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.Tests\EasyBluetoothAudio.Tests.csproj -c Release --no-restore --filter MainViewModelTests -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-service-loss-cancel-out\bin\"` passed with 35/35 `MainViewModelTests` green. + +## Initial Connect Cancellation Handling + +- [x] Inspect the initial `ConnectAsync()` retry path and confirm where stale retries can outlive a later disconnect/stop request. +- [x] Add a cancellable operation scope/generation guard for the initial connect auto-reconnect flow so stale retries and stale fallback UI updates are discarded. +- [x] Add a regression test for disconnecting during the initial auto-reconnect delay, then re-run the targeted `MainViewModelTests` suite. + +## Review + +- `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now gives user-triggered initial connects their own cancellation/generation scope. `Disconnect()` cancels that scope, `ConnectWithAutoReconnectAsync(...)` discards stale late-success results, and `ApplyManualFallbackStateAsync(...)` now re-checks whether the original connect attempt is still current before applying fallback UI guidance. +- `EasyBluetoothAudio.Tests/MainViewModelTests.cs` now includes `ConnectAsync_DoesNotRetry_WhenUserDisconnectsDuringPendingRetryDelay`, which starts `ConnectAsync()`, waits for the auto-reconnect delay window, disconnects, and asserts the original connect task exits without running the delayed retry or applying stale fallback state. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.Tests\EasyBluetoothAudio.Tests.csproj -c Release --no-restore --filter MainViewModelTests -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-initial-connect-cancel-out\bin\"` passed with 36/36 `MainViewModelTests` green. From b81ec6bdcfe150c0f7e9a1836c850d512bb99c98 Mon Sep 17 00:00:00 2001 From: GorangN Date: Tue, 21 Apr 2026 17:41:37 +0200 Subject: [PATCH 13/13] =?UTF-8?q?Updated=20AudioService.cs=20(line=2056)?= =?UTF-8?q?=20so=20the=20app=20now=20uses=20AudioPlaybackConnection.GetDev?= =?UTF-8?q?iceSelector()=20presence=20as=20the=20=E2=80=9Cphysical=20link?= =?UTF-8?q?=20still=20up=E2=80=9D=20signal=20for=20\SNK=20devices.=20IsBlu?= =?UTF-8?q?etoothPhysicallyConnectedAsync(...)=20no=20longer=20depends=20o?= =?UTF-8?q?n=20System.Devices.Aep.IsConnected,=20and=20device=20discovery?= =?UTF-8?q?=20now=20uses=20the=20same=20selector-backed=20source=20of=20tr?= =?UTF-8?q?uth,=20which=20keeps=20the=20fallback=20on=20Reconnect=20when?= =?UTF-8?q?=20Windows=20still=20exposes=20the=20remote=20source.=20IAudioS?= =?UTF-8?q?ervice.cs=20(line=2050)=20now=20documents=20that=20contract.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EasyBluetoothAudio.Tests/AudioServiceTests.cs | 60 +++++++++++++++++++ EasyBluetoothAudio.Tests/TestAudioService.cs | 31 ++++++++++ EasyBluetoothAudio/Services/AudioService.cs | 53 ++++++++-------- .../Services/Interfaces/IAudioService.cs | 6 +- tasks/lessons.md | 1 + tasks/todo.md | 14 +++++ 6 files changed, 136 insertions(+), 29 deletions(-) create mode 100644 EasyBluetoothAudio.Tests/AudioServiceTests.cs create mode 100644 EasyBluetoothAudio.Tests/TestAudioService.cs diff --git a/EasyBluetoothAudio.Tests/AudioServiceTests.cs b/EasyBluetoothAudio.Tests/AudioServiceTests.cs new file mode 100644 index 0000000..63db7d1 --- /dev/null +++ b/EasyBluetoothAudio.Tests/AudioServiceTests.cs @@ -0,0 +1,60 @@ +using EasyBluetoothAudio.Services.Interfaces; +using Moq; + +namespace EasyBluetoothAudio.Tests; + +/// +/// Tests for selector-based Bluetooth presence checks. +/// +public class AudioServiceTests +{ + private readonly Mock _dispatcherServiceMock = new(); + + /// + /// Verifies that selector presence is treated as a physical Bluetooth connection. + /// + [Fact] + public async Task IsBluetoothPhysicallyConnectedAsync_ReturnsTrue_WhenDeviceAppearsInSelectorSnapshot() + { + var service = new TestAudioService( + _dispatcherServiceMock.Object, + [(@"BTHHFENUM\DEV_00112233\SNK", "iPhone")]); + + var isConnected = await service.IsBluetoothPhysicallyConnectedAsync(@"BTHHFENUM\DEV_00112233\SNK"); + + Assert.True(isConnected); + } + + /// + /// Verifies that selector absence is treated as a missing physical Bluetooth connection. + /// + [Fact] + public async Task IsBluetoothPhysicallyConnectedAsync_ReturnsFalse_WhenDeviceIsMissingFromSelectorSnapshot() + { + var service = new TestAudioService( + _dispatcherServiceMock.Object, + [(@"BTHHFENUM\DEV_00112233\SNK", "iPhone")]); + + var isConnected = await service.IsBluetoothPhysicallyConnectedAsync(@"BTHHFENUM\DEV_99887766\SNK"); + + Assert.False(isConnected); + } + + /// + /// Verifies that discovered selector devices are surfaced as connected sources. + /// + [Fact] + public async Task GetBluetoothDevicesAsync_MarksSelectorDevicesAsConnected() + { + var service = new TestAudioService( + _dispatcherServiceMock.Object, + [(@"BTHHFENUM\DEV_00112233\SNK", "iPhone")]); + + var devices = (await service.GetBluetoothDevicesAsync()).ToList(); + + var device = Assert.Single(devices); + Assert.Equal(@"BTHHFENUM\DEV_00112233\SNK", device.Id); + Assert.Equal("iPhone", device.Name); + Assert.True(device.IsConnected); + } +} diff --git a/EasyBluetoothAudio.Tests/TestAudioService.cs b/EasyBluetoothAudio.Tests/TestAudioService.cs new file mode 100644 index 0000000..6402b7e --- /dev/null +++ b/EasyBluetoothAudio.Tests/TestAudioService.cs @@ -0,0 +1,31 @@ +using EasyBluetoothAudio.Services; +using EasyBluetoothAudio.Services.Interfaces; + +namespace EasyBluetoothAudio.Tests; + +/// +/// Test double for that injects a deterministic selector snapshot. +/// +internal sealed class TestAudioService : AudioService +{ + private readonly IReadOnlyList<(string Id, string Name)> _connectedDevices; + + /// + /// Initializes a new instance of the class. + /// + /// The dispatcher service dependency required by the base class. + /// The selector snapshot to expose to the test. + internal TestAudioService( + IDispatcherService dispatcherService, + IReadOnlyList<(string Id, string Name)> connectedDevices) + : base(dispatcherService) + { + _connectedDevices = connectedDevices; + } + + /// + internal override Task> GetConnectedAudioPlaybackDevicesAsync() + { + return Task.FromResult(_connectedDevices); + } +} diff --git a/EasyBluetoothAudio/Services/AudioService.cs b/EasyBluetoothAudio/Services/AudioService.cs index d8961fd..0a828ff 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -45,41 +45,40 @@ public AudioService(IDispatcherService dispatcherService) _dispatcherService = dispatcherService; } + /// + /// Enumerates the remote devices currently exposed through the + /// selector. + /// Presence in this selector is the app's authoritative signal that Windows still sees the + /// remote source as an available Bluetooth audio-playback device, so callers must not + /// reinterpret these results through the unreliable AEP connectivity property. + /// + /// A snapshot of connected audio-playback device identifiers and display names. + internal virtual async Task> GetConnectedAudioPlaybackDevicesAsync() + { + var selector = AudioPlaybackConnection.GetDeviceSelector(); + var devices = await DeviceInformation.FindAllAsync(selector); + return devices.Select(device => (device.Id, device.Name)).ToList(); + } + /// public async Task> GetBluetoothDevicesAsync() { var result = new List(); try { - var selector = AudioPlaybackConnection.GetDeviceSelector(); - string[] requestedProperties = { "System.Devices.Aep.IsConnected" }; - var devices = await DeviceInformation.FindAllAsync(selector, requestedProperties); + var devices = await GetConnectedAudioPlaybackDevicesAsync(); foreach (var device in devices) { - var connected = false; - try - { - if (device.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) - && value is bool isConnected) - { - connected = isConnected; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[DeviceDiscover] Error retrieving properties for {device.Name}: {ex.Message}"); - } - result.Add(new BluetoothDevice { Name = device.Name, Id = device.Id, - IsConnected = connected, + IsConnected = true, IsPhoneOrComputer = true }); - Debug.WriteLine($"[DeviceDiscover] Found Source: {device.Name} (ID: {device.Id}, Connected: {connected})"); + Debug.WriteLine($"[DeviceDiscover] Found Source: {device.Name} (ID: {device.Id}, Connected: true via selector)"); } } catch (Exception ex) @@ -267,17 +266,17 @@ public async Task IsBluetoothPhysicallyConnectedAsync(string deviceId) { try { - var deviceInfo = await DeviceInformation.CreateFromIdAsync( - deviceId, - new[] { "System.Devices.Aep.IsConnected" }); - - if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) - && value is bool btConnected) + if (string.Equals(_activeDeviceId, deviceId, StringComparison.OrdinalIgnoreCase) + && _audioConnection?.State == AudioPlaybackConnectionState.Opened) { - return btConnected; + return true; } - return false; + var devices = await GetConnectedAudioPlaybackDevicesAsync(); + var isConnected = devices.Any(device => + string.Equals(device.Id, deviceId, StringComparison.OrdinalIgnoreCase)); + Debug.WriteLine($"[IsPhysicallyConnected] returning {isConnected}: reason=selector-presence"); + return isConnected; } catch (Exception ex) { diff --git a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs index 65875f5..cd4cf3a 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -42,8 +42,10 @@ public interface IAudioService /// /// The unique identifier of the device to check. /// - /// true if System.Devices.Aep.IsConnected reports ; - /// otherwise false. + /// true if the device appears in the current + /// selector enumeration; otherwise + /// false. This avoids the unreliable System.Devices.Aep.IsConnected property on + /// WinRT \SNK endpoints. /// Task IsBluetoothPhysicallyConnectedAsync(string deviceId); diff --git a/tasks/lessons.md b/tasks/lessons.md index 9e7fdcf..5e2ab7e 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -2,6 +2,7 @@ - When a health check is supposed to reason about one remote source, never fall back to an aggregated endpoint-level meter that can be kept alive by unrelated local audio. Prefer source- or session-specific telemetry, and return `null` when isolation is not yet trustworthy. - For WinRT Bluetooth `\SNK` audio endpoints, do not use `System.Devices.Aep.IsConnected` as a reconnect/settle gate in either direction. It can report `True` when no audio connection is open, and `False` when the phone is still BT-connected and the next `OpenAsync` succeeds immediately. Track the "is the phone still up?" distinction via our own teardown bookkeeping (e.g. a `preserveDisconnectTimestamp` flag on internal recycles) instead of the AEP property. +- When reconnect fallback needs to know whether a remote audio source is still physically present, query the current `AudioPlaybackConnection.GetDeviceSelector()` results instead of re-reading `System.Devices.Aep.IsConnected`. Selector presence matches the WinRT remote-audio endpoint model more reliably for `\SNK` devices and keeps `Reconnect` vs `Connect` guidance aligned with what Windows can actually reopen. - Do not use `x:Reference` from a tray-hosted `ContextMenu` back to the `TaskbarIcon` while that icon is still being constructed. In WPF this can create a cyclic XAML dependency and crash startup; assign `ContextMenu.DataContext` after the window has been created instead. - For tray-hosted WPF `ContextMenu` commands, prefer binding each `MenuItem.Command` via `PlacementTarget.DataContext.` on the ancestor `ContextMenu`. It avoids both startup-time path errors against the `TaskbarIcon` object and markup-time recursion from trying to push a shared `DataContext` into the menu. - For Hardcodet tray menus, direct event wiring can be more reliable than WPF command bindings. If tray `MenuItem` commands regress across `ContextMenu`/`TaskbarIcon` binding changes, wire `Click` handlers after window creation and invoke the ViewModel commands explicitly. diff --git a/tasks/todo.md b/tasks/todo.md index 548517f..2636ef8 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -202,3 +202,17 @@ - `EasyBluetoothAudio/ViewModels/MainViewModel.cs` now gives user-triggered initial connects their own cancellation/generation scope. `Disconnect()` cancels that scope, `ConnectWithAutoReconnectAsync(...)` discards stale late-success results, and `ApplyManualFallbackStateAsync(...)` now re-checks whether the original connect attempt is still current before applying fallback UI guidance. - `EasyBluetoothAudio.Tests/MainViewModelTests.cs` now includes `ConnectAsync_DoesNotRetry_WhenUserDisconnectsDuringPendingRetryDelay`, which starts `ConnectAsync()`, waits for the auto-reconnect delay window, disconnects, and asserts the original connect task exits without running the delayed retry or applying stale fallback state. - `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.Tests\EasyBluetoothAudio.Tests.csproj -c Release --no-restore --filter MainViewModelTests -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-initial-connect-cancel-out\bin\"` passed with 36/36 `MainViewModelTests` green. + +## Reconnect Fallback Gating + +- [x] Inspect the reconnect fallback path in `MainViewModel` and the current `AudioService.IsBluetoothPhysicallyConnectedAsync(...)` implementation. +- [x] Replace the unreliable AEP-based physical-link check with selector-based audio-playback device presence that matches the app's supported endpoint model. +- [x] Add a focused `AudioService` regression test for reconnect fallback gating, then re-run targeted verification. + +## Review + +- `EasyBluetoothAudio/Services/AudioService.cs` now funnels both device discovery and `IsBluetoothPhysicallyConnectedAsync(...)` through `AudioPlaybackConnection.GetDeviceSelector()` enumeration, so reconnect fallback guidance is based on whether Windows still exposes the source as an available remote audio-playback device instead of on `System.Devices.Aep.IsConnected`. +- `EasyBluetoothAudio/Services/Interfaces/IAudioService.cs` now documents the selector-based contract explicitly, matching the WinRT `\SNK` behavior the codebase has already seen in logs and lessons. +- `EasyBluetoothAudio.Tests/AudioServiceTests.cs` and `EasyBluetoothAudio.Tests/TestAudioService.cs` add service-level regression coverage for selector-present, selector-missing, and selector-discovery cases so this fallback bug is no longer masked behind `MainViewModel` mocks. +- `dotnet build C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-selector-fallback-out\bin\"` passed with 0 warnings and 0 errors. +- `dotnet test C:\dev\EasyBluetoothAudio\EasyBluetoothAudio.slnx --no-build -p:BaseOutputPath="$env:TEMP\EasyBluetoothAudio-selector-fallback-out\bin\"` passed with 104/104 tests green.