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

+5
View File
@@ -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'
+10
View File
@@ -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")
}
+3
View File
@@ -38,6 +38,9 @@
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
<!-- Presence uses RPC only; do not expose unused SDK voice/auth components. -->
<service android:name="com.discord.socialsdk.ForegroundService" tools:node="remove" />
<activity android:name="com.discord.socialsdk.AuthenticationActivity" tools:node="remove" />
<activity
android:name=".AudioOutputActivity"
android:exported="false"
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('native Discord SDK can initialize, stop, and restart', (
tester,
) async {
const channel = MethodChannel('com.zarz.spotiflac/discord');
// Staging the official SDK is a prerequisite for this device-only test.
// No account is linked and no listening activity is published.
for (var i = 0; i < 2; i++) {
try {
expect(await channel.invokeMethod<bool>('initialize'), isTrue);
await tester.pump(const Duration(milliseconds: 350));
await channel.invokeMethod<void>('clear');
} finally {
await channel.invokeMethod<void>('shutdown');
}
}
});
}
+1
View File
@@ -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"
+1
View File
@@ -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"
+6
View File
@@ -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
+2
View File
@@ -114,6 +114,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>spotiflac</string>
<string>discord-1549854098801692862</string>
</array>
</dict>
</array>
@@ -122,6 +123,7 @@
<key>LSApplicationQueriesSchemes</key>
<array>
<string>spotify</string>
<string>discord</string>
<string>deezer</string>
<string>tidal</string>
<string>youtube-music</string>
+11
View File
@@ -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"
+12 -1
View File
@@ -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"
}
+5
View File
@@ -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,
+3
View File
@@ -23,6 +23,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'discordRichPresenceEnabled': instance.discordRichPresenceEnabled,
'usbBitPerfect': instance.usbBitPerfect,
'usbDirect': instance.usbDirect,
'usbDsdOverPcm': instance.usbDsdOverPcm,
+5
View File
@@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDiscordRichPresenceEnabled(bool enabled) {
state = state.copyWith(discordRichPresenceEnabled: enabled);
_saveSettings();
}
void setAutoplay(bool enabled) {
state = state.copyWith(autoplay: enabled);
_saveSettings();
+12
View File
@@ -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<MainShell>
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<MainShell>
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),
@@ -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<PlaybackSettingsPage> {
],
),
),
if (Platform.isAndroid || Platform.isIOS) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Discord'),
),
const SliverToBoxAdapter(child: DiscordPresenceSettings()),
],
if (Platform.isAndroid)
SliverToBoxAdapter(
child: SettingsSectionHeader(
+167
View File
@@ -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<Track> 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<String?> Function(MediaItem)? historyCover,
Future<List<Track>> Function(String)? search,
}) : _historyCover = historyCover ?? _fromHistory,
_search = search ?? _searchCatalog;
final Future<String?> Function(MediaItem) _historyCover;
final Future<List<Track>> Function(String) _search;
final _cache = <String, ({String? url, DateTime expires})>{};
final _pending = <String, Future<String?>>{};
Future<String?> 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<String?> _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<String?> _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<List<Track>> _searchCatalog(String query) async => [
for (final row in await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 8,
includeExtensions: true,
))
Track.fromBackendMap(row),
];
}
+373
View File
@@ -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<String, Object>? 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<String?> Function()? readRefreshToken,
Future<void> Function(String?)? writeRefreshToken,
DateTime Function()? now,
Future<String?> 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<String?> _readToken() => _storage.read(key: _tokenKey);
static Future<void> _writeToken(String? token) => token == null
? _storage.delete(key: _tokenKey)
: _storage.write(key: _tokenKey, value: token);
final MethodChannel _channel;
final bool requiresLink;
final Future<String?> Function() _readRefreshToken;
final Future<void> Function(String?) _writeRefreshToken;
final DateTime Function() _now;
final Future<String?> Function(MediaItem) _resolveArtwork;
String? _artworkKey;
String? _artwork;
int _artworkRequest = 0;
final status = ValueNotifier(DiscordPresenceStatus.disabled);
final _subscriptions = <StreamSubscription<dynamic>>[];
AudioHandler? _handler;
bool _enabled = false;
bool _initialized = false;
bool _ready = false;
bool _authenticating = false;
bool _visible = false;
int _generation = 0;
Map<String, Object>? _lastPayload;
DateTime? _lastUpdate;
Timer? _pending;
Timer? _retry;
Timer? _refresh;
Future<void> _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<void> 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<bool>('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<void> link() => _authenticate(interactive: true);
Future<void> 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<void> _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<String, dynamic>(
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<void>('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<void> _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<String, Object> 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<void> _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<void> _invoke(String method, [Map<String, Object>? arguments]) async {
final generation = _generation;
try {
await _channel.invokeMethod<void>(method, arguments);
} catch (_) {
if (_enabled && generation == _generation) {
status.value = DiscordPresenceStatus.unavailable;
}
}
}
Future<void> dispose() async {
await setEnabled(false);
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
_channel.setMethodCallHandler(null);
status.dispose();
}
}
+2
View File
@@ -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<MusicPlayerHandler> _doInitMusicPlayer() async {
_handler = handler;
if (Platform.isAndroid || Platform.isIOS) {
PlayerWidgetService.instance.bind(handler);
DiscordPresenceService.instance.bind(handler);
}
_handlerReadyController.add(handler);
return handler;
@@ -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<DiscordPresenceStatus>(
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,
),
],
),
);
}
}
+46
View File
@@ -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:
+4
View File
@@ -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
+5 -1
View File
@@ -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"
+8 -5
View File
@@ -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
+29
View File
@@ -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."
+18 -11
View File
@@ -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__":
+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);
});
}
+4
View File
@@ -0,0 +1,4 @@
/sdk/
/android/.gradle/
/android/.cxx/
/ios/Frameworks/
+65
View File
@@ -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 <device-id>` on an iOS simulator or Android device to test native startup,
shutdown and restart without linking an account or publishing any activity.
+15
View File
@@ -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()
+25
View File
@@ -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' }
}
}
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries><package android:name="com.discord" /></queries>
</manifest>
@@ -0,0 +1,107 @@
#include <jni.h>
#ifdef SPOTIFLAC_DISCORD_SDK
#define DISCORDPP_IMPLEMENTATION
#include <discordpp.h>
#include <memory>
#include <string>
namespace {
std::unique_ptr<discordpp::Client> 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<jbyteArray>(env->CallObjectMethod(value, getBytes, charset));
std::string result;
if (bytes) {
result.resize(env->GetArrayLength(bytes));
env->GetByteArrayRegion(bytes, 0, static_cast<jsize>(result.size()),
reinterpret_cast<jbyte*>(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<discordpp::Client>();
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
}
@@ -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<String>("title") ?: "",
call.argument<String>("state") ?: "",
call.argument<String>("album") ?: "",
call.argument<String>("artwork") ?: "",
call.argument<Number>("start")?.toLong() ?: 0L,
call.argument<Number>("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()
}
@@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface DiscordPresencePlugin : NSObject <FlutterPlugin>
@end
@@ -0,0 +1,156 @@
#import "DiscordPresencePlugin.h"
#ifdef SPOTIFLAC_DISCORD_SDK
#define DISCORDPP_IMPLEMENTATION
#include <discord_partner_sdk/discordpp.h>
#include <memory>
#endif
@implementation DiscordPresencePlugin {
FlutterMethodChannel *_channel;
NSTimer *_timer;
FlutterResult _authResult;
NSUInteger _generation;
#ifdef SPOTIFLAC_DISCORD_SDK
std::unique_ptr<discordpp::Client> _client;
#endif
}
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)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<discordpp::Client>();
_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<FlutterPluginRegistrar> *)registrar {
[self shutdown];
}
@end
@@ -0,0 +1,2 @@
// Discord Social SDK ships only an arm64 iOS simulator slice.
EXCLUDED_ARCHS[sdk=iphonesimulator*] = $(inherited) i386 x86_64
@@ -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
@@ -0,0 +1,2 @@
/// Native implementation is exposed through com.zarz.spotiflac/discord.
library;
+17
View File
@@ -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