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
+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()