mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
fix(android): bundle Discord SDK in local and CI releases
This commit is contained in:
1 parent
8e7e71827c
commit
64f76293ec
13 files changed
+545
-17
No files matched your search
@@ -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_android.sh'
|
||||||
- 'scripts/build_rust_backend.sh'
|
- 'scripts/build_rust_backend.sh'
|
||||||
- 'scripts/check_backend_apk.py'
|
- '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/**'
|
- 'lib/**'
|
||||||
- 'assets/**'
|
- 'assets/**'
|
||||||
- 'pubspec.yaml'
|
- 'pubspec.yaml'
|
||||||
@@ -211,16 +216,35 @@ jobs:
|
|||||||
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
|
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
|
||||||
with:
|
with:
|
||||||
gradle-version: "9.7.1"
|
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
|
- name: Install Android SDK & NDK
|
||||||
run: |
|
run: |
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
"ndk;29.0.14206865" \
|
"ndk;29.0.14206865" \
|
||||||
"platforms;android-37.0" \
|
"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"
|
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
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -240,16 +264,18 @@ jobs:
|
|||||||
run: gradle -p android :app:assembleDebug :app:testDebugUnitTest
|
run: gradle -p android :app:assembleDebug :app:testDebugUnitTest
|
||||||
|
|
||||||
- name: Build Rust release application
|
- name: Build Rust release application
|
||||||
|
if: env.SPOTIFLAC_DISCORD_SDK_DIR != ''
|
||||||
run: bash scripts/build_android.sh --target lib/main.dart
|
run: bash scripts/build_android.sh --target lib/main.dart
|
||||||
|
|
||||||
- name: Verify Rust release APK payloads
|
- name: Verify Rust release APK payloads
|
||||||
|
if: env.SPOTIFLAC_DISCORD_SDK_DIR != ''
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/check_backend_apk.py \
|
python3 scripts/check_backend_apk.py \
|
||||||
build/app/outputs/flutter-apk/app-arm64-v8a-release.apk \
|
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 \
|
python3 scripts/check_backend_apk.py \
|
||||||
build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk \
|
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.
|
# PR builds use Gradle's test key; production signing stays in Release.
|
||||||
for abi in arm64-v8a armeabi-v7a; do
|
for abi in arm64-v8a armeabi-v7a; do
|
||||||
"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --verbose \
|
"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --verbose \
|
||||||
|
|||||||
@@ -88,10 +88,11 @@ jobs:
|
|||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches/modules-2
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
# Do not publish transforms of the decrypted SDK in shared caches.
|
||||||
restore-keys: gradle-${{ runner.os }}-
|
key: gradle-public-dependencies-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: gradle-public-dependencies-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Cache Android NDK
|
- name: Cache Android NDK
|
||||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
|
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
|
||||||
@@ -106,14 +107,23 @@ jobs:
|
|||||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
|
||||||
# Accept licenses
|
# 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+)
|
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||||
# Keep the installed platform aligned with compileSdk/targetSdk.
|
# 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
|
# 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
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
@@ -199,9 +209,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apk_dir=build/app/outputs/flutter-apk
|
apk_dir=build/app/outputs/flutter-apk
|
||||||
python3 scripts/check_backend_apk.py "$apk_dir/SpotiFLAC-${VERSION}-arm64.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" \
|
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
|
for abi in arm64 arm32; do
|
||||||
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 4 \
|
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 4 \
|
||||||
"$apk_dir/SpotiFLAC-${VERSION}-${abi}.apk"
|
"$apk_dir/SpotiFLAC-${VERSION}-${abi}.apk"
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ AGENTS.md
|
|||||||
/android/*.keystore
|
/android/*.keystore
|
||||||
/android/app/*.jks
|
/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 generated state
|
||||||
/ios/build/
|
/ios/build/
|
||||||
/ios/Frameworks/
|
/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
|
automatically. Include the same `--dart-define` when running Flutter build
|
||||||
commands directly; without it, the footer shows only the copyright.
|
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
|
## Project Boundaries
|
||||||
|
|
||||||
```text
|
```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 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
|
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") }
|
val discordSdkAar = discordSdkDir?.let { file("$it/lib/release/discord_partner_sdk.aar") }
|
||||||
if (discordSdkAar != null) {
|
if (discordSdkAar != null) {
|
||||||
require(discordSdkAar.isFile) { "SPOTIFLAC_DISCORD_SDK_DIR must contain the official Social SDK" }
|
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") {
|
val discordNotices = if (discordSdkDir != null) tasks.register<Copy>("copyDiscordNotices") {
|
||||||
from(file("$discordSdkDir/License-Notices.txt"))
|
from(file("$discordSdkDir/License-Notices.txt"))
|
||||||
@@ -173,6 +176,13 @@ val buildRustBackend = tasks.register<Exec>("buildRustBackend") {
|
|||||||
}
|
}
|
||||||
tasks.named("preBuild").configure { dependsOn(buildRustBackend) }
|
tasks.named("preBuild").configure { dependsOn(buildRustBackend) }
|
||||||
if (discordNotices != null) tasks.named("preBuild").configure { dependsOn(discordNotices) }
|
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 {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|||||||
OUTPUT_DIR="$PROJECT_DIR/build/app/outputs/flutter-apk"
|
OUTPUT_DIR="$PROJECT_DIR/build/app/outputs/flutter-apk"
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
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)"
|
BUILD_GIT_COMMIT="$(git rev-parse --short=8 HEAD)"
|
||||||
flutter build apk \
|
flutter build apk \
|
||||||
--release \
|
--release \
|
||||||
@@ -16,11 +18,19 @@ flutter build apk \
|
|||||||
--dart-define="GIT_COMMIT=$BUILD_GIT_COMMIT" \
|
--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
|
if [[ ! -f "$OUTPUT_DIR/$apk" ]]; then
|
||||||
echo "Error: expected APK was not created: $OUTPUT_DIR/$apk" >&2
|
echo "Error: expected APK was not created: $OUTPUT_DIR/$apk" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
python3 scripts/check_backend_apk.py "$OUTPUT_DIR/$apk" \
|
||||||
|
--backend rust --abis "$abis" --require-discord
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Built Android release APKs in $OUTPUT_DIR"
|
echo "Built Android release APKs in $OUTPUT_DIR"
|
||||||
@@ -15,6 +15,11 @@ ABI_LAYOUT = {
|
|||||||
"armeabi-v7a": (1, 40),
|
"armeabi-v7a": (1, 40),
|
||||||
}
|
}
|
||||||
CORE_LIBRARIES = ("libapp.so", "libflutter.so")
|
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):
|
class AuditError(Exception):
|
||||||
@@ -106,7 +111,21 @@ def check_backend_markers(
|
|||||||
raise AuditError("Go APK contains forbidden libspotiflac_mobile.so")
|
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():
|
if not path.is_file():
|
||||||
raise AuditError("APK is not a regular file: " + str(path))
|
raise AuditError("APK is not a regular file: " + str(path))
|
||||||
digest = artifact_sha256(path)
|
digest = artifact_sha256(path)
|
||||||
@@ -124,6 +143,9 @@ def audit(path: Path, backend: str, abis: Sequence[str]) -> str:
|
|||||||
libraries = CORE_LIBRARIES + (backend_library,)
|
libraries = CORE_LIBRARIES + (backend_library,)
|
||||||
if backend == "rust":
|
if backend == "rust":
|
||||||
libraries += ("libjnidispatch.so",)
|
libraries += ("libjnidispatch.so",)
|
||||||
|
if require_discord:
|
||||||
|
check_discord(zf, infos)
|
||||||
|
libraries += DISCORD_LIBRARIES
|
||||||
for abi in abis:
|
for abi in abis:
|
||||||
for library in libraries:
|
for library in libraries:
|
||||||
entry_path = f"lib/{abi}/{library}"
|
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("apk", type=Path, help="release APK to audit")
|
||||||
parser.add_argument("--backend", choices=("rust", "go"), required=True)
|
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("--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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -147,11 +171,12 @@ def main(argv: Sequence[str] = None) -> int:
|
|||||||
args = make_parser().parse_args(argv)
|
args = make_parser().parse_args(argv)
|
||||||
try:
|
try:
|
||||||
abis = parse_abis(args.abis)
|
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:
|
except (AuditError, OSError) as exc:
|
||||||
print("error: " + str(exc), file=sys.stderr)
|
print("error: " + str(exc), file=sys.stderr)
|
||||||
return 1
|
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
|
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