Files
plezy/test/services/settings_service_test.dart
T
edde746 41ffaa7f2b fix(automotive): stop playback while a vehicle restricts the app
Plezy declares appCategory="video", so on Android Automotive OS it is a
parked app bound by car app quality DD-2/DD-3: audio must stop when the
vehicle starts driving and must not be resumable while driving. Two paths
kept audio alive. Music playback ran under a mediaPlayback foreground
service whose lifecycle observer was registered for Apple TV only, so it
never paused when Android backgrounded the app. Video pausing hung off
AppLifecycleState.hidden, which Flutter only synthesizes once Android
delivers onStop; a car without the Automotive compatibility mode delivers
onPause alone, which maps to AppLifecycleState.inactive and the player
ignored.

Gate every path that can start audio on a new lifecycle predicate,
automotivePlaybackAllowed, which permits playback on a car only while the
app is resumed and fails closed on an unknown lifecycle state. That covers
explicit play, gapless arming and track transitions, live retry and
channel switch, frame-rate-match resume, VOD/live startup, and the queue
navigation commands of the OS media session, plus a last-resort pause for
when the platform player resumes itself on native audio-focus regain.
Playback authority on the media-session router is deliberately left alone:
the router consumes a denied event, so gating it would swallow PauseEvent
and leave the OS unable to stop audio. Reacting to lifecycle callbacks is
the mechanism the platform documents as sufficient, so no android.car
dependency is added.

The music queue no longer requests POST_NOTIFICATIONS on a car, where the
foreground service and its notification never start: there is nothing to
authorize, and the prompt would take focus and make the gate discard the
first play intent.

Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV
verdict, so a rotary-only head unit no longer inherits the leanback
experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE,
which cars lack, so the app's UI cannot stay on screen while driving, and
nothing forces a preferred orientation on a fixed-orientation display.
2026-07-28 23:28:25 +02:00

399 lines
15 KiB
Dart

import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/audio_quality_preset.dart';
import 'package:plezy/models/hotkey_model.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/trackers/tracker_constants.dart';
import 'package:plezy/utils/platform_detector.dart';
import '../test_helpers/prefs.dart';
void main() {
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
TvDetectionService.debugSetAutomotiveOverride(null);
});
group('SettingsService.parseMpvConfigText', () {
test('parses plain key=value lines', () {
final out = SettingsService.parseMpvConfigText('hwdec=auto\nvolume=100');
expect(out, {'hwdec': 'auto', 'volume': '100'});
});
test('trims whitespace around key and value', () {
final out = SettingsService.parseMpvConfigText(' hwdec = auto ');
expect(out, {'hwdec': 'auto'});
});
test('skips blank lines', () {
final out = SettingsService.parseMpvConfigText('\n\nhwdec=auto\n\n');
expect(out, {'hwdec': 'auto'});
});
test('skips # comment lines (even with leading whitespace)', () {
final out = SettingsService.parseMpvConfigText('# this is a comment\n # indented comment\nhwdec=auto');
expect(out, {'hwdec': 'auto'});
});
test('skips lines without an = sign', () {
final out = SettingsService.parseMpvConfigText('justakey\nfoo=bar');
expect(out, {'foo': 'bar'});
});
test('skips lines starting with = (empty key)', () {
final out = SettingsService.parseMpvConfigText('=value\nfoo=bar');
expect(out, {'foo': 'bar'});
});
test('preserves = signs in value (splits on first only)', () {
final out = SettingsService.parseMpvConfigText('params=a=1,b=2');
expect(out, {'params': 'a=1,b=2'});
});
test('allows empty value', () {
final out = SettingsService.parseMpvConfigText('flag=');
expect(out, {'flag': ''});
});
test('later duplicate key overrides earlier', () {
final out = SettingsService.parseMpvConfigText('k=1\nk=2');
expect(out, {'k': '2'});
});
test('empty input yields empty map', () {
expect(SettingsService.parseMpvConfigText(''), isEmpty);
});
});
group('SettingsService keyboard hotkey defaults', () {
test('includes Ctrl+S screenshot shortcut', () {
final hotkey = SettingsService.defaultKeyboardHotkeys()['screenshot'];
expect(hotkey, isNotNull);
expect(hotkey!.key, PhysicalKeyboardKey.keyS);
expect(hotkey.modifiers, [HotKeyModifier.control]);
});
});
group('SettingsService mute volume restoration', () {
test('keeps 37 persisted across mute and restores it on unmute', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.volume, 37.0);
final mute = settings.resolveMuteToggle(37);
await settings.write(SettingsService.volume, mute.persistedVolume);
expect(mute.playerVolume, 0);
expect(settings.read(SettingsService.volume), 37);
final unmute = settings.resolveMuteToggle(mute.playerVolume);
expect(unmute.playerVolume, 37);
expect(unmute.persistedVolume, 37);
});
test('restores amplified volumes when the configured maximum permits them', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.maxVolume, 250);
await settings.write(SettingsService.volume, 175.0);
final mute = settings.resolveMuteToggle(175);
await settings.write(SettingsService.volume, mute.persistedVolume);
final unmute = settings.resolveMuteToggle(mute.playerVolume);
expect(mute.playerVolume, 0);
expect(mute.persistedVolume, 175);
expect(unmute.playerVolume, 175);
expect(unmute.persistedVolume, 175);
});
test('falls back to 100 when no previous non-zero volume exists', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.maxVolume, 200);
await settings.write(SettingsService.volume, 0.0);
final unmute = settings.resolveMuteToggle(0);
expect(unmute.playerVolume, 100);
expect(unmute.persistedVolume, 100);
});
});
group('SettingsService TV card defaults', () {
test('full card layout starts disabled', () async {
final settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.tvFullCardLayout), isFalse);
});
});
group('SettingsService episode action', () {
test('defaults to play and resets to play', () async {
final settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.episodeAction), EpisodeAction.play);
await settings.write(SettingsService.episodeAction, EpisodeAction.details);
expect(settings.read(SettingsService.episodeAction), EpisodeAction.details);
await settings.resetAllSettings();
expect(settings.read(SettingsService.episodeAction), EpisodeAction.play);
});
});
group('SettingsService music quality', () {
test('defaults to original and persists changes by enum name', () async {
var settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.musicQualityPreset), AudioQualityPreset.original);
await settings.write(SettingsService.musicQualityPreset, AudioQualityPreset.medium);
expect(settings.prefs.getString(SettingsService.musicQualityPreset.key), 'medium');
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.musicQualityPreset), AudioQualityPreset.medium);
});
});
group('SettingsService app locale', () {
test('persists script-specific locales by enum name', () async {
var settings = await SettingsService.getInstance();
await settings.write(SettingsService.appLocale, AppLocale.zhHant);
expect(settings.prefs.getString(SettingsService.appLocale.key), 'zhHant');
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.appLocale), AppLocale.zhHant);
});
test('continues to read legacy language-code values', () async {
var settings = await SettingsService.getInstance();
await settings.prefs.setString(SettingsService.appLocale.key, 'zh');
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.appLocale), AppLocale.zh);
});
});
group('SettingsService platform gates', () {
test('audio passthrough stays available on desktop and Apple TV', () {
expect(PlatformDetector.supportsAudioPassthrough(), isTrue);
TvDetectionService.debugSetAppleTVOverride(true);
expect(PlatformDetector.supportsAudioPassthrough(), isTrue);
});
test('audio passthrough defaults off on a non-Android-TV host and honors explicit writes', () async {
final settings = await SettingsService.getInstance();
// The Android-TV-on-ExoPlayer default-on branch depends on Platform.isAndroid,
// which is false (and unmockable) on the test host, so the default is off here.
expect(settings.read(SettingsService.audioPassthrough), isFalse);
await settings.write(SettingsService.audioPassthrough, true);
expect(settings.read(SettingsService.audioPassthrough), isTrue);
await settings.write(SettingsService.audioPassthrough, false);
expect(settings.read(SettingsService.audioPassthrough), isFalse);
});
test('forces external player off on Apple TV even when stored enabled', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.useExternalPlayer, true);
TvDetectionService.debugSetAppleTVOverride(true);
expect(settings.read(SettingsService.useExternalPlayer), isFalse);
});
test('forces auto PiP off on Apple TV even when stored enabled', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.autoPip, true);
TvDetectionService.debugSetAppleTVOverride(true);
expect(settings.read(SettingsService.autoPip), isFalse);
});
test('forces auto PiP off on automotive while honoring the stored value elsewhere', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.autoPip, true);
expect(settings.read(SettingsService.autoPip), isTrue);
TvDetectionService.debugSetAutomotiveOverride(true);
expect(settings.read(SettingsService.autoPip), isFalse);
});
});
group('SettingsService companion remote prefs', () {
test('last manual host address trims whitespace and drops blanks', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.companionRemoteLastHostAddress, ' 192.168.1.10:48632 ');
expect(settings.read(SettingsService.companionRemoteLastHostAddress), '192.168.1.10:48632');
await settings.write(SettingsService.companionRemoteLastHostAddress, ' ');
expect(settings.read(SettingsService.companionRemoteLastHostAddress), isNull);
});
test('resetAllSettings clears the last manual host address', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.companionRemoteLastHostAddress, '192.168.1.10:48632');
expect(settings.read(SettingsService.companionRemoteLastHostAddress), isNotNull);
await settings.resetAllSettings();
expect(settings.read(SettingsService.companionRemoteLastHostAddress), isNull);
});
});
group('SettingsService Watch Together relay', () {
test('typed writes canonicalize valid bases and reject invalid replacement', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.customRelayUrl, ' HTTPS://Relay.Example.Test/path/// ');
expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path');
await expectLater(
settings.write(SettingsService.customRelayUrl, 'ws://relay.example.test'),
throwsFormatException,
);
expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path');
await settings.write(SettingsService.customRelayUrl, ' ');
expect(settings.read(SettingsService.customRelayUrl), isNull);
});
test('startup canonicalizes valid history and removes invalid history', () async {
resetSharedPreferencesForTest(
initialAsync: {SettingsService.customRelayUrl.key: ' http://Relay.Example.Test:8080/prefix// '},
);
SettingsService.resetForTesting();
var settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix');
resetSharedPreferencesForTest(
initialAsync: {SettingsService.customRelayUrl.key: 'https://relay.example.test/path?wrong=route'},
);
SettingsService.resetForTesting();
settings = await SettingsService.getInstance();
expect(settings.read(SettingsService.customRelayUrl), isNull);
});
});
group('SettingsService listenables', () {
test('refreshListenables updates active prefs outside the resettable surface', () async {
final settings = await SettingsService.getInstance();
final crashReporting = settings.listenable(SettingsService.crashReporting);
expect(crashReporting.value, isTrue);
await settings.prefs.setBool(SettingsService.crashReporting.key, false);
expect(crashReporting.value, isTrue);
settings.refreshListenables();
expect(crashReporting.value, isFalse);
});
test('resetAllSettings refreshes active dynamic tracker prefs', () async {
final settings = await SettingsService.getInstance();
final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt);
final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt);
await settings.write(modePref, TrackerLibraryFilterMode.whitelist);
await settings.write(idsPref, ['library-1']);
final mode = settings.listenable(modePref);
final ids = settings.listenable(idsPref);
expect(mode.value, TrackerLibraryFilterMode.whitelist);
expect(ids.value, ['library-1']);
await settings.resetAllSettings();
expect(mode.value, TrackerLibraryFilterMode.blacklist);
expect(ids.value, isEmpty);
});
test('tracker library filter only allows unknown libraries when no filter is configured', () async {
final settings = await SettingsService.getInstance();
final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt);
final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt);
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isTrue);
await settings.write(idsPref, ['server:blocked']);
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse);
await settings.write(modePref, TrackerLibraryFilterMode.whitelist);
await settings.write(idsPref, ['server:allowed']);
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse);
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue);
});
});
group('BaseSharedPreferencesService initialization generations', () {
test('a reset-raced initialization resolves to the replacement backend', () async {
resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'old'});
final firstOnInit = Completer<void>();
final firstStarted = Completer<void>();
var constructions = 0;
final raced = BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(() {
final construction = constructions++;
return _GenerationTestPreferences(
construction,
onInitStarted: construction == 0 ? firstStarted : null,
onInitGate: construction == 0 ? firstOnInit : null,
);
});
await firstStarted.future;
resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'new'});
final replacement = await BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(
() => _GenerationTestPreferences(constructions++),
);
firstOnInit.complete();
final recovered = await raced;
expect(recovered, same(replacement));
expect(recovered.construction, 1);
expect(recovered.readString('generation_marker'), 'new');
expect(constructions, 2);
});
});
}
class _GenerationTestPreferences extends BaseSharedPreferencesService {
_GenerationTestPreferences(this.construction, {this.onInitStarted, this.onInitGate});
final int construction;
final Completer<void>? onInitStarted;
final Completer<void>? onInitGate;
@override
Future<void> onInit() async {
onInitStarted?.complete();
await onInitGate?.future;
}
}