diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74400f41..a84ebebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: filters: | dart: - 'third_party/audio_service/**' + - 'third_party/spotiflac_discord/**' - 'lib/**' - 'test/**' - 'android/app/src/test/resources/**' @@ -50,6 +51,8 @@ jobs: - '.github/workflows/ci.yml' ios: - 'third_party/audio_service/**' + - 'third_party/spotiflac_discord/**' + - 'scripts/setup_discord_sdk.sh' - 'ios/**' - 'scripts/build_ios.sh' - 'scripts/check_backend_ios.py' @@ -63,6 +66,8 @@ jobs: - '.github/workflows/ci.yml' android: - 'third_party/audio_service/**' + - 'third_party/spotiflac_discord/**' + - 'scripts/setup_discord_sdk.sh' - 'android/**' - 'rust_backend/**' - 'scripts/build_android.sh' diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 05496bf7..267263b3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -35,6 +35,9 @@ android { buildFeatures { buildConfig = true } + packaging { + jniLibs.pickFirsts += "**/libdiscord_partner_sdk.so" + } externalNativeBuild { cmake { @@ -167,6 +170,10 @@ flutter { } dependencies { + val discordSdk = file("../../third_party/spotiflac_discord/sdk/discord_partner_sdk.aar") + if (discordSdk.exists()) { + implementation(files(discordSdk)) + } coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") implementation("net.java.dev.jna:jna:5.19.1@aar") @@ -180,6 +187,9 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20260814") + // Keep Flutter integration_test's older runner aligned with native tests. + debugImplementation("androidx.test:runner:1.7.0") + debugImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test:runner:1.7.0") androidTestImplementation("androidx.test.ext:junit:1.3.0") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b00bce3b..5afcd9a7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,9 @@ android:networkSecurityConfig="@xml/network_security_config" android:enableOnBackInvokedCallback="true" android:localeConfig="@xml/locale_config"> + + + ('initialize'), isTrue); + await tester.pump(const Duration(milliseconds: 350)); + await channel.invokeMethod('clear'); + } finally { + await channel.invokeMethod('shutdown'); + } + } + }); +} diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index ec97fc6f..a9a38d20 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1,3 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +#include? "../../third_party/spotiflac_discord/ios/Frameworks/DiscordSimulator.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index c4855bfe..e3b83f5f 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,3 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" +#include? "../../third_party/spotiflac_discord/ios/Frameworks/DiscordSimulator.xcconfig" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7e853beb..0ce66ad8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2,11 +2,14 @@ PODS: - Flutter (1.0.0) - open_filex (0.0.2): - Flutter + - spotiflac_discord (0.1.0): + - Flutter - SpotiFLACBackend (0.1.0) DEPENDENCIES: - Flutter (from `Flutter`) - open_filex (from `.symlinks/plugins/open_filex/ios`) + - spotiflac_discord (from `.symlinks/plugins/spotiflac_discord/ios`) - SpotiFLACBackend (from `../rust_backend/target/ios`) EXTERNAL SOURCES: @@ -14,12 +17,15 @@ EXTERNAL SOURCES: :path: Flutter open_filex: :path: ".symlinks/plugins/open_filex/ios" + spotiflac_discord: + :path: ".symlinks/plugins/spotiflac_discord/ios" SpotiFLACBackend: :path: "../rust_backend/target/ios" SPEC CHECKSUMS: Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 + spotiflac_discord: 4f9417d5583e2d8a8ef15b5a746041d2cb064fd5 SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011 PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4efc1f8c..553675fb 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -114,6 +114,7 @@ CFBundleURLSchemes spotiflac + discord-1549854098801692862 @@ -122,6 +123,7 @@ LSApplicationQueriesSchemes spotify + discord deezer tidal youtube-music diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e869d47a..78d2b008 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7071,6 +7071,17 @@ "motionArtworkDescription": "Loop video covers supplied by metadata providers. Turn off to use still artwork and reduce battery use.", "libraryLoading": "Loading Library…", "libraryLoadFailed": "Library could not be loaded. Retry to load the saved folders and track index.", + "discordPresenceDescription": "Share the title, artist, album and progress of music played in the built-in player on your Discord profile. Local audio and artwork files are never uploaded. Clears when playback pauses or stops.", + "discordPresenceDisabled": "Disabled", + "discordPresenceLink": "Link Discord account", + "discordPresenceConnecting": "Connecting to Discord…", + "discordPresenceReady": "Ready when music plays", + "discordPresenceActive": "Sharing current track", + "discordPresenceUnavailable": "Discord is unavailable. Retrying during playback.", + "discordPresenceBuildUnavailable": "Discord SDK is not available in this build.", + "discordPresenceAndroidHelp": "Install Discord and sign in on this phone. Allow activity sharing in Discord. External players are not supported.", + "discordPresenceIosHelp": "Link your account through Discord and allow activity sharing. Tap to link again if authorization fails.", + "discordPresenceDisconnect": "Disconnect and forget account", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 7ccd4f4f..70794bcb 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6986,5 +6986,16 @@ "weeklyReleasesLoadError": "Rilis baru tidak dapat diperiksa. Tarik untuk memuat ulang.", "motionArtworkDescription": "Putar berulang cover video dari penyedia metadata. Matikan untuk memakai cover diam dan menghemat baterai.", "libraryLoading": "Memuat Library…", - "libraryLoadFailed": "Library tidak dapat dimuat. Coba lagi untuk memuat folder tersimpan dan indeks lagu." + "libraryLoadFailed": "Library tidak dapat dimuat. Coba lagi untuk memuat folder tersimpan dan indeks lagu.", + "discordPresenceDescription": "Tampilkan judul, artis, album, dan progres musik dari pemutar bawaan di profil Discord Anda. File audio dan cover lokal tidak diunggah. Status dihapus saat musik dijeda atau dihentikan.", + "discordPresenceDisabled": "Nonaktif", + "discordPresenceLink": "Hubungkan akun Discord", + "discordPresenceConnecting": "Menghubungkan ke Discord…", + "discordPresenceReady": "Siap saat musik diputar", + "discordPresenceActive": "Menampilkan lagu saat ini", + "discordPresenceUnavailable": "Discord tidak tersedia. Mencoba lagi saat musik diputar.", + "discordPresenceBuildUnavailable": "SDK Discord tidak tersedia di build ini.", + "discordPresenceAndroidHelp": "Pasang Discord dan masuk di ponsel ini. Izinkan berbagi aktivitas di Discord. Pemutar eksternal tidak didukung.", + "discordPresenceIosHelp": "Hubungkan akun melalui Discord dan izinkan berbagi aktivitas. Ketuk untuk menghubungkan ulang jika otorisasi gagal.", + "discordPresenceDisconnect": "Putuskan dan lupakan akun" } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index acf3b190..67e37496 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -32,6 +32,7 @@ class AppSettings { // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; final bool autoMix; + final bool discordRichPresenceEnabled; final bool usbBitPerfect; final bool usbDirect; final bool usbDsdOverPcm; @@ -153,6 +154,7 @@ class AppSettings { this.embedReplayGain = false, this.playbackNormalization = false, this.autoMix = false, + this.discordRichPresenceEnabled = false, this.usbBitPerfect = false, this.usbDirect = false, this.usbDsdOverPcm = false, @@ -249,6 +251,7 @@ class AppSettings { bool? embedReplayGain, bool? playbackNormalization, bool? autoMix, + bool? discordRichPresenceEnabled, bool? usbBitPerfect, bool? usbDirect, bool? usbDsdOverPcm, @@ -341,6 +344,8 @@ class AppSettings { playbackNormalization: playbackNormalization ?? this.playbackNormalization, autoMix: autoMix ?? this.autoMix, + discordRichPresenceEnabled: + discordRichPresenceEnabled ?? this.discordRichPresenceEnabled, usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect, usbDirect: usbDirect ?? this.usbDirect, usbDsdOverPcm: usbDsdOverPcm ?? this.usbDsdOverPcm, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 109ce340..72fff67f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -23,6 +23,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, autoMix: json['autoMix'] as bool? ?? false, + discordRichPresenceEnabled: + json['discordRichPresenceEnabled'] as bool? ?? false, usbBitPerfect: json['usbBitPerfect'] as bool? ?? false, usbDirect: json['usbDirect'] as bool? ?? false, usbDsdOverPcm: json['usbDsdOverPcm'] as bool? ?? false, @@ -132,6 +134,7 @@ Map _$AppSettingsToJson( 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, 'autoMix': instance.autoMix, + 'discordRichPresenceEnabled': instance.discordRichPresenceEnabled, 'usbBitPerfect': instance.usbBitPerfect, 'usbDirect': instance.usbDirect, 'usbDsdOverPcm': instance.usbDsdOverPcm, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a423281a..1722b0ca 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setDiscordRichPresenceEnabled(bool enabled) { + state = state.copyWith(discordRichPresenceEnabled: enabled); + _saveSettings(); + } + void setAutoplay(bool enabled) { state = state.copyWith(autoplay: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 86086732..792b4c14 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -29,6 +29,7 @@ import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/music_playback_deck.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/listening_statistics.dart'; +import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; @@ -107,6 +108,11 @@ class _MainShellState extends ConsumerState ref.read(settingsProvider).playbackNormalization, ); setAutoMixEnabled(ref.read(settingsProvider).autoMix); + unawaited( + DiscordPresenceService.instance.setEnabled( + ref.read(settingsProvider).discordRichPresenceEnabled, + ), + ); listeningRecorder.setEnabled( ref.read(settingsProvider).listeningStatisticsEnabled, ); @@ -785,6 +791,12 @@ class _MainShellState extends ConsumerState ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) { setAutoMixEnabled(enabled); }); + ref.listen(settingsProvider.select((s) => s.discordRichPresenceEnabled), ( + _, + enabled, + ) { + unawaited(DiscordPresenceService.instance.setEnabled(enabled)); + }); ref.listen( settingsProvider.select((s) => s.listeningStatisticsEnabled), (_, enabled) => listeningRecorder.setEnabled(enabled), diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart index 7e404fd0..2040b5fd 100644 --- a/lib/screens/settings/playback_settings_page.dart +++ b/lib/screens/settings/playback_settings_page.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/discord_presence_settings.dart'; class PlaybackSettingsPage extends ConsumerStatefulWidget { const PlaybackSettingsPage({super.key}); @@ -132,6 +133,12 @@ class _PlaybackSettingsPageState extends ConsumerState { ], ), ), + if (Platform.isAndroid || Platform.isIOS) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Discord'), + ), + const SliverToBoxAdapter(child: DiscordPresenceSettings()), + ], if (Platform.isAndroid) SliverToBoxAdapter( child: SettingsSectionHeader( diff --git a/lib/services/discord_artwork_resolver.dart b/lib/services/discord_artwork_resolver.dart new file mode 100644 index 00000000..cb9d1b74 --- /dev/null +++ b/lib/services/discord_artwork_resolver.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +/// Discord fetches images itself. Local artwork must never be sent as an asset +/// key, and private/signed URLs must not be published as listening activity. +String? discordPublicArtwork(String? value) { + final uri = Uri.tryParse(value?.trim() ?? ''); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasFragment || + uri.toString().length > 300) { + return null; + } + final host = uri.host.toLowerCase(); + if (!host.contains('.') || + host.endsWith('.local') || + host.endsWith('.localhost') || + host.endsWith('.internal') || + InternetAddress.tryParse(host) != null) { + return null; + } + // Image CDN transforms are public, unlike authentication/signature params. + const transforms = { + 'w', + 'h', + 'width', + 'height', + 'size', + 'quality', + 'q', + 'format', + 'fm', + 'fit', + 'crop', + 'auto', + }; + if (uri.queryParametersAll.entries.any( + (entry) => + !transforms.contains(entry.key.toLowerCase()) || + entry.value.any( + (value) => !RegExp(r'^[a-zA-Z0-9,._-]{1,40}$').hasMatch(value), + ), + )) { + return null; + } + return uri.toString(); +} + +String _normalized(String? text) => + (text ?? '').trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); + +/// Strictly retain version/album qualifiers: a remix or reissue must not borrow +/// a different track's cover merely because it ranked first in search. +String? discordMatchedArtwork(MediaItem item, Iterable candidates) { + for (final track in candidates) { + if (_normalized(track.name) != _normalized(item.title) || + _normalized(track.artistName) != _normalized(item.artist) || + (item.album?.trim().isNotEmpty == true && + _normalized(track.albumName) != _normalized(item.album))) { + continue; + } + if (item.duration != null && + track.duration > 0 && + (item.duration!.inSeconds - track.duration).abs() > 5) { + continue; + } + final cover = discordPublicArtwork(track.coverUrl); + if (cover != null) return cover; + } + return null; +} + +class DiscordArtworkResolver { + DiscordArtworkResolver({ + Future Function(MediaItem)? historyCover, + Future> Function(String)? search, + }) : _historyCover = historyCover ?? _fromHistory, + _search = search ?? _searchCatalog; + + final Future Function(MediaItem) _historyCover; + final Future> Function(String) _search; + final _cache = {}; + final _pending = >{}; + + Future resolve(MediaItem item) async { + final direct = discordPublicArtwork(item.artUri?.toString()); + if (direct != null) return direct; + final key = [ + item.id, + item.title, + item.artist, + item.album, + item.artUri, + item.duration, + ].join('\u0000'); + final cached = _cache[key]; + if (cached != null && cached.expires.isAfter(DateTime.now())) { + return cached.url; + } + if (_pending[key] case final pending?) return pending; + final deadline = DateTime.now().add(const Duration(seconds: 8)); + final request = _lookup( + item, + deadline, + ).timeout(const Duration(seconds: 8), onTimeout: () => null); + _pending[key] = request; + try { + final url = await request; + _cache.remove(key); + while (_cache.length >= 64) { + _cache.remove(_cache.keys.first); + } + _cache[key] = ( + url: url, + expires: DateTime.now().add(Duration(minutes: url == null ? 5 : 60)), + ); + return url; + } finally { + _pending.remove(key); + } + } + + Future _lookup(MediaItem item, DateTime deadline) async { + try { + final saved = discordPublicArtwork(await _historyCover(item)); + if (saved != null) return saved; + } catch (_) { + /* Library access must not block presence. */ + } + if (!DateTime.now().isBefore(deadline) || + item.title.trim().isEmpty || + (item.artist ?? '').trim().isEmpty) { + return null; + } + try { + return discordMatchedArtwork( + item, + await _search('${item.title} ${item.artist}'), + ); + } catch (_) { + return null; + } + } + + static Future _fromHistory(MediaItem item) async { + final source = item.extras?['source']; + final row = source is String && source.isNotEmpty + ? await HistoryDatabase.instance.findByFilePath(source) + : await HistoryDatabase.instance.getById(item.id); + return row?['coverUrl'] as String?; + } + + static Future> _searchCatalog(String query) async => [ + for (final row in await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 8, + includeExtensions: true, + )) + Track.fromBackendMap(row), + ]; +} diff --git a/lib/services/discord_presence_service.dart b/lib/services/discord_presence_service.dart new file mode 100644 index 00000000..6b806899 --- /dev/null +++ b/lib/services/discord_presence_service.dart @@ -0,0 +1,373 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:spotiflac_android/services/discord_artwork_resolver.dart'; + +enum DiscordPresenceStatus { + disabled, + linkRequired, + connecting, + ready, + active, + unavailable, + sdkUnavailable, +} + +/// Only presentation metadata goes to Discord. Never send media IDs, file paths, +/// content URIs, credentials in artwork URLs, or upload local artwork. +Map? discordPresencePayload( + MediaItem? item, + PlaybackState state, + DateTime now, +) { + if (item == null || + !state.playing || + state.processingState != AudioProcessingState.ready) { + return null; + } + String text(String? value) => + String.fromCharCodes((value ?? '').trim().runes.take(128)); + final artwork = discordPublicArtwork(item.artUri?.toString()) ?? ''; + final elapsed = now.difference(state.updateTime).inMilliseconds; + final position = + (state.updatePosition.inMilliseconds + + elapsed.clamp(0, 1 << 50) * state.speed) + .clamp(0, item.duration?.inMilliseconds ?? (1 << 50)); + final speed = state.speed; + return { + 'title': text(item.title), + 'artist': text(item.artist), + 'state': text(item.artist), + 'artwork': artwork, + if (speed > 0) ...{ + 'start': ((now.millisecondsSinceEpoch - position / speed) / 1000).floor(), + if (item.duration != null) + 'end': + ((now.millisecondsSinceEpoch + + (item.duration!.inMilliseconds - position) / speed) / + 1000) + .floor(), + }, + }; +} + +/// Owns one SDK connection, independent of the player screen's lifetime. +/// All failures stay in this optional integration and cannot stop playback. +class DiscordPresenceService { + DiscordPresenceService({ + MethodChannel channel = const MethodChannel('com.zarz.spotiflac/discord'), + bool? requiresLink, + Future Function()? readRefreshToken, + Future Function(String?)? writeRefreshToken, + DateTime Function()? now, + Future Function(MediaItem)? resolveArtwork, + }) : _channel = channel, + requiresLink = requiresLink ?? Platform.isIOS, + _readRefreshToken = readRefreshToken ?? _readToken, + _writeRefreshToken = writeRefreshToken ?? _writeToken, + _now = now ?? DateTime.now, + _resolveArtwork = resolveArtwork ?? DiscordArtworkResolver().resolve { + _channel.setMethodCallHandler(_onNativeCall); + } + + static final instance = DiscordPresenceService(); + static const _tokenKey = 'discord_presence_refresh_token'; + static const _storage = FlutterSecureStorage( + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ); + static Future _readToken() => _storage.read(key: _tokenKey); + static Future _writeToken(String? token) => token == null + ? _storage.delete(key: _tokenKey) + : _storage.write(key: _tokenKey, value: token); + + final MethodChannel _channel; + final bool requiresLink; + final Future Function() _readRefreshToken; + final Future Function(String?) _writeRefreshToken; + final DateTime Function() _now; + final Future Function(MediaItem) _resolveArtwork; + String? _artworkKey; + String? _artwork; + int _artworkRequest = 0; + final status = ValueNotifier(DiscordPresenceStatus.disabled); + final _subscriptions = >[]; + AudioHandler? _handler; + bool _enabled = false; + bool _initialized = false; + bool _ready = false; + bool _authenticating = false; + bool _visible = false; + int _generation = 0; + Map? _lastPayload; + DateTime? _lastUpdate; + Timer? _pending; + Timer? _retry; + Timer? _refresh; + Future _tokenWrites = Future.value(); + + void bind(AudioHandler handler) { + if (identical(handler, _handler)) return; + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + _handler = handler; + _subscriptions.add(handler.mediaItem.listen((_) => _schedule())); + _subscriptions.add(handler.playbackState.listen((_) => _schedule())); + _schedule(); + } + + Future setEnabled(bool enabled) async { + if (_enabled == enabled) return; + _enabled = enabled; + final generation = ++_generation; + _artworkRequest++; + _artworkKey = null; + _artwork = null; + _pending?.cancel(); + _retry?.cancel(); + _refresh?.cancel(); + _ready = false; + _visible = false; + _lastPayload = null; + _lastUpdate = null; + _authenticating = false; + if (!enabled) { + _initialized = false; + status.value = DiscordPresenceStatus.disabled; + await _invoke('shutdown'); + return; + } + status.value = DiscordPresenceStatus.connecting; + try { + final available = await _channel.invokeMethod('initialize'); + if (generation != _generation) return; + _initialized = available == true; + if (!_initialized) { + status.value = DiscordPresenceStatus.sdkUnavailable; + return; + } + _retry = Timer.periodic(const Duration(seconds: 30), (_) { + if (status.value == DiscordPresenceStatus.unavailable) { + _lastPayload = null; + _schedule(); + } + }); + if (requiresLink) { + await _authenticate(interactive: false); + } else { + _ready = true; + status.value = DiscordPresenceStatus.ready; + _schedule(); + } + } catch (_) { + if (generation == _generation) { + status.value = DiscordPresenceStatus.sdkUnavailable; + } + } + } + + Future link() => _authenticate(interactive: true); + + Future forgetAccount() async { + await setEnabled(false); + // Serialize writes so a late refresh cannot restore a removed account. + _tokenWrites = _tokenWrites.then((_) => _writeRefreshToken(null)); + try { + await _tokenWrites; + } catch (_) { + _tokenWrites = Future.value(); + status.value = DiscordPresenceStatus.unavailable; + } + } + + Future _authenticate({required bool interactive}) async { + if (!_enabled || !_initialized || _authenticating || !requiresLink) return; + _authenticating = true; + _ready = false; + _pending?.cancel(); + _refresh?.cancel(); + final generation = _generation; + status.value = DiscordPresenceStatus.connecting; + try { + await _tokenWrites; + final token = interactive ? null : await _readRefreshToken(); + if (generation != _generation) return; + if (!interactive && (token == null || token.isEmpty)) { + status.value = DiscordPresenceStatus.linkRequired; + return; + } + final credentials = await _channel.invokeMapMethod( + interactive ? 'authorize' : 'refresh', + interactive ? null : {'refresh': token}, + ); + if (generation != _generation) return; + final access = credentials?['access'] as String?; + final refresh = credentials?['refresh'] as String?; + if (access == null || + access.isEmpty || + refresh == null || + refresh.isEmpty) { + status.value = DiscordPresenceStatus.linkRequired; + return; + } + _tokenWrites = _tokenWrites.then((_) => _writeRefreshToken(refresh)); + await _tokenWrites; + if (generation != _generation) return; + await _channel.invokeMethod('connect', {'access': access}); + if (generation != _generation) return; + final expires = (credentials?['expiresIn'] as num?)?.toInt() ?? 3600; + _refresh = Timer(Duration(seconds: (expires - 60).clamp(30, 604800)), () { + unawaited(_authenticate(interactive: false)); + }); + } catch (_) { + // Do not log OAuth tokens or native exception details. + _tokenWrites = Future.value(); + if (generation == _generation) { + status.value = DiscordPresenceStatus.linkRequired; + } + } finally { + if (generation == _generation) _authenticating = false; + } + } + + Future _onNativeCall(MethodCall call) async { + if (call.method != 'status' || !_enabled) return; + switch (call.arguments) { + case 'ready': + _ready = true; + _lastPayload = null; + status.value = DiscordPresenceStatus.ready; + _schedule(); + case 'active': + if (_visible) status.value = DiscordPresenceStatus.active; + case 'connecting': + _ready = false; + status.value = DiscordPresenceStatus.connecting; + case 'expired': + unawaited(_authenticate(interactive: false)); + case 'unavailable': + status.value = DiscordPresenceStatus.unavailable; + } + } + + bool _samePayload(Map payload) { + final previous = _lastPayload; + if (previous == null) return false; + for (final key in ['title', 'artist', 'state', 'artwork']) { + if (payload[key] != previous[key]) return false; + } + for (final key in ['start', 'end']) { + final a = payload[key] as int?; + final b = previous[key] as int?; + if (a == null || b == null) { + if (a != b) return false; + } else if ((a - b).abs() > 2) { + return false; + } + } + return true; + } + + void _schedule() { + if (!_enabled || !_initialized) return; + final handler = _handler; + if (handler == null) return; + final now = _now(); + final payload = discordPresencePayload( + handler.mediaItem.value, + handler.playbackState.value, + now, + ); + if (payload == null) { + _artworkRequest++; + _artworkKey = null; + _artwork = null; + _pending?.cancel(); + _pending = null; + _lastPayload = null; + if (_visible) { + _visible = false; + status.value = DiscordPresenceStatus.ready; + unawaited(_invoke('clear')); + } + return; + } + if (!_ready) return; + final item = handler.mediaItem.value!; + final key = [ + item.id, + item.title, + item.artist, + item.album, + item.artUri, + item.duration, + ].join('\u0000'); + if (_artworkKey != key) { + _artworkKey = key; + _artwork = null; + final request = ++_artworkRequest; + if (payload['artwork'] == '') { + unawaited(_loadArtwork(item, request)); + } + } + if (payload['artwork'] == '' && _artwork != null) { + payload['artwork'] = _artwork!; + } + if (_samePayload(payload)) return; + final elapsed = _lastUpdate == null + ? const Duration(seconds: 15) + : now.difference(_lastUpdate!); + if (elapsed < const Duration(seconds: 15)) { + _pending ??= Timer(const Duration(seconds: 15) - elapsed, () { + _pending = null; + _schedule(); + }); + return; + } + _pending?.cancel(); + _pending = null; + _lastPayload = payload; + _lastUpdate = now; + _visible = true; + unawaited(_invoke('update', payload)); + } + + Future _loadArtwork(MediaItem item, int request) async { + try { + final url = discordPublicArtwork(await _resolveArtwork(item)); + if (!_enabled || request != _artworkRequest || url == null) return; + _artwork = url; + _schedule(); + } catch (_) { + // Artwork is optional; an offline lookup cannot interrupt playback. + } + } + + Future _invoke(String method, [Map? arguments]) async { + final generation = _generation; + try { + await _channel.invokeMethod(method, arguments); + } catch (_) { + if (_enabled && generation == _generation) { + status.value = DiscordPresenceStatus.unavailable; + } + } + } + + Future dispose() async { + await setEnabled(false); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + _subscriptions.clear(); + _channel.setMethodCallHandler(null); + status.dispose(); + } +} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index fa48d15e..308bb71a 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/services/playback_notification.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; +import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:audio_session/audio_session.dart' show AudioSession, AudioSessionConfiguration, AudioInterruptionType; import 'package:audioplayers/audioplayers.dart'; @@ -2092,6 +2093,7 @@ Future _doInitMusicPlayer() async { _handler = handler; if (Platform.isAndroid || Platform.isIOS) { PlayerWidgetService.instance.bind(handler); + DiscordPresenceService.instance.bind(handler); } _handlerReadyController.add(handler); return handler; diff --git a/lib/widgets/discord_presence_settings.dart b/lib/widgets/discord_presence_settings.dart new file mode 100644 index 00000000..cddc6ac2 --- /dev/null +++ b/lib/widgets/discord_presence_settings.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/discord_presence_service.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class DiscordPresenceSettings extends ConsumerWidget { + const DiscordPresenceSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch( + settingsProvider.select((s) => s.discordRichPresenceEnabled), + ); + final service = DiscordPresenceService.instance; + final l10n = context.l10n; + return ValueListenableBuilder( + valueListenable: service.status, + builder: (context, status, _) => SettingsGroup( + children: [ + SettingsSwitchItem( + icon: Icons.headphones_outlined, + title: 'Discord Rich Presence', + subtitle: l10n.discordPresenceDescription, + value: enabled, + onChanged: ref + .read(settingsProvider.notifier) + .setDiscordRichPresenceEnabled, + showDivider: enabled, + ), + if (enabled) + SettingsItem( + icon: status == DiscordPresenceStatus.active + ? Icons.check_circle_outline + : Icons.info_outline, + title: switch (status) { + DiscordPresenceStatus.disabled => l10n.discordPresenceDisabled, + DiscordPresenceStatus.linkRequired => l10n.discordPresenceLink, + DiscordPresenceStatus.connecting => + l10n.discordPresenceConnecting, + DiscordPresenceStatus.ready => l10n.discordPresenceReady, + DiscordPresenceStatus.active => l10n.discordPresenceActive, + DiscordPresenceStatus.unavailable => + l10n.discordPresenceUnavailable, + DiscordPresenceStatus.sdkUnavailable => + l10n.discordPresenceBuildUnavailable, + }, + subtitle: Platform.isAndroid + ? l10n.discordPresenceAndroidHelp + : l10n.discordPresenceIosHelp, + onTap: status == DiscordPresenceStatus.linkRequired + ? () => unawaited(service.link()) + : null, + showDivider: Platform.isIOS, + ), + if (enabled && Platform.isIOS) + SettingsItem( + icon: Icons.link_off, + title: l10n.discordPresenceDisconnect, + onTap: () { + ref + .read(settingsProvider.notifier) + .setDiscordRichPresenceEnabled(false); + unawaited(service.forgetAccount()); + }, + showDivider: false, + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index c2d633e3..8bac56db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -508,6 +508,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.5" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -643,6 +648,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -715,6 +725,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1091,6 +1106,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -1272,6 +1295,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + spotiflac_discord: + dependency: "direct main" + description: + path: "third_party/spotiflac_discord" + relative: true + source: path + version: "0.1.0" sqflite: dependency: "direct main" description: @@ -1352,6 +1382,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1560,6 +1598,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 33bbafa9..aba76214 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,8 @@ dependencies: # Storage & Persistence shared_preferences: ^2.5.5 flutter_secure_storage: ^11.0.0 + spotiflac_discord: + path: third_party/spotiflac_discord path_provider: ^2.1.6 path: ^1.9.1 sqflite: ^2.4.3 @@ -87,6 +89,8 @@ dependency_overrides: path: third_party/audioplayers_android dev_dependencies: + integration_test: + sdk: flutter flutter_test: sdk: flutter analyzer: ^14.4.0 diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 4a06fc15..73af831a 100644 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -6,6 +6,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" OUTPUT_DIR="$PROJECT_DIR/build/app/outputs/flutter-apk" +AUDIT_FLAGS=() +if [[ -f "$PROJECT_DIR/third_party/spotiflac_discord/sdk/discord_partner_sdk.aar" ]]; then + AUDIT_FLAGS+=(--discord-sdk) +fi cd "$PROJECT_DIR" BUILD_GIT_COMMIT="$(git rev-parse --short=8 HEAD)" @@ -28,7 +32,7 @@ for target in armeabi-v7a arm64-v8a universal; do exit 1 fi python3 scripts/check_backend_apk.py "$OUTPUT_DIR/$apk" \ - --backend rust --abis "$abis" + --backend rust --abis "$abis" "${AUDIT_FLAGS[@]}" done echo "Built Android release APKs in $OUTPUT_DIR" diff --git a/scripts/check_backend_apk.py b/scripts/check_backend_apk.py index dbee99c9..b3b8802b 100644 --- a/scripts/check_backend_apk.py +++ b/scripts/check_backend_apk.py @@ -15,7 +15,7 @@ ABI_LAYOUT = { "armeabi-v7a": (1, 40), } CORE_LIBRARIES = ("libapp.so", "libflutter.so") -REMOVED_LIBRARIES = ("libspotiflac_discord.so", "libdiscord_partner_sdk.so") +DISCORD_LIBRARIES = ("libspotiflac_discord.so", "libdiscord_partner_sdk.so") class AuditError(Exception): @@ -107,7 +107,7 @@ def check_backend_markers( raise AuditError("Go APK contains forbidden libspotiflac_mobile.so") -def audit(path: Path, backend: str, abis: Sequence[str]) -> str: +def audit(path: Path, backend: str, abis: Sequence[str], discord_sdk: bool = False) -> str: if not path.is_file(): raise AuditError("APK is not a regular file: " + str(path)) digest = artifact_sha256(path) @@ -116,8 +116,8 @@ def audit(path: Path, backend: str, abis: Sequence[str]) -> str: infos = zf.infolist() names = [info.filename for info in infos] for name in names: - if name.rsplit("/", 1)[-1] in REMOVED_LIBRARIES: - raise AuditError("APK contains removed SDK library: " + name) + if not discord_sdk and name.rsplit("/", 1)[-1] == "libdiscord_partner_sdk.so": + raise AuditError("APK unexpectedly contains Discord SDK library: " + name) if "assets/flutter_assets/kernel_blob.bin" in names: raise AuditError("debug APK contains assets/flutter_assets/kernel_blob.bin") check_abi_directories(names, abis) @@ -128,6 +128,8 @@ def audit(path: Path, backend: str, abis: Sequence[str]) -> str: libraries = CORE_LIBRARIES + (backend_library,) if backend == "rust": libraries += ("libjnidispatch.so",) + if discord_sdk: + libraries += DISCORD_LIBRARIES for abi in abis: for library in libraries: entry_path = f"lib/{abi}/{library}" @@ -144,6 +146,7 @@ def make_parser() -> argparse.ArgumentParser: parser.add_argument("apk", type=Path, help="release APK to audit") parser.add_argument("--backend", choices=("rust", "go"), required=True) parser.add_argument("--abis", required=True, metavar="ABI[,ABI...]", help="expected APK ABIs") + parser.add_argument("--discord-sdk", action="store_true", help="require Discord SDK and bridge in every ABI") return parser @@ -151,7 +154,7 @@ def main(argv: Sequence[str] = None) -> int: args = make_parser().parse_args(argv) try: abis = parse_abis(args.abis) - digest = audit(args.apk, args.backend, abis) + digest = audit(args.apk, args.backend, abis, args.discord_sdk) except (AuditError, OSError) as exc: print("error: " + str(exc), file=sys.stderr) return 1 diff --git a/scripts/setup_discord_sdk.sh b/scripts/setup_discord_sdk.sh new file mode 100644 index 00000000..99a97753 --- /dev/null +++ b/scripts/setup_discord_sdk.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# SDK binaries are downloaded from the application's Discord Developer Portal, +# not mirrored in this repository. No client secret or bot token is needed. +sdk_source="${1:-${DISCORD_SOCIAL_SDK_DIR:-}}" +if [[ -z "$sdk_source" ]]; then + echo "Usage: bash scripts/setup_discord_sdk.sh /path/to/discord_social_sdk" >&2 + exit 1 +fi +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sdk_target="$repo_root/third_party/spotiflac_discord/sdk" +for sdk_file in include/discordpp.h lib/release/discord_partner_sdk.aar lib/release/discord_partner_sdk.xcframework/Info.plist; do + if [[ ! -f "$sdk_source/$sdk_file" ]]; then + echo "Missing SDK file: $sdk_file" >&2 + exit 1 + fi +done +mkdir -p "$sdk_target/android" +cp "$sdk_source/lib/release/discord_partner_sdk.aar" "$sdk_target/" +unzip -oq "$sdk_target/discord_partner_sdk.aar" -d "$sdk_target/android" +mkdir -p "$repo_root/third_party/spotiflac_discord/ios/Frameworks" +cp -R "$sdk_source/lib/release/discord_partner_sdk.xcframework" "$repo_root/third_party/spotiflac_discord/ios/Frameworks/" +cp "$sdk_source/License-Notices.txt" "$sdk_target/" +mkdir -p "$sdk_target/notices" +cp "$sdk_source/License-Notices.txt" "$sdk_target/notices/Discord-License-Notices.txt" +cp "$sdk_source/License-Notices.txt" "$repo_root/third_party/spotiflac_discord/ios/Frameworks/Discord-License-Notices.txt" +cp "$repo_root/third_party/spotiflac_discord/ios/DiscordSimulator.xcconfig" "$repo_root/third_party/spotiflac_discord/ios/Frameworks/" +echo "Discord SDK staged. Run flutter pub get and rebuild both platforms." diff --git a/scripts/test_check_backend_apk.py b/scripts/test_check_backend_apk.py index 47bf3e84..d318a467 100644 --- a/scripts/test_check_backend_apk.py +++ b/scripts/test_check_backend_apk.py @@ -1,4 +1,4 @@ -"""Verify native backend payloads and reject removed SDKs in release APKs.""" +"""Verify native backend payloads and optional Discord SDKs in release APKs.""" import struct import tempfile @@ -24,11 +24,11 @@ class BackendApkAuditTest(unittest.TestCase): ): self.entries[f"lib/{abi}/{library}"] = bytes(header) - def audit(self, abis=("arm64-v8a", "armeabi-v7a")): + def audit(self, abis=("arm64-v8a", "armeabi-v7a"), discord_sdk=False): with zipfile.ZipFile(self.apk, "w") as zf: for name, data in self.entries.items(): zf.writestr(name, data) - return checker.audit(self.apk, "rust", abis) + return checker.audit(self.apk, "rust", abis, discord_sdk) def test_universal_passes(self): self.assertEqual(len(self.audit()), 64) @@ -57,15 +57,22 @@ class BackendApkAuditTest(unittest.TestCase): with self.assertRaisesRegex(checker.AuditError, "ELF class"): self.audit() - def test_removed_sdk_cannot_leak_from_build_cache(self): + def test_disabled_sdk_cannot_leak_from_build_cache(self): for abi in checker.ABI_LAYOUT: - for library in checker.REMOVED_LIBRARIES: - with self.subTest(abi=abi, library=library): - path = f"lib/{abi}/{library}" - self.entries[path] = self.entries[f"lib/{abi}/libapp.so"] - with self.assertRaisesRegex(checker.AuditError, "removed SDK"): - self.audit() - del self.entries[path] + path = f"lib/{abi}/libdiscord_partner_sdk.so" + self.entries[path] = self.entries[f"lib/{abi}/libapp.so"] + with self.assertRaisesRegex(checker.AuditError, "unexpectedly contains Discord"): + self.audit() + del self.entries[path] + + def test_enabled_sdk_requires_both_libraries_for_each_abi(self): + for abi in checker.ABI_LAYOUT: + for library in checker.DISCORD_LIBRARIES: + self.entries[f"lib/{abi}/{library}"] = self.entries[f"lib/{abi}/libapp.so"] + self.assertEqual(len(self.audit(discord_sdk=True)), 64) + del self.entries["lib/armeabi-v7a/libdiscord_partner_sdk.so"] + with self.assertRaisesRegex(checker.AuditError, "missing APK entry"): + self.audit(discord_sdk=True) if __name__ == "__main__": diff --git a/test/discord_artwork_resolver_test.dart b/test/discord_artwork_resolver_test.dart new file mode 100644 index 00000000..bf4439e1 --- /dev/null +++ b/test/discord_artwork_resolver_test.dart @@ -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>(); + 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); + }); +} diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart new file mode 100644 index 00000000..98a9fd56 --- /dev/null +++ b/test/discord_presence_service_test.dart @@ -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 = []; + 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 = []; + 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 = []; + final requests = >{}; + 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()).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 = []; + final login = Completer>(); + final stored = []; + 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 = []; + final stored = []; + 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(); + 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); + }); +} diff --git a/third_party/spotiflac_discord/.gitignore b/third_party/spotiflac_discord/.gitignore new file mode 100644 index 00000000..ef104f9b --- /dev/null +++ b/third_party/spotiflac_discord/.gitignore @@ -0,0 +1,4 @@ +/sdk/ +/android/.gradle/ +/android/.cxx/ +/ios/Frameworks/ diff --git a/third_party/spotiflac_discord/README.md b/third_party/spotiflac_discord/README.md new file mode 100644 index 00000000..d791a41a --- /dev/null +++ b/third_party/spotiflac_discord/README.md @@ -0,0 +1,65 @@ +# Playback presence + +Small Android/iOS bridge to Discord's official Social SDK (tested with +1.10.19337). It publishes a Listening activity and never joins voice calls or +changes the app's audio session. The SDK is initialized only after opt-in. + +## Build setup + +Download the C++ Social SDK from the SpotiFLAC Mobile application's **Social SDK +→ Downloads** page in the Discord Developer Portal. Extract it, then run: + +```sh +bash scripts/setup_discord_sdk.sh /path/to/discord_social_sdk +flutter pub get +``` + +On CI, provide the official SDK through the build environment and run the same +script before building. Vendor binaries and their license notices stay under +the ignored `sdk/` and `ios/Frameworks/` directories; do not commit developer credentials or rehost SDK +downloads. Distributions containing the SDK must include its license notices +and follow Discord's SDK terms. The setup script stages notices for inclusion +in Android assets and iOS resources. Without these files the app still builds, but +the presence setting reports that the SDK is unavailable. A clean clone alone +does not produce a Discord-enabled build. + +Android uses the installed Discord app's signed-in session (Social SDK 1.10+). +The AAR and CMake use the same staged native library; the host packages one copy +with `jniLibs.pickFirsts`. Microphone/Bluetooth permissions and the SDK voice +service are removed by the host manifest. Do not remove the app's own audio +service or playback permissions. + +iOS uses the SDK's OAuth PKCE flow with `openid sdk.social_layer_presence`. +SDK-enabled simulator builds require an Apple Silicon Mac (arm64). The setup +script stages an optional simulator configuration; builds without the SDK keep +Flutter's normal simulator architecture support. +Configure the application as a **Public Client**, and register exactly: + +```text +discord-1549854098801692862:/authorize/callback +``` + +The Application ID is public. Never embed a client secret or a Discord user +token. Only the OAuth refresh token is persisted, using the host's secure +storage; access tokens stay in memory. Disabling presence clears the activity +and closes the SDK. Disconnecting also removes the locally stored refresh +token. Users can revoke authorization in Discord's Authorized Apps settings. + +## Runtime + +`DiscordPresenceService` subscribes to the audio handler, not a player widget. +Track/seek changes are coalesced to one update per 15 seconds. Pause, stop and +disable clear immediately. Presence displays title and artist only. Public +HTTPS artwork may include image-sizing parameters, but never credentials or +signed URL parameters. For local artwork, the resolver first checks download +history, then searches enabled metadata providers for an exact title, +artist and album match (and compatible duration). Results are cached, and late +lookups cannot update a different track or a disabled session. Local artwork +is never uploaded. If no public match exists, artwork is omitted; Discord +controls its own fallback presentation. SDK failures cannot interrupt music +playback. Presence depends on +Discord's activity privacy settings, connectivity and mobile background limits. + +After staging the SDK, run `flutter test integration_test/discord_sdk_test.dart +-d ` on an iOS simulator or Android device to test native startup, +shutdown and restart without linking an account or publishing any activity. diff --git a/third_party/spotiflac_discord/android/CMakeLists.txt b/third_party/spotiflac_discord/android/CMakeLists.txt new file mode 100644 index 00000000..60295c21 --- /dev/null +++ b/third_party/spotiflac_discord/android/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.22.1) +project(spotiflac_discord LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +add_library(spotiflac_discord SHARED src/main/cpp/presence.cpp) +target_compile_options(spotiflac_discord PRIVATE -Wall -Wextra -Werror -fvisibility=hidden) +target_link_options(spotiflac_discord PRIVATE "-Wl,-z,max-page-size=16384") +set(SDK "${CMAKE_CURRENT_SOURCE_DIR}/../sdk/android") +if(EXISTS "${SDK}/jni/${ANDROID_ABI}/libdiscord_partner_sdk.so") + add_library(discord_partner_sdk SHARED IMPORTED) + set_target_properties(discord_partner_sdk PROPERTIES IMPORTED_LOCATION + "${SDK}/jni/${ANDROID_ABI}/libdiscord_partner_sdk.so") + target_include_directories(spotiflac_discord PRIVATE "${SDK}/prefab/modules/discord_partner_sdk/include") + target_compile_definitions(spotiflac_discord PRIVATE SPOTIFLAC_DISCORD_SDK=1) + target_link_libraries(spotiflac_discord PRIVATE discord_partner_sdk) +endif() diff --git a/third_party/spotiflac_discord/android/build.gradle b/third_party/spotiflac_discord/android/build.gradle new file mode 100644 index 00000000..8eaaa49a --- /dev/null +++ b/third_party/spotiflac_discord/android/build.gradle @@ -0,0 +1,25 @@ +group 'com.zarz.spotiflac.discord' +version '1.0' + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'com.zarz.spotiflac.discord' + compileSdk 37 + defaultConfig { + minSdk 24 + externalNativeBuild { + cmake { arguments '-DANDROID_STL=c++_static' } + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_25 + targetCompatibility JavaVersion.VERSION_25 + } + kotlinOptions { jvmTarget = '25' } + sourceSets.main.assets.srcDir('../sdk/notices') + externalNativeBuild { + cmake { path 'CMakeLists.txt'; version '3.22.1' } + } +} diff --git a/third_party/spotiflac_discord/android/src/main/AndroidManifest.xml b/third_party/spotiflac_discord/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7b5a981b --- /dev/null +++ b/third_party/spotiflac_discord/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/third_party/spotiflac_discord/android/src/main/cpp/presence.cpp b/third_party/spotiflac_discord/android/src/main/cpp/presence.cpp new file mode 100644 index 00000000..94d93531 --- /dev/null +++ b/third_party/spotiflac_discord/android/src/main/cpp/presence.cpp @@ -0,0 +1,107 @@ +#include + +#ifdef SPOTIFLAC_DISCORD_SDK +#define DISCORDPP_IMPLEMENTATION +#include +#include +#include + +namespace { +std::unique_ptr client; +int status = 0; +uint64_t generation = 0; + +std::string utf8(JNIEnv* env, jstring value) { + // JNI's modified UTF-8 corrupts supplementary Unicode (e.g. emoji). + auto stringClass = env->FindClass("java/lang/String"); + auto getBytes = env->GetMethodID(stringClass, "getBytes", "(Ljava/lang/String;)[B"); + auto charset = env->NewStringUTF("UTF-8"); + auto bytes = static_cast(env->CallObjectMethod(value, getBytes, charset)); + std::string result; + if (bytes) { + result.resize(env->GetArrayLength(bytes)); + env->GetByteArrayRegion(bytes, 0, static_cast(result.size()), + reinterpret_cast(result.data())); + env->DeleteLocalRef(bytes); + } + env->DeleteLocalRef(charset); + env->DeleteLocalRef(stringClass); + return result; +} +} +#endif + +#define JNI_METHOD(name) Java_com_zarz_spotiflac_discord_DiscordPresencePlugin_##name + +extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nativeStart)(JNIEnv*, jobject) { +#ifdef SPOTIFLAC_DISCORD_SDK + if (!client) { + client = std::make_unique(); + client->SetApplicationId(1549854098801692862ULL); + client->SetEngineManagedAudioSession(true); + status = 0; + } + return true; +#else + return false; +#endif +} + +extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeTick)(JNIEnv*, jobject) { +#ifdef SPOTIFLAC_DISCORD_SDK + discordpp::RunCallbacks(); + return status; +#else + return 2; +#endif +} + +extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeUpdate)( + [[maybe_unused]] JNIEnv* env, jobject, + [[maybe_unused]] jstring title, [[maybe_unused]] jstring artist, + [[maybe_unused]] jstring album, [[maybe_unused]] jstring artwork, + [[maybe_unused]] jlong start, [[maybe_unused]] jlong end) { +#ifdef SPOTIFLAC_DISCORD_SDK + if (!client) return; + discordpp::Activity activity; + activity.SetName("SpotiFLAC Mobile"); + activity.SetType(discordpp::ActivityTypes::Listening); + activity.SetDetails(utf8(env, title)); + activity.SetState(utf8(env, artist)); + const auto image = utf8(env, artwork); + if (!image.empty()) { + discordpp::ActivityAssets assets; + assets.SetLargeImage(image); + activity.SetAssets(assets); + } + discordpp::ActivityTimestamps timestamps; + if (start > 0) timestamps.SetStart(start); + if (end > 0) timestamps.SetEnd(end); + activity.SetTimestamps(timestamps); + auto request = ++generation; + client->UpdateRichPresence(activity, [request](discordpp::ClientResult result) { + if (generation == request) status = result.Successful() ? 1 : 2; + }); +#endif +} + +extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeClear)(JNIEnv*, jobject) { +#ifdef SPOTIFLAC_DISCORD_SDK + ++generation; + if (client) client->ClearRichPresence(); + status = 0; +#endif +} + +extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeStop)(JNIEnv*, jobject) { +#ifdef SPOTIFLAC_DISCORD_SDK + ++generation; + if (client) { + client->ClearRichPresence(); + client->Disconnect(); + discordpp::RunCallbacks(); + client.reset(); + } + status = 0; +#endif +} diff --git a/third_party/spotiflac_discord/android/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresencePlugin.kt b/third_party/spotiflac_discord/android/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresencePlugin.kt new file mode 100644 index 00000000..ac8af151 --- /dev/null +++ b/third_party/spotiflac_discord/android/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresencePlugin.kt @@ -0,0 +1,105 @@ +package com.zarz.spotiflac.discord + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class DiscordPresencePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware { + private lateinit var channel: MethodChannel + private val handler = Handler(Looper.getMainLooper()) + private var activity: Activity? = null + private var running = false + private var loaded = false + private var lastStatus = -1 + private val tick = object : Runnable { + override fun run() { + if (!running) return + val status = nativeTick() + if (status != lastStatus) { + lastStatus = status + channel.invokeMethod("status", when (status) { + 1 -> "active" + 2 -> "unavailable" + else -> "ready" + }) + } + handler.postDelayed(this, 100) + } + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/discord") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + try { + when (call.method) { + "initialize" -> { + val host = activity + if (host == null) { result.success(false); return } + // Only load/start the SDK after the user enables presence. + val init = Class.forName("com.discord.socialsdk.DiscordSocialSdkInit") + init.getMethod("setEngineActivity", Activity::class.java).invoke(null, host) + if (!loaded) { System.loadLibrary("spotiflac_discord"); loaded = true } + if (!running) { + running = nativeStart() + lastStatus = -1 + if (running) handler.post(tick) + } + result.success(running) + } + "update" -> { + if (running) nativeUpdate( + call.argument("title") ?: "", + call.argument("state") ?: "", + call.argument("album") ?: "", + call.argument("artwork") ?: "", + call.argument("start")?.toLong() ?: 0L, + call.argument("end")?.toLong() ?: 0L, + ) + result.success(null) + } + "clear" -> { if (running) nativeClear(); result.success(null) } + "shutdown" -> { stop(); result.success(null) } + else -> result.notImplemented() + } + } catch (_: ClassNotFoundException) { + result.success(false) + } catch (_: LinkageError) { + result.error("sdk_unavailable", "Discord SDK is unavailable in this build.", null) + } catch (_: Exception) { + result.error("discord_unavailable", "Unable to connect to Discord.", null) + } + } + + private fun stop() { + handler.removeCallbacks(tick) + if (running) nativeStop() + running = false + lastStatus = -1 + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + stop() + channel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity } + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + override fun onDetachedFromActivityForConfigChanges() { activity = null } + override fun onDetachedFromActivity() { activity = null } + + private external fun nativeStart(): Boolean + private external fun nativeTick(): Int + private external fun nativeUpdate(title: String, artist: String, album: String, artwork: String, start: Long, end: Long) + private external fun nativeClear() + private external fun nativeStop() +} diff --git a/third_party/spotiflac_discord/ios/Classes/DiscordPresencePlugin.h b/third_party/spotiflac_discord/ios/Classes/DiscordPresencePlugin.h new file mode 100644 index 00000000..812f6139 --- /dev/null +++ b/third_party/spotiflac_discord/ios/Classes/DiscordPresencePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface DiscordPresencePlugin : NSObject +@end diff --git a/third_party/spotiflac_discord/ios/Classes/DiscordPresencePlugin.mm b/third_party/spotiflac_discord/ios/Classes/DiscordPresencePlugin.mm new file mode 100644 index 00000000..3f85240a --- /dev/null +++ b/third_party/spotiflac_discord/ios/Classes/DiscordPresencePlugin.mm @@ -0,0 +1,156 @@ +#import "DiscordPresencePlugin.h" + +#ifdef SPOTIFLAC_DISCORD_SDK +#define DISCORDPP_IMPLEMENTATION +#include +#include +#endif + +@implementation DiscordPresencePlugin { + FlutterMethodChannel *_channel; + NSTimer *_timer; + FlutterResult _authResult; + NSUInteger _generation; +#ifdef SPOTIFLAC_DISCORD_SDK + std::unique_ptr _client; +#endif +} + ++ (void)registerWithRegistrar:(NSObject *)registrar { + DiscordPresencePlugin *plugin = [[DiscordPresencePlugin alloc] init]; + plugin->_channel = [FlutterMethodChannel methodChannelWithName:@"com.zarz.spotiflac/discord" + binaryMessenger:registrar.messenger]; + [registrar addMethodCallDelegate:plugin channel:plugin->_channel]; +} + +- (void)status:(NSString *)value { + [_channel invokeMethod:@"status" arguments:value]; +} + +- (void)finishAuth:(id)value { + FlutterResult result = _authResult; + _authResult = nil; + if (result) result(value); +} + +- (void)shutdown { + ++_generation; + [self finishAuth:[FlutterError errorWithCode:@"cancelled" message:@"Discord linking cancelled." details:nil]]; + [_timer invalidate]; + _timer = nil; +#ifdef SPOTIFLAC_DISCORD_SDK + if (_client) { + _client->AbortAuthorize(); + _client->ClearRichPresence(); + _client->Disconnect(); + discordpp::RunCallbacks(); + _client.reset(); + } +#endif +} + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { +#ifndef SPOTIFLAC_DISCORD_SDK + result([call.method isEqualToString:@"initialize"] ? @NO : nil); +#else + NSDictionary *args = [call.arguments isKindOfClass:[NSDictionary class]] ? call.arguments : @{}; + if ([call.method isEqualToString:@"initialize"]) { + if (!_client) { + _client = std::make_unique(); + _client->SetApplicationId(1549854098801692862ULL); + _client->SetEngineManagedAudioSession(true); + __weak DiscordPresencePlugin *weakSelf = self; + _client->SetStatusChangedCallback([weakSelf](discordpp::Client::Status status, discordpp::Client::Error, int32_t) { + DiscordPresencePlugin *self = weakSelf; + if (!self) return; + [self status:status == discordpp::Client::Status::Ready ? @"ready" : @"connecting"]; + }); + _client->SetTokenExpirationCallback([weakSelf]() { [weakSelf status:@"expired"]; }); + _timer = [NSTimer timerWithTimeInterval:0.1 repeats:YES block:^(NSTimer *) { discordpp::RunCallbacks(); }]; + [[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes]; + } + result(@YES); + return; + } + if ([call.method isEqualToString:@"shutdown"]) { [self shutdown]; result(nil); return; } + if (!_client) { result([FlutterError errorWithCode:@"not_initialized" message:@"Discord is disabled." details:nil]); return; } + + if ([call.method isEqualToString:@"authorize"] || [call.method isEqualToString:@"refresh"]) { + if (_authResult) { result([FlutterError errorWithCode:@"busy" message:@"Discord linking is in progress." details:nil]); return; } + _authResult = [result copy]; + const auto generation = _generation; + __weak DiscordPresencePlugin *weakSelf = self; + auto exchanged = [weakSelf, generation](discordpp::ClientResult response, std::string access, + std::string refresh, discordpp::AuthorizationTokenType, int32_t expires, std::string) { + DiscordPresencePlugin *self = weakSelf; + if (!self || self->_generation != generation) return; + if (!response.Successful()) { + [self finishAuth:[FlutterError errorWithCode:@"authorization_failed" message:@"Discord authorization failed. Please try again." details:nil]]; + return; + } + [self finishAuth:@{@"access": @(access.c_str()), @"refresh": @(refresh.c_str()), @"expiresIn": @(expires)}]; + }; + if ([call.method isEqualToString:@"refresh"]) { + _client->RefreshToken(1549854098801692862ULL, [args[@"refresh"] UTF8String], exchanged); + } else { + auto verifier = _client->CreateAuthorizationCodeVerifier(); + discordpp::AuthorizationArgs auth; + auth.SetClientId(1549854098801692862ULL); + auth.SetScopes(discordpp::Client::GetDefaultPresenceScopes()); + auth.SetCodeChallenge(verifier.Challenge()); + _client->Authorize(auth, [weakSelf, generation, verifier, exchanged](discordpp::ClientResult response, std::string code, std::string redirect) { + DiscordPresencePlugin *self = weakSelf; + if (!self || self->_generation != generation || !self->_client) return; + if (!response.Successful()) { + [self finishAuth:[FlutterError errorWithCode:@"cancelled" message:@"Discord linking was cancelled or unavailable." details:nil]]; + return; + } + self->_client->GetToken(1549854098801692862ULL, code, verifier.Verifier(), redirect, exchanged); + }); + } + } else if ([call.method isEqualToString:@"connect"]) { + const auto generation = _generation; + __weak DiscordPresencePlugin *weakSelf = self; + _client->UpdateToken(discordpp::AuthorizationTokenType::Bearer, [args[@"access"] UTF8String], + [weakSelf, generation](discordpp::ClientResult response) { + DiscordPresencePlugin *self = weakSelf; + if (!self || self->_generation != generation || !self->_client) return; + if (response.Successful()) self->_client->Connect(); + else [self status:@"unavailable"]; + }); + result(nil); + } else if ([call.method isEqualToString:@"update"]) { + discordpp::Activity activity; + activity.SetName("SpotiFLAC Mobile"); + activity.SetType(discordpp::ActivityTypes::Listening); + activity.SetDetails([args[@"title"] UTF8String]); + activity.SetState([args[@"state"] UTF8String]); + if ([args[@"artwork"] length]) { + discordpp::ActivityAssets assets; + assets.SetLargeImage([args[@"artwork"] UTF8String]); + activity.SetAssets(assets); + } + discordpp::ActivityTimestamps timestamps; + if ([args[@"start"] unsignedLongLongValue]) timestamps.SetStart([args[@"start"] unsignedLongLongValue]); + if ([args[@"end"] unsignedLongLongValue]) timestamps.SetEnd([args[@"end"] unsignedLongLongValue]); + activity.SetTimestamps(timestamps); + const auto generation = _generation; + __weak DiscordPresencePlugin *weakSelf = self; + _client->UpdateRichPresence(activity, [weakSelf, generation](discordpp::ClientResult response) { + DiscordPresencePlugin *self = weakSelf; + if (self && self->_generation == generation) [self status:response.Successful() ? @"active" : @"unavailable"]; + }); + result(nil); + } else if ([call.method isEqualToString:@"clear"]) { + _client->ClearRichPresence(); + result(nil); + } else { + result(FlutterMethodNotImplemented); + } +#endif +} + +- (void)detachFromEngineForRegistrar:(NSObject *)registrar { + [self shutdown]; +} +@end diff --git a/third_party/spotiflac_discord/ios/DiscordSimulator.xcconfig b/third_party/spotiflac_discord/ios/DiscordSimulator.xcconfig new file mode 100644 index 00000000..3a9aba3e --- /dev/null +++ b/third_party/spotiflac_discord/ios/DiscordSimulator.xcconfig @@ -0,0 +1,2 @@ +// Discord Social SDK ships only an arm64 iOS simulator slice. +EXCLUDED_ARCHS[sdk=iphonesimulator*] = $(inherited) i386 x86_64 diff --git a/third_party/spotiflac_discord/ios/spotiflac_discord.podspec b/third_party/spotiflac_discord/ios/spotiflac_discord.podspec new file mode 100644 index 00000000..bac9c2fc --- /dev/null +++ b/third_party/spotiflac_discord/ios/spotiflac_discord.podspec @@ -0,0 +1,25 @@ +Pod::Spec.new do |s| + s.name = 'spotiflac_discord' + s.version = '0.1.0' + s.summary = 'Playback presence through the Discord Social SDK.' + s.homepage = 'https://github.com/spotiflacapp/SpotiFLAC-Mobile' + s.license = { :type => 'MIT' } + s.author = 'SpotiFLAC' + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '16.0' + sdk = 'Frameworks/discord_partner_sdk.xcframework' + if File.directory?(File.join(__dir__, sdk)) + s.vendored_frameworks = sdk + s.resources = 'Frameworks/Discord-License-Notices.txt' + s.pod_target_xcconfig = { + 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++20', + 'OTHER_LDFLAGS' => '$(inherited) -framework discord_partner_sdk', + 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) SPOTIFLAC_DISCORD_SDK=1', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386 x86_64' + } + # The official SDK contains an arm64 simulator slice only. + s.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386 x86_64' } + end +end diff --git a/third_party/spotiflac_discord/lib/spotiflac_discord.dart b/third_party/spotiflac_discord/lib/spotiflac_discord.dart new file mode 100644 index 00000000..f7040dc3 --- /dev/null +++ b/third_party/spotiflac_discord/lib/spotiflac_discord.dart @@ -0,0 +1,2 @@ +/// Native implementation is exposed through com.zarz.spotiflac/discord. +library; diff --git a/third_party/spotiflac_discord/pubspec.yaml b/third_party/spotiflac_discord/pubspec.yaml new file mode 100644 index 00000000..aa69f927 --- /dev/null +++ b/third_party/spotiflac_discord/pubspec.yaml @@ -0,0 +1,17 @@ +name: spotiflac_discord +description: Mobile Discord Social SDK bridge for playback presence. +version: 0.1.0 +publish_to: none +environment: + sdk: ^3.10.0 +dependencies: + flutter: + sdk: flutter +flutter: + plugin: + platforms: + android: + package: com.zarz.spotiflac.discord + pluginClass: DiscordPresencePlugin + ios: + pluginClass: DiscordPresencePlugin