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:
zarzet
2026-09-17 02:12:24 +07:00
parent b2ea07c41e
commit f0ed293d48
17 changed files with 662 additions and 1 deletions
+32
View File
@@ -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() }
}
}
}
+9
View File
@@ -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"
+8
View File
@@ -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
}