diff --git a/.github/actions/discord-sdk/action.yml b/.github/actions/discord-sdk/action.yml deleted file mode 100644 index dbd675a1..00000000 --- a/.github/actions/discord-sdk/action.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Prepare Discord Social SDK -description: Decrypt and verify the pinned Android SDK before Gradle configures native builds. -inputs: - passphrase: - description: Decryption key for the bundled Android SDK, supplied from a repository secret. - required: true -runs: - using: composite - steps: - - name: Decrypt and verify SDK - shell: bash - env: - DISCORD_SDK_PASSPHRASE: ${{ inputs.passphrase }} - run: | - set -euo pipefail - sdk_dir="$(python3 scripts/prepare_discord_sdk.py)" - printf 'SPOTIFLAC_DISCORD_SDK_DIR=%s\n' "$sdk_dir" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77e43d1f..115d9e71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,10 +66,6 @@ jobs: - 'scripts/build_rust_backend.sh' - 'scripts/check_backend_apk.py' - 'scripts/test_check_backend_apk.py' - - 'scripts/prepare_discord_sdk.py' - - 'scripts/test_prepare_discord_sdk.py' - - '.github/actions/discord-sdk/**' - - 'third_party/discord/**' - 'lib/**' - 'assets/**' - 'pubspec.yaml' @@ -231,20 +227,10 @@ jobs: "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" - - name: Test Discord SDK preparation and APK audits + - name: Test APK audits run: | - python3 -m unittest discover -s scripts -p 'test_prepare_discord_sdk.py' python3 -m unittest discover -s scripts -p 'test_check_backend_apk.py' - # Fork PRs do not receive SDK decryption secrets. They still compile the - # optional debug bridge and run native tests, but never produce a release - # APK with the advertised Discord feature silently missing. - - name: Prepare pinned Discord SDK - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/actions/discord-sdk - with: - passphrase: ${{ secrets.DISCORD_SDK_PASSPHRASE }} - - name: Get Flutter dependencies run: flutter pub get @@ -264,18 +250,16 @@ jobs: run: gradle -p android :app:assembleDebug :app:testDebugUnitTest - name: Build Rust release application - if: env.SPOTIFLAC_DISCORD_SDK_DIR != '' run: bash scripts/build_android.sh --target lib/main.dart - name: Verify Rust release APK payloads - if: env.SPOTIFLAC_DISCORD_SDK_DIR != '' run: | python3 scripts/check_backend_apk.py \ build/app/outputs/flutter-apk/app-arm64-v8a-release.apk \ - --backend rust --abis arm64-v8a --require-discord + --backend rust --abis arm64-v8a python3 scripts/check_backend_apk.py \ build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk \ - --backend rust --abis armeabi-v7a --require-discord + --backend rust --abis armeabi-v7a # PR builds use Gradle's test key; production signing stays in Release. for abi in arm64-v8a armeabi-v7a; do "$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --verbose \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1e98f06..1e1f0d28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -120,11 +120,6 @@ jobs: # Set NDK path echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" - - name: Prepare pinned Discord SDK - uses: ./.github/actions/discord-sdk - with: - passphrase: ${{ secrets.DISCORD_SDK_PASSPHRASE }} - - name: Setup Flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: @@ -209,9 +204,9 @@ jobs: run: | apk_dir=build/app/outputs/flutter-apk python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm64.apk" \ - --backend rust --abis arm64-v8a --require-discord + --backend rust --abis arm64-v8a python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm32.apk" \ - --backend rust --abis armeabi-v7a --require-discord + --backend rust --abis armeabi-v7a for abi in arm64 arm32; do "$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 4 \ "$apk_dir/SpotiFLAC-${VERSION}-${abi}.apk" diff --git a/.gitignore b/.gitignore index c2f22e7a..38c39956 100644 --- a/.gitignore +++ b/.gitignore @@ -53,9 +53,8 @@ AGENTS.md /android/*.keystore /android/app/*.jks -# Only the encrypted Discord SDK archive belongs in the checkout. +# Keep obsolete local SDK copies out of the checkout. /third_party/discord/* -!/third_party/discord/*.zip.gpg # iOS generated state /ios/build/ diff --git a/DISCORD.md b/DISCORD.md deleted file mode 100644 index f91307b8..00000000 --- a/DISCORD.md +++ /dev/null @@ -1,77 +0,0 @@ -# Discord Rich Presence builds - -SpotiFLAC uses the official Discord Social SDK **1.10.19337** on Android. -The integration was inspired by [@itsmegaaa's contribution](https://github.com/spotiflacapp/SpotiFLAC-Mobile/pull/576) -and [issue #575](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues/575). -It publishes through the installed, signed-in Discord Android client. It does -not collect a Discord user token. iOS support is not implemented. - -## Local release builds - -Download the standalone C++ archive from the -[Discord Developer Portal](https://discord.com/developers/applications/select/social-sdk/downloads) -for your SDK-enabled application, then stage it once: - -```bash -python3 scripts/prepare_discord_sdk.py --archive /path/to/DiscordSocialSdk-1.10.19337.zip -``` - -An already extracted SDK works too: - -```bash -python3 scripts/prepare_discord_sdk.py --source-dir /path/to/discord_social_sdk -``` - -The helper verifies pinned SHA-256 checksums and stages only the Android release -AAR and license notices under `.dart_tool/discord-sdk/1.10.19337`. Do not commit -unencrypted SDK binaries or the decryption key. Use the repository's pinned -Flutter, Java, NDK and CMake versions: - -```bash -fvm exec bash scripts/build_android.sh --target lib/main.dart -``` - -Both split APKs and the universal APK are checked for the Discord JNI library, -SDK library, surviving JNI/SDK classes in DEX, correct ARM ELF architectures, -and license notices. Release builds fail if the SDK is missing. Debug builds -without the SDK remain available for contributor development. - -## CI and releases - -The Android-only archive is stored at -`third_party/discord/discord-android-1.10.19337.zip.gpg`, encrypted with GnuPG -AES-256. Set the repository Actions secret **`DISCORD_SDK_PASSPHRASE`** to its -decryption key. This follows GitHub's documented -[large-secret storage pattern](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#storing-large-secrets), -so releases need no external hosting or expiring portal download URL. - -CI and Release use the same preparation action to decrypt the archive with -GnuPG (included on Ubuntu runners), verify both pinned file checksums, and -export `SPOTIFLAC_DISCORD_SDK_DIR` before Gradle runs. The key is passed over -standard input to GnuPG and is never printed or put in its command arguments. -Missing keys, failed decryption, and checksum failures stop the build. -Gradle caches are limited to public dependency downloads; transformed SDK -binaries are not saved in the shared Actions cache. -The release workflow rechecks the final signed APKs before uploading them. -Fork PRs, which cannot access repository secrets, run debug/native validation -without generating release APKs. - -The archive contains only `lib/release/discord_partner_sdk.aar` and -`License-Notices.txt`. To update the SDK, obtain the official archive, update -the pinned version/checksums in the preparation helper and Gradle cache path, -and encrypt a new Android-only ZIP using `gpg --symmetric --cipher-algo AES256`. -Keep the passphrase outside the checkout and save it in Actions secrets; -never add it to Git, workflow logs, or build artifacts. - -## Verification - -```bash -python3 -m unittest discover -s scripts -p 'test_prepare_discord_sdk.py' -python3 -m unittest discover -s scripts -p 'test_check_backend_apk.py' -fvm flutter test test/discord_presence_service_test.dart -``` - -`DiscordPresenceTest` exercises the real SDK lifecycle on an Android emulator -without Discord installed. This proves native loading and callback handling; -visible presence still needs testing on a device with a signed-in Discord -client, including playback, seeking, pausing, and disabling the feature. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index da5066ab..e08d113d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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("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("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") diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt deleted file mode 100644 index 2e7c5139..00000000 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt +++ /dev/null @@ -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() } - } - } -} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fdb17053..db0024c4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,12 +3,11 @@ package="com.zarz.spotiflac"> - + - - - - -#include -#include -#include - -namespace { -// All entry points and SDK callbacks run on Android's main thread. -std::unique_ptr 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(c); - else if (c < 0x800) { - out += static_cast(0xc0 | (c >> 6)); - out += static_cast(0x80 | (c & 0x3f)); - } else if (c < 0x10000) { - out += static_cast(0xe0 | (c >> 12)); - out += static_cast(0x80 | ((c >> 6) & 0x3f)); - out += static_cast(0x80 | (c & 0x3f)); - } else { - out += static_cast(0xf0 | (c >> 18)); - out += static_cast(0x80 | ((c >> 12) & 0x3f)); - out += static_cast(0x80 | ((c >> 6) & 0x3f)); - out += static_cast(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(); - client->SetApplicationId(static_cast(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(start)); - if (end > start) timestamps.SetEnd(static_cast(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()); -} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 106fc41e..7a3942d6 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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() diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt deleted file mode 100644 index 773fcc6e..00000000 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt +++ /dev/null @@ -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("enabled") == true)) - "update" -> { - if (enabled) { - DiscordNative.start(APPLICATION_ID) - val cover = call.argument("cover").orEmpty() - DiscordNative.update( - call.argument("title").orEmpty().take(128), - call.argument("artist").orEmpty().take(128), - call.argument("album").orEmpty().take(128), - cover.takeIf { it.startsWith("https://") && it.length <= 300 }.orEmpty(), - call.argument("start")?.toLong()?.coerceAtLeast(0) ?: 0, - call.argument("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 -} diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 14151c58..2545f78d 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "ar", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 69d3d437..24bb4a4f 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "de", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d9b8e5dd..9850a47f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "en", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 345bfb08..c9dabd98 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "es_ES", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index fd1003bf..77ad8cc5 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "fr", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 1d2b74b6..99189ce5 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "hi", "@@last_modified": "2026-04-28", "appName": "Dd", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index f8dd8334..ae16f230 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Bagian tampilan untuk memilih gaya visual aplikasi" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Bagikan lagu yang diputar di pemutar bawaan ke Discord. Discord harus terpasang dan sudah login.", - "discordPresenceMissing": "Pasang Discord dan login, lalu matikan dan nyalakan kembali pengaturan ini.", - "discordPresenceUnavailable": "Discord Rich Presence tidak tersedia pada build atau perangkat ini.", "@@locale": "id", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 1b64e455..1cea7d5b 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "it", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 6c9e1873..bcc7ddf1 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "ja", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 8349dd13..13019d98 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "디스코드 리치 프레즌스", - "discordPresenceDescription": "내부 플레이어에서 재생 중인 트랙을 디스코드에 공유합니다. 디스코드가 설치되어 있고 로그인되어 있어야 합니다", - "discordPresenceMissing": "디스코드를 설치하고 로그인한 다음 이 설정을 비활성화했다가 다시 활성화하세요", - "discordPresenceUnavailable": "이 빌드 또는 이 기기에서는 디스코드 리치 프레즌스를 사용할 수 없습니다", "@@locale": "ko", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index b4c1c5dc..efc2f2e3 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "nl", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index e2c5809f..f8abd485 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "pt_PT", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 1f778bd8..5487de86 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Установите Discord и войдите в систему, а затем отключите и снова включите этот параметр.", - "discordPresenceUnavailable": "Discord Rich Presence не доступен в этой сборке или на этом устройстве.", "@@locale": "ru", "@@last_modified": "2026-04-28", "appName": "Spotify", diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 59541367..e54e3a33 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "tr", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_uk.arb b/lib/l10n/arb/app_uk.arb index b03fcdd8..3ffa3697 100644 --- a/lib/l10n/arb/app_uk.arb +++ b/lib/l10n/arb/app_uk.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "uk", "@@last_modified": "2026-04-28", "appName": "", diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 732bb34f..b57746d1 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "zh_CN", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC 移动版", diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index beb46b2c..c3caa317 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -39,10 +39,6 @@ "@appearanceStyle": { "description": "Appearance section for choosing the app visual style" }, - "discordRichPresence": "Discord Rich Presence", - "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", - "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", - "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "zh_TW", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC 行動版", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index b48164bc..0187635d 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -32,7 +32,6 @@ class AppSettings { // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; final bool autoMix; - final bool discordRichPresence; final bool isFirstLaunch; final bool checkForUpdates; final String updateChannel; @@ -145,7 +144,6 @@ class AppSettings { this.embedReplayGain = false, this.playbackNormalization = false, this.autoMix = false, - this.discordRichPresence = false, this.isFirstLaunch = true, this.checkForUpdates = true, this.updateChannel = 'stable', @@ -233,7 +231,6 @@ class AppSettings { bool? embedReplayGain, bool? playbackNormalization, bool? autoMix, - bool? discordRichPresence, bool? isFirstLaunch, bool? checkForUpdates, String? updateChannel, @@ -317,7 +314,6 @@ class AppSettings { playbackNormalization: playbackNormalization ?? this.playbackNormalization, autoMix: autoMix ?? this.autoMix, - discordRichPresence: discordRichPresence ?? this.discordRichPresence, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, updateChannel: updateChannel ?? this.updateChannel, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index a0a6c688..9be94cc4 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -23,7 +23,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, autoMix: json['autoMix'] as bool? ?? false, - discordRichPresence: json['discordRichPresence'] as bool? ?? false, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, updateChannel: json['updateChannel'] as String? ?? 'stable', @@ -123,7 +122,6 @@ Map _$AppSettingsToJson( 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, 'autoMix': instance.autoMix, - 'discordRichPresence': instance.discordRichPresence, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, 'updateChannel': instance.updateChannel, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 53a2e120..a038823b 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -556,11 +556,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setDiscordRichPresence(bool enabled) { - state = state.copyWith(discordRichPresence: enabled); - _saveSettings(); - } - void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 9908b63d..29b5f106 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -12,7 +12,6 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/screens/upgrade_intro_screen.dart'; import 'package:spotiflac_android/services/upgrade_intro_service.dart'; -import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/repo_provider.dart'; @@ -92,11 +91,6 @@ class _MainShellState extends ConsumerState ref.read(settingsProvider).playbackNormalization, ); setAutoMixEnabled(ref.read(settingsProvider).autoMix); - unawaited( - DiscordPresenceService.instance.setEnabled( - ref.read(settingsProvider).discordRichPresence, - ), - ); // Deezer & co. localize artist/genre names by IP unless told the app's // language (issue #480). unawaited( @@ -727,12 +721,6 @@ class _MainShellState extends ConsumerState @override Widget build(BuildContext context) { - ref.listen(settingsProvider.select((s) => s.discordRichPresence), ( - _, - enabled, - ) { - unawaited(DiscordPresenceService.instance.setEnabled(enabled)); - }); ref.listen(settingsProvider.select((s) => s.playbackNormalization), ( _, enabled, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 9f65db5a..01a52170 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -11,7 +11,6 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; -import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; @@ -891,27 +890,8 @@ class _LibrarySettingsPageState extends ConsumerState { value: settings.autoMix, onChanged: (value) => ref.read(settingsProvider.notifier).setAutoMix(value), - showDivider: Platform.isAndroid, + showDivider: false, ), - if (Platform.isAndroid) - ValueListenableBuilder( - valueListenable: DiscordPresenceService.instance.status, - builder: (context, status, _) => SettingsSwitchItem( - icon: Icons.share_outlined, - title: context.l10n.discordRichPresence, - subtitle: status == 'discord_missing' - ? context.l10n.discordPresenceMissing - : status == 'sdk_unavailable' || - status == 'unavailable' - ? context.l10n.discordPresenceUnavailable - : context.l10n.discordPresenceDescription, - value: settings.discordRichPresence, - onChanged: (value) => ref - .read(settingsProvider.notifier) - .setDiscordRichPresence(value), - showDivider: false, - ), - ), ], ), ), diff --git a/lib/services/discord_presence_service.dart b/lib/services/discord_presence_service.dart deleted file mode 100644 index 754cf03a..00000000 --- a/lib/services/discord_presence_service.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -/// Opt-in Rich Presence, inspired by @itsmegaaa's contribution in #575/#576. -/// Playback remains independent of Discord availability. -class DiscordPresenceService { - DiscordPresenceService({ - MethodChannel channel = const MethodChannel('com.zarz.spotiflac/discord'), - }) : _channel = channel { - _channel.setMethodCallHandler((call) async { - if (call.method == 'status' && _enabled && _transportReady) { - status.value = call.arguments as String; - } - }); - } - - static final instance = DiscordPresenceService(); - final MethodChannel _channel; - final ValueNotifier status = ValueNotifier('disabled'); - StreamSubscription? _mediaSubscription; - StreamSubscription? _stateSubscription; - MediaItem? _item; - PlaybackState? _playback; - bool _enabled = false; - bool _configured = false; - bool _transportReady = false; - int _generation = 0; - String? _lastPayload; - int? _lastStart; - Future _pending = Future.value(); - - void bind(AudioHandler handler) { - unawaited(_mediaSubscription?.cancel()); - unawaited(_stateSubscription?.cancel()); - _mediaSubscription = handler.mediaItem.listen((item) { - _item = item; - _publish(); - }); - _stateSubscription = handler.playbackState.listen((state) { - _playback = state; - _publish(); - }); - } - - Future setEnabled(bool enabled) { - if (_configured && _enabled == enabled) return _pending; - _configured = true; - _enabled = enabled; - _transportReady = false; - final generation = ++_generation; - _lastPayload = null; - _lastStart = null; - _pending = _pending.then((_) async { - try { - final result = await _channel.invokeMethod('configure', { - 'enabled': enabled, - }); - if (generation != _generation) return; - status.value = result ?? 'unavailable'; - _transportReady = enabled && result == 'ready'; - if (_transportReady) _publish(); - } on PlatformException { - if (generation == _generation) status.value = 'unavailable'; - } on MissingPluginException { - if (generation == _generation) status.value = 'sdk_unavailable'; - } - }); - return _pending; - } - - void _publish() { - if (!_enabled || !_transportReady) return; - final item = _item; - final playback = _playback; - final playing = - item != null && - playback != null && - playback.playing && - playback.processingState == AudioProcessingState.ready; - final position = playback?.position ?? Duration.zero; - final now = DateTime.now().millisecondsSinceEpoch; - final start = now - position.inMilliseconds; - final payload = playing - ? { - 'title': item.title, - 'artist': item.artist ?? '', - 'album': item.album ?? '', - 'cover': item.artUri?.scheme == 'https' - ? item.artUri.toString() - : '', - 'start': start, - 'end': item.duration == null - ? 0 - : start + item.duration!.inMilliseconds, - } - : null; - // Position streams tick frequently. Only republish on track/state changes - // or a seek that shifts the playback timeline by at least two seconds. - final key = playing - ? '${item.id}|${item.title}|${item.artist}|${item.album}|${item.artUri}|${item.duration}' - : 'clear'; - if (key == _lastPayload && - (!playing || - (_lastStart != null && (start - _lastStart!).abs() < 2000))) { - return; - } - _lastPayload = key; - _lastStart = start; - final generation = _generation; - _pending = _pending.then((_) async { - if (!_enabled || generation != _generation) return; - try { - await _channel.invokeMethod( - payload == null ? 'clear' : 'update', - payload, - ); - } on PlatformException { - _lastPayload = null; - } on MissingPluginException { - _lastPayload = null; - } - }); - } - - Future dispose() async { - await _mediaSubscription?.cancel(); - await _stateSubscription?.cancel(); - await setEnabled(false); - _channel.setMethodCallHandler(null); - status.dispose(); - } -} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index ef884754..bcc47361 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; -import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; import 'package:audio_session/audio_session.dart' show AudioSession, AudioSessionConfiguration, AudioInterruptionType; @@ -1648,7 +1647,6 @@ Future _doInitMusicPlayer() async { ), ); _handler = handler; - DiscordPresenceService.instance.bind(handler); if (Platform.isAndroid || Platform.isIOS) { PlayerWidgetService.instance.bind(handler); } diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 3b116c65..4a06fc15 100644 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -8,8 +8,6 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")" OUTPUT_DIR="$PROJECT_DIR/build/app/outputs/flutter-apk" cd "$PROJECT_DIR" -SPOTIFLAC_DISCORD_SDK_DIR="$(python3 scripts/prepare_discord_sdk.py)" -export SPOTIFLAC_DISCORD_SDK_DIR BUILD_GIT_COMMIT="$(git rev-parse --short=8 HEAD)" flutter build apk \ --release \ @@ -30,7 +28,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" --require-discord + --backend rust --abis "$abis" done echo "Built Android release APKs in $OUTPUT_DIR" diff --git a/scripts/check_backend_apk.py b/scripts/check_backend_apk.py index ee506285..dbee99c9 100644 --- a/scripts/check_backend_apk.py +++ b/scripts/check_backend_apk.py @@ -15,11 +15,7 @@ ABI_LAYOUT = { "armeabi-v7a": (1, 40), } CORE_LIBRARIES = ("libapp.so", "libflutter.so") -DISCORD_LIBRARIES = ("libspotiflac_discord.so", "libdiscord_partner_sdk.so") -DISCORD_CLASSES = ( - b"Lcom/zarz/spotiflac/discord/DiscordNative;", - b"Lcom/discord/socialsdk/DiscordSocialSdkInit;", -) +REMOVED_LIBRARIES = ("libspotiflac_discord.so", "libdiscord_partner_sdk.so") class AuditError(Exception): @@ -111,21 +107,7 @@ def check_backend_markers( raise AuditError("Go APK contains forbidden libspotiflac_mobile.so") -def check_discord(zf: zipfile.ZipFile, infos: Sequence[zipfile.ZipInfo]) -> None: - notices = required_entry(infos, "assets/discord-sdk-notices.txt") - if not zf.read(notices).strip(): - raise AuditError("Discord SDK notices are empty") - missing = set(DISCORD_CLASSES) - for info in infos: - if info.filename.endswith(".dex"): - data = zf.read(info) - missing = {marker for marker in missing if marker not in data} - if missing: - raise AuditError("Discord JNI/SDK classes missing from DEX: " + - ", ".join(sorted(marker.decode() for marker in missing))) - - -def audit(path: Path, backend: str, abis: Sequence[str], require_discord: bool = False) -> str: +def audit(path: Path, backend: str, abis: Sequence[str]) -> str: if not path.is_file(): raise AuditError("APK is not a regular file: " + str(path)) digest = artifact_sha256(path) @@ -133,6 +115,9 @@ def audit(path: Path, backend: str, abis: Sequence[str], require_discord: bool = with zipfile.ZipFile(path, "r") as zf: 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 "assets/flutter_assets/kernel_blob.bin" in names: raise AuditError("debug APK contains assets/flutter_assets/kernel_blob.bin") check_abi_directories(names, abis) @@ -143,9 +128,6 @@ def audit(path: Path, backend: str, abis: Sequence[str], require_discord: bool = libraries = CORE_LIBRARIES + (backend_library,) if backend == "rust": libraries += ("libjnidispatch.so",) - if require_discord: - check_discord(zf, infos) - libraries += DISCORD_LIBRARIES for abi in abis: for library in libraries: entry_path = f"lib/{abi}/{library}" @@ -162,8 +144,6 @@ 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("--require-discord", action="store_true", - help="require Discord JNI/SDK libraries, classes and notices") return parser @@ -171,12 +151,11 @@ 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, args.require_discord) + digest = audit(args.apk, args.backend, abis) except (AuditError, OSError) as exc: print("error: " + str(exc), file=sys.stderr) return 1 - print(f"OK sha256={digest} backend={args.backend} abis={','.join(abis)} " - f"discord={'required' if args.require_discord else 'optional'}") + print(f"OK sha256={digest} backend={args.backend} abis={','.join(abis)}") return 0 diff --git a/scripts/prepare_discord_sdk.py b/scripts/prepare_discord_sdk.py deleted file mode 100644 index 11173693..00000000 --- a/scripts/prepare_discord_sdk.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -"""Stage the pinned official Discord Android SDK for local builds and CI.""" - -import argparse -import hashlib -import os -import shutil -import subprocess -import sys -import tempfile -import zipfile -from pathlib import Path -from typing import Optional - - -VERSION = "1.10.19337" -PROJECT_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_OUTPUT = PROJECT_ROOT / ".dart_tool" / "discord-sdk" / VERSION -ENCRYPTED_ARCHIVE = PROJECT_ROOT / "third_party" / "discord" / f"discord-android-{VERSION}.zip.gpg" -FILES = { - "lib/release/discord_partner_sdk.aar": - "b1b2491f1e1848c79fd6f1986d5aa1e0c8019e88a6e62c7be06b89e8e4870933", - "License-Notices.txt": - "e8afa66340c225431e69768543cc34a7240f3494a9d759189fe118620ea8eebf", -} - - -class SdkError(Exception): - pass - - -def verify(directory: Path) -> None: - for relative, expected in FILES.items(): - path = directory / relative - if not path.is_file(): - raise SdkError("Missing Discord SDK file: " + relative) - if hashlib.sha256(path.read_bytes()).hexdigest() != expected: - raise SdkError(f"Discord SDK {VERSION} checksum mismatch: {relative}") - - -def unpack(archive: Path, destination: Path) -> None: - with zipfile.ZipFile(archive) as zf: - for relative in FILES: - matches = [info for info in zf.infolist() if not info.is_dir() and - (info.filename == relative or info.filename.endswith("/" + relative))] - if len(matches) != 1: - raise SdkError("SDK archive must contain exactly one " + relative) - path = destination / relative - path.parent.mkdir(parents=True, exist_ok=True) - # Only these fixed Android destinations are written. Never extract - # arbitrary ZIP paths, desktop binaries, or voice-model assets. - with zf.open(matches[0]) as source, path.open("wb") as target: - shutil.copyfileobj(source, target) - - -def prepare(output: Path, source_dir: Optional[Path] = None, - archive: Optional[Path] = None, passphrase: Optional[str] = None) -> Path: - output = output.expanduser().resolve() - if source_dir is None and archive is None and output.is_dir(): - verify(output) - return output - if source_dir is None and archive is None and not passphrase: - raise SdkError( - f"Discord SDK {VERSION} is required. Set SPOTIFLAC_DISCORD_SDK_DIR " - "to the extracted official SDK, or provide DISCORD_SDK_PASSPHRASE " - "in CI. See DISCORD.md." - ) - output.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="discord-sdk-", dir=output.parent) as temporary: - staging = Path(temporary) / "sdk" - if source_dir is not None: - source_dir = source_dir.expanduser().resolve() - verify(source_dir) - for relative in FILES: - target = staging / relative - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source_dir / relative, target) - else: - if archive is None: - archive = Path(temporary) / "sdk.zip" - try: - result = subprocess.run( - ["gpg", "--batch", "--quiet", "--pinentry-mode", "loopback", - "--passphrase-fd", "0", "--output", str(archive), - "--decrypt", str(ENCRYPTED_ARCHIVE)], - input=passphrase.encode(), stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, check=False, - ) - except FileNotFoundError as exc: - raise SdkError("Install GnuPG to decrypt the bundled Discord SDK") from exc - if result.returncode != 0: - raise SdkError("SDK decryption failed; check DISCORD_SDK_PASSPHRASE") - unpack(archive, staging) - verify(staging) - for relative in FILES: - target = output / relative - target.parent.mkdir(parents=True, exist_ok=True) - (staging / relative).replace(target) - verify(output) - return output - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--source-dir", type=Path, - default=os.environ.get("SPOTIFLAC_DISCORD_SDK_DIR")) - parser.add_argument("--archive", type=Path, help="official SDK ZIP for offline setup") - args = parser.parse_args() - try: - output = prepare(args.output, args.source_dir, args.archive, - os.environ.get("DISCORD_SDK_PASSPHRASE")) - except (SdkError, OSError, zipfile.BadZipFile) as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - # stdout is just the path so build scripts can export it without eval. - print(output) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test_check_backend_apk.py b/scripts/test_check_backend_apk.py index a6c5705c..47bf3e84 100644 --- a/scripts/test_check_backend_apk.py +++ b/scripts/test_check_backend_apk.py @@ -1,4 +1,4 @@ -"""Reject APKs that silently lose the optional native Discord integration.""" +"""Verify native backend payloads and reject removed SDKs in release APKs.""" import struct import tempfile @@ -9,43 +9,40 @@ from pathlib import Path import check_backend_apk as checker -class DiscordApkAuditTest(unittest.TestCase): +class BackendApkAuditTest(unittest.TestCase): def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.apk = Path(directory.name) / "release.apk" - self.entries = { - "assets/discord-sdk-notices.txt": b"SDK license notices", - "classes.dex": b"\0".join(checker.DISCORD_CLASSES), - } + self.entries = {"classes.dex": b"backend classes"} for abi, (elf_class, machine) in checker.ABI_LAYOUT.items(): header = bytearray(20) header[:6] = b"\x7fELF" + bytes([elf_class, 1]) struct.pack_into(" calls; - - Future settle() async { - for (var i = 0; i < 5; i++) { - await Future.delayed(Duration.zero); - } - } - - setUp(() { - calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'configure' - ? ((call.arguments as Map)['enabled'] == true - ? 'ready' - : 'disabled') - : null; - }); - player = BaseAudioHandler(); - service = DiscordPresenceService(channel: channel)..bind(player); - }); - - tearDown(() async { - await service.dispose(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - void play({String title = 'Track 🎵'}) { - player.mediaItem.add( - MediaItem( - id: 'local-track', - title: title, - artist: 'Lead & Guest', - album: 'Album', - duration: const Duration(minutes: 3), - artUri: Uri.parse('file:///private/cover.jpg'), - ), - ); - player.playbackState.add( - PlaybackState(playing: true, processingState: AudioProcessingState.ready), - ); - } - - test('opt-in persists and defaults to off', () { - expect(AppSettings.fromJson({}).discordRichPresence, isFalse); - final settings = const AppSettings().copyWith(discordRichPresence: true); - expect(AppSettings.fromJson(settings.toJson()).discordRichPresence, isTrue); - }); - - test('publishes playback only after opt-in and clears on pause', () async { - play(); - await settle(); - expect(calls, isEmpty); - await service.setEnabled(true); - await settle(); - final update = calls.singleWhere((c) => c.method == 'update'); - final data = update.arguments as Map; - expect(data['title'], 'Track 🎵'); - expect(data['artist'], 'Lead & Guest'); - expect(data['cover'], ''); - expect((data['end'] as int) - (data['start'] as int), 180000); - player.playbackState.add( - PlaybackState( - playing: false, - processingState: AudioProcessingState.ready, - ), - ); - await settle(); - expect(calls.last.method, 'clear'); - await service.setEnabled(false); - final count = calls.length; - play(title: 'Next'); - await settle(); - expect(calls.length, count); - }); - - test('late configure completion cannot republish after disabling', () async { - final ready = Completer(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return (call.arguments as Map)['enabled'] == true - ? ready.future - : 'disabled'; - }); - play(); - final enabling = service.setEnabled(true); - await settle(); - final disabling = service.setEnabled(false); - ready.complete('ready'); - await Future.wait([enabling, disabling]); - await settle(); - expect(calls.map((c) => c.method), ['configure', 'configure']); - expect(service.status.value, 'disabled'); - }); - - test('position ticks are deduplicated but seek republishes', () async { - play(); - await service.setEnabled(true); - await settle(); - final initial = calls.where((c) => c.method == 'update').length; - for (var i = 0; i < 8; i++) { - player.playbackState.add( - PlaybackState( - playing: true, - processingState: AudioProcessingState.ready, - ), - ); - } - await settle(); - expect(calls.where((c) => c.method == 'update').length, initial); - player.playbackState.add( - PlaybackState( - playing: true, - processingState: AudioProcessingState.ready, - updatePosition: const Duration(seconds: 30), - ), - ); - await settle(); - expect(calls.where((c) => c.method == 'update').length, initial + 1); - }); - - test('missing Discord does not publish or break playback', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return 'discord_missing'; - }); - play(); - await service.setEnabled(true); - await settle(); - expect(service.status.value, 'discord_missing'); - expect(calls.map((c) => c.method), ['configure']); - expect(player.playbackState.value.playing, isTrue); - }); -} diff --git a/third_party/discord/discord-android-1.10.19337.zip.gpg b/third_party/discord/discord-android-1.10.19337.zip.gpg deleted file mode 100644 index bce141fe..00000000 Binary files a/third_party/discord/discord-android-1.10.19337.zip.gpg and /dev/null differ