feat(android): share podcast playback and foreground broadcasting
Some checks failed
Forgejo Release / release (push) Has been cancelled
Dotnet build and test / build (push) Successful in 21m51s

Add native Camera2 capture, MPEG-TS segmentation and foreground service with notification stop. Reuse shared authenticated uploads and replay finalization, add capture regression tests and document platform limitations. Update Avalonia to 12.1.4.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-19 22:18:22 +01:00
commit ad92744a94
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
24 changed files with 1495 additions and 70 deletions

View file

@ -0,0 +1,100 @@
#if ANDROID
using System;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Android.App;
using Android.Content;
using Android.OS;
using Avalonia.Threading;
using PostIt.Android;
using PostIt.Android.Services;
using PostIt.Services;
namespace PostIt.Tests;
/// <summary>Debug-only local encoder test; never connects to the podcast API.</summary>
[Activity(Name = "fr.pschneider.postit.PodcastCaptureTestActivity", Exported = true)]
[SupportedOSPlatform("android26.0")]
public sealed class AndroidPodcastCaptureTestActivity : Activity
{
private bool _microphone = true;
private bool _notificationStop;
private int _cameraIndex;
protected override async void OnCreate(Bundle? state)
{
base.OnCreate(state);
_microphone = Intent?.GetStringExtra("microphone") != "false";
_notificationStop = Intent?.GetStringExtra("notificationStop") == "true";
_cameraIndex = Intent?.GetStringExtra("camera") == "front" ? 1 : 0;
var resultPath = Path.Combine(FilesDir!.AbsolutePath, "podcast-capture-test-result.txt");
File.WriteAllText(resultPath, "RUNNING");
var result = new Bundle();
try
{
await RunAsync(result);
File.WriteAllText(resultPath, $"PASS\nsegments={result.GetInt("segments")}\ndirectory={result.GetString("directory")}\nbackground={result.GetString("background")}");
}
catch (Exception ex)
{
File.WriteAllText(resultPath, $"FAIL\n{ex}");
}
finally { Finish(); }
}
private async Task RunAsync(Bundle result)
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26))
throw new PlatformNotSupportedException("Android 8 required.");
var context = this;
var intent = new Intent(context, typeof(MainActivity)).AddFlags(ActivityFlags.NewTask);
StartActivity(intent);
for (var i = 0; i < 100 && !MainActivity.IsForeground; i++) await Task.Delay(100);
await Task.Delay(1000);
var directory = Path.Combine(context.FilesDir!.AbsolutePath, "podcast-capture-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var devices = AndroidPodcastCapture.GetDevices();
if (devices.Cameras.Count == 0) throw new InvalidOperationException("No emulator camera.");
IPodcastCapture? capture = null;
try
{
capture = await Dispatcher.UIThread.InvokeAsync(() => AndroidPodcastCapture.StartAsync(
devices.Cameras[Math.Min(_cameraIndex, devices.Cameras.Count - 1)].Value,
_microphone ? "microphone" : null, directory, CancellationToken.None));
await Task.Delay(3000);
var before = SegmentCount(directory);
context.StartActivity(new Intent(Intent.ActionMain).AddCategory(Intent.CategoryHome).AddFlags(ActivityFlags.NewTask));
await Task.Delay(9000);
var after = SegmentCount(directory);
if (capture.Completion.IsFaulted) await capture.Completion;
if (after <= before) throw new InvalidOperationException($"Capture did not continue in background: {before} -> {after} segments.");
if (_notificationStop)
{
using var stop = PendingIntent.GetService(context, 1,
new Intent(context, typeof(PodcastCaptureService)).SetAction(PodcastCaptureService.StopAction),
PendingIntentFlags.NoCreate | PendingIntentFlags.Immutable)
?? throw new InvalidOperationException("Notification stop action missing.");
stop.Send();
}
else await capture.StopAsync().WaitAsync(TimeSpan.FromSeconds(15));
await capture.Completion.WaitAsync(TimeSpan.FromSeconds(15));
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8")));
if (segments.Count < 3) throw new InvalidOperationException("Insufficient capture segments.");
result.PutInt("segments", segments.Count);
result.PutString("directory", directory);
result.PutString("background", $"segments {before} -> {after}");
}
finally
{
if (capture is not null) await capture.DisposeAsync();
}
}
private static int SegmentCount(string directory)
{
var path = Path.Combine(directory, "index.m3u8");
return File.Exists(path) ? HlsCapturePlaylist.Parse(File.ReadAllText(path)).Count : 0;
}
}
#endif

View file

@ -464,11 +464,31 @@ public class BlogReaderTests
}
[AvaloniaFact]
public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control()
public void Video_viewmodel_resolves_to_the_shared_page_with_a_platform_video_surface_slot()
{
var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4");
var page = Assert.IsType<VideoPage>(new ViewLocator().Build(vm));
Assert.NotNull(page.FindControl<LibVLCSharp.Avalonia.VideoView>("Video"));
Assert.NotNull(page.FindControl<ContentControl>("VideoSurface"));
Assert.NotNull(page.FindControl<Button>("PlayButton"));
}
[AvaloniaFact]
public async Task Video_page_observes_platform_lifecycle_only_while_navigated()
{
var nav = new NavigationPage();
await nav.PushAsync(new ContentPage());
var page = new VideoPage();
await nav.PushAsync(page);
var status = page.FindControl<TextBlock>("PlaybackStatus")!;
Platform.SuspendPlayback();
Assert.Equal("Lecture suspendue.", status.Text);
Assert.False(page.FindControl<Button>("PlayButton")!.IsEnabled);
Platform.ResumePlayback();
Assert.Null(page.FindControl<ContentControl>("VideoSurface")!.Content);
await nav.PopAsync();
status.Text = "No longer active";
Platform.SuspendPlayback();
Assert.Equal("No longer active", status.Text);
}
[Fact]

View file

@ -0,0 +1,175 @@
using System.Net;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastBroadcasterTests
{
[AvaloniaFact]
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-podcast-{Guid.NewGuid():N}.json");
var devices = Platform.GetPodcastCaptureDevices;
var permissions = Platform.RequestPodcastPermissionsAsync;
var factory = Platform.CreatePodcastCaptureAsync;
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(new Settings(), store);
await using var transport = new Transport();
try
{
Platform.GetPodcastCaptureDevices = () => new(
[new("Front", "front"), new("Rear", "rear")],
[new("None", ""), new("Android microphone", "microphone")]);
Platform.RequestPodcastPermissionsAsync = (_, _) =>
Task.FromException(new UnauthorizedAccessException("Camera denied"));
Platform.CreatePodcastCaptureAsync = (_, _, _, _) =>
throw new InvalidOperationException("Capture must not start after permission denial.");
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
var vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
{
Title = "Android live"
};
Assert.True(vm.CanBroadcast);
vm.DetectDevicesCommand.Execute(null);
Assert.Equal(2, vm.Cameras.Count);
Assert.Equal("front", vm.SelectedCamera?.Value);
Assert.Equal("microphone", vm.SelectedMicrophone?.Value);
Assert.True(vm.StartCommand.CanExecute(null));
await vm.StartCommand.ExecuteAsync(null);
Assert.Contains("Camera denied", vm.Status);
Assert.Equal(0, transport.CreateCount);
Assert.False(vm.IsBusy);
Assert.False(vm.IsBroadcasting);
}
finally
{
Platform.GetPodcastCaptureDevices = devices;
Platform.RequestPodcastPermissionsAsync = permissions;
Platform.CreatePodcastCaptureAsync = factory;
File.Delete(tokenPath);
}
}
[Fact]
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
{
await using var transport = new Transport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
FakeCapture? capture = null;
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
(directory, _) =>
{
capture = new FakeCapture(directory);
return Task.FromResult<IPodcastCapture>(capture);
}, TestContext.Current.CancellationToken);
await transport.FirstUpload.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await capture!.StopAsync();
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.Equal(new[] { 0, 1 }, transport.Uploads);
Assert.Equal(new[] { "upload:0", "upload:1", "complete" }, transport.Events);
Assert.True(capture.Stopped);
}
[Fact]
public async Task Native_failure_stops_capture_and_preserves_unacknowledged_segments()
{
await using var transport = new Transport { FailUpload = true };
var client = new PodcastApiClient(transport, () => "https://api.example/");
FakeCapture? capture = null;
var broadcaster = await PodcastBroadcaster.StartAsync(client, "Android",
(directory, _) =>
{
capture = new FakeCapture(directory);
return Task.FromResult<IPodcastCapture>(capture);
}, TestContext.Current.CancellationToken);
try
{
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.Completion);
Assert.True(capture!.Stopped);
Assert.True(File.Exists(Path.Combine(capture.Directory, "segment-00000000.ts")));
Assert.DoesNotContain("complete", transport.Events);
await Assert.ThrowsAsync<HttpRequestException>(() => broadcaster.DisposeAsync().AsTask());
Assert.True(capture.Disposed);
}
finally
{
if (capture is not null) Directory.Delete(capture.Directory, recursive: true);
}
}
[Fact]
public async Task Startup_failure_finalizes_empty_server_broadcast()
{
await using var transport = new Transport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => PodcastBroadcaster.StartAsync(client, "Denied",
(_, _) => Task.FromException<IPodcastCapture>(new UnauthorizedAccessException("Camera denied")),
TestContext.Current.CancellationToken));
Assert.Equal(new[] { "complete" }, transport.Events);
}
private sealed class FakeCapture : IPodcastCapture
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public string Directory { get; }
public bool Stopped { get; private set; }
public bool Disposed { get; private set; }
public Task Completion => _completion.Task;
public FakeCapture(string directory)
{
Directory = directory;
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
}
public Task StopAsync()
{
if (Stopped) return Task.CompletedTask;
File.WriteAllBytes(Path.Combine(Directory, "segment-00000001.ts"), [0x47, 2]);
File.AppendAllText(Path.Combine(Directory, "index.m3u8"), "#EXTINF:0.5,\nsegment-00000001.ts\n");
Stopped = true;
_completion.TrySetResult();
return Task.CompletedTask;
}
public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
}
private sealed class Transport : IYavscApiClient
{
private readonly PodcastResponse _podcast = new(Guid.NewGuid(), "Test", "alice", "Alice",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Live, 0, 0, 0);
public HttpClient Http { get; } = new();
public List<int> Uploads { get; } = [];
public List<string> Events { get; } = [];
public bool FailUpload { get; init; }
public int CreateCount { get; private set; }
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
else CreateCount++;
return Task.FromResult((T)(object)_podcast);
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
if (FailUpload) throw new HttpRequestException("Denied", null, HttpStatusCode.Forbidden);
var sequence = int.Parse(new Uri(path).Segments[^1], System.Globalization.CultureInfo.InvariantCulture);
Uploads.Add(sequence);
Events.Add($"upload:{sequence}");
FirstUpload.TrySetResult();
return Task.FromResult((T)(object)_podcast);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, body, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<PodcastResponse>(method, path, contentFactory, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> throw new NotSupportedException();
public ValueTask DisposeAsync() { Http.Dispose(); return ValueTask.CompletedTask; }
}
}

View file

@ -0,0 +1,146 @@
using PostIt.Services;
namespace PostIt.Tests;
public sealed class TransportStreamCaptureTests
{
[Fact]
public async Task Segments_on_IDR_and_flushes_final_segment_with_real_PTS_durations()
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream();
input.Write(Pat());
input.Write(Pmt());
for (var i = 0; i <= 120; i++) input.Write(Video(i * 3000L, i % 60 == 0));
input.Position = 0;
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
Assert.Equal(3, segments.Count);
Assert.Equal(2, segments[0].DurationSeconds);
Assert.Equal(2, segments[1].DurationSeconds);
Assert.InRange(segments[2].DurationSeconds, 0.033, 0.034);
foreach (var segment in segments)
{
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
Assert.Equal(0, bytes.Length % 188);
Assert.Equal(Pat(), bytes[..188]);
Assert.Equal(Pmt(), bytes[188..376]);
Assert.Contains((byte)0x65, bytes[376..]);
}
}
finally { Directory.Delete(directory, recursive: true); }
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Rejects_corrupt_or_truncated_transport_without_publishing_a_segment(bool truncated)
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream(new byte[truncated ? 187 : 188]);
await Assert.ThrowsAsync<InvalidDataException>(() =>
new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken));
Assert.False(File.Exists(Path.Combine(directory, "index.m3u8")));
}
finally { Directory.Delete(directory, recursive: true); }
}
[Fact]
public async Task Preserves_out_of_band_H264_configuration_at_every_segment_start()
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream();
input.Write(Pat());
input.Write(Pmt());
var configuration = Video(0, false);
configuration[22] = 0x67;
new byte[] { 0, 0, 0, 1, 0x68, 0x80 }.CopyTo(configuration, 25);
input.Write(configuration);
for (var i = 0; i <= 120; i++) input.Write(Video(90000 + i * 3000L, i % 60 == 0));
input.Position = 0;
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
Assert.Equal(3, segments.Count);
foreach (var segment in segments)
{
var bytes = await File.ReadAllBytesAsync(Path.Combine(directory, segment.FileName), TestContext.Current.CancellationToken);
Assert.Equal(0x67, bytes[376 + 22]);
Assert.Equal(0x68, bytes[376 + 29]);
Assert.Equal(0x65, bytes[564 + 22]);
Assert.Equal((bytes[564 + 3] - 1) & 0x0f, bytes[376 + 3] & 0x0f);
Assert.Equal(bytes[(564 + 13)..(564 + 18)], bytes[(376 + 13)..(376 + 18)]);
}
}
finally { Directory.Delete(directory, recursive: true); }
}
[Fact]
public async Task Handles_33_bit_PTS_wrap_without_negative_duration()
{
var directory = Path.Combine(Path.GetTempPath(), $"postit-ts-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
using var input = new MemoryStream();
input.Write(Pat());
input.Write(Pmt());
for (var i = 0; i <= 120; i++)
input.Write(Video(((1L << 33) - 90000 + i * 3000L) & ((1L << 33) - 1), i % 60 == 0));
input.Position = 0;
await new TransportStreamCapture(directory).RunAsync(input, TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "index.m3u8"), TestContext.Current.CancellationToken));
Assert.Equal(3, segments.Count);
Assert.Equal(2, segments[0].DurationSeconds);
}
finally { Directory.Delete(directory, recursive: true); }
}
private static byte[] Packet(int pid)
{
var packet = Enumerable.Repeat((byte)0xff, 188).ToArray();
packet[0] = 0x47;
packet[1] = (byte)(0x40 | (pid >> 8));
packet[2] = (byte)pid;
packet[3] = 0x10;
return packet;
}
private static byte[] Pat()
{
var packet = Packet(0);
packet[4] = 0;
new byte[] { 0, 0xb0, 13, 0, 1, 0xc1, 0, 0, 0, 1, 0xe1, 0 }.CopyTo(packet, 5);
return packet;
}
private static byte[] Pmt()
{
var packet = Packet(256);
packet[4] = 0;
new byte[] { 2, 0xb0, 18, 0, 1, 0xc1, 0, 0, 0xe1, 1, 0xf0, 0,
0x1b, 0xe1, 1, 0xf0, 0 }.CopyTo(packet, 5);
return packet;
}
private static byte[] Video(long pts, bool idr)
{
var packet = Packet(257);
new byte[] { 0, 0, 1, 0xe0, 0, 0, 0x84, 0x80, 5 }.CopyTo(packet, 4);
packet[13] = (byte)(0x21 | ((pts >> 29) & 0xe));
packet[14] = (byte)(pts >> 22);
packet[15] = (byte)(1 | ((pts >> 14) & 0xfe));
packet[16] = (byte)(pts >> 7);
packet[17] = (byte)(1 | ((pts << 1) & 0xfe));
new byte[] { 0, 0, 0, 1, idr ? (byte)0x65 : (byte)0x41, 0x88 }.CopyTo(packet, 18);
return packet;
}
}