refactor(discord): remove rich presence and bundled SDK

Remove the presence setting, platform bridge, SDK archive, and SDK preparation from local and CI builds. Reject leftover Discord native libraries in release APK audits.
This commit is contained in:
zarzet
2026-09-26 03:07:04 +07:00
parent 863d1536dd
commit 728d179d69
43 changed files with 37 additions and 1171 deletions
-39
View File
@@ -15,19 +15,6 @@ if (keystorePropertiesFile.exists()) {
}
val rustBackendDir = rootProject.file("../rust_backend")
val cachedDiscordSdkDir = rootProject.file("../.dart_tool/discord-sdk/1.10.19337")
val discordSdkDir = providers.environmentVariable("SPOTIFLAC_DISCORD_SDK_DIR").orNull
?: cachedDiscordSdkDir.takeIf { it.isDirectory }?.absolutePath
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" }
require(file("$discordSdkDir/License-Notices.txt").isFile) { "Discord SDK license notices are required" }
}
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()
@@ -47,16 +34,12 @@ 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 {
@@ -87,7 +70,6 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
buildConfigField("boolean", "HAS_DISCORD_SDK", (discordSdkAar != null).toString())
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
minSdk = flutter.minSdkVersion
targetSdk = 37
@@ -101,15 +83,6 @@ android {
}
}
if (discordSdkAar != null) {
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
@@ -175,24 +148,12 @@ 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) }
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
doFirst {
check(discordSdkAar != null) {
"Release APKs require Discord SDK. Run python3 scripts/prepare_discord_sdk.py; see DISCORD.md."
}
}
}
flutter {
source = "../.."
}
dependencies {
if (discordSdkAar != null) {
implementation(files(discordSdkAar))
implementation("androidx.browser:browser:1.10.0")
}
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("net.java.dev.jna:jna:5.19.1@aar")
@@ -1,54 +0,0 @@
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 -5
View File
@@ -3,12 +3,11 @@
package="com.zarz.spotiflac">
<!-- Permissions -->
<!-- Presence never records audio or uses the SDK voice service. -->
<!-- Playback never records audio. -->
<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"
@@ -36,9 +35,6 @@
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"
android:exported="true"
-8
View File
@@ -1,8 +0,0 @@
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")
@@ -1,105 +0,0 @@
// 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());
}
@@ -959,7 +959,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
PlayerWidgetBridge.attach(this, 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()
@@ -1,113 +0,0 @@
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
}