fix(runtime): harden application service boundaries

This commit is contained in:
edde746
2026-07-24 03:46:46 +02:00
parent 658da37b48
commit e0bf66eea8
309 changed files with 32574 additions and 4369 deletions
@@ -0,0 +1,112 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/companion_remote/remote_command.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/companion_remote/companion_remote_receiver.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/mock_player_channels.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.seekTimeSmall, 7);
});
testWidgets('in-flight Companion seeks stay bound to the receipt-time player', (tester) async {
final nativeInitialize = Completer<bool>();
final playerA = _ControlledSeekPlayer(position: const Duration(seconds: 30));
final playerB = _ControlledSeekPlayer(position: const Duration(seconds: 50));
addTearDown(playerA.dispose);
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
if (call.method == 'initialize') return nativeInitialize.future;
return Future<Object?>.value(null);
},
eventHandler: (_) async => null,
testBody: () async {
final key = GlobalKey<VideoPlayerScreenState>();
await tester.pumpWidget(_screen(key));
expect(key.currentState, isNotNull);
key.currentState!.player = playerA;
CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekForward), null);
expect(playerA.seekTargets, [const Duration(seconds: 37)]);
key.currentState!.player = playerB;
playerA.completeSeek();
await tester.pump();
expect(playerB.seekTargets, isEmpty);
key.currentState!.player = playerA;
CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekBackward), null);
expect(playerA.seekTargets.last, const Duration(seconds: 23));
key.currentState!.player = playerB;
playerA.completeSeek();
await tester.pump();
expect(playerB.seekTargets, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
nativeInitialize.complete(true);
await tester.pump();
CompanionRemoteReceiver.instance.handleCommand(const RemoteCommand(type: RemoteCommandType.seekForward), null);
expect(playerB.seekTargets, isEmpty, reason: 'disposed owner callbacks must be inert');
},
);
});
}
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
return ChangeNotifierProvider(
create: (_) => PlaybackStateProvider(),
child: MaterialApp(
home: VideoPlayerScreen(
key: key,
metadata: testMediaItem(title: 'Companion target test'),
isOffline: true,
),
),
);
}
class _ControlledSeekPlayer implements Player {
_ControlledSeekPlayer({required Duration position})
: _state = PlayerState(position: position, duration: const Duration(minutes: 10), seekable: true);
final PlayerState _state;
final List<Duration> seekTargets = [];
Completer<void>? _seekCompleter;
void completeSeek() {
_seekCompleter?.complete();
_seekCompleter = null;
}
@override
PlayerState get state => _state;
@override
Future<void> seek(Duration position) {
seekTargets.add(position);
return (_seekCompleter = Completer<void>()).future;
}
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,60 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/video_player/frame_rate_matcher.dart';
void main() {
test('single suppression window lasts through its configured deadline', () {
fakeAsync((async) {
final matcher = FrameRateMatcher();
matcher.beginSuppressWindow(2);
expect(matcher.suppressesMediaPause, isTrue);
async.elapse(const Duration(milliseconds: 4999));
expect(matcher.suppressesMediaPause, isTrue);
async.elapse(const Duration(milliseconds: 1));
expect(matcher.suppressesMediaPause, isFalse);
matcher.dispose();
});
});
test('overlapping suppression windows remain active until the newest deadline', () {
fakeAsync((async) {
final matcher = FrameRateMatcher();
matcher.beginSuppressWindow(0);
async.elapse(const Duration(seconds: 2));
matcher.beginSuppressWindow(0);
expect(async.nonPeriodicTimerCount, 1);
async.elapse(const Duration(seconds: 1));
expect(
matcher.suppressesMediaPause,
isTrue,
reason: 'the older window must not clear the overlapping newer window',
);
async.elapse(const Duration(milliseconds: 1999));
expect(matcher.suppressesMediaPause, isTrue);
async.elapse(const Duration(milliseconds: 1));
expect(matcher.suppressesMediaPause, isFalse);
matcher.dispose();
});
});
test('dispose cancels an active suppression deadline', () {
fakeAsync((async) {
final matcher = FrameRateMatcher()..beginSuppressWindow(0);
expect(async.nonPeriodicTimerCount, 1);
matcher.dispose();
expect(matcher.suppressesMediaPause, isFalse);
expect(async.nonPeriodicTimerCount, 0);
async.elapse(const Duration(seconds: 10));
expect(matcher.suppressesMediaPause, isFalse);
});
});
}
@@ -0,0 +1,208 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/models/livetv_capture_buffer.dart';
import 'package:plezy/screens/video_player/live_timeline_report.dart';
void main() {
group('runLiveTimelineReport', () {
test('late pre-channel heartbeat cannot replace adopted channel buffer', () async {
final bufferA = _buffer(1000);
final bufferB = _buffer(2000);
final freshBufferB = _buffer(2100);
final sessionA = _FakeSession(bufferA);
final sessionB = _FakeSession(bufferB);
LiveTvPlaybackSession? currentSession = sessionA;
var generation = 1;
var currentBuffer = bufferA;
var commits = 0;
final oldHeartbeat = _run(
sessionA,
generation,
state: 'playing',
currentSession: () => currentSession,
currentGeneration: () => generation,
commit: (buffer) {
commits++;
currentBuffer = buffer;
},
);
generation++;
final stopped = _run(
sessionA,
generation,
state: 'stopped',
currentSession: () => currentSession,
currentGeneration: () => generation,
commit: (buffer) {
commits++;
currentBuffer = buffer;
},
);
sessionA.complete(1, _buffer(1200));
await stopped;
currentSession = sessionB;
currentBuffer = bufferB;
generation++;
sessionA.complete(0, _buffer(1300));
await oldHeartbeat;
expect(sessionA.states, ['playing', 'stopped']);
expect(commits, 0);
expect(currentBuffer, same(bufferB));
final currentHeartbeat = _run(
sessionB,
generation,
state: 'playing',
currentSession: () => currentSession,
currentGeneration: () => generation,
commit: (buffer) {
commits++;
currentBuffer = buffer;
},
);
sessionB.complete(0, freshBufferB);
await currentHeartbeat;
expect(commits, 1);
expect(currentBuffer, same(freshBufferB));
});
test('session replacement invalidates report without generation change', () async {
final sessionA = _FakeSession(_buffer(1000));
final sessionB = _FakeSession(_buffer(2000));
LiveTvPlaybackSession? currentSession = sessionA;
const generation = 4;
var commits = 0;
final report = _run(
sessionA,
generation,
state: 'playing',
currentSession: () => currentSession,
currentGeneration: () => generation,
commit: (_) => commits++,
);
currentSession = sessionB;
sessionA.complete(0, _buffer(1100));
await report;
expect(commits, 0);
});
test('generation change invalidates report for the same session', () async {
final session = _FakeSession(_buffer(1000));
final currentSession = session;
var generation = 8;
var commits = 0;
final report = _run(
session,
generation,
state: 'paused',
currentSession: () => currentSession,
currentGeneration: () => generation,
commit: (_) => commits++,
);
generation++;
session.complete(0, _buffer(1100));
await report;
expect(commits, 0);
});
test('terminal and unmounted responses never commit but are still sent', () async {
final session = _FakeSession(_buffer(1000));
var mounted = true;
var commits = 0;
final stopped = _run(
session,
1,
state: 'stopped',
currentSession: () => session,
currentGeneration: () => 1,
isMounted: () => mounted,
commit: (_) => commits++,
);
session.complete(0, _buffer(1100));
await stopped;
final playing = _run(
session,
1,
state: 'playing',
currentSession: () => session,
currentGeneration: () => 1,
isMounted: () => mounted,
commit: (_) => commits++,
);
mounted = false;
session.complete(1, _buffer(1200));
await playing;
expect(session.states, ['stopped', 'playing']);
expect(commits, 0);
});
});
}
Future<void> _run(
LiveTvPlaybackSession session,
int generation, {
required String state,
required LiveTvPlaybackSession? Function() currentSession,
required int Function() currentGeneration,
bool Function()? isMounted,
required void Function(CaptureBuffer) commit,
}) {
return runLiveTimelineReport(
requestSession: session,
requestGeneration: generation,
state: state,
positionMs: 321,
currentSession: currentSession,
currentGeneration: currentGeneration,
isMounted: isMounted ?? () => true,
commit: commit,
);
}
CaptureBuffer _buffer(double startedAt) => CaptureBuffer(startedAt: startedAt, seekStartSeconds: 0, seekEndSeconds: 60);
class _FakeSession implements LiveTvPlaybackSession {
_FakeSession(this.captureBuffer);
@override
final CaptureBuffer captureBuffer;
final List<String> states = [];
final List<Completer<CaptureBuffer?>> _reports = [];
void complete(int index, CaptureBuffer? buffer) => _reports[index].complete(buffer);
@override
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession;
@override
bool get canTimeShift => true;
@override
LiveProgramInfo get program => const LiveProgramInfo(durationMs: 999);
@override
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async => this;
@override
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs}) {
states.add(state);
final completer = Completer<CaptureBuffer?>();
_reports.add(completer);
return completer.future;
}
@override
Future<String?> streamUrlAt({int? offsetSeconds}) async => 'https://example.invalid/live';
}
@@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:plezy/screens/video_player/media_control_router.dart';
void main() {
test('denied playback and media-item commands are consumed without mutation', () {
var canControl = false;
var canNavigate = false;
final calls = <String>[];
final router = _router(canControl: () => canControl, canNavigate: () => canNavigate, calls: calls);
final denied = <MediaControlEvent>[
const PlayEvent(),
const PauseEvent(),
const TogglePlayPauseEvent(),
const SeekEvent(Duration(seconds: 8)),
const SkipForwardEvent(Duration(seconds: 10)),
const SkipBackwardEvent(null),
const SetSpeedEvent(1.5),
const NextTrackEvent(),
const PreviousTrackEvent(),
];
for (final event in denied) {
expect(router.route(event), isTrue, reason: '$event must be consumed');
}
expect(calls, isEmpty);
expect(router.route(const StopEvent()), isTrue);
expect(calls, ['stop']);
canControl = true;
for (final event in denied) {
router.route(event);
}
expect(calls, ['stop', 'play', 'pause', 'toggle', 'seek:8', 'forward:10', 'backward:null', 'speed:1.5']);
canNavigate = true;
router.route(const NextTrackEvent());
router.route(const PreviousTrackEvent());
expect(calls.sublist(calls.length - 2), ['next', 'previous']);
});
test('unknown lifecycle events are left to the screen lifecycle handler', () {
final router = _router(canControl: () => false, canNavigate: () => false, calls: []);
expect(router.route(const AudioInterruptionBeganEvent()), isFalse);
expect(router.route(const AudioRouteOldDeviceUnavailableEvent()), isFalse);
});
}
VideoPlayerMediaControlRouter _router({
required bool Function() canControl,
required bool Function() canNavigate,
required List<String> calls,
}) {
return VideoPlayerMediaControlRouter(
canControlPlayback: canControl,
canNavigateMediaItems: canNavigate,
onPlay: () => calls.add('play'),
onPause: () => calls.add('pause'),
onTogglePlayPause: () => calls.add('toggle'),
onSeek: (position) => calls.add('seek:${position.inSeconds}'),
onNext: () => calls.add('next'),
onPrevious: () => calls.add('previous'),
onStop: () => calls.add('stop'),
onSkipForward: (interval) => calls.add('forward:${interval?.inSeconds}'),
onSkipBackward: (interval) => calls.add('backward:${interval?.inSeconds}'),
onSetSpeed: (speed) => calls.add('speed:$speed'),
);
}
@@ -0,0 +1,118 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/focus/focusable_button.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/media_items.dart';
import '../../test_helpers/mock_player_channels.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
testWidgets('initialization ownership serializes rollback, retry, and route removal', (tester) async {
final failedDispose = Completer<void>();
final replacementInitialize = Completer<bool>();
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
var initializeCount = 0;
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
initializeCount++;
if (initializeCount == 2) return replacementInitialize.future;
return Future<Object?>.value(true);
case 'observeProperty':
if (initializeCount == 1) {
throw PlatformException(code: 'post_creation_failure', message: 'forced observation failure');
}
return Future<Object?>.value(null);
case 'dispose':
if (initializeCount == 1) return failedDispose.future;
return Future<Object?>.value(null);
default:
return Future<Object?>.value(null);
}
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
final key = GlobalKey<VideoPlayerScreenState>();
await tester.pumpWidget(_screen(key));
await _pumpUntil(tester, () => calls.any((call) => call.method == 'dispose'));
expect(key.currentState?.player, isNull);
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
expect(initializeCount, 1);
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
failedDispose.complete();
await _pumpUntil(tester, () => find.widgetWithText(FilledButton, 'Retry').evaluate().isNotEmpty);
final retryButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Retry'));
final retryFocusable = tester.widget<FocusableButton>(
find.ancestor(of: find.widgetWithText(FilledButton, 'Retry'), matching: find.byType(FocusableButton)),
);
retryButton.onPressed!();
retryFocusable.onPressed!();
await _pumpUntil(tester, () => initializeCount == 2);
expect(initializeCount, 2);
expect(key.currentState?.player, isNull);
expect(calls.where((call) => call.method == 'dispose'), hasLength(1));
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(1));
await tester.pumpWidget(const SizedBox.shrink());
replacementInitialize.completeError(PlatformException(code: 'late_failure', message: 'forced late failure'));
await _pumpUntil(tester, () => calls.where((call) => call.method == 'dispose').length == 2);
expect(find.widgetWithText(FilledButton, 'Retry'), findsNothing);
expect(initializeCount, 2);
expect(calls.where((call) => call.method == 'dispose'), hasLength(2));
expect(eventCalls.where((call) => call.method == 'cancel'), hasLength(2));
},
);
});
}
Widget _screen(GlobalKey<VideoPlayerScreenState> key) {
return ChangeNotifierProvider(
create: (_) => PlaybackStateProvider(),
child: MaterialApp(
home: VideoPlayerScreen(
key: key,
metadata: testMediaItem(title: 'Lifecycle test video'),
isOffline: true,
),
),
);
}
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
for (var i = 0; i < 200 && !condition(); i++) {
await tester.pump(const Duration(milliseconds: 10));
if (!condition()) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 5)));
}
}
expect(condition(), isTrue);
}
@@ -0,0 +1,176 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/video_player/wakelock_controller.dart';
void main() {
group('WakelockController', () {
test('drains enable then disable without overlapping platform work', () async {
final platform = _ControlledPlatformToggle();
final controller = WakelockController(platformToggle: platform.call);
final enable = controller.setEnabled(true);
await _flushTasks();
expect(platform.calls, [true]);
final disable = controller.setEnabled(false);
await _flushTasks();
expect(platform.calls, [true]);
expect(platform.maxConcurrent, 1);
platform.succeedNext();
await _flushTasks();
expect(platform.calls, [true, false]);
platform.succeedNext();
await Future.wait([enable, disable]);
expect(platform.completed, [true, false]);
expect(platform.active, 0);
expect(platform.maxConcurrent, 1);
});
test('drains disable then enable to the latest desired state', () async {
final platform = _ControlledPlatformToggle();
final controller = WakelockController(platformToggle: platform.call);
final initialEnable = controller.setEnabled(true);
await _flushTasks();
platform.succeedNext();
await initialEnable;
final disable = controller.setEnabled(false);
await _flushTasks();
expect(platform.calls, [true, false]);
final enable = controller.setEnabled(true);
await _flushTasks();
expect(platform.calls, [true, false]);
platform.succeedNext();
await _flushTasks();
expect(platform.calls, [true, false, true]);
platform.succeedNext();
await Future.wait([disable, enable]);
expect(platform.completed, [true, false, true]);
expect(platform.maxConcurrent, 1);
});
test('absorbs acquisition failure and retries only when requested', () async {
var calls = 0;
final controller = WakelockController(
platformToggle: (enabled) async {
calls++;
expect(enabled, isTrue);
if (calls == 1) throw StateError('enable failed');
},
);
await expectLater(controller.setEnabled(true), completes);
await _flushTasks();
expect(calls, 1, reason: 'a failure must not start an automatic retry loop');
await expectLater(controller.setEnabled(true), completes);
expect(calls, 2, reason: 'the failed state must remain explicitly retryable');
await controller.setEnabled(true);
expect(calls, 2, reason: 'only successful platform work becomes effective');
});
test('applies a newer opposing request after acquisition fails', () async {
final platform = _ControlledPlatformToggle();
final controller = WakelockController(platformToggle: platform.call);
final enable = controller.setEnabled(true);
await _flushTasks();
final disable = controller.setEnabled(false);
platform.failNext(StateError('enable failed'));
await _flushTasks();
expect(platform.calls, [true, false]);
expect(platform.maxConcurrent, 1);
platform.succeedNext();
await expectLater(Future.wait([enable, disable]), completes);
expect(platform.completed, [false]);
expect(platform.active, 0);
});
test('keeps effective state after release failure and retries disable', () async {
var disableAttempts = 0;
final calls = <bool>[];
final controller = WakelockController(
platformToggle: (enabled) async {
calls.add(enabled);
if (!enabled && disableAttempts++ == 0) {
throw StateError('disable failed');
}
},
);
await controller.setEnabled(true);
await expectLater(controller.setEnabled(false), completes);
expect(calls, [true, false]);
await expectLater(controller.setEnabled(false), completes);
expect(calls, [true, false, false]);
await controller.setEnabled(false);
expect(calls, [true, false, false]);
});
test('detached teardown disable drains after an in-flight enable', () async {
final platform = _ControlledPlatformToggle();
final controller = WakelockController(platformToggle: platform.call);
final enable = controller.setEnabled(true);
await _flushTasks();
unawaited(controller.setEnabled(false));
platform.succeedNext();
await _flushTasks();
expect(platform.calls, [true, false]);
platform.succeedNext();
await enable;
await _flushTasks();
expect(platform.completed, [true, false]);
expect(platform.active, 0);
expect(platform.maxConcurrent, 1);
});
});
}
Future<void> _flushTasks() => Future<void>.delayed(Duration.zero);
class _ControlledPlatformToggle {
final calls = <bool>[];
final completed = <bool>[];
final _pending = <({bool enabled, Completer<void> completer})>[];
int active = 0;
int maxConcurrent = 0;
Future<void> call(bool enabled) async {
calls.add(enabled);
active++;
if (active > maxConcurrent) maxConcurrent = active;
final completer = Completer<void>();
_pending.add((enabled: enabled, completer: completer));
try {
await completer.future;
completed.add(enabled);
} finally {
active--;
}
}
void succeedNext() => _pending.removeAt(0).completer.complete();
void failNext(Object error) => _pending.removeAt(0).completer.completeError(error);
}