mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-28 20:32:15 +02:00
fix(android): bundle Discord SDK in local and CI releases
This commit is contained in:
@@ -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"
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 = "../.."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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.
Reference in New Issue
Block a user