diff --git a/.github/actions/discord-sdk/action.yml b/.github/actions/discord-sdk/action.yml new file mode 100644 index 00000000..dbd675a1 --- /dev/null +++ b/.github/actions/discord-sdk/action.yml @@ -0,0 +1,17 @@ +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 3b091fe2..77e43d1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,11 @@ jobs: - 'scripts/build_android.sh' - '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' @@ -211,16 +216,35 @@ jobs: uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5 with: gradle-version: "9.7.1" + # Cache public dependencies, not transforms of the decrypted SDK. + gradle-home-cache-includes: | + caches/modules-2 + notifications - name: Install Android SDK & NDK run: | - yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true - $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ "ndk;29.0.14206865" \ "platforms;android-37.0" \ - "build-tools;37.0.0" + "build-tools;37.0.0" \ + "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + - name: Test Discord SDK preparation and 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 @@ -240,16 +264,18 @@ 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 + --backend rust --abis arm64-v8a --require-discord python3 scripts/check_backend_apk.py \ build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk \ - --backend rust --abis armeabi-v7a + --backend rust --abis armeabi-v7a --require-discord # 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 6597e4d1..c1e98f06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,10 +88,11 @@ jobs: uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | - ~/.gradle/caches + ~/.gradle/caches/modules-2 ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: gradle-${{ runner.os }}- + # Do not publish transforms of the decrypted SDK in shared caches. + key: gradle-public-dependencies-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-public-dependencies-${{ runner.os }}- - name: Cache Android NDK uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 @@ -106,14 +107,23 @@ jobs: echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" # Accept licenses - yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true # Install NDK r29 (supports 16KB page size for Android 15+) # Keep the installed platform aligned with compileSdk/targetSdk. - $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37.0" "build-tools;37.0.0" + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ + "ndk;29.0.14206865" \ + "platforms;android-37.0" \ + "build-tools;37.0.0" \ + "cmake;3.22.1" # Set NDK path - echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV + 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 @@ -199,9 +209,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 + --backend rust --abis arm64-v8a --require-discord python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm32.apk" \ - --backend rust --abis armeabi-v7a + --backend rust --abis armeabi-v7a --require-discord 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 d9e68432..c2f22e7a 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,10 @@ AGENTS.md /android/*.keystore /android/app/*.jks +# Only the encrypted Discord SDK archive belongs in the checkout. +/third_party/discord/* +!/third_party/discord/*.zip.gpg + # iOS generated state /ios/build/ /ios/Frameworks/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b708158..e64f7cff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,11 @@ compile time. The Android build script and iOS release workflow supply it automatically. Include the same `--dart-define` when running Flutter build commands directly; without it, the footer shows only the copyright. +Android release builds also require the pinned Discord Social SDK. See +[Discord build setup](DISCORD.md) for local staging and the CI decryption secret. +`bash scripts/build_android.sh` verifies that every release APK contains the +native integration; debug builds can run without the SDK. + ## Project Boundaries ```text diff --git a/DISCORD.md b/DISCORD.md new file mode 100644 index 00000000..f91307b8 --- /dev/null +++ b/DISCORD.md @@ -0,0 +1,77 @@ +# 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 20da8ce3..da5066ab 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,10 +15,13 @@ 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")) @@ -173,6 +176,13 @@ val buildRustBackend = tasks.register("buildRustBackend") { } 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 = "../.." diff --git a/scripts/build_android.sh b/scripts/build_android.sh index ed4f74e3..3b116c65 100644 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -8,6 +8,8 @@ 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 \ @@ -16,11 +18,19 @@ flutter build apk \ --dart-define="GIT_COMMIT=$BUILD_GIT_COMMIT" \ "$@" -for apk in app-armeabi-v7a-release.apk app-arm64-v8a-release.apk; do +for target in armeabi-v7a arm64-v8a universal; do + apk="app-$target-release.apk" + abis="$target" + if [[ "$target" == "universal" ]]; then + apk=app-release.apk + abis=armeabi-v7a,arm64-v8a + fi if [[ ! -f "$OUTPUT_DIR/$apk" ]]; then echo "Error: expected APK was not created: $OUTPUT_DIR/$apk" >&2 exit 1 fi + python3 scripts/check_backend_apk.py "$OUTPUT_DIR/$apk" \ + --backend rust --abis "$abis" --require-discord done echo "Built Android release APKs in $OUTPUT_DIR" diff --git a/scripts/check_backend_apk.py b/scripts/check_backend_apk.py index 181888ac..ee506285 100644 --- a/scripts/check_backend_apk.py +++ b/scripts/check_backend_apk.py @@ -15,6 +15,11 @@ 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;", +) class AuditError(Exception): @@ -106,7 +111,21 @@ def check_backend_markers( raise AuditError("Go APK contains forbidden libspotiflac_mobile.so") -def audit(path: Path, backend: str, abis: Sequence[str]) -> str: +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: if not path.is_file(): raise AuditError("APK is not a regular file: " + str(path)) digest = artifact_sha256(path) @@ -124,6 +143,9 @@ def audit(path: Path, backend: str, abis: Sequence[str]) -> str: 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}" @@ -140,6 +162,8 @@ 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 @@ -147,11 +171,12 @@ 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) + digest = audit(args.apk, args.backend, abis, args.require_discord) 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)}") + print(f"OK sha256={digest} backend={args.backend} abis={','.join(abis)} " + f"discord={'required' if args.require_discord else 'optional'}") return 0 diff --git a/scripts/prepare_discord_sdk.py b/scripts/prepare_discord_sdk.py new file mode 100644 index 00000000..11173693 --- /dev/null +++ b/scripts/prepare_discord_sdk.py @@ -0,0 +1,122 @@ +#!/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 new file mode 100644 index 00000000..a6c5705c --- /dev/null +++ b/scripts/test_check_backend_apk.py @@ -0,0 +1,90 @@ +"""Reject APKs that silently lose the optional native Discord integration.""" + +import struct +import tempfile +import unittest +import zipfile +from pathlib import Path + +import check_backend_apk as checker + + +class DiscordApkAuditTest(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), + } + for abi, (elf_class, machine) in checker.ABI_LAYOUT.items(): + header = bytearray(20) + header[:6] = b"\x7fELF" + bytes([elf_class, 1]) + struct.pack_into("