mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat: add opt-in Discord Rich Presence on Android
Use the official Discord Social SDK through JNI to share built-in player activity, clear presence when playback stops, and guard against late callbacks after disabling. Package the SDK optionally through SPOTIFLAC_DISCORD_SDK_DIR with license notices. Credit: @itsmegaaa for the Rich Presence initiative and contribution in #575 and #576. Validation: 15 Dart tests, scoped Flutter analysis, and one Android 16 ARM64 SDK/JNI instrumentation test passed. Visible presence on a signed-in Discord profile remains unverified.
This commit is contained in:
@@ -15,6 +15,16 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
val rustBackendDir = rootProject.file("../rust_backend")
|
||||
val discordSdkDir = providers.environmentVariable("SPOTIFLAC_DISCORD_SDK_DIR").orNull
|
||||
val discordSdkAar = discordSdkDir?.let { file("$it/lib/release/discord_partner_sdk.aar") }
|
||||
if (discordSdkAar != null) {
|
||||
require(discordSdkAar.isFile) { "SPOTIFLAC_DISCORD_SDK_DIR must contain the official Social SDK" }
|
||||
}
|
||||
val discordNotices = if (discordSdkDir != null) tasks.register<Copy>("copyDiscordNotices") {
|
||||
from(file("$discordSdkDir/License-Notices.txt"))
|
||||
into(layout.buildDirectory.dir("generated/discordAssets"))
|
||||
rename { "discord-sdk-notices.txt" }
|
||||
} else null
|
||||
val rustAndroidAbis = providers.environmentVariable("SPOTIFLAC_RUST_ANDROID_ABIS")
|
||||
.orElse("arm64-v8a,armeabi-v7a")
|
||||
.get()
|
||||
@@ -34,12 +44,16 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
prefab = discordSdkAar != null
|
||||
}
|
||||
|
||||
sourceSets.getByName("main") {
|
||||
java.srcDir("src/rust/kotlin")
|
||||
java.srcDir(rustBackendDir.resolve("target/bindings/kotlin"))
|
||||
jniLibs.srcDir(rustBackendDir.resolve("target/android/jniLibs"))
|
||||
if (discordNotices != null) {
|
||||
assets.srcDir(layout.buildDirectory.dir("generated/discordAssets").get().asFile)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -67,6 +81,8 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
buildConfigField("boolean", "HAS_DISCORD_SDK", (discordSdkAar != null).toString())
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 37
|
||||
versionCode = flutter.versionCode
|
||||
@@ -79,6 +95,15 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
if (discordSdkAar != null) {
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
@@ -144,12 +169,17 @@ val buildRustBackend = tasks.register<Exec>("buildRustBackend") {
|
||||
outputs.dir(rustBackendDir.resolve("target/android/jniLibs"))
|
||||
}
|
||||
tasks.named("preBuild").configure { dependsOn(buildRustBackend) }
|
||||
if (discordNotices != null) tasks.named("preBuild").configure { dependsOn(discordNotices) }
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (discordSdkAar != null) {
|
||||
implementation(files(discordSdkAar))
|
||||
implementation("androidx.browser:browser:1.9.0")
|
||||
}
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
implementation("net.java.dev.jna:jna:5.17.0@aar")
|
||||
|
||||
@@ -162,4 +192,6 @@ dependencies {
|
||||
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.zarz.spotiflac.discord
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.zarz.spotiflac.BuildConfig
|
||||
import com.zarz.spotiflac.MainActivity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Exercises the actual JNI library/SDK in the emulator, without account credentials. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DiscordPresenceTest {
|
||||
@Test
|
||||
fun testSdkLifecycleAndLateCallbacks() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
assumeTrue("Build with SPOTIFLAC_DISCORD_SDK_DIR", BuildConfig.HAS_DISCORD_SDK)
|
||||
assumeTrue("This test must not publish to a signed-in account",
|
||||
instrumentation.targetContext.packageManager.getLaunchIntentForPackage("com.discord") == null)
|
||||
val activity = instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
assertEquals("discord_missing", DiscordPresenceBridge.configure(activity, true))
|
||||
assertEquals("disabled", DiscordPresenceBridge.configure(activity, false))
|
||||
System.loadLibrary("spotiflac_discord")
|
||||
Class.forName("com.discord.socialsdk.DiscordSocialSdkInit")
|
||||
.getMethod("setEngineActivity", android.app.Activity::class.java)
|
||||
.invoke(null, activity)
|
||||
DiscordNative.start(1549854098801692862L)
|
||||
DiscordNative.update("Track 🎵", "Lead & Guest", "Album", "", 1000, 2000)
|
||||
DiscordNative.clear()
|
||||
DiscordNative.stop()
|
||||
}
|
||||
repeat(20) {
|
||||
Thread.sleep(50)
|
||||
instrumentation.runOnMainSync { assertEquals("disabled", DiscordNative.poll()) }
|
||||
}
|
||||
instrumentation.runOnMainSync {
|
||||
DiscordNative.start(1549854098801692862L)
|
||||
assertEquals("ready", DiscordNative.poll())
|
||||
DiscordNative.stop()
|
||||
assertEquals("disabled", DiscordNative.poll())
|
||||
}
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { activity.finish() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.zarz.spotiflac">
|
||||
|
||||
<!-- Permissions -->
|
||||
<!-- Presence never records audio or uses the SDK voice service. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" tools:node="remove" />
|
||||
<queries><package android:name="com.discord" /></queries>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -29,6 +36,8 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
<service android:name="com.discord.socialsdk.ForegroundService" tools:node="remove" />
|
||||
<activity android:name="com.discord.socialsdk.AuthenticationActivity" tools:node="remove" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(spotiflac_discord LANGUAGES CXX)
|
||||
|
||||
find_package(discord_partner_sdk REQUIRED CONFIG)
|
||||
add_library(spotiflac_discord SHARED discord_presence.cpp)
|
||||
target_compile_features(spotiflac_discord PRIVATE cxx_std_20)
|
||||
target_link_libraries(spotiflac_discord discord_partner_sdk::discord_partner_sdk)
|
||||
target_link_options(spotiflac_discord PRIVATE "-Wl,-z,max-page-size=16384")
|
||||
@@ -0,0 +1,105 @@
|
||||
// Rich Presence initiative: @itsmegaaa, SpotiFLAC-Mobile #575 / #576.
|
||||
// Uses the official Social SDK transport instead of a custom user Gateway.
|
||||
#define DISCORDPP_IMPLEMENTATION
|
||||
#include <discordpp.h>
|
||||
#include <jni.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
// All entry points and SDK callbacks run on Android's main thread.
|
||||
std::unique_ptr<discordpp::Client> client;
|
||||
uint64_t generation = 0;
|
||||
std::string status = "disabled";
|
||||
|
||||
std::string utf8(JNIEnv* env, jstring value) {
|
||||
if (!value) return {};
|
||||
// JNI's modified UTF-8 is not suitable for emoji in track titles.
|
||||
const auto length = env->GetStringLength(value);
|
||||
const auto* chars = env->GetStringChars(value, nullptr);
|
||||
std::string out;
|
||||
for (jsize i = 0; i < length; ++i) {
|
||||
uint32_t c = chars[i];
|
||||
if (c >= 0xd800 && c <= 0xdbff && i + 1 < length &&
|
||||
chars[i + 1] >= 0xdc00 && chars[i + 1] <= 0xdfff) {
|
||||
c = 0x10000 + ((c - 0xd800) << 10) + (chars[++i] - 0xdc00);
|
||||
} else if (c >= 0xd800 && c <= 0xdfff) {
|
||||
c = 0xfffd;
|
||||
}
|
||||
if (c < 0x80) out += static_cast<char>(c);
|
||||
else if (c < 0x800) {
|
||||
out += static_cast<char>(0xc0 | (c >> 6));
|
||||
out += static_cast<char>(0x80 | (c & 0x3f));
|
||||
} else if (c < 0x10000) {
|
||||
out += static_cast<char>(0xe0 | (c >> 12));
|
||||
out += static_cast<char>(0x80 | ((c >> 6) & 0x3f));
|
||||
out += static_cast<char>(0x80 | (c & 0x3f));
|
||||
} else {
|
||||
out += static_cast<char>(0xf0 | (c >> 18));
|
||||
out += static_cast<char>(0x80 | ((c >> 12) & 0x3f));
|
||||
out += static_cast<char>(0x80 | ((c >> 6) & 0x3f));
|
||||
out += static_cast<char>(0x80 | (c & 0x3f));
|
||||
}
|
||||
}
|
||||
env->ReleaseStringChars(value, chars);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_zarz_spotiflac_discord_DiscordNative_start(JNIEnv*, jobject, jlong appId) {
|
||||
if (client) return;
|
||||
++generation;
|
||||
client = std::make_unique<discordpp::Client>();
|
||||
client->SetApplicationId(static_cast<uint64_t>(appId));
|
||||
status = "ready";
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_zarz_spotiflac_discord_DiscordNative_update(
|
||||
JNIEnv* env, jobject, jstring title, jstring artist, jstring album,
|
||||
jstring cover, jlong start, jlong end) {
|
||||
if (!client) return;
|
||||
discordpp::Activity activity;
|
||||
activity.SetType(discordpp::ActivityTypes::Listening);
|
||||
activity.SetDetails(utf8(env, title));
|
||||
activity.SetState(utf8(env, artist));
|
||||
discordpp::ActivityTimestamps timestamps;
|
||||
timestamps.SetStart(static_cast<uint64_t>(start));
|
||||
if (end > start) timestamps.SetEnd(static_cast<uint64_t>(end));
|
||||
activity.SetTimestamps(timestamps);
|
||||
const auto image = utf8(env, cover);
|
||||
if (!image.empty()) {
|
||||
discordpp::ActivityAssets assets;
|
||||
assets.SetLargeImage(image);
|
||||
assets.SetLargeText(utf8(env, album));
|
||||
activity.SetAssets(assets);
|
||||
}
|
||||
const auto current = ++generation;
|
||||
status = "updating";
|
||||
client->UpdateRichPresence(activity, [current](discordpp::ClientResult result) {
|
||||
if (current != generation) return;
|
||||
status = result.Successful() ? "sharing" : "unavailable";
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_zarz_spotiflac_discord_DiscordNative_clear(JNIEnv*, jobject) {
|
||||
++generation;
|
||||
if (client) client->ClearRichPresence();
|
||||
status = client ? "ready" : "disabled";
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_zarz_spotiflac_discord_DiscordNative_stop(JNIEnv*, jobject) {
|
||||
++generation;
|
||||
if (client) client->ClearRichPresence();
|
||||
client.reset();
|
||||
status = "disabled";
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_com_zarz_spotiflac_discord_DiscordNative_poll(JNIEnv* env, jobject) {
|
||||
discordpp::RunCallbacks();
|
||||
return env->NewStringUTF(status.c_str());
|
||||
}
|
||||
@@ -949,6 +949,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
com.zarz.spotiflac.discord.DiscordPresenceBridge.attach(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
// Select and initialize the runtime before Flutter can dispatch a call.
|
||||
val selectedBackend = coreBackend
|
||||
sweepStaleCacheFiles()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.zarz.spotiflac.discord
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.zarz.spotiflac.BuildConfig
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
internal object DiscordNative {
|
||||
external fun start(appId: Long)
|
||||
external fun update(title: String, artist: String, album: String, cover: String, start: Long, end: Long)
|
||||
external fun clear()
|
||||
external fun stop()
|
||||
external fun poll(): String
|
||||
}
|
||||
|
||||
/** Official client RPC only. No user tokens, custom Gateway, or background login. */
|
||||
internal object DiscordPresenceBridge {
|
||||
private const val APPLICATION_ID = 1549854098801692862L
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var loaded = false
|
||||
private var enabled = false
|
||||
private var status = "disabled"
|
||||
private var channel: MethodChannel? = null
|
||||
private val pump = object : Runnable {
|
||||
override fun run() {
|
||||
if (!enabled) return
|
||||
val next = DiscordNative.poll()
|
||||
if (next != status) {
|
||||
status = next
|
||||
channel?.invokeMethod("status", status)
|
||||
}
|
||||
if (status != "unavailable") handler.postDelayed(this, 250)
|
||||
}
|
||||
}
|
||||
|
||||
fun attach(activity: Activity, messenger: BinaryMessenger) {
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = MethodChannel(messenger, "com.zarz.spotiflac/discord").also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
when (call.method) {
|
||||
"configure" -> result.success(configure(activity, call.argument<Boolean>("enabled") == true))
|
||||
"update" -> {
|
||||
if (enabled) {
|
||||
DiscordNative.start(APPLICATION_ID)
|
||||
val cover = call.argument<String>("cover").orEmpty()
|
||||
DiscordNative.update(
|
||||
call.argument<String>("title").orEmpty().take(128),
|
||||
call.argument<String>("artist").orEmpty().take(128),
|
||||
call.argument<String>("album").orEmpty().take(128),
|
||||
cover.takeIf { it.startsWith("https://") && it.length <= 300 }.orEmpty(),
|
||||
call.argument<Number>("start")?.toLong()?.coerceAtLeast(0) ?: 0,
|
||||
call.argument<Number>("end")?.toLong()?.coerceAtLeast(0) ?: 0,
|
||||
)
|
||||
handler.removeCallbacks(pump)
|
||||
handler.post(pump)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"clear" -> {
|
||||
if (enabled) {
|
||||
handler.removeCallbacks(pump)
|
||||
DiscordNative.stop()
|
||||
status = "ready"
|
||||
channel.invokeMethod("status", status)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"status" -> result.success(status)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
result.error("discord_unavailable", "Discord presence is unavailable", null)
|
||||
} catch (error: LinkageError) {
|
||||
result.error("discord_unavailable", "Discord SDK could not be loaded", null)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (loaded) setActivity(activity)
|
||||
}
|
||||
|
||||
private fun setActivity(activity: Activity) {
|
||||
Class.forName("com.discord.socialsdk.DiscordSocialSdkInit")
|
||||
.getMethod("setEngineActivity", Activity::class.java).invoke(null, activity)
|
||||
}
|
||||
|
||||
internal fun configure(activity: Activity, requested: Boolean): String {
|
||||
check(Looper.myLooper() == Looper.getMainLooper())
|
||||
handler.removeCallbacks(pump)
|
||||
enabled = false
|
||||
if (loaded) DiscordNative.stop()
|
||||
if (!requested) {
|
||||
status = "disabled"
|
||||
return status
|
||||
}
|
||||
if (!BuildConfig.HAS_DISCORD_SDK) return "sdk_unavailable".also { status = it }
|
||||
if (!isDiscordInstalled(activity)) return "discord_missing".also { status = it }
|
||||
if (!loaded) {
|
||||
System.loadLibrary("spotiflac_discord")
|
||||
loaded = true
|
||||
}
|
||||
setActivity(activity)
|
||||
enabled = true
|
||||
status = "ready"
|
||||
return status
|
||||
}
|
||||
|
||||
private fun isDiscordInstalled(context: Context): Boolean =
|
||||
context.packageManager.getLaunchIntentForPackage("com.discord") != null
|
||||
}
|
||||
Reference in New Issue
Block a user