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 committed 2026-09-17 02:12:24 +07:00
1 parent b2ea07c41e
commit f0ed293d48
17 files changed
+662 -1

No files matched your search

+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
}