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,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;
}
}