[feat] add native Android controller

This commit is contained in:
dijunkun
2026-10-01 23:17:47 +08:00
parent 149d20cbd1
commit f08194f686
73 changed files with 5466 additions and 15 deletions
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Build the native controller in isolation from desktop and iOS caches."""
import fcntl
import os
from pathlib import Path
import shutil
import subprocess
import re
ANDROID = Path(__file__).resolve().parents[1]
ROOT = ANDROID.parents[1]
SOURCE = ROOT / 'deps/submodules/minirtc'
CACHE = ANDROID / '.native'
PACKAGE_REVISION = 'eda39de3fb99b420c168f1ab9ab2d1791e11b662'
def main():
sdk = Path(os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT')
or Path.home() / 'Library/Android/sdk')
ndk = sdk / 'ndk/28.2.13676358'
if not ndk.is_dir():
raise SystemExit('Install Android NDK 28.2.13676358 using SDK Manager.')
bundled = Path.home() / '.cache/crossdesk/toolchains/xmake-3.1.1/xmake'
xmake = os.environ.get('XMAKE_BIN') or (str(bundled) if bundled.is_file() else shutil.which('xmake'))
if not xmake:
raise SystemExit('xmake is required; install it or set XMAKE_BIN.')
version = subprocess.check_output([xmake, '--version'], text=True)
if not re.search(r'xmake v3\.1\.1(?:\+|[,\s])', version):
raise SystemExit('Use Xmake 3.1.1 (set XMAKE_BIN to its executable).')
if not (SOURCE / 'xmake.lua').is_file():
raise SystemExit('Initialize the MiniRTC submodule before building.')
env = os.environ.copy()
env['XMAKE_PKG_INSTALLDIR'] = str(CACHE / 'packages')
env['XMAKE_GLOBALDIR'] = str(CACHE / 'global')
env['ANDROID_NDK_HOME'] = str(ndk)
env['ANDROID_NDK_ROOT'] = str(ndk)
env['NO_COLOR'] = '1'
work = ANDROID / 'native'
repository = CACHE / 'global/.xmake/repositories/xmake-repo'
if not (repository / '.git').is_dir():
subprocess.run([xmake, 'repo', '--update'], cwd=work, env=env, check=True)
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repository, text=True).strip()
if revision != PACKAGE_REVISION:
subprocess.run(['git', 'fetch', 'origin', PACKAGE_REVISION], cwd=repository, check=True)
subprocess.run(['git', 'checkout', '--detach', PACKAGE_REVISION], cwd=repository, check=True)
subprocess.run([xmake, 'f', '-P', str(work), '-y', '-p', 'android', '-a', 'arm64-v8a',
'-m', 'release', '--ndk=' + str(ndk), '--ndk_sdkver=26',
'--runtimes=c++_static', '--policies=package.precompiled:n',
'-o', str(CACHE / 'build')], cwd=work, env=env, check=True)
subprocess.run([xmake, '-P', str(work), '-y', 'crossdesk_android'], cwd=work, env=env, check=True)
if __name__ == '__main__':
CACHE.mkdir(parents=True, exist_ok=True)
with (CACHE / 'build.lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Package existing reviewed notices for the Android runtime dependency set."""
import json
import hashlib
from pathlib import Path
import subprocess
from urllib.parse import quote
ANDROID = Path(__file__).resolve().parents[1]
ROOT = ANDROID.parents[1]
COMPONENTS = {
'asio', 'concurrentqueue', 'dav1d', 'glib', 'inih', 'nlohmann_json',
'kcp', 'libdatachannel', 'libffi', 'libnice', 'libsrtp', 'libyuv',
'minirtc', 'openfec', 'openssl3', 'libopus', 'pcre2', 'plog',
'proxy-libintl', 'spdlog', 'svt-av1', 'usrsctp', 'webrtc', 'websocketpp', 'zlib',
}
def git(*args, cwd=ROOT):
return subprocess.check_output(['git', *args], cwd=cwd, text=True).strip()
def write_pages(output, catalog, additional, revision, minirtc):
"""Small navigation catalog and separate, offline-selectable license documents."""
source = f'https://github.com/kunkundi/crossdesk/tree/{revision}'
mini_source = f'https://github.com/kunkundi/minirtc/tree/{minirtc}'
components = []
for entry in catalog['components']:
if entry['id'] not in COMPONENTS | {'crossdesk'}:
continue
component = dict(entry)
if component['id'] == 'crossdesk':
component['sourceURL'] = source
component['documents'] = [
{'title': 'LICENSE', 'text': (ROOT / 'LICENSE').read_text()},
{'title': 'apps/android/licenses/OPEN_SOURCE_NOTICE.txt',
'text': (ANDROID / 'licenses/OPEN_SOURCE_NOTICE.txt').read_text()},
]
if component['id'] in {'minirtc', 'inih', 'webrtc'}:
component.update(version=minirtc, sourceURL=mini_source)
if '/thirdparty/' in component.get('buildSourceURL', ''):
recipe = component['buildSourceURL'].split('/thirdparty/', 1)[1]
component['buildSourceURL'] = f'{mini_source}/thirdparty/{recipe}'
components.append(component)
components += additional
# Ship the actual build instructions and recipes used by this local build. The Android
# directory may not exist at the public base commit of an unpublished checkout.
bundled = [
('构建说明', ANDROID / 'README.md'),
('原生构建配方', ANDROID / 'native/xmake.lua'),
('OpenSSL 构建配方', ROOT / 'deps/submodules/minirtc/thirdparty/openssl/xmake.lua'),
('MiniRTC 构建配方', ROOT / 'deps/submodules/minirtc/xmake.lua'),
('原生构建脚本', ANDROID / 'scripts/build_native.py'),
('SVT-AV1 构建配方', ROOT / 'deps/submodules/minirtc/thirdparty/svt-av1/xmake.lua'),
]
build_documents = []
for index, (title, path) in enumerate(bundled):
asset = f'about/build/{index}.txt'
target = output / asset
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(path.read_text(), encoding='utf-8')
build_documents.append({'title': title, 'asset': asset})
for component in components:
if component['id'] in {'minirtc', 'glib', 'libnice', 'openfec', 'openh264', 'openssl3', 'svt-av1'}:
component['buildDocuments'] = build_documents[1:]
documents = []
for index, document in enumerate(component['documents']):
asset = f"about/licenses/{component['id']}/{index}.txt"
target = output / asset
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(document['text'], encoding='utf-8')
documents.append({'title': document['title'], 'asset': asset,
'sha256': hashlib.sha256(document['text'].encode('utf-8')).hexdigest()})
component['documents'] = documents
(output / 'ThirdPartyLicenses.json').write_text(json.dumps(
{'schemaVersion': 1, 'components': components}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
tags = sorted(tag for tag in git('tag', '--points-at', 'HEAD').splitlines() if tag.startswith('v'))
metadata = {
'schemaVersion': 1, 'revision': revision, 'tag': tags[0] if tags else None,
'isModified': bool(git('status', '--porcelain', '--untracked-files=normal', '--ignore-submodules=none')),
'sourceURL': source,
'buildInstructionsURL': f'https://github.com/kunkundi/crossdesk/blob/{quote(revision, safe="")}/apps/android/README.md',
'buildDocuments': build_documents, 'miniRTCRevision': minirtc,
}
(output / 'SourceMetadata.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
def main():
output = ANDROID / 'app/build/generated/notices'
output.mkdir(parents=True, exist_ok=True)
catalog = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
minirtc = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT / 'deps/submodules/minirtc', text=True).strip()
parts = [
'CrossDesk Android — open-source notices\n',
'CrossDesk: GPL-3.0-only; MiniRTC: LGPL-3.0-only. Other components retain their own licenses.\n'
'Open-source licenses govern use, modification and redistribution; their warranty limitations apply.\n'
'You can rebuild/relink modified libraries and install an APK signed with your own key.\n',
f'Application base revision: {revision}\nMiniRTC base revision: {minirtc}\n'
'This development build may contain local changes in CrossDesk and the MiniRTC submodule.\n'
'Source: https://github.com/kunkundi/crossdesk\n'
'Build: apps/android/README.md; native source: deps/submodules/minirtc\n',
(ROOT / 'LICENSE').read_text(),
]
for component in catalog['components']:
if component['id'] not in COMPONENTS:
continue
parts.append(f"\n{component['name']} — {component['license']}\n{component['sourceURL']}\n")
for document in component['documents']:
parts.extend([document['title'], document['text']])
additional = json.loads((ANDROID / 'licenses/additional-notices.json').read_text())
for component in additional:
parts.append(component['name']+' '+component['version'])
for document in component['documents']:
parts.extend([document['title'], document['text']])
(output / 'THIRD_PARTY_NOTICES.txt').write_text('\n\n'.join(parts), encoding='utf-8')
write_pages(output, catalog, additional, revision, minirtc)
if __name__ == '__main__':
main()
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Check Android's native packets against the shared desktop wire parser."""
import os
from pathlib import Path
import subprocess
import tempfile
ANDROID = Path(__file__).resolve().parents[1]
ROOT = ANDROID.parents[1]
headers = list((ANDROID / '.native/packages/n/nlohmann_json').glob('*/**/include/nlohmann/json.hpp'))
if not headers:
raise SystemExit('Build the Android app first to populate its pinned native dependencies.')
with tempfile.TemporaryDirectory(prefix='crossdesk-android-protocol-') as temporary:
binary = Path(temporary) / 'controller-protocol-test'
subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17',
'-I' + str(headers[0].parents[1]),
'-I' + str(ANDROID / 'native'),
'-I' + str(ROOT / 'deps/submodules/minirtc/src/api'),
'-I' + str(ROOT / 'libs/wire/include'),
str(ANDROID / 'native/tests/controller_protocol_test.cpp'),
str(ROOT / 'libs/wire/src/remote_action.cpp'), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
video_test = Path(temporary) / 'video-pipeline-test'
subprocess.run([os.environ.get('CXX', 'c++'), '-std=c++17', '-pthread',
'-I' + str(ANDROID / 'native'),
'-I' + str(ROOT / 'deps/submodules/minirtc/src/api'),
str(ANDROID / 'native/tests/video_pipeline_test.cpp'),
'-o', str(video_test)], check=True)
subprocess.run([str(video_test)], check=True, timeout=10)
print('PASS: Android video settings / desktop wire round-trip and native network fields')
print('PASS: long video latency, invalid clocks, latest-frame rendering and shutdown')
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Check the shipped native ABI, ELF load alignment and APK zip alignment."""
from pathlib import Path
import hashlib
import json
import struct
import sys
import zipfile
def verify(path):
with zipfile.ZipFile(path) as archive, open(path, 'rb') as raw:
libraries = [i for i in archive.infolist() if i.filename.endswith('.so')]
if [i.filename for i in libraries] != ['lib/arm64-v8a/libcrossdesk_android.so']:
raise ValueError('APK must contain exactly the arm64 native controller library')
library = libraries[0]
data = archive.read(library)
if data[:6] != b'\x7fELF\x02\x01' or struct.unpack_from('<H', data, 18)[0] != 183:
raise ValueError('Native library is not little-endian ELF64 AArch64')
offset = struct.unpack_from('<Q', data, 32)[0]
size, count = struct.unpack_from('<HH', data, 54)
segments = [struct.unpack_from('<IIQQQQQQ', data, offset + i * size) for i in range(count)]
loads = [s for s in segments if s[0] == 1]
if not loads or any(s[7] < 16384 or (s[2] - s[3]) % 16384 for s in loads):
raise ValueError('ELF PT_LOAD segments must be aligned to at least 16 KB')
if library.compress_type != zipfile.ZIP_STORED:
raise ValueError('Native library must be stored uncompressed')
raw.seek(library.header_offset + 26)
name, extra = struct.unpack('<HH', raw.read(4))
if (library.header_offset + 30 + name + extra) % 16384:
raise ValueError('APK native library entry is not 16 KB aligned')
if 'assets/THIRD_PARTY_NOTICES.txt' not in archive.namelist():
raise ValueError('Missing third-party notices')
catalog = json.loads(archive.read('assets/ThirdPartyLicenses.json'))
source = json.loads(archive.read('assets/SourceMetadata.json'))
components = catalog['components']
ids = [item['id'] for item in components]
if catalog['schemaVersion'] != 1 or len(ids) != len(set(ids)) or not {
'crossdesk', 'minirtc', 'openfec', 'openh264', 'libiconv', 'android-ndk', 'svt-av1'}.issubset(ids):
raise ValueError('Incomplete About catalog')
for component in components:
if not component['documents']:
raise ValueError('Missing component documents')
for document in component['documents']:
text = archive.read('assets/'+document['asset'])
if not text or hashlib.sha256(text).hexdigest() != document['sha256']:
raise ValueError('Incomplete license document: '+document['asset'])
for document in component.get('buildDocuments', []):
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing build document')
if source['schemaVersion'] != 1 or len(source['revision']) != 40:
raise ValueError('Missing source revision')
for document in source['buildDocuments']:
if not archive.read('assets/'+document['asset']):
raise ValueError('Missing source build instructions')
print(f'PASS: {Path(path).name}: arm64, 16 KB ELF/ZIP alignment, notices, About catalog and documents')
if __name__ == '__main__':
verify(sys.argv[1])