Share podcast devices with calls and persist network selection

Reuse camera and microphone preferences across podcast and chat-room pages. Restore available LAN/VPN addresses without persisting disclosure consent, and cover shared preferences and save failures with regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-11-02 15:17:23 +00:00
commit 377afc711a
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
8 changed files with 236 additions and 13 deletions

View file

@ -2,12 +2,103 @@ using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using PostIt.ViewModels;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class LanCallViewModelTests
{
[AvaloniaFact]
public async Task Calls_restore_shared_devices_and_persist_choices_and_network_address()
{
var path = Path.Combine(Path.GetTempPath(), $"postit-call-preferences-{Guid.NewGuid():N}.json");
var previousDevices = Platform.GetLanCallDevices;
try
{
var settings = new Settings(path)
{
PodcastCameras = [new("Front", "front"), new("Rear", "rear")],
PodcastMicrophones = [new("None", ""), new("USB", "usb"), new("Built-in", "built-in")],
PodcastCameraId = "rear", PodcastMicrophoneId = "usb"
};
await using (var vm = new LanCallViewModel(new Signaling(), settings: settings))
{
Assert.Equal("rear", vm.Camera?.Value);
Assert.Equal("usb", vm.Microphone?.Value);
Assert.False(vm.AllowAddressDisclosure);
vm.Camera = vm.Cameras[0];
vm.Microphone = vm.Microphones[1];
Assert.Equal("front", settings.PodcastCameraId);
Assert.Equal("built-in", settings.PodcastMicrophoneId);
Assert.NotEmpty(vm.Addresses);
vm.Address = vm.Addresses.Last();
settings.Save();
var preferred = vm.Address;
Platform.GetLanCallDevices = () => new(
[new("Rear", "rear"), new("Front", "front")],
[new("Built-in", "built-in"), new("USB", "usb")]);
vm.RefreshDevicesCommand.Execute(null);
Assert.Equal("front", vm.Camera?.Value);
Assert.Equal("built-in", vm.Microphone?.Value);
Assert.Equal(preferred, vm.Address);
Assert.Contains(settings.PodcastMicrophones, d => d.Value == "");
var reloaded = new Settings(path);
reloaded.Authentication.Authority = "";
reloaded.Load();
await using var restored = new LanCallViewModel(new Signaling(), settings: reloaded);
Assert.Equal(preferred, restored.Address);
Assert.Equal("front", restored.Camera?.Value);
Assert.Equal("built-in", restored.Microphone?.Value);
Assert.False(restored.AllowAddressDisclosure);
settings.PodcastCameraId = "rear";
Assert.Equal("rear", vm.Camera?.Value);
settings.PodcastMicrophoneId = "";
Assert.Null(vm.Microphone);
vm.AllowAddressDisclosure = true;
vm.Room = "General";
vm.Target = "bob";
await vm.InviteCommand.ExecuteAsync(null);
Assert.Contains("microphone", vm.Status);
}
}
finally { Platform.GetLanCallDevices = previousDevices; File.Delete(path); }
}
[AvaloniaFact]
public async Task Call_preference_save_failures_are_reported_without_losing_the_choice()
{
var path = Path.Combine(Path.GetTempPath(), $"postit-call-preferences-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
try
{
var settings = new Settings(path);
await using var vm = new LanCallViewModel(new Signaling(), settings: settings);
vm.Address = null;
vm.Address = "127.0.0.1";
Assert.Equal("127.0.0.1", settings.LanCallAddress);
Assert.Contains("sauvegarde impossible", vm.Status);
Assert.True(settings.IsDirty);
}
finally { Directory.Delete(path); }
}
[AvaloniaFact]
public async Task Missing_saved_network_address_requires_a_new_selection_and_does_not_restore_consent()
{
var path = Path.Combine(Path.GetTempPath(), $"postit-call-preferences-{Guid.NewGuid():N}.json");
try
{
var settings = new Settings(path) { LanCallAddress = "not-a-local-address" };
await using var vm = new LanCallViewModel(new Signaling(), settings: settings);
Assert.Null(vm.Address);
Assert.False(vm.AllowAddressDisclosure);
Assert.Equal("not-a-local-address", settings.LanCallAddress);
}
finally { File.Delete(path); }
}
private static CallSession Session() => new(Guid.NewGuid(), "General", "alice", "bob", false,
DateTimeOffset.UtcNow.AddMinutes(1), CallTransports.LanSrtp);

View file

@ -2,6 +2,8 @@ using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Api.Client;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Streaming;
namespace PostIt.Tests;
@ -37,13 +39,18 @@ public sealed class PodcastPreferencesTests
Assert.Equal("rear", restored.PodcastCameraId);
Assert.Equal("", restored.PodcastMicrophoneId);
Platform.GetPodcastCaptureDevices = () => throw new InvalidOperationException("Must use saved devices.");
await using var otherApi = new YavscApiClient(restored, new TokenStore(path + ".tokens"));
await using var otherApi = new PreferencesApi(restored, new TokenStore(path + ".tokens"));
var other = new PodcastsPageViewModel(new PodcastApiClient(otherApi, () => restored.ApiUrl), otherApi, session);
Assert.Equal(2, other.Cameras.Count);
Assert.Equal(2, other.Microphones.Count);
Assert.Equal("rear", other.SelectedCamera?.Value);
Assert.Equal("", other.SelectedMicrophone?.Value);
Assert.Equal(vm.Title, other.Title);
restored.PodcastCameraId = "front";
restored.PodcastMicrophoneId = "usb";
await other.ActivateAsync();
Assert.Equal("front", other.SelectedCamera?.Value);
Assert.Equal("usb", other.SelectedMicrophone?.Value);
Platform.GetPodcastCaptureDevices = () => new([], []);
other.DetectDevicesCommand.Execute(null);
@ -57,6 +64,17 @@ public sealed class PodcastPreferencesTests
File.Delete(path);
File.Delete(path + ".tokens");
}
}
private sealed class PreferencesApi(Settings settings, TokenStore store) : YavscApiClient(settings, store)
{
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
object response = path.EndsWith("/account/me", StringComparison.Ordinal)
? new Me { UserId = "owner" } : new List<PodcastResponse>();
return Task.FromResult((T)response);
}
}
[AvaloniaFact]

View file

@ -222,7 +222,7 @@ namespace PostIt.ViewModels.Chat
Calls = new CallSignalingClient(_connection);
if (SupportsLanCalls)
LanCall = new LanCallViewModel(Calls) { Room = SelectedRoom ?? "" };
LanCall = new LanCallViewModel(Calls, settings: _settings) { Room = SelectedRoom ?? "" };
else
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; transport non disponible sur cette plateforme."));

View file

@ -31,6 +31,8 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
private string? _activeMicrophone;
private string? _activeAddress;
private int _activePort;
private readonly Settings? _settings;
private bool _restoringPreferences;
public ObservableCollection<string> Addresses { get; } = [];
public ObservableCollection<CaptureDevice> Cameras { get; } = [];
@ -53,9 +55,11 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
[ObservableProperty] public partial Control? RemoteVideo { get; set; }
public bool CanConfigure => !Busy || Incoming;
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null)
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null,
Settings? settings = null)
{
_signaling = signaling;
_settings = settings;
_createMedia = createMedia ?? Platform.CreateLanCallMedia ??
((address, port) => new LinuxLanCallVideo(address, port));
signaling.Invitation += OnInvitation;
@ -64,22 +68,98 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
signaling.Ended += OnEnded;
signaling.Interrupted += OnInterrupted;
Platform.PlaybackSuspending += OnSuspending;
if (settings != null)
{
_restoringPreferences = true;
try
{
foreach (var device in settings.PodcastCameras) Cameras.Add(device);
foreach (var device in settings.PodcastMicrophones.Where(d => !string.IsNullOrEmpty(d.Value)))
Microphones.Add(device);
RestoreDevices();
RefreshAddresses();
}
catch (NetworkInformationException ex) { Status = $"Adresses reseau indisponibles : {ex.Message}"; }
finally { _restoringPreferences = false; }
settings.PropertyChanged += OnSettingsChanged;
}
}
private void OnSettingsChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
{
if (_restoringPreferences || !CanConfigure) return;
if (args.PropertyName is not (nameof(Settings.PodcastCameraId) or nameof(Settings.PodcastMicrophoneId)
or nameof(Settings.PodcastCameras) or nameof(Settings.PodcastMicrophones))) return;
_restoringPreferences = true;
try
{
Cameras.Clear();
Microphones.Clear();
foreach (var device in _settings!.PodcastCameras) Cameras.Add(device);
foreach (var device in _settings.PodcastMicrophones.Where(d => !string.IsNullOrEmpty(d.Value))) Microphones.Add(device);
RestoreDevices();
}
finally { _restoringPreferences = false; }
}
private void RestoreDevices()
{
Camera = Cameras.FirstOrDefault(d => d.Value == _settings?.PodcastCameraId) ?? Cameras.FirstOrDefault();
Microphone = _settings?.PodcastMicrophoneId == "" ? null
: Microphones.FirstOrDefault(d => d.Value == _settings?.PodcastMicrophoneId) ?? Microphones.FirstOrDefault();
}
private void RefreshAddresses()
{
var preferred = _settings?.LanCallAddress ?? Address;
Addresses.Clear();
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address);
Address = preferred == null ? Addresses.FirstOrDefault() : Addresses.FirstOrDefault(a => a == preferred);
}
partial void OnCameraChanged(CaptureDevice? value)
{
if (_restoringPreferences || _settings == null || value == null) return;
_settings.PodcastCameraId = value.Value;
SavePreferences();
}
partial void OnMicrophoneChanged(CaptureDevice? value)
{
if (_restoringPreferences || _settings == null || value == null) return;
_settings.PodcastMicrophoneId = value.Value;
SavePreferences();
}
partial void OnAddressChanged(string? value)
{
if (_restoringPreferences || _settings == null || value == null) return;
_settings.LanCallAddress = value;
SavePreferences();
}
private void SavePreferences()
{
try { _settings?.Save(); }
catch (Exception ex) when (ex is System.IO.IOException or UnauthorizedAccessException)
{
Status = $"Preferences d'appel appliquees, mais sauvegarde impossible : {ex.Message}";
}
}
[RelayCommand]
private void RefreshDevices()
{
_restoringPreferences = true;
try
{
if (!CanConfigure) throw new InvalidOperationException("Terminez l'appel avant de changer les peripheriques.");
Addresses.Clear();
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address);
Address = Addresses.FirstOrDefault();
RefreshAddresses();
Cameras.Clear();
Microphones.Clear();
if (Platform.GetLanCallDevices is { } getDevices)
@ -97,14 +177,24 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
foreach (var device in streamer.GetCaptureDevices()) Cameras.Add(new(device.Key, device.Value));
foreach (var device in streamer.GetAudioCaptureDevices()) Microphones.Add(new(device.Key, device.Value));
}
Camera = Cameras.FirstOrDefault();
Microphone = Microphones.FirstOrDefault();
RestoreDevices();
Status = Cameras.Count == 0 ? "Aucune camera detectee. Verifiez les peripheriques puis actualisez."
: Incoming && _call != null
? $"{_call.Caller} propose un appel dans {_call.RoomName}. Selectionnez vos peripheriques puis acceptez ou refusez."
: "Selectionnez le pair, l'adresse LAN/VPN et les peripheriques. Utilisez un casque.";
if (_settings != null)
{
_settings.PodcastCameras = Cameras.ToArray();
var noMicrophone = _settings.PodcastMicrophones.Where(d => string.IsNullOrEmpty(d.Value));
_settings.PodcastMicrophones = noMicrophone.Concat(Microphones).ToArray();
if (Camera != null) _settings.PodcastCameraId = Camera.Value;
if (Microphone != null) _settings.PodcastMicrophoneId = Microphone.Value;
if (Address != null) _settings.LanCallAddress = Address;
SavePreferences();
}
}
catch (Exception error) { Status = "Peripheriques indisponibles : " + error.Message; }
finally { _restoringPreferences = false; }
}
private void ValidateSelection()
@ -324,6 +414,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
_signaling.Ended -= OnEnded;
_signaling.Interrupted -= OnInterrupted;
Platform.PlaybackSuspending -= OnSuspending;
if (_settings != null) _settings.PropertyChanged -= OnSettingsChanged;
try { await StopAsync("Appel arrete.", true); }
finally { _lifetime.Dispose(); }
}

View file

@ -267,6 +267,16 @@ public partial class PodcastsPageViewModel : ViewModelBase
public async Task ActivateAsync()
{
_restoringPreferences = true;
try
{
Cameras.Clear();
Microphones.Clear();
foreach (var device in _api.Settings.PodcastCameras) Cameras.Add(device);
foreach (var device in _api.Settings.PodcastMicrophones) Microphones.Add(device);
RestoreDeviceSelection();
}
finally { _restoringPreferences = false; }
if (_pageCancellation.IsCancellationRequested)
{
_pageCancellation.Dispose();

View file

@ -56,6 +56,8 @@ public partial class Settings : ViewModelBase
public partial string? PodcastCameraId { get; set; }
[ObservableProperty]
public partial string? PodcastMicrophoneId { get; set; }
[ObservableProperty]
public partial string? LanCallAddress { get; set; }
[ObservableProperty]
[JsonIgnore]
@ -156,6 +158,7 @@ public partial class Settings : ViewModelBase
partial void OnPodcastMicrophonesChanged(CaptureDevice[] value) => MarkDirty();
partial void OnPodcastCameraIdChanged(string? value) => MarkDirty();
partial void OnPodcastMicrophoneIdChanged(string? value) => MarkDirty();
partial void OnLanCallAddressChanged(string? value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
@ -418,6 +421,7 @@ public partial class Settings : ViewModelBase
PodcastMicrophones = settings.PodcastMicrophones ?? [];
PodcastCameraId = settings.PodcastCameraId;
PodcastMicrophoneId = settings.PodcastMicrophoneId;
LanCallAddress = settings.LanCallAddress;
if (!(settings.Authentication is null))
{
this.Authentication = new AuthenticationSettings();