fix(android): bundle Discord SDK in local and CI releases

This commit is contained in:
zarzet
2026-09-26 02:02:49 +07:00
parent 8e7e71827c
commit 64f76293ec
13 changed files with 545 additions and 17 deletions
+17
View File
@@ -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"
+31 -5
View File
@@ -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 \
+18 -8
View File
@@ -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"
+4
View File
@@ -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/
+5
View File
@@ -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
+77
View File
@@ -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.
+10
View File
@@ -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<Copy>("copyDiscordNotices") {
from(file("$discordSdkDir/License-Notices.txt"))
@@ -173,6 +176,13 @@ val buildRustBackend = tasks.register<Exec>("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 = "../.."
+11 -1
View File
@@ -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"
+28 -3
View File
@@ -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
+122
View File
@@ -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())
+90
View File
@@ -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("<H", header, 18, machine)
for library in checker.CORE_LIBRARIES + checker.DISCORD_LIBRARIES + (
"libspotiflac_mobile.so", "libjnidispatch.so",
):
self.entries[f"lib/{abi}/{library}"] = bytes(header)
def audit(self, abis=("arm64-v8a", "armeabi-v7a"), require_discord=True):
with zipfile.ZipFile(self.apk, "w") as zf:
for name, data in self.entries.items():
zf.writestr(name, data)
return checker.audit(self.apk, "rust", abis, require_discord)
def test_universal_with_discord_passes(self):
self.assertEqual(len(self.audit()), 64)
def test_split_apks_require_discord_for_their_abi(self):
original = self.entries.copy()
for abi in checker.ABI_LAYOUT:
self.entries = {name: data for name, data in original.items()
if not name.startswith("lib/") or name.startswith(f"lib/{abi}/")}
self.assertEqual(len(self.audit((abi,))), 64)
def test_either_discord_library_missing_in_either_abi_fails(self):
for abi in checker.ABI_LAYOUT:
for library in checker.DISCORD_LIBRARIES:
with self.subTest(abi=abi, library=library):
path = f"lib/{abi}/{library}"
data = self.entries.pop(path)
with self.assertRaisesRegex(checker.AuditError, "missing APK entry"):
self.audit()
self.entries[path] = data
def test_wrong_architecture_fails(self):
self.entries["lib/armeabi-v7a/libdiscord_partner_sdk.so"] = self.entries[
"lib/arm64-v8a/libdiscord_partner_sdk.so"
]
with self.assertRaisesRegex(checker.AuditError, "ELF class"):
self.audit()
def test_missing_or_empty_notices_fail(self):
self.entries.pop("assets/discord-sdk-notices.txt")
with self.assertRaisesRegex(checker.AuditError, "missing APK entry"):
self.audit()
self.entries["assets/discord-sdk-notices.txt"] = b" "
with self.assertRaisesRegex(checker.AuditError, "notices are empty"):
self.audit()
def test_removed_or_renamed_jni_classes_fail(self):
for marker in checker.DISCORD_CLASSES:
self.entries["classes.dex"] = marker
with self.assertRaisesRegex(checker.AuditError, "classes missing"):
self.audit()
def test_multidex_classes_pass(self):
self.entries["classes.dex"] = checker.DISCORD_CLASSES[0]
self.entries["classes2.dex"] = checker.DISCORD_CLASSES[1]
self.assertEqual(len(self.audit()), 64)
def test_explicit_backend_only_audit_still_works(self):
self.entries = {name: data for name, data in self.entries.items()
if "discord" not in name}
self.entries["classes.dex"] = b"backend only"
self.assertEqual(len(self.audit(require_discord=False)), 64)
if __name__ == "__main__":
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
"""Exercise SDK staging without redistributing the vendor SDK in fixtures."""
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",
"License-Notices.txt": b"test license notices",
}
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=""):
with zipfile.ZipFile(self.archive, "w") as zf:
for name, data in self.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_full_and_android_only_archives(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_cached_local_build(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_not_trusted(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)
with zipfile.ZipFile(self.archive, "w") as zf:
for name in self.contents:
zf.writestr(name, b"wrong SDK version")
with self.assertRaisesRegex(sdk.SdkError, "checksum mismatch"):
sdk.prepare(self.output, archive=self.archive)
self.assert_prepared()
def test_duplicate_or_missing_required_file_fails(self):
for duplicate in (False, True):
self.make_archive()
if duplicate:
with zipfile.ZipFile(self.archive, "a") as zf:
zf.writestr("other/License-Notices.txt", b"duplicate")
else:
with zipfile.ZipFile(self.archive, "w") as zf:
zf.writestr("License-Notices.txt", b"only notices")
with self.assertRaisesRegex(sdk.SdkError, "exactly one"):
sdk.prepare(self.output, archive=self.archive)
self.assertFalse(self.output.exists())
def test_missing_configuration_is_actionable(self):
with self.assertRaisesRegex(sdk.SdkError, "DISCORD_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()
Binary file not shown.