mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 05:57:48 +02:00
feat(discord): add opt-in mobile rich presence with track artwork
This commit is contained in:
1 parent
bbe2caa754
commit
0b2cf0e4e5
40 files changed
+1839
-18
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,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"
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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__":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/sdk/
|
||||
/android/.gradle/
|
||||
/android/.cxx/
|
||||
/ios/Frameworks/
|
||||
+65
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
+105
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user