Skip to content

Commit c8a891d

Browse files
authored
Merge pull request #58 from OpenIPC/fix/android-apk-and-recording
fix(android): repair corrupt release APK + zero-byte motion recordings
2 parents aef7dab + ac43dd6 commit c8a891d

3 files changed

Lines changed: 67 additions & 0 deletions

File tree

.github/workflows/build.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,25 @@ jobs:
214214
- name: Publish APK (android-x64)
215215
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
216216

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

src/OpenIPC.Viewer.Android/OpenIPC.Viewer.Android.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
mismatch). We don't need AOT yet, so keep it off until we revisit it as a
2121
page-alignment-safe polish step. -->
2222
<RunAOTCompilation>false</RunAOTCompilation>
23+
<!-- Assembly store OFF. With it on (the .NET 10 Android default) the current
24+
workload packs all managed assemblies into lib/<abi>/libassembly-store.so
25+
with a ZIP CRC-32 that doesn't match the stored bytes, so Android rejects
26+
the whole APK as "package corrupt" on every device (fresh install AND
27+
update). Confirmed by CRC-testing shipped APKs: v0.3.2 clean, v0.3.4 and
28+
v0.3.6 both corrupt on libassembly-store.so, with zero packaging changes
29+
between them — a floating-toolchain regression. Off = assemblies packed
30+
individually, which sidesteps the broken store. The CI build now also
31+
hard-verifies APK integrity (unzip -t) so a corrupt APK can't ship again. -->
32+
<AndroidUseAssemblyStore>false</AndroidUseAssemblyStore>
2333
<RuntimeIdentifiers>android-arm64;android-x64</RuntimeIdentifiers>
2434
<!--
2535
FFmpeg native libs are produced by tools/build-ffmpeg-android.sh from

src/OpenIPC.Viewer.Video/Recording/LibavformatRecordingSession.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Globalization;
33
using System.IO;
44
using System.Reactive.Subjects;
5+
using System.Runtime.InteropServices;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using FFmpeg.AutoGen.Abstractions;
@@ -34,6 +35,13 @@ internal sealed class LibavformatRecordingSession : IRecordingSession
3435
private string? _outputPath;
3536
private volatile bool _stopRequested;
3637

38+
// Rooted so the unmanaged function pointer stays valid for the demuxer's
39+
// life. Lets StopAsync/Dispose actually abort a blocking av_read_frame /
40+
// avformat_open_input on a stalled camera — without it a hung read leaves
41+
// the writer thread stuck before av_write_trailer, so the .mp4 is never
42+
// flushed and lands on disk at 0 bytes (the corrupted-recording report).
43+
private AVIOInterruptCB_callback? _interruptDelegate;
44+
3745
public DateTime StartedAt { get; } = DateTime.UtcNow;
3846
public string? CurrentSegmentPath => _outputPath;
3947
public IObservable<RecordingEvent> Events => _events;
@@ -126,6 +134,17 @@ private unsafe void Run()
126134
// --- Input ---
127135
BuildInputOpts(&inputOpts);
128136
inputCtx = ffmpeg.avformat_alloc_context();
137+
// Wire the interrupt callback before open so a cancelled token
138+
// aborts even the connect/handshake, not just the read loop.
139+
_interruptDelegate = OnInterrupt;
140+
inputCtx->interrupt_callback = new AVIOInterruptCB
141+
{
142+
callback = new AVIOInterruptCB_callback_func
143+
{
144+
Pointer = Marshal.GetFunctionPointerForDelegate(_interruptDelegate),
145+
},
146+
opaque = null,
147+
};
129148
var url = BuildRtspUri(_options.RtspUri, _options.Credentials);
130149
var ret = ffmpeg.avformat_open_input(&inputCtx, url, null, &inputOpts);
131150
FfmpegError.ThrowIfError(ret, "avformat_open_input");
@@ -183,6 +202,14 @@ private unsafe void Run()
183202
ret = ffmpeg.av_read_frame(inputCtx, packet);
184203
if (ret < 0)
185204
{
205+
// Our interrupt callback fired (graceful stop) — not an error.
206+
// Fall through to av_write_trailer below so the file is still
207+
// finalized instead of left half-written.
208+
if (ct.IsCancellationRequested)
209+
{
210+
stopReason = RecordingStopReason.User;
211+
break;
212+
}
186213
if (ret == ffmpeg.AVERROR_EOF)
187214
{
188215
_logger.LogInformation("Recording input EOF");
@@ -251,10 +278,21 @@ private unsafe void Run()
251278
}
252279
}
253280

281+
// Returns 1 to tell libavformat to abort the current blocking call. Bound to
282+
// the session CTS so StopAsync (which cancels it) unblocks a stalled read or
283+
// a connect to an unreachable camera.
284+
private unsafe int OnInterrupt(void* opaque) => _cts.IsCancellationRequested ? 1 : 0;
285+
254286
private static unsafe void BuildInputOpts(AVDictionary** opts)
255287
{
256288
ffmpeg.av_dict_set(opts, "rtsp_transport", "tcp", 0);
289+
// "timeout" is the current RTSP socket-timeout key; "stimeout" was renamed
290+
// and is ignored on the n7.1 build we bundle. Set both so a dead stream
291+
// errors out instead of blocking forever (belt-and-braces with the
292+
// interrupt callback). rw_timeout covers the non-RTSP protocols.
293+
ffmpeg.av_dict_set(opts, "timeout", "5000000", 0);
257294
ffmpeg.av_dict_set(opts, "stimeout", "5000000", 0);
295+
ffmpeg.av_dict_set(opts, "rw_timeout", "5000000", 0);
258296
ffmpeg.av_dict_set(opts, "max_delay", "200000", 0);
259297
}
260298

0 commit comments

Comments
 (0)