mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 00:33:22 +08:00
[feat] add native Android controller
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user