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/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/MainViewModelTests.cs b/EasyBluetoothAudio.Tests/MainViewModelTests.cs index 36132af..c1e3157 100644 --- a/EasyBluetoothAudio.Tests/MainViewModelTests.cs +++ b/EasyBluetoothAudio.Tests/MainViewModelTests.cs @@ -1,10 +1,12 @@ +using System; using System.Threading; 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; @@ -36,20 +38,26 @@ 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()); + _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() { - 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); @@ -61,12 +69,12 @@ private MainViewModel CreateViewModel() [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); } /// @@ -80,14 +88,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"); } /// @@ -101,16 +109,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); } /// @@ -124,13 +132,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); } /// @@ -139,12 +147,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); } /// @@ -158,12 +166,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); } /// @@ -173,34 +181,123 @@ 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); + _audioServiceMock.Setup(service => service.IsBluetoothPhysicallyConnectedAsync("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.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)); + } - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + /// + /// 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. + /// + [Fact] + public async Task ConnectAsync_RetriesAfterInitialFailure_AndRecovers() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); - Assert.False(vm.IsConnected); - Assert.Equal("WAITING FOR SOURCE...", vm.StatusText); + var connectResults = new Queue(new[] { false, true }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")) + .ReturnsAsync(() => connectResults.Dequeue()); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _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); } /// @@ -209,12 +306,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); } /// @@ -224,15 +321,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); } /// @@ -242,18 +339,112 @@ 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 viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + + viewModel.Disconnect(); + + Assert.False(viewModel.IsConnected); + Assert.Equal("DISCONNECTED", viewModel.StatusText); + _audioServiceMock.Verify(service => service.Disconnect(It.IsAny()), Times.Once); + } + + /// + /// 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)); + } - var vm = CreateViewModel(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); + /// + /// Verifies that manual reconnect is no longer a disconnected-state alias for full connect. + /// + [Fact] + public async Task ReconnectAsync_DoesNothing_WhenNotConnectedOrRecoverable() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); - vm.Disconnect(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ReconnectAsync(); - Assert.False(vm.IsConnected); - Assert.Equal("DISCONNECTED", vm.StatusText); - _audioServiceMock.Verify(s => s.Disconnect(), Times.Once); + 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); + } + + /// + /// 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)); } /// @@ -262,9 +453,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)); } /// @@ -274,14 +465,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)); } /// @@ -290,9 +481,25 @@ public async Task CanConnect_FalseWhenAlreadyConnected() [Fact] public void CanDisconnect_FalseWhenNotConnected() { - var vm = CreateViewModel(); + var viewModel = CreateViewModel(); - Assert.False(vm.DisconnectCommand.CanExecute(null)); + Assert.False(viewModel.DisconnectCommand.CanExecute(null)); + } + + /// + /// 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_FalseWhenOnlyDeviceSelected() + { + var device = new BluetoothDevice { Name = "iPhone", Id = "1" }; + _audioServiceMock.Setup(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + + Assert.False(viewModel.ReconnectCommand.CanExecute(null)); } /// @@ -301,11 +508,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); } /// @@ -314,14 +521,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); } /// @@ -330,11 +537,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); } @@ -345,11 +552,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); } @@ -360,235 +567,200 @@ 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 falls back to manual reconnect after the bounded retry budget is + /// exhausted while the physical Bluetooth link remains present. /// [Fact] - public async Task Monitor_StopsOnDisconnect() + public async Task ConnectionLost_Event_FallsBackToReconnect_WhenPhysicalLinkRemains() { 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(); - - await Task.Delay(6000); - - _audioServiceMock.Verify(s => s.IsBluetoothDeviceConnectedAsync(It.IsAny()), Times.Never); + _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(true); + + var balloonCount = 0; + _messenger.Register(this, (_, _) => balloonCount++); + + 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 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 connection monitor successfully reconnects. + /// 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 Monitor_ReconnectSucceeds_ResumesStreaming() + public async Task ConnectionLost_Event_FallsBackToConnect_WhenPhysicalLinkIsGone() { 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; - }); - - var vm = CreateViewModel(); - _settingsServiceMock.Setup(s => s.Load()).Returns(new AppSettings()); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); - - // Initial settle ~5s + monitor poll 10s + reconnect settle 5s + margin = ~23s - await Task.Delay(23000); - - Assert.True(vm.IsConnected); - Assert.Equal("STREAMING ACTIVE", vm.StatusText); - - vm.Disconnect(); + _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 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 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_UpdatesUiImmediately() + public async Task ConnectionLost_Event_DoesNotThrow_WhenUserDisconnectsDuringPendingReconnectDelay() { 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(false); - 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); + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); + viewModel.Disconnect(); await Task.Delay(200); - Assert.False(vm.IsConnected); - Assert.Equal("RECONNECTING...", vm.StatusText); - - vm.Disconnect(); + 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 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 the monitor does not perform hidden idle-time recycling while the device stays connected. /// [Fact] - public async Task ConnectionLost_Event_DoesNotUpdateUi_WhenCrossCheckShowsStillConnected() + 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); - // 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(); - - Assert.True(vm.IsConnected); - - _audioServiceMock.Raise(s => s.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(); + _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 viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); + await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); + + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); + _audioServiceMock.Verify(service => service.ConnectBluetoothAudioAsync("1"), Times.Once); } /// - /// Verifies that ConnectAsync waits for the settling delay when called - /// immediately after Disconnect, to allow Windows to complete Bluetooth teardown. + /// Verifies that the connection monitor stops polling after disconnect. /// [Fact] - public async Task ConnectAsync_WaitsForSettleDelay_WhenCalledRightAfterDisconnect() + 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); - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); + _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(); - await vm.RefreshDevicesAsync(); - await vm.ConnectAsync(); - vm.Disconnect(); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - var sw = System.Diagnostics.Stopwatch.StartNew(); - await vm.ConnectAsync(); - sw.Stop(); + viewModel.Disconnect(); + await Task.Delay(MainViewModel.MonitorPollDelayMs + 500); - 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(); + _audioServiceMock.Verify(service => service.IsBluetoothDeviceConnectedAsync(It.IsAny()), Times.Never); } /// - /// Verifies that the reconnect loop applies a settling delay before the first reconnect - /// attempt, meaning ConnectBluetoothAudioAsync is not called immediately after disconnect detection. + /// 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 Monitor_ReconnectLoop_WaitsForSettleDelayBeforeFirstAttempt() + public async Task ConnectionLost_Event_DoesNotUpdateUi_WhenCrossCheckShowsStillConnected() { 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(service => service.GetBluetoothDevicesAsync()).ReturnsAsync(new[] { device }); + _audioServiceMock.Setup(service => service.ConnectBluetoothAudioAsync("1")).ReturnsAsync(true); + _audioServiceMock.Setup(service => service.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(true); - _audioServiceMock.Setup(s => s.IsBluetoothDeviceConnectedAsync("1")).ReturnsAsync(false); + var viewModel = CreateViewModel(); + await viewModel.RefreshDevicesAsync(); + await viewModel.ConnectAsync(); - 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"); + _audioServiceMock.Raise(service => service.ConnectionLost += null, EventArgs.Empty); + await Task.Delay(200); - vm.Disconnect(); + Assert.True(viewModel.IsConnected); + Assert.Equal("STREAMING ACTIVE", viewModel.StatusText); } /// @@ -598,13 +770,13 @@ public async Task Monitor_ReconnectLoop_WaitsForSettleDelayBeforeFirstAttempt() 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 +786,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.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/App.xaml.cs b/EasyBluetoothAudio/App.xaml.cs index f66e213..25a9cce 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,34 @@ 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.ReconnectTrayMenuItem.Click += async (_, _) => + { + if (mainViewModel.ReconnectCommand.CanExecute(null)) + { + await mainViewModel.ReconnectCommand.ExecuteAsync(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 +137,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 +215,14 @@ 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 + // and require a reboot or driver reset to disappear. + (ServiceProvider?.GetService() as IDisposable)?.Dispose(); + if (_ownsMutex) { _mutex?.ReleaseMutex(); diff --git a/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs b/EasyBluetoothAudio/Messages/ShowBalloonRequestedMessage.cs new file mode 100644 index 0000000..17fd2ae --- /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 recovery flow) 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 444381b..0a828ff 100644 --- a/EasyBluetoothAudio/Services/AudioService.cs +++ b/EasyBluetoothAudio/Services/AudioService.cs @@ -2,10 +2,10 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; -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; @@ -15,10 +15,23 @@ 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, 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; + 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; @@ -32,40 +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 d in devices) + foreach (var device in devices) { - bool connected = false; - try - { - if (d.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}"); - } - result.Add(new BluetoothDevice { - Name = d.Name, - Id = d.Id, - IsConnected = connected, + Name = device.Name, + Id = device.Id, + IsConnected = true, 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: true via selector)"); } } catch (Exception ex) @@ -81,17 +94,19 @@ public async Task ConnectBluetoothAudioAsync(string deviceId) { try { - _audioConnection?.Dispose(); - _audioConnection = null; - _isAudioConnectionActive = false; - _activeDeviceId = null; + TearDownAudioConnection("pre-connect", updateDisconnectTimestamp: false); Debug.WriteLine($"[ConnectBT] Connecting to audio endpoint {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 - // ensures the same threading behaviour as a user-initiated connect. + // 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; + AudioPlaybackConnectionOpenResult? openResult = null; await _dispatcherService.InvokeAsync(async () => { @@ -103,6 +118,17 @@ await _dispatcherService.InvokeAsync(async () => _audioConnection.StateChanged += OnAudioConnectionStateChanged; _audioConnection.Start(); + + if (needsSettle) + { + 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(); }); @@ -117,37 +143,57 @@ await _dispatcherService.InvokeAsync(async () => Debug.WriteLine("[ConnectBT] AudioPlaybackConnection Success!"); _activeDeviceId = deviceId; _isAudioConnectionActive = true; + _hasConnectedBefore = true; + _lastDisconnectTime = DateTime.MinValue; return true; } 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. 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). + /// when this teardown represents a real disconnect or failed connect attempt; otherwise . + private void TearDownAudioConnection(string reason, bool updateDisconnectTimestamp = true) + { + if (_audioConnection != null) + { + Debug.WriteLine($"[AudioService] Tearing down connection (reason={reason})."); + _audioConnection.StateChanged -= OnAudioConnectionStateChanged; + _audioConnection.Dispose(); + _audioConnection = null; + } + + _isAudioConnectionActive = false; + _activeDeviceId = null; + if (updateDisconnectTimestamp) + { + _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); } @@ -166,36 +212,42 @@ 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; } - // 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 && !btConnected) + if (deviceInfo.Properties.TryGetValue("System.Devices.Aep.IsConnected", out var value) + && value is bool btConnected + && !btConnected) { - _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) { - // 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; } @@ -214,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 val) - && val 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) { @@ -234,16 +286,12 @@ public async Task IsBluetoothPhysicallyConnectedAsync(string deviceId) } /// - public void Disconnect() + public void Disconnect(string reason = "unspecified", bool preserveDisconnectTimestamp = false) { if (_audioConnection != null) { - Debug.WriteLine("[Disconnect] Closing connection..."); - _audioConnection.StateChanged -= OnAudioConnectionStateChanged; - _audioConnection.Dispose(); - _audioConnection = null; - _activeDeviceId = null; - _isAudioConnectionActive = false; + Debug.WriteLine($"[Disconnect] Closing connection (reason={reason})..."); + TearDownAudioConnection(reason, updateDisconnectTimestamp: !preserveDisconnectTimestamp); } } @@ -264,7 +312,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..cd4cf3a 100644 --- a/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs +++ b/EasyBluetoothAudio/Services/Interfaces/IAudioService.cs @@ -42,13 +42,25 @@ 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); /// /// Disconnects the active Bluetooth audio connection and releases resources. /// - void Disconnect(); + /// + /// 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. + /// + /// + /// 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 b2eadb5..144a403 100644 --- a/EasyBluetoothAudio/ViewModels/MainViewModel.cs +++ b/EasyBluetoothAudio/ViewModels/MainViewModel.cs @@ -4,7 +4,6 @@ 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; @@ -12,6 +11,7 @@ using EasyBluetoothAudio.Models; using EasyBluetoothAudio.Services; using EasyBluetoothAudio.Services.Interfaces; +using Hardcodet.Wpf.TaskbarNotification; namespace EasyBluetoothAudio.ViewModels; @@ -36,33 +36,40 @@ public partial class MainViewModel( 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. + /// 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 ReconnectSettleDelayMs = 5_000; + internal const int AutoReconnectAttemptLimit = 2; /// - /// 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. + /// Delay in milliseconds between automatic reconnect attempts. /// - internal const int ReconnectPhysicallyConnectedDelayMs = 0; + internal const int AutoReconnectDelayMs = 3_000; + /// + /// Poll interval in milliseconds for verifying that the currently monitored device is still connected. + /// + internal const int MonitorPollDelayMs = 10_000; + + /// + /// 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; + + private CancellationTokenSource? _connectAttemptCts; private CancellationTokenSource? _monitorCts; private string? _lastDeviceId; private string? _monitoredDeviceId; + private string? _monitoredDeviceName; private bool _isRefreshing; private volatile bool _isReconnecting; - private DateTime _lastDisconnectTime = DateTime.UtcNow; - - /// - /// 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; + private int _autoReconnectInFlight; + private int _connectAttemptGeneration; + private int _monitorGeneration; + private bool _hasShownReconnectBalloon; /// /// Gets or sets the currently selected Bluetooth device. @@ -70,6 +77,7 @@ public partial class MainViewModel( /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectCommand))] private BluetoothDevice? _selectedBluetoothDevice; /// @@ -77,14 +85,26 @@ 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. /// [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectCommand))] private bool _isBusy; /// @@ -106,6 +126,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. /// @@ -155,8 +197,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 @@ -176,7 +216,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 @@ -199,8 +239,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); @@ -208,7 +248,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) @@ -229,9 +269,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) @@ -239,41 +280,39 @@ internal async Task ConnectAsync() return; } + var deviceId = SelectedBluetoothDevice.Id; + var deviceName = SelectedBluetoothDevice.Name; + var (connectAttemptCts, cancellationToken, connectAttemptGeneration) = BeginConnectAttempt(); + try { 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) + ResetReconnectGuidance(); + StopConnectionMonitor(); + IsRecoverableConnectionLoss = false; + StatusText = $"CONNECTING TO {deviceName}..."; + + var connected = await ConnectWithAutoReconnectAsync( + deviceId, + deviceName, + cancellationToken, + () => IsCurrentConnectAttempt(cancellationToken, connectAttemptGeneration)); + + if (!IsCurrentConnectAttempt(cancellationToken, connectAttemptGeneration)) { - var timeSinceDisconnect = (DateTime.UtcNow - _lastDisconnectTime).TotalMilliseconds; - var settleRemaining = ReconnectSettleDelayMs - (int)timeSinceDisconnect; - if (settleRemaining > 0) - { - await Task.Delay(settleRemaining); - } + return; } - var ok = await audioService.ConnectBluetoothAudioAsync(SelectedBluetoothDevice.Id); - if (!ok) + if (!connected) { - StatusText = "WAITING FOR SOURCE..."; - StartConnectionMonitor(SelectedBluetoothDevice.Id, SelectedBluetoothDevice.Name); + await ApplyManualFallbackStateAsync( + deviceId, + showGuidanceBalloon: true, + () => IsCurrentConnectAttempt(cancellationToken, connectAttemptGeneration)); return; } - IsConnected = true; - StatusText = "STREAMING ACTIVE"; - messenger.Send(new ConnectionEstablishedMessage(SelectedBluetoothDevice.Name)); - - StartConnectionMonitor(SelectedBluetoothDevice.Id, SelectedBluetoothDevice.Name); + StartConnectionMonitor(deviceId, deviceName); } catch (Exception ex) { @@ -282,36 +321,94 @@ internal async Task ConnectAsync() } finally { + CompleteConnectAttempt(connectAttemptCts); IsBusy = false; } } /// - /// Disconnects the current Bluetooth audio device. + /// Disconnects the current Bluetooth audio device and clears any recoverable fallback state. /// - [CommunityToolkit.Mvvm.Input.RelayCommand(CanExecute = nameof(CanDisconnect))] + [RelayCommand(CanExecute = nameof(CanDisconnect))] internal void Disconnect() { + CancelPendingConnectAttempt(); StopConnectionMonitor(); try { - audioService.Disconnect(); + audioService.Disconnect("user"); } catch (Exception ex) { Debug.WriteLine($"[Disconnect] Error: {ex.Message}"); } - IsConnected = false; - StatusText = "DISCONNECTED"; - _lastDisconnectTime = DateTime.UtcNow; + ApplyDisconnectedState("DISCONNECTED"); + } + + /// + /// 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 || !CanReconnect()) + { + return; + } + + var deviceId = SelectedBluetoothDevice.Id; + var deviceName = SelectedBluetoothDevice.Name; + + try + { + IsBusy = true; + ResetReconnectGuidance(); + StopConnectionMonitor(); + IsRecoverableConnectionLoss = false; + 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) + { + await ApplyManualFallbackStateAsync(deviceId, showGuidanceBalloon: false); + 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; @@ -322,7 +419,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 @@ -342,7 +439,7 @@ private async Task OpenBluetoothSettingsAsync() /// /// Requests the main window to show itself. /// - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] private void Open() { RequestShow?.Invoke(); @@ -351,7 +448,7 @@ private void Open() /// /// Requests the application to exit. /// - [CommunityToolkit.Mvvm.Input.RelayCommand] + [RelayCommand] private void Exit() { RequestExit?.Invoke(); @@ -376,6 +473,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. /// @@ -385,14 +502,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 @@ -408,7 +517,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; } @@ -426,164 +535,469 @@ 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; } - Disconnect(); - _ = ConnectAsync(); + _ = ReconnectAsync(); }); } /// /// 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. + /// if the app has an active, recoverable, or in-flight reconnect route to clear. private bool CanDisconnect() { - return IsConnected || _isReconnecting; + return IsConnected || IsRecoverableConnectionLoss || _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, no blocking operation is running, and a recoverable route exists. + 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. /// /// The device identifier to monitor. /// The friendly device name for status messages. private void StartConnectionMonitor(string deviceId, string deviceName) { StopConnectionMonitor(); + _monitoredDeviceId = deviceId; + _monitoredDeviceName = deviceName; audioService.ConnectionLost += OnConnectionLostFromService; _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"; - }); - } continue; } - // Connection lost – enter reconnect loop - dispatcherService.Invoke(() => - { - _isReconnecting = true; - DisconnectCommand.NotifyCanExecuteChanged(); - StatusText = "RECONNECTING..."; - IsConnected = false; - }); - - 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); - } + 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)); - 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() { + Interlocked.Increment(ref _monitorGeneration); audioService.ConnectionLost -= OnConnectionLostFromService; + _monitoredDeviceId = null; + _monitoredDeviceName = null; _isReconnecting = false; _monitorCts?.Cancel(); _monitorCts?.Dispose(); _monitorCts = null; + DisconnectCommand.NotifyCanExecuteChanged(); } /// - /// 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). + /// Attempts an initial connection and, if that fails, performs a bounded automatic reconnect sequence. + /// + /// 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 connected = await audioService.ConnectBluetoothAudioAsync(deviceId); + if (connected) + { + if (!shouldApplyResult()) + { + audioService.Disconnect("stale-auto-reconnect"); + return false; + } + + ApplyConnectedState(deviceName); + return true; + } + + return await TryAutoReconnectAsync(deviceId, deviceName, cancellationToken, shouldApplyResult); + } + + /// + /// 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++) + { + if (cancellationToken.IsCancellationRequested || !shouldApplyResult()) + { + return false; + } + + 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) + { + continue; + } + + if (!shouldApplyResult()) + { + audioService.Disconnect("stale-auto-reconnect"); + return false; + } + + dispatcherService.Invoke(() => ApplyConnectedState(deviceName)); + return true; + } + + 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 the + /// correct manual action for the remaining Bluetooth state. + /// + /// 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) + { + return; + } + + try + { + try + { + audioService.Disconnect(disconnectReason); + } + catch (Exception ex) + { + Debug.WriteLine($"[Reconnect] Disconnect error: {ex.Message}"); + } + + var connected = await TryAutoReconnectAsync( + deviceId, + deviceName, + cancellationToken, + () => !cancellationToken.IsCancellationRequested && monitorGeneration == _monitorGeneration); + + if (connected || cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + { + return; + } + + var isPhysicallyConnected = await audioService.IsBluetoothPhysicallyConnectedAsync(deviceId); + if (cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + { + return; + } + + dispatcherService.Invoke(() => + { + ApplyReconnectRequiredState(isPhysicallyConnected); + ShowReconnectGuidanceBalloon(isPhysicallyConnected); + }); + StopConnectionMonitor(); + } + finally + { + Interlocked.Exchange(ref _autoReconnectInFlight, 0); + } + } + + /// + /// 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; + IsRecoverableConnectionLoss = false; + StatusText = "STREAMING ACTIVE"; + DisconnectCommand.NotifyCanExecuteChanged(); + messenger.Send(new ConnectionEstablishedMessage(deviceName)); + } + + /// + /// Applies the transient reconnecting UI state used during bounded automatic reconnect. + /// + private void ApplyReconnectingState() + { + _isReconnecting = true; + IsConnected = false; + IsRecoverableConnectionLoss = false; + StatusText = "RECONNECTING..."; + DisconnectCommand.NotifyCanExecuteChanged(); + } + + /// + /// Applies the stable failure state used after the automatic reconnect budget is exhausted. + /// + /// 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; + 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, + Func? shouldApplyResult = null) + { + var canApplyResult = shouldApplyResult ?? (() => true); + var isPhysicallyConnected = await audioService.IsBluetoothPhysicallyConnectedAsync(deviceId); + if (!canApplyResult()) + { + return; + } + + ApplyReconnectRequiredState(isPhysicallyConnected); + + if (showGuidanceBalloon) + { + ShowReconnectGuidanceBalloon(isPhysicallyConnected); + } + } + + /// + /// 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. + /// + /// to guide the user to the manual reconnect action; otherwise guide them to a full connect. + private void ShowReconnectGuidanceBalloon(bool guideToReconnect) + { + if (_hasShownReconnectBalloon) + { + return; + } + + _hasShownReconnectBalloon = true; + var actionLabel = guideToReconnect ? "Reconnect" : "Connect"; + messenger.Send(new ShowBalloonRequestedMessage(new BalloonContent( + "Bluetooth Audio", + $"Use {actionLabel} or toggle the route on the iPhone.", + BalloonIcon.Warning))); + } + + /// + /// 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; + + try { - return; - } + var stillConnected = await audioService.IsBluetoothDeviceConnectedAsync(deviceId); + if (stillConnected || cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) + { + return; + } - dispatcherService.Invoke(() => + await HandleConfirmedConnectionLossAsync( + deviceId, + deviceName, + "service-connection-lost", + cancellationToken, + monitorGeneration); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || monitorGeneration != _monitorGeneration) { - IsConnected = false; - _isReconnecting = true; - DisconnectCommand.NotifyCanExecuteChanged(); - StatusText = "RECONNECTING..."; - }); + } } } diff --git a/EasyBluetoothAudio/Views/BluetoothConfigView.xaml b/EasyBluetoothAudio/Views/BluetoothConfigView.xaml index 54dc528..6355ad5 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 new file mode 100644 index 0000000..5e2ab7e --- /dev/null +++ b/tasks/lessons.md @@ -0,0 +1,16 @@ +# 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 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. +- 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. +- 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 new file mode 100644 index 0000000..2636ef8 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,218 @@ +# 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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.