feat(discord): add opt-in mobile rich presence with track artwork

This commit is contained in:
zarzet committed 2026-10-03 02:14:11 +07:00
1 parent bbe2caa754
commit 0b2cf0e4e5
40 files changed
+1839 -18

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/discord_artwork_resolver.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final item = MediaItem(
id: 'local:1',
title: 'Song',
artist: 'Artist',
album: 'Album',
duration: const Duration(seconds: 240),
artUri: Uri.file('/private/cover.jpg'),
);
Track candidate({
String name = 'Song',
String artist = 'Artist',
String album = 'Album',
int duration = 240,
}) => Track(
id: 'catalog:1',
name: name,
artistName: artist,
albumName: album,
duration: duration,
coverUrl: 'https://example.com/cover.jpg',
);
test('accepts public CDN sizing but excludes private and signed URLs', () {
expect(
discordPublicArtwork('https://example.com/a.jpg?w=512&format=jpg'),
'https://example.com/a.jpg?w=512&format=jpg',
);
for (final url in [
'file:///private/cover.jpg',
'content://media/1',
'https://localhost/a.jpg',
'https://127.0.0.1/a.jpg',
'https://192.168.0.2/a.jpg',
'https://nas.local/a.jpg',
'https://user:password@example.com/a.jpg',
'https://example.com/a.jpg?token=secret',
'https://example.com/a.jpg?signature=secret',
]) {
expect(discordPublicArtwork(url), isNull, reason: url);
}
});
test('rejects a different remix, artist, album or recording length', () {
expect(
discordMatchedArtwork(item, [
candidate(name: 'Song (Remix)'),
candidate(artist: 'Other Artist'),
candidate(album: 'Other Album'),
candidate(duration: 300),
]),
isNull,
);
expect(
discordMatchedArtwork(item, [candidate(name: ' song ')]),
'https://example.com/cover.jpg',
);
});
test('uses original public history cover without catalog requests', () async {
final resolver = DiscordArtworkResolver(
historyCover: (_) async => 'https://example.com/original.jpg',
search: (_) async => throw StateError('Should not search'),
);
expect(await resolver.resolve(item), 'https://example.com/original.jpg');
});
test('default catalog searches enabled metadata extensions', () async {
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'searchTracksWithMetadataProviders');
expect((call.arguments as Map)['include_extensions'], isTrue);
return [
{
'id': 'catalog:1',
'name': 'Song',
'artists': 'Artist',
'album_name': 'Album',
'duration_ms': 240000,
'cover_url': 'https://example.com/cover.jpg',
},
];
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final resolver = DiscordArtworkResolver(historyCover: (_) async => null);
expect(await resolver.resolve(item), 'https://example.com/cover.jpg');
});
test('deduplicates local-cover lookups and caches catalog matches', () async {
var lookups = 0;
final response = Completer<List<Track>>();
final resolver = DiscordArtworkResolver(
historyCover: (_) async => null,
search: (query) {
lookups++;
expect(query, 'Song Artist');
return response.future;
},
);
final first = resolver.resolve(item);
final second = resolver.resolve(item);
response.complete([candidate()]);
expect(await first, 'https://example.com/cover.jpg');
expect(await second, 'https://example.com/cover.jpg');
expect(await resolver.resolve(item), 'https://example.com/cover.jpg');
expect(lookups, 1);
});
test('offline lookup is optional and negatively cached', () async {
var lookups = 0;
final resolver = DiscordArtworkResolver(
historyCover: (_) async => throw StateError('Storage offline'),
search: (_) async {
lookups++;
throw StateError('Network offline');
},
);
expect(await resolver.resolve(item), isNull);
expect(await resolver.resolve(item), isNull);
expect(lookups, 1);
});
}
+341
View File
@@ -0,0 +1,341 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/discord');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
MediaItem track(String title, {String? artwork}) => MediaItem(
id: '/private/music/$title.flac',
title: title,
artist: 'Artist',
album: 'Album',
duration: const Duration(minutes: 4),
artUri: artwork == null ? null : Uri.parse(artwork),
extras: const {'source': 'content://private/tree/music'},
);
PlaybackState playing(DateTime now, {int position = 20, double speed = 1}) =>
PlaybackState(
playing: true,
processingState: AudioProcessingState.ready,
updatePosition: Duration(seconds: position),
updateTime: now,
speed: speed,
);
test('old settings default to private and opt-in survives round trip', () {
expect(AppSettings.fromJson({}).discordRichPresenceEnabled, isFalse);
final settings = const AppSettings().copyWith(
discordRichPresenceEnabled: true,
);
expect(
AppSettings.fromJson(settings.toJson()).discordRichPresenceEnabled,
isTrue,
);
});
test('payload uses seconds, extrapolates progress and omits local data', () {
final now = DateTime.utc(2026, 10, 2, 12);
final payload = discordPresencePayload(
track('Title', artwork: 'file:///private/cover.jpg'),
playing(now.subtract(const Duration(seconds: 5))),
now,
)!;
expect(payload['start'], now.millisecondsSinceEpoch ~/ 1000 - 25);
expect(payload['end'], now.millisecondsSinceEpoch ~/ 1000 + 215);
expect(payload['state'], 'Artist');
expect(payload.containsKey('album'), isFalse);
expect(payload['artwork'], '');
expect(payload.toString(), isNot(contains('/private')));
expect(payload.toString(), isNot(contains('content://')));
final fast = discordPresencePayload(
track('Title'),
playing(now, speed: 2),
now,
)!;
expect(fast['end'], now.millisecondsSinceEpoch ~/ 1000 + 110);
});
test('only public HTTPS artwork is eligible; metadata handles Unicode', () {
final now = DateTime.utc(2026);
for (final uri in [
'content://media/1',
'http://example.com/cover.jpg',
'https://user:secret@example.com/cover.jpg',
'https://example.com/cover.jpg?token=private',
]) {
expect(
discordPresencePayload(
track('Title', artwork: uri),
playing(now),
now,
)!['artwork'],
'',
);
}
final payload = discordPresencePayload(
track('🎵' * 150, artwork: 'https://example.com/cover.jpg'),
playing(now),
now,
)!;
expect((payload['title']! as String).runes.length, 128);
expect(payload['artwork'], 'https://example.com/cover.jpg');
expect(
discordPresencePayload(track('Title'), PlaybackState(), now),
isNull,
);
});
testWidgets(
'off makes no SDK calls; updates coalesce and pause cancels pending track',
(tester) async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return call.method == 'initialize' ? true : null;
});
final service = DiscordPresenceService(
channel: channel,
requiresLink: false,
resolveArtwork: (_) async => null,
now: tester.binding.clock.now,
);
final player = BaseAudioHandler();
service.bind(player);
player.mediaItem.add(track('First'));
player.playbackState.add(playing(tester.binding.clock.now()));
await tester.pump();
expect(calls, isEmpty);
await service.setEnabled(true);
await tester.pump();
expect(calls.where((c) => c.method == 'update'), hasLength(1));
player.mediaItem.add(track('Second'));
await tester.pump(const Duration(seconds: 3));
player.mediaItem.add(track('Latest'));
await tester.pump(const Duration(seconds: 12));
final updates = calls.where((c) => c.method == 'update').toList();
expect(updates, hasLength(2));
expect((updates.last.arguments as Map)['title'], 'Latest');
player.mediaItem.add(track('Must not send'));
player.playbackState.add(PlaybackState());
await tester.pump();
expect(calls.last.method, 'clear');
await tester.pump(const Duration(seconds: 30));
expect(calls.where((c) => c.method == 'update'), hasLength(2));
await tester.runAsync(service.dispose);
messenger.setMockMethodCallHandler(channel, null);
},
);
testWidgets('position ticks do not publish, but a seek updates timestamps', (
tester,
) async {
final calls = <MethodCall>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return call.method == 'initialize' ? true : null;
});
final service = DiscordPresenceService(
channel: channel,
requiresLink: false,
resolveArtwork: (_) async => null,
now: tester.binding.clock.now,
);
final player = BaseAudioHandler();
service.bind(player);
player.mediaItem.add(track('First'));
player.playbackState.add(playing(tester.binding.clock.now()));
await service.setEnabled(true);
await tester.pump();
for (var i = 1; i <= 20; i++) {
await tester.pump(const Duration(seconds: 1));
player.playbackState.add(
playing(tester.binding.clock.now(), position: 20 + i),
);
await tester.pump();
}
expect(calls.where((c) => c.method == 'update'), hasLength(1));
player.playbackState.add(
playing(tester.binding.clock.now(), position: 120),
);
await tester.pump();
expect(calls.where((c) => c.method == 'update'), hasLength(2));
await tester.runAsync(service.dispose);
messenger.setMockMethodCallHandler(channel, null);
});
testWidgets(
'late artwork follows the current track and stops when disabled',
(tester) async {
final calls = <MethodCall>[];
final requests = <String, Completer<String?>>{};
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return call.method == 'initialize' ? true : null;
});
final service = DiscordPresenceService(
channel: channel,
requiresLink: false,
now: tester.binding.clock.now,
resolveArtwork: (item) =>
(requests[item.title] = Completer<String?>()).future,
);
final player = BaseAudioHandler();
service.bind(player);
player.mediaItem.add(track('First'));
player.playbackState.add(playing(tester.binding.clock.now()));
await tester.pump();
expect(requests, isEmpty);
await service.setEnabled(true);
await tester.pump();
player.mediaItem.add(track('Second'));
await tester.pump();
requests['First']!.complete('https://example.com/first.jpg');
requests['Second']!.complete('https://example.com/second.jpg');
await tester.pump(const Duration(seconds: 15));
final updates = calls.where((call) => call.method == 'update').toList();
expect(updates, hasLength(2));
expect((updates.last.arguments as Map)['title'], 'Second');
expect(
(updates.last.arguments as Map)['artwork'],
'https://example.com/second.jpg',
);
expect(
updates.any(
(call) =>
(call.arguments as Map)['artwork'] ==
'https://example.com/first.jpg',
),
isFalse,
);
player.mediaItem.add(track('Third'));
await tester.pump();
await service.setEnabled(false);
requests['Third']!.complete('https://example.com/third.jpg');
await tester.pump(const Duration(seconds: 30));
expect(calls.where((call) => call.method == 'update'), hasLength(2));
await tester.runAsync(service.dispose);
messenger.setMockMethodCallHandler(channel, null);
},
);
testWidgets(
'iOS never opens authorization automatically; disabling ignores a late login',
(tester) async {
final calls = <MethodCall>[];
final login = Completer<Map<String, Object>>();
final stored = <String?>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'authorize') return login.future;
return null;
});
final service = DiscordPresenceService(
channel: channel,
requiresLink: true,
resolveArtwork: (_) async => null,
readRefreshToken: () async => null,
writeRefreshToken: (token) async => stored.add(token),
);
await service.setEnabled(true);
expect(service.status.value, DiscordPresenceStatus.linkRequired);
expect(calls.map((c) => c.method), ['initialize']);
final linking = service.link();
await tester.pump();
await service.setEnabled(false);
login.complete({
'access': 'test-access',
'refresh': 'test-refresh',
'expiresIn': 3600,
});
await linking;
expect(stored, isEmpty);
expect(calls.where((c) => c.method == 'connect'), isEmpty);
expect(service.status.value, DiscordPresenceStatus.disabled);
await tester.runAsync(service.dispose);
messenger.setMockMethodCallHandler(channel, null);
},
);
testWidgets('SDK failure stays isolated from playback', (tester) async {
messenger.setMockMethodCallHandler(
channel,
(_) async => throw PlatformException(code: 'sdk_unavailable'),
);
final service = DiscordPresenceService(
channel: channel,
requiresLink: false,
resolveArtwork: (_) async => null,
);
final player = BaseAudioHandler();
service.bind(player);
player.mediaItem.add(track('First'));
player.playbackState.add(playing(DateTime.now()));
await service.setEnabled(true);
expect(service.status.value, DiscordPresenceStatus.sdkUnavailable);
expect(player.playbackState.value.playing, isTrue);
await tester.runAsync(service.dispose);
messenger.setMockMethodCallHandler(channel, null);
});
testWidgets('saved iOS account rotates refresh token and waits for ready', (
tester,
) async {
final calls = <MethodCall>[];
final stored = <String?>[];
messenger.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (call.method == 'initialize') return true;
if (call.method == 'refresh') {
expect((call.arguments as Map)['refresh'], 'previous-refresh');
return {
'access': 'temporary-access',
'refresh': 'rotated-refresh',
'expiresIn': 3600,
};
}
return null;
});
final service = DiscordPresenceService(
channel: channel,
requiresLink: true,
resolveArtwork: (_) async => null,
readRefreshToken: () async => 'previous-refresh',
writeRefreshToken: (token) async => stored.add(token),
now: tester.binding.clock.now,
);
final player = BaseAudioHandler();
service.bind(player);
player.mediaItem.add(track('First'));
player.playbackState.add(playing(tester.binding.clock.now()));
await service.setEnabled(true);
await tester.pump();
expect(stored, ['rotated-refresh']);
expect(calls.where((c) => c.method == 'authorize'), isEmpty);
expect(calls.where((c) => c.method == 'update'), isEmpty);
final handled = Completer<void>();
messenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(const MethodCall('status', 'ready')),
(_) => handled.complete(),
);
await tester.pump();
await handled.future;
expect(calls.where((c) => c.method == 'update'), hasLength(1));
await service.forgetAccount();
expect(stored, ['rotated-refresh', null]);
expect(service.status.value, DiscordPresenceStatus.disabled);
await tester.runAsync(service.dispose);
messenger.setMockMethodCallHandler(channel, null);
});
}