diff --git a/.github/actions/discord-sdk/action.yml b/.github/actions/discord-sdk/action.yml new file mode 100644 index 00000000..54651516 --- /dev/null +++ b/.github/actions/discord-sdk/action.yml @@ -0,0 +1,31 @@ +name: Prepare Discord Social SDK +description: Decrypt, verify and stage the pinned Android and iOS SDK. +inputs: + passphrase: + description: Decryption key supplied from an Actions repository secret. + required: true +runs: + using: composite + steps: + - name: Ensure GnuPG is available + shell: bash + run: | + if ! command -v gpg >/dev/null 2>&1; then + if [[ "$RUNNER_OS" == "macOS" ]]; then + brew install gnupg + else + echo "GnuPG is required to prepare the Discord SDK." >&2 + exit 1 + fi + fi + + - name: Decrypt, verify and stage SDK + shell: bash + env: + DISCORD_MOBILE_SDK_PASSPHRASE: ${{ inputs.passphrase }} + run: | + set -euo pipefail + umask 077 + sdk_dir="$(python3 scripts/prepare_discord_sdk.py)" + bash scripts/setup_discord_sdk.sh "$sdk_dir" + printf 'SPOTIFLAC_DISCORD_SDK=1\n' >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a84ebebd..8d510dc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: - 'third_party/audio_service/**' - 'third_party/spotiflac_discord/**' - 'scripts/setup_discord_sdk.sh' + - 'scripts/prepare_discord_sdk.py' + - 'scripts/test_prepare_discord_sdk.py' + - '.github/actions/discord-sdk/**' + - 'third_party/discord/*.zip.gpg' - 'ios/**' - 'scripts/build_ios.sh' - 'scripts/check_backend_ios.py' @@ -68,6 +72,10 @@ jobs: - 'third_party/audio_service/**' - 'third_party/spotiflac_discord/**' - 'scripts/setup_discord_sdk.sh' + - 'scripts/prepare_discord_sdk.py' + - 'scripts/test_prepare_discord_sdk.py' + - '.github/actions/discord-sdk/**' + - 'third_party/discord/*.zip.gpg' - 'android/**' - 'rust_backend/**' - 'scripts/build_android.sh' @@ -151,7 +159,7 @@ jobs: run: bash scripts/check_rust_backend.sh - name: Install Android NDK - run: $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;29.0.14206865"' - name: Build Android ARM32 and ARM64 libraries run: ANDROID_NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" bash scripts/build_rust_backend.sh android @@ -161,6 +169,8 @@ jobs: runs-on: macos-latest needs: changes if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.ios == 'true' + env: + SPOTIFLAC_DISCORD_SDK: "0" steps: - name: Checkout repository @@ -180,6 +190,12 @@ jobs: flutter-version-file: .fvmrc cache: true + - 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_MOBILE_SDK_PASSPHRASE }} + - name: Configure Rust application pods run: | flutter pub get @@ -190,6 +206,10 @@ jobs: run: | python3 -m unittest discover -s scripts -p 'test_check_backend_ios.py' flutter build ios --release --no-codesign --target lib/main.dart + if [[ "$SPOTIFLAC_DISCORD_SDK" == "1" ]]; then + test -f build/ios/iphoneos/Runner.app/Frameworks/discord_partner_sdk.framework/discord_partner_sdk + test -f build/ios/iphoneos/Runner.app/Discord-License-Notices.txt + fi python3 scripts/check_backend_ios.py build/ios/iphoneos/Runner.app \ --backend rust --archs arm64 --platform ios --release @@ -198,6 +218,8 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.android == 'true' + env: + SPOTIFLAC_DISCORD_SDK: "0" steps: - name: Checkout repository @@ -235,10 +257,19 @@ jobs: "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" - - name: Test APK audits + - name: Test SDK preparation, release selection and APK audits run: | + python3 -m unittest discover -s scripts -p 'test_prepare_discord_sdk.py' + python3 -m unittest discover -s scripts -p 'test_build_android.py' python3 -m unittest discover -s scripts -p 'test_check_backend_apk.py' + # Fork PRs cannot access repository secrets and exercise the Lite build. + - 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_MOBILE_SDK_PASSPHRASE }} + - name: Get Flutter dependencies run: flutter pub get @@ -258,16 +289,25 @@ jobs: run: gradle -p android :app:assembleDebug :app:testDebugUnitTest - name: Build Rust release application - run: bash scripts/build_android.sh --target lib/main.dart + run: | + if [[ "$SPOTIFLAC_DISCORD_SDK" == "1" ]]; then + bash scripts/build_android.sh --production --target lib/main.dart + else + bash scripts/build_android.sh --lite --target lib/main.dart + fi - name: Verify Rust release APK payloads run: | + audit_flags=() + if [[ "$SPOTIFLAC_DISCORD_SDK" == "1" ]]; then + audit_flags+=(--discord-sdk) + fi 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 "${audit_flags[@]}" 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 "${audit_flags[@]}" # 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 84846387..f9f1e0ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,11 @@ jobs: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Prepare pinned Discord SDK + uses: ./.github/actions/discord-sdk + with: + passphrase: ${{ secrets.DISCORD_MOBILE_SDK_PASSPHRASE }} + - name: Setup Java uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: @@ -240,6 +245,11 @@ jobs: - name: Checkout repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Prepare pinned Discord SDK + uses: ./.github/actions/discord-sdk + with: + passphrase: ${{ secrets.DISCORD_MOBILE_SDK_PASSPHRASE }} + - name: Select Xcode 26.1.1 run: | sudo xcode-select -s /Applications/Xcode_26.1.1.app @@ -334,7 +344,7 @@ jobs: mkdir Payload cp -r Runner.app Payload/ # Use absolute path to avoid relative path issues - zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload + zip -r "$GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" Payload rm -rf Payload - name: Verify IPA created @@ -517,8 +527,12 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add apps.json - git diff --cached --quiet && echo "No changes to commit" || \ - (git commit -m "chore: update AltStore source to ${VERSION}" && git push) + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "chore: update AltStore source to ${VERSION}" + git push + fi notify-telegram: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index eb6503b5..c49e093b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,8 +53,9 @@ AGENTS.md /android/*.keystore /android/app/*.jks -# Keep obsolete local SDK copies out of the checkout. +# Only the pinned encrypted CI payload belongs in the checkout. /third_party/discord/* +!/third_party/discord/discord-mobile-1.10.19337.zip.gpg # iOS generated state /ios/build/ diff --git a/scripts/prepare_discord_sdk.py b/scripts/prepare_discord_sdk.py new file mode 100644 index 00000000..33593fa1 --- /dev/null +++ b/scripts/prepare_discord_sdk.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Verify and prepare the pinned official Discord mobile SDK for 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-social-sdk" / VERSION +ENCRYPTED_ARCHIVE = PROJECT_ROOT / "third_party" / "discord" / f"discord-mobile-{VERSION}.zip.gpg" +XCFRAMEWORK = "lib/release/discord_partner_sdk.xcframework" +FILES = { + "License-Notices.txt": + "e8afa66340c225431e69768543cc34a7240f3494a9d759189fe118620ea8eebf", + "include/discordpp.h": + "822e71509bf11b7155becca5fb00d215a7db7b305f357c0f3b5f6e27ff45b6ab", + "lib/release/discord_partner_sdk.aar": + "b1b2491f1e1848c79fd6f1986d5aa1e0c8019e88a6e62c7be06b89e8e4870933", + f"{XCFRAMEWORK}/Info.plist": + "bb227b40a5adb4e4a02447b46a91b7a0fe28c9d238fbb80e19b7215c7ae3f431", +} +for slice_name, plist, binary in ( + ("ios-arm64", + "41bc76fed4b9d3adb7d711e5b44848b87f775598ed24173abfd1f4a6a0656a90", + "8d86063b1a6c5c562b822378d34f55d335b37358e20f3425d446cc335ffae8bd"), + ("ios-arm64-simulator", + "a3e162d5587897f9435e7897dbcde4d5b32c6dd445a0c8ff897157f5f340e4a1", + "eddc98dcb0e5614d64bef4c17476e5bb1ad53b3a7e8d6d8fc550b3abfc067f98"), +): + framework = f"{XCFRAMEWORK}/{slice_name}/discord_partner_sdk.framework" + FILES.update({ + f"{framework}/Info.plist": plist, + f"{framework}/discord_partner_sdk": binary, + f"{framework}/Headers/cdiscord.h": + "86ef9280ab8432493952bb974bd18c5a25d7725011393b5dba05162bcfdb3371", + f"{framework}/Headers/discord_partner_sdk.h": + "b049099dab197146011b3ceecc823ec25fdbbda7a37ea1584be8e472501a390b", + f"{framework}/Headers/discordpp.h": FILES["include/discordpp.h"], + f"{framework}/Modules/module.modulemap": + "48879037011d015ad2c9802fdc478862785ba073e7f96dfdf6c0865b6075a542", + }) +MAX_FILE_BYTES = 64 * 1024 * 1024 + + +class SdkError(Exception): + pass + + +def verify(directory: Path) -> None: + for relative, expected in FILES.items(): + path = directory / relative + if not path.is_file() or path.is_symlink(): + raise SdkError("Missing or invalid Discord SDK file: " + relative) + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + if digest.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) + if matches[0].file_size > MAX_FILE_BYTES: + raise SdkError("SDK archive entry exceeds size limit: " + relative) + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + # Write only pinned destinations, never paths supplied by the ZIP. + with zf.open(matches[0]) as source, path.open("wb") as target: + shutil.copyfileobj(source, target) + if relative.endswith(".framework/discord_partner_sdk"): + path.chmod(0o755) + + +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. Supply --source-dir, --archive, " + "or the Actions secret DISCORD_MOBILE_SDK_PASSPHRASE." + ) + 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.copy2(source_dir / relative, target) + else: + if archive is None: + archive = Path(temporary) / "sdk.zip" + try: + result = subprocess.run( + ["gpg", "--batch", "--quiet", "--no-symkey-cache", + "--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 Discord SDK") from exc + if result.returncode != 0: + raise SdkError("SDK decryption failed; check DISCORD_MOBILE_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) + 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_MOBILE_SDK_PASSPHRASE")) + except (SdkError, OSError, zipfile.BadZipFile) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + # stdout contains only the verified directory, suitable for setup_discord_sdk.sh. + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_prepare_discord_sdk.py b/scripts/test_prepare_discord_sdk.py new file mode 100644 index 00000000..3a886c23 --- /dev/null +++ b/scripts/test_prepare_discord_sdk.py @@ -0,0 +1,147 @@ +"""Check SDK integrity, bounded extraction and encrypted CI preparation.""" + +import hashlib +import shutil +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +import prepare_discord_sdk as sdk + + +class PrepareDiscordSdkTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name).resolve() + self.output = self.root / "prepared" + self.archive = self.root / "sdk.zip" + self.contents = { + "lib/release/discord_partner_sdk.aar": b"test Android library", + f"{sdk.XCFRAMEWORK}/ios-arm64/discord_partner_sdk.framework/discord_partner_sdk": + b"test iOS device library", + f"{sdk.XCFRAMEWORK}/ios-arm64-simulator/discord_partner_sdk.framework/discord_partner_sdk": + b"test iOS simulator library", + "License-Notices.txt": b"test license notices", + "include/discordpp.h": b"test C++ header", + } + checksums = {name: hashlib.sha256(data).hexdigest() + for name, data in self.contents.items()} + patch = mock.patch.object(sdk, "FILES", checksums) + patch.start() + self.addCleanup(patch.stop) + + def make_archive(self, prefix="", contents=None): + with zipfile.ZipFile(self.archive, "w") as zf: + for name, data in (self.contents if contents is None else contents).items(): + zf.writestr(prefix + name, data) + zf.writestr("../../outside.txt", b"must not be extracted") + zf.writestr("lib/release/desktop.dll", b"unused") + + def assert_prepared(self): + files = {str(path.relative_to(self.output)): path.read_bytes() + for path in self.output.rglob("*") if path.is_file()} + self.assertEqual(files, self.contents) + + def test_root_and_prefixed_archives_extract_only_mobile_files(self): + for prefix in ("", "discord_social_sdk/"): + with self.subTest(prefix=prefix): + self.make_archive(prefix) + sdk.prepare(self.output, archive=self.archive) + self.assert_prepared() + self.assertFalse((self.root / "outside.txt").exists()) + + def test_source_directory_and_verified_cache(self): + source = self.root / "download" + for name, data in self.contents.items(): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + sdk.prepare(self.output, source_dir=source) + shutil.rmtree(source) + self.assertEqual(sdk.prepare(self.output), self.output) + self.assert_prepared() + + def test_corrupt_cache_is_rejected(self): + self.make_archive() + sdk.prepare(self.output, archive=self.archive) + (self.output / "License-Notices.txt").write_bytes(b"tampered") + with self.assertRaisesRegex(sdk.SdkError, "checksum mismatch"): + sdk.prepare(self.output) + + def test_bad_archive_does_not_replace_verified_sdk(self): + self.make_archive() + sdk.prepare(self.output, archive=self.archive) + contents = self.contents.copy() + contents["include/discordpp.h"] = b"wrong SDK version" + self.make_archive(contents=contents) + with self.assertRaisesRegex(sdk.SdkError, "checksum mismatch"): + sdk.prepare(self.output, archive=self.archive) + self.assert_prepared() + + def test_each_missing_mobile_component_is_rejected(self): + for name in self.contents: + with self.subTest(name=name): + self.make_archive(contents={key: value for key, value in self.contents.items() + if key != name}) + with self.assertRaisesRegex(sdk.SdkError, "exactly one"): + sdk.prepare(self.output, archive=self.archive) + self.assertFalse(self.output.exists()) + + def test_duplicate_file_is_rejected(self): + self.make_archive() + with zipfile.ZipFile(self.archive, "a") as zf: + zf.writestr("other/License-Notices.txt", b"duplicate") + with self.assertRaisesRegex(sdk.SdkError, "exactly one"): + sdk.prepare(self.output, archive=self.archive) + + def test_oversized_file_is_rejected_before_writing_output(self): + self.make_archive() + with mock.patch.object(sdk, "MAX_FILE_BYTES", 1): + with self.assertRaisesRegex(sdk.SdkError, "size limit"): + sdk.prepare(self.output, archive=self.archive) + self.assertFalse(self.output.exists()) + + def test_missing_configuration_names_the_required_secret(self): + with self.assertRaisesRegex(sdk.SdkError, "DISCORD_MOBILE_SDK_PASSPHRASE"): + sdk.prepare(self.output) + + def test_decryption_failure_does_not_echo_key(self): + key = "private-test-passphrase" + with mock.patch.object(sdk.subprocess, "run", return_value=mock.Mock(returncode=2)): + with self.assertRaises(sdk.SdkError) as error: + sdk.prepare(self.output, passphrase=key) + self.assertNotIn(key, str(error.exception)) + self.assertFalse(self.output.exists()) + + def test_missing_gpg_is_actionable(self): + with mock.patch.object(sdk.subprocess, "run", side_effect=FileNotFoundError): + with self.assertRaisesRegex(sdk.SdkError, "Install GnuPG"): + sdk.prepare(self.output, passphrase="test") + + @unittest.skipUnless(shutil.which("gpg"), "GnuPG is required for encrypted archive test") + def test_encrypted_archive_roundtrip_and_wrong_key(self): + self.make_archive() + encrypted = self.root / "sdk.zip.gpg" + key = "test-fixture-passphrase" + gnupg_home = self.root / "gnupg" + gnupg_home.mkdir(mode=0o700) + with mock.patch.dict(sdk.os.environ, {"GNUPGHOME": str(gnupg_home)}): + subprocess.run( + ["gpg", "--batch", "--quiet", "--pinentry-mode", "loopback", + "--passphrase-fd", "0", "--cipher-algo", "AES256", "--symmetric", + "--output", str(encrypted), str(self.archive)], + input=key.encode(), check=True, capture_output=True, + ) + with mock.patch.object(sdk, "ENCRYPTED_ARCHIVE", encrypted): + with self.assertRaisesRegex(sdk.SdkError, "decryption failed"): + sdk.prepare(self.output, passphrase="incorrect") + sdk.prepare(self.output, passphrase=key) + self.assert_prepared() + + +if __name__ == "__main__": + unittest.main() diff --git a/third_party/discord/discord-mobile-1.10.19337.zip.gpg b/third_party/discord/discord-mobile-1.10.19337.zip.gpg new file mode 100644 index 00000000..906afe52 Binary files /dev/null and b/third_party/discord/discord-mobile-1.10.19337.zip.gpg differ diff --git a/third_party/spotiflac_discord/README.md b/third_party/spotiflac_discord/README.md index c0346125..6edba6b0 100644 --- a/third_party/spotiflac_discord/README.md +++ b/third_party/spotiflac_discord/README.md @@ -14,10 +14,19 @@ bash scripts/setup_discord_sdk.sh /path/to/discord_social_sdk flutter pub get ``` -On CI, provide the official SDK through the build environment and run the same -script before building. Vendor binaries and their license notices stay under -the ignored `sdk/` and `ios/Frameworks/` directories; do not commit developer credentials or rehost SDK -downloads. Distributions containing the SDK must include its license notices +CI prepares the pinned mobile SDK with `.github/actions/discord-sdk` before +building. Its AES-256 encrypted payload contains only the Android AAR, iOS +XCFramework, C++ header and license notices from the official download. Set the +Actions repository secret `DISCORD_MOBILE_SDK_PASSPHRASE` to the payload's key. +The helper verifies SHA-256 checksums for all 16 required files before staging +them. A missing key, failed decryption or changed SDK stops the build. + +Decrypted vendor binaries and notices stay under the ignored `.dart_tool/`, +`sdk/` and `ios/Frameworks/` directories. Do not commit the encryption key, +developer credentials or unencrypted SDK downloads. This uses GitHub's +[large-secret storage pattern](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#storing-large-secrets); +the portal's signed download link expires after one hour and is unsuitable for +release automation. Distributions containing the SDK must include its license notices and follow Discord's SDK terms. The setup script stages notices for inclusion in Android assets and iOS resources. Without these files the app still builds, but the presence setting reports that the SDK is unavailable. A clean clone alone @@ -31,8 +40,9 @@ as unavailable; the staged SDK files are retained for subsequent full builds. Production Android builds use `bash scripts/build_android.sh --production`, which forces Discord on, checks the staged SDK and audits it in every APK ABI. `--production --lite` is rejected. Release CI requires the SDK on both platforms -and stops before publication if the SDK is absent. Provision the official SDK -in the build environment before running that workflow. +and stops before publication if the SDK is absent. CI on trusted branches also +builds with Discord; fork PRs run native/debug checks and explicit Lite builds +without receiving the decryption key. Android uses the installed Discord app's signed-in session (Social SDK 1.10+). The AAR and CMake use the same staged native library; the host packages one copy