Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,25 @@ jobs:
- name: Publish APK (android-x64)
run: dotnet publish src/OpenIPC.Viewer.Android/OpenIPC.Viewer.Android.csproj -c Release -f net10.0-android -r android-x64 -o publish/android-x64 -p:ApplicationVersion="${APP_VERSION:-1}" -p:ApplicationDisplayVersion="${APP_DISPLAY_VERSION:-0.1.0-beta}" $SIGN_ARGS

# Hard gate against shipping a corrupt APK. Both v0.3.4 and v0.3.6 went out
# with a bad CRC-32 on lib/<abi>/libassembly-store.so (a floating-toolchain
# packaging regression), so Android rejected them as "package corrupt" on
# every device. `unzip -t` walks every entry and fails on a CRC mismatch,
# so a malformed APK breaks the build here instead of reaching users.
# Also rename the x64 APK: both ABIs publish as org.openipc.viewer-Signed.apk,
# and the download-artifact merge in the release job would otherwise keep
# only one of them. arm64 stays canonical (what phones need); x64 gets a
# distinct name so both survive as separate release assets.
- name: Verify APK integrity + disambiguate x64
run: |
arm=$(ls publish/android-arm64/*-Signed.apk)
x64=$(ls publish/android-x64/*-Signed.apk)
for apk in "$arm" "$x64"; do
echo "Integrity check: $apk"
unzip -t "$apk" > /dev/null
done
mv "$x64" publish/android-x64/org.openipc.viewer-x64-Signed.apk

- name: Upload artifact (arm64)
uses: actions/upload-artifact@v4
with:
Expand Down
10 changes: 10 additions & 0 deletions src/OpenIPC.Viewer.Android/OpenIPC.Viewer.Android.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
mismatch). We don't need AOT yet, so keep it off until we revisit it as a
page-alignment-safe polish step. -->
<RunAOTCompilation>false</RunAOTCompilation>
<!-- Assembly store OFF. With it on (the .NET 10 Android default) the current
workload packs all managed assemblies into lib/<abi>/libassembly-store.so
with a ZIP CRC-32 that doesn't match the stored bytes, so Android rejects
the whole APK as "package corrupt" on every device (fresh install AND
update). Confirmed by CRC-testing shipped APKs: v0.3.2 clean, v0.3.4 and
v0.3.6 both corrupt on libassembly-store.so, with zero packaging changes
between them — a floating-toolchain regression. Off = assemblies packed
individually, which sidesteps the broken store. The CI build now also
hard-verifies APK integrity (unzip -t) so a corrupt APK can't ship again. -->
<AndroidUseAssemblyStore>false</AndroidUseAssemblyStore>
<RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>
<!--
FFmpeg native libs are produced by tools/build-ffmpeg-android.sh from
Expand Down
38 changes: 38 additions & 0 deletions src/OpenIPC.Viewer.Video/Recording/LibavformatRecordingSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Globalization;
using System.IO;
using System.Reactive.Subjects;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using FFmpeg.AutoGen.Abstractions;
Expand Down Expand Up @@ -34,6 +35,13 @@ internal sealed class LibavformatRecordingSession : IRecordingSession
private string? _outputPath;
private volatile bool _stopRequested;

// Rooted so the unmanaged function pointer stays valid for the demuxer's
// life. Lets StopAsync/Dispose actually abort a blocking av_read_frame /
// avformat_open_input on a stalled camera — without it a hung read leaves
// the writer thread stuck before av_write_trailer, so the .mp4 is never
// flushed and lands on disk at 0 bytes (the corrupted-recording report).
private AVIOInterruptCB_callback? _interruptDelegate;

public DateTime StartedAt { get; } = DateTime.UtcNow;
public string? CurrentSegmentPath => _outputPath;
public IObservable<RecordingEvent> Events => _events;
Expand Down Expand Up @@ -126,6 +134,17 @@ private unsafe void Run()
// --- Input ---
BuildInputOpts(&inputOpts);
inputCtx = ffmpeg.avformat_alloc_context();
// Wire the interrupt callback before open so a cancelled token
// aborts even the connect/handshake, not just the read loop.
_interruptDelegate = OnInterrupt;
inputCtx->interrupt_callback = new AVIOInterruptCB
{
callback = new AVIOInterruptCB_callback_func
{
Pointer = Marshal.GetFunctionPointerForDelegate(_interruptDelegate),
},
opaque = null,
};
var url = BuildRtspUri(_options.RtspUri, _options.Credentials);
var ret = ffmpeg.avformat_open_input(&inputCtx, url, null, &inputOpts);
FfmpegError.ThrowIfError(ret, "avformat_open_input");
Expand Down Expand Up @@ -183,6 +202,14 @@ private unsafe void Run()
ret = ffmpeg.av_read_frame(inputCtx, packet);
if (ret < 0)
{
// Our interrupt callback fired (graceful stop) — not an error.
// Fall through to av_write_trailer below so the file is still
// finalized instead of left half-written.
if (ct.IsCancellationRequested)
{
stopReason = RecordingStopReason.User;
break;
}
if (ret == ffmpeg.AVERROR_EOF)
{
_logger.LogInformation("Recording input EOF");
Expand Down Expand Up @@ -251,10 +278,21 @@ private unsafe void Run()
}
}

// Returns 1 to tell libavformat to abort the current blocking call. Bound to
// the session CTS so StopAsync (which cancels it) unblocks a stalled read or
// a connect to an unreachable camera.
private unsafe int OnInterrupt(void* opaque) => _cts.IsCancellationRequested ? 1 : 0;

private static unsafe void BuildInputOpts(AVDictionary** opts)
{
ffmpeg.av_dict_set(opts, "rtsp_transport", "tcp", 0);
// "timeout" is the current RTSP socket-timeout key; "stimeout" was renamed
// and is ignored on the n7.1 build we bundle. Set both so a dead stream
// errors out instead of blocking forever (belt-and-braces with the
// interrupt callback). rw_timeout covers the non-RTSP protocols.
ffmpeg.av_dict_set(opts, "timeout", "5000000", 0);
ffmpeg.av_dict_set(opts, "stimeout", "5000000", 0);
ffmpeg.av_dict_set(opts, "rw_timeout", "5000000", 0);
ffmpeg.av_dict_set(opts, "max_delay", "200000", 0);
}

Expand Down
Loading